mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-13 01:21:58 +00:00
* feat(devices): add load-dependent DC-to-AC efficiency curve for GENETIC inverters Real inverters are much less efficient at low load than at rated power (e.g. 0.80 at 2 % vs. 0.93 at 7 % load). A constant dc_to_ac_efficiency overrates night-time battery discharge into a small base load and, with it, arbitrage and grid charging. Add two optional inverter settings for the GENETIC optimizer: - dc_to_ac_efficiency_curve: list of (load_fraction, efficiency) points, load fraction = AC power / max_power_w. Points must be strictly increasing in [0, 1] with efficiencies in (0, 1]; at least two points. Linear interpolation, clamped at both ends. When set, the curve replaces dc_to_ac_efficiency. - dc_to_ac_efficiency_reference_load_fraction (default 0.06): load at which the curve values stored energy without a specific conversion. Battery discharges in Inverter.process_energy() use the efficiency at the AC energy of each conversion. The end-of-horizon value, the terminal and tail value curves and the AC charge break-even penalty use Inverter.reference_dc_to_ac_efficiency. Without a curve, dc_to_ac_efficiency_at() and the reference efficiency return dc_to_ac_efficiency itself, so results are bit-identical to the previous model. GENETIC0 is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(genetic): make the fitness cache size configurable The per-run fitness cache of the GENETIC optimizer is unbounded. It grows with individuals x generations (~110k keys at 300 x 400, about 1.1 kB each, 1.6 kB with an EV) and adds 120-170 MB to the peak memory of a run, while only 6-15 % of the lookups hit. On 512 MB boards this is the largest part of the optimizer's memory. Add optimization.genetic.fitness_cache_max_entries: - None (default): unbounded, the previous behaviour. - 0: no cache; every genome is evaluated. - N > 0: at most N keys. The cache is then an OrderedDict and evicts the oldest key first (FIFO, O(1)); LRU would cost bookkeeping on every hit for a cache that rarely hits. A cache hit returns exactly what the deterministic evaluation would return, so the plan for a fixed seed is identical with any setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: import heavy optional dependencies on first use A GENETIC server with external (import) providers and EOSdash disabled loads several large packages at start that it never uses, which costs a noticeable part of the memory on 512 MB boards: - matplotlib via the GENETIC/GENETIC0 PDF report modules (server/eos.py) - statsmodels via the Holt-Winters fallback in prediction/priceabc.py - pvlib via the weather base class and the pvlib-based providers - bs4/lxml via the ClearOutside provider - scipy via the self-consumption interpolator singletons, which were loaded at import time - the GENETIC0 optimizer, imported by core/ems.py - tzfpy, whose polygon data the computed general.timezone loads Import these where they are used. The interpolator tables are loaded on first use (thread-safe). Nothing changes in what is computed. general.timezone is computed from latitude/longitude and cannot be set. Add general.timezone_override (IANA name, validated with pendulum): when set, general.timezone returns it and the lookup is skipped. When unset (default) the lookup is unchanged. Tests that patched the moved names now patch them at their source. A new test starts the server module in a subprocess and checks that none of these modules is loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(prediction): evaluate self-consumption tables without SciPy The GENETIC inverter (and the GENETIC0 one) evaluates a pickled scipy.interpolate.RegularGridInterpolator for the self-consumption probabilities. SciPy is used nowhere else in EOS, yet loading it costs about 35 MB RSS (21 MB anonymous) on Linux arm64, which every optimization run pays. Add utils/gridinterpolator.py: - LinearGridInterpolator: 2-D linear interpolation with the same steps as SciPy's linear 2-D path (interval search, normalised distances, corner weights summed in the same order, fill value outside the grid, NaN for NaN input). - load_grid_interpolator(): reads the shipped pickles with a restricted unpickler that maps the SciPy class to a stand-in and accepts only the NumPy globals needed for the arrays. SciPy is not imported, and the data files no longer depend on SciPy's private module paths. Results are bit-identical to SciPy on Linux (aarch64, checked on 3.6M points per table including the query pattern of the inverter). The macOS arm64 SciPy wheel fuses multiply-add, so there SciPy itself differs by <= 1.1e-16; GENETIC plans for fixed seeds are unchanged on both platforms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(prediction): make the SciPy-free table evaluation opt-in Add optimization.self_consumption_interpolator (scipy | numpy, default scipy). The default keeps evaluating the self-consumption tables with SciPy's RegularGridInterpolator, built from the same grid, values and fill value the pickle holds, so results are unchanged. With numpy the tables are evaluated by LinearGridInterpolator and SciPy is never imported. The backend is read on every evaluation, so a config change applies to the next run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(genetic): freeze the optimization configuration for the duration of a run The genetic optimization reads horizon_hours, tail_horizon_hours, interval_sec and prediction.hours from the live configuration on every access (control_slots, tail_slots, ... are properties). A client that updates the configuration while a run is in progress - DVhub shrinks or grows the control horizon as the known price horizon moves during the night - changed these values mid-run. Genomes and device arrays sized at the start then no longer matched the simulation loop and the final evaluation failed: IndexError: index 84 is out of bounds for axis 0 with size 84 Long runs on slow hardware hit this almost every night. optimize_ems() now takes a deep copy of the genetic configuration and the prediction hours at the start of a run, all reads in genetic.py go through it, and it is released when the run ends (also on errors). New values apply to the next run. Tests: tests/test_genetic_config_frozen_per_run.py reproduces the error without the fix (horizon 20 -> 24 mid-run: index 20 out of bounds) and passes with it; genetic/optimization tests 439 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(devices): evaluate the efficiency curve at the delivered AC load When the battery limits a discharge (low state of charge or its power limit), the conversion ran with the efficiency of the requested AC energy. With a 1000 Wh battery at 10 % SoC, a 1000 W inverter and the curve [(0, .8), (.5, .95), (1, .95)], a request of 500 Wh AC returned 95 Wh AC from the available 100 Wh DC, although the curve gives 82.5 % at that load. The delivered AC energy now solves a = dc * efficiency(a / rated) exactly on the piecewise-linear curve (solve_ac_for_dc_energy), bounded by the request. Unlimited discharges and inverters without a curve are unchanged. Regression tests use a real Battery: low SoC and a power-limited discharge, each with 60- and 15-minute slots, battery discharge losses, the load path through process_energy, and constant efficiency. * docs: regenerate OpenAPI for the efficiency-curve fix * docs: regenerate OpenAPI after merging the efficiency-curve fix --------- Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Andreas <drbacke@gmx.de>
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""The genetic configuration is frozen for the duration of an optimization run.
|
|
|
|
A client (e.g. an energy manager that shrinks the control horizon as the known
|
|
price horizon moves on during the night) may update the configuration while a
|
|
run is in progress. Genomes and device arrays are sized when the run starts, so
|
|
a mid-run change of horizon, tail or prediction hours must not reach the final
|
|
evaluation - before the snapshot it failed with
|
|
``IndexError: index 84 is out of bounds for axis 0 with size 84``.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from akkudoktoreos.config.config import ConfigEOS
|
|
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
|
from akkudoktoreos.core.coreabc import get_ems
|
|
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
|
from akkudoktoreos.optimization.genetic.geneticparams import (
|
|
GeneticOptimizationParameters,
|
|
)
|
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
|
|
|
ems_eos = get_ems(init=True) # init once
|
|
|
|
HOURS = 24
|
|
|
|
|
|
def _setup(config_eos: ConfigEOS, horizon_hours: int) -> GeneticOptimizationParameters:
|
|
config_eos.merge_settings_from_dict(
|
|
{
|
|
"prediction": {"hours": HOURS},
|
|
"optimization": {
|
|
"genetic": {
|
|
"individuals": 20,
|
|
"generations": 10,
|
|
"tail_horizon_hours": 0,
|
|
"horizon_hours": horizon_hours,
|
|
"interval_sec": 3600,
|
|
}
|
|
},
|
|
"feedintariff": {"direct_marketing_enabled": True},
|
|
"devices": {
|
|
"max_batteries": 1,
|
|
"batteries": {
|
|
"battery1": {"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
|
CacheEnergyManagementStore().clear()
|
|
return GeneticOptimizationParameters.model_validate(
|
|
dict(
|
|
ems={
|
|
"pv_prognose_wh": [0.0] * HOURS,
|
|
"strompreis_euro_pro_wh": [0.0003] * HOURS,
|
|
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
|
|
"preis_euro_pro_wh_akku": 0.0,
|
|
"gesamtlast": [200.0] * HOURS,
|
|
},
|
|
pv_battery={
|
|
"device_id": "battery1",
|
|
"capacity_wh": 10000,
|
|
"initial_soc_percentage": 100,
|
|
"min_soc_percentage": 0,
|
|
"max_charge_power_w": 5000,
|
|
},
|
|
inverter={
|
|
"device_id": "inverter1",
|
|
"max_power_wh": 10000,
|
|
"battery_id": "battery1",
|
|
},
|
|
ev=None,
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("start_horizon,changed_horizon", [(20, 24), (24, 20)])
|
|
def test_horizon_change_during_run_does_not_reach_the_run(
|
|
config_eos: ConfigEOS, start_horizon: int, changed_horizon: int
|
|
):
|
|
parameters = _setup(config_eos, start_horizon)
|
|
optimization = GeneticOptimization(fixed_seed=42)
|
|
|
|
original_optimize = optimization.optimize
|
|
|
|
def optimize_then_change_config(*args, **kwargs):
|
|
result = original_optimize(*args, **kwargs)
|
|
# A client updates the configuration while the run is still going.
|
|
config_eos.merge_settings_from_dict(
|
|
{"optimization": {"genetic": {"horizon_hours": changed_horizon}}}
|
|
)
|
|
return result
|
|
|
|
optimization.optimize = optimize_then_change_config # type: ignore[method-assign]
|
|
|
|
solution = optimization.optimize_ems(parameters=parameters, start_hour=0, ngen=2)
|
|
|
|
# The run finished on the horizon it started with ...
|
|
assert len(solution.battery_grid_export_factor) == start_horizon
|
|
# ... and the new value is live for the next run.
|
|
assert config_eos.optimization.genetic.horizon_hours == changed_horizon
|
|
assert optimization._run_genetic_cfg is None
|
|
|
|
|
|
def test_snapshot_is_released_when_the_run_fails(config_eos: ConfigEOS):
|
|
parameters = _setup(config_eos, 24)
|
|
optimization = GeneticOptimization(fixed_seed=42)
|
|
|
|
def failing_optimize(*args, **kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
optimization.optimize = failing_optimize # type: ignore[method-assign]
|
|
with pytest.raises(RuntimeError):
|
|
optimization.optimize_ems(parameters=parameters, start_hour=0, ngen=1)
|
|
assert optimization._run_genetic_cfg is None
|
|
assert optimization._run_prediction_hours is None
|