fix(genetic): freeze the optimization configuration for the duration of a run (#1381)

* 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>
This commit is contained in:
Christin
2026-10-11 10:45:47 +02:00
committed by GitHub
co-authored by Christin Claude Opus 5.5 Andreas
parent 7f26ea3f3f
commit 720809397c
3 changed files with 201 additions and 42 deletions
+3
View File
@@ -89,6 +89,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta
([#1303](https://github.com/Akkudoktor-EOS/EOS/issues/1303)).
- Environment variables are applied to the configuration on server startup instead of taking effect
only after the first configuration change.
- GENETIC reads its horizon, interval and prediction settings once at the start of a run. A
configuration change during a run (for example a client adapting the control horizon) no
longer fails the run with an out-of-bounds error; it applies to the next run.
## 0.3.0 (2026-03-17)
@@ -710,28 +710,54 @@ class GeneticOptimization(OptimizationBase):
SOFT_RESTART_SURVIVOR_FRACTION = 0.20
POINT_MUTATION_EXPECTED_GENES = 3.0
# Per-run configuration snapshot. The optimization reads horizon, tail,
# interval and prediction hours many times during a run. A client that
# updates the configuration while a run is in progress (e.g. a shrinking
# control horizon as the known price horizon moves on during the night)
# must not change these mid-run: genomes and device arrays sized at the
# start would no longer match the simulation loop, and the final
# evaluation fails with an IndexError. The snapshot is taken in
# optimize_ems() and released when the run ends; new values apply to the
# next run.
_run_genetic_cfg: Any = None
_run_prediction_hours: Optional[float] = None
@property
def _genetic_cfg(self) -> Any:
"""Genetic configuration, frozen for the duration of a run."""
if self._run_genetic_cfg is not None:
return self._run_genetic_cfg
return self.config.optimization.genetic
@property
def _prediction_hours_cfg(self) -> Any:
"""Prediction hours, frozen for the duration of a run."""
if self._run_prediction_hours is not None:
return self._run_prediction_hours
return self.config.prediction.hours
# Independent forecast and control durations on the optimization grid.
@property
def slot_duration_h(self) -> float:
"""Length of one optimization slot in hours (1.0 hourly, 0.25 at 15 min)."""
interval = self.config.optimization.genetic.interval_sec or 3600
interval = self._genetic_cfg.interval_sec or 3600
return interval / 3600
@property
def slots_per_hour(self) -> int:
"""Number of optimization slots per hour (1 hourly, 4 at 15 min)."""
interval = self.config.optimization.genetic.interval_sec or 3600
interval = self._genetic_cfg.interval_sec or 3600
return 3600 // interval
@property
def control_slots(self) -> int:
"""Number of executable control intervals, measured from now."""
return self.config.optimization.genetic.horizon_hours * self.slots_per_hour
return self._genetic_cfg.horizon_hours * self.slots_per_hour
@property
def prediction_slots(self) -> int:
"""Forecast duration, independent of the control genome."""
return int(self.config.prediction.hours * self.slots_per_hour)
return int(self._prediction_hours_cfg * self.slots_per_hour)
@property
def tail_slots(self) -> int:
@@ -741,7 +767,7 @@ class GeneticOptimization(OptimizationBase):
tail rather than failing the run, so the shortfall is not reported as
missing provider data.
"""
requested = self.config.optimization.genetic.tail_horizon_hours * self.slots_per_hour
requested = self._genetic_cfg.tail_horizon_hours * self.slots_per_hour
budget = max(0, self.prediction_slots - self.control_slots)
return min(requested, budget)
@@ -766,12 +792,12 @@ class GeneticOptimization(OptimizationBase):
fixed_seed: Optional[int] = None,
):
"""Initialize the optimization problem with the required parameters."""
if self.config.optimization.genetic.interval_sec not in (900, 3600):
if self._genetic_cfg.interval_sec not in (900, 3600):
logger.warning(
"Genetic optimization interval {} seconds is unsupported; using 3600 seconds.",
self.config.optimization.genetic.interval_sec,
self._genetic_cfg.interval_sec,
)
self.config.optimization.genetic.interval_sec = 3600
self._genetic_cfg.interval_sec = 3600
self.opti_param: dict[str, Any] = {}
# EV genes cover precisely the control horizon; no fixed prediction tail.
self.fixed_eauto_hours = 0
@@ -888,7 +914,7 @@ class GeneticOptimization(OptimizationBase):
the total slot grid.
"""
start_slot = self._control_start_slot()
horizon_slots = self.config.optimization.genetic.horizon_hours * self.slots_per_hour
horizon_slots = self._genetic_cfg.horizon_hours * self.slots_per_hour
return min(self.control_end_slot, start_slot + horizon_slots)
def _ev_deadline_slot(self, parameters: GeneticOptimizationParameters) -> Optional[int]:
@@ -966,7 +992,7 @@ class GeneticOptimization(OptimizationBase):
self._forecast_reason = ""
if available < requested:
self._forecast_reason = (
f"Tail forecast shortened: requested {self.config.optimization.genetic.tail_horizon_hours} h, "
f"Tail forecast shortened: requested {self._genetic_cfg.tail_horizon_hours} h, "
f"effective {self._effective_tail_slots * self.slot_duration_h:g} h; "
f"limited by {', '.join(limiting)}. Continuation starts at slot {available}."
)
@@ -993,8 +1019,8 @@ class GeneticOptimization(OptimizationBase):
self._terminal_value_reason = "no battery in this optimization"
return None
try:
mode = self.config.optimization.genetic.terminal_value_mode
window_hours = self.config.optimization.genetic.terminal_value_window_hours
mode = self._genetic_cfg.terminal_value_mode
window_hours = self._genetic_cfg.terminal_value_window_hours
except Exception:
self._terminal_value_reason = "terminal value configuration unavailable"
return None
@@ -1111,10 +1137,10 @@ class GeneticOptimization(OptimizationBase):
The credit in EUR and the result object for the solution.
"""
diagnostics = dict(
control_horizon_hours=self.config.optimization.genetic.horizon_hours,
requested_tail_hours=self.config.optimization.genetic.tail_horizon_hours,
control_horizon_hours=self._genetic_cfg.horizon_hours,
requested_tail_hours=self._genetic_cfg.tail_horizon_hours,
effective_tail_hours=0.0,
tail_end_hour=float(self.config.optimization.genetic.horizon_hours),
tail_end_hour=float(self._genetic_cfg.horizon_hours),
)
battery = self.simulation.battery
if battery is None:
@@ -1133,9 +1159,7 @@ class GeneticOptimization(OptimizationBase):
if isinstance(curve, TailValueCurve):
tail_operating_euro, continuation_value_euro = curve.component_values(energy_wh)
tail_plan = (
curve.diagnostic_plan(
energy_wh, float(self.config.optimization.genetic.horizon_hours)
)
curve.diagnostic_plan(energy_wh, float(self._genetic_cfg.horizon_hours))
if include_tail_plan
else []
)
@@ -1144,8 +1168,8 @@ class GeneticOptimization(OptimizationBase):
tail_plan = []
return credit, TerminalValueResult(
mode="TAIL" if isinstance(curve, TailValueCurve) else "AUTO",
control_horizon_hours=self.config.optimization.genetic.horizon_hours,
requested_tail_hours=self.config.optimization.genetic.tail_horizon_hours,
control_horizon_hours=self._genetic_cfg.horizon_hours,
requested_tail_hours=self._genetic_cfg.tail_horizon_hours,
effective_tail_hours=getattr(self, "_effective_tail_slots", 0)
* self.slot_duration_h,
tail_end_hour=(self.control_end_slot + getattr(self, "_effective_tail_slots", 0))
@@ -1465,17 +1489,14 @@ class GeneticOptimization(OptimizationBase):
) -> list[float]:
data = np.asarray(values, dtype=float)
# API inputs default to hourly; native callers declare their interval.
native = (
parameters.forecast_interval_seconds
== self.config.optimization.genetic.interval_sec
)
native = parameters.forecast_interval_seconds == self._genetic_cfg.interval_sec
if parameters.forecast_interval_seconds is None:
max_hourly = self.config.prediction.hours + math.ceil(
max_hourly = self._prediction_hours_cfg + math.ceil(
self._start_day_slot() / self.slots_per_hour
)
if self.slots_per_hour > 1 and max_hourly < len(data) < self.prediction_slots:
raise ValueError(
f"{name}: ambiguous forecast interval; expected either {self.config.prediction.hours} hourly values or {self.prediction_slots} native values. Set forecast_interval_seconds for shortened native forecasts."
f"{name}: ambiguous forecast interval; expected either {self._prediction_hours_cfg} hourly values or {self.prediction_slots} native values. Set forecast_interval_seconds for shortened native forecasts."
)
native = self.slots_per_hour == 1 or len(data) >= self.prediction_slots
if parameters.forecast_interval_seconds == 900 and self.slots_per_hour == 1:
@@ -1525,7 +1546,7 @@ class GeneticOptimization(OptimizationBase):
update={
"ems": normalized_ems,
"temperature_forecast": temperature_forecast,
"forecast_interval_seconds": self.config.optimization.genetic.interval_sec,
"forecast_interval_seconds": self._genetic_cfg.interval_sec,
},
deep=True,
)
@@ -1541,8 +1562,7 @@ class GeneticOptimization(OptimizationBase):
n_appliance_genes = self.appliance_layout.n_genes
expected_length = self.control_end_slot * (2 if self.optimize_ev else 1) + n_appliance_genes
hourly_length = (
self.config.optimization.genetic.horizon_hours * (2 if self.optimize_ev else 1)
+ n_appliance_genes
self._genetic_cfg.horizon_hours * (2 if self.optimize_ev else 1) + n_appliance_genes
)
if len(start_solution) == expected_length or self.slots_per_hour == 1:
@@ -1550,10 +1570,10 @@ class GeneticOptimization(OptimizationBase):
if len(start_solution) != hourly_length:
return list(start_solution)
battery_end = self.config.optimization.genetic.horizon_hours
battery_end = self._genetic_cfg.horizon_hours
migrated = np.repeat(start_solution[:battery_end], self.slots_per_hour).tolist()
if self.optimize_ev:
ev_end = battery_end + self.config.optimization.genetic.horizon_hours
ev_end = battery_end + self._genetic_cfg.horizon_hours
migrated.extend(
np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist()
)
@@ -2949,7 +2969,7 @@ class GeneticOptimization(OptimizationBase):
def _configured_fitness_cache_max_entries(self) -> Optional[int]:
"""Return the configured fitness cache limit (None = unbounded)."""
try:
max_entries = self.config.optimization.genetic.fitness_cache_max_entries
max_entries = self._genetic_cfg.fitness_cache_max_entries
except Exception:
return None
if max_entries is None:
@@ -3142,9 +3162,7 @@ class GeneticOptimization(OptimizationBase):
# Configurable penalty multiplier (default 1 = economic loss in €)
try:
ac_penalty_factor = float(
self.config.optimization.genetic.penalties["ac_charge_break_even"]
)
ac_penalty_factor = float(self._genetic_cfg.penalties["ac_charge_break_even"])
except Exception:
ac_penalty_factor = 1.0
@@ -3207,7 +3225,7 @@ class GeneticOptimization(OptimizationBase):
if self.optimize_ev and parameters.ev and self.simulation.ev:
try:
penalty = self.config.optimization.genetic.penalties["ev_soc_miss"]
penalty = self._genetic_cfg.penalties["ev_soc_miss"]
except:
# Use default
penalty = 10
@@ -3239,7 +3257,7 @@ class GeneticOptimization(OptimizationBase):
# Set the number of inviduals in a generation
if individuals is None:
try:
individuals = self.config.optimization.genetic.individuals
individuals = self._genetic_cfg.individuals
if individuals is None:
raise ValueError("individuals is not configured")
except Exception:
@@ -3472,7 +3490,29 @@ class GeneticOptimization(OptimizationBase):
ngen: Optional[int] = None,
individuals: Optional[int] = None,
) -> GeneticSolution:
"""Perform EMS (Energy Management System) optimization and visualize results."""
"""Perform EMS (Energy Management System) optimization and visualize results.
The genetic configuration and the prediction hours are frozen for the
run (see ``_genetic_cfg``); updates made while it runs apply to the next.
"""
self.config.validate_optimization_horizons()
self._run_genetic_cfg = self.config.optimization.genetic.model_copy(deep=True)
self._run_prediction_hours = self.config.prediction.hours
try:
return self._optimize_ems_run(parameters, start_hour, worst_case, ngen, individuals)
finally:
self._run_genetic_cfg = None
self._run_prediction_hours = None
def _optimize_ems_run(
self,
parameters: GeneticOptimizationParameters,
start_hour: Optional[int],
worst_case: bool,
ngen: Optional[int],
individuals: Optional[int],
) -> GeneticSolution:
"""Run the optimization with the configuration snapshot in place."""
self.config.validate_optimization_horizons()
direct_marketing_enabled = self._direct_marketing_enabled()
parameters = self._parameters_for_config(parameters)
@@ -3497,7 +3537,7 @@ class GeneticOptimization(OptimizationBase):
generations = ngen
if generations is None:
try:
generations = self.config.optimization.genetic.generations
generations = self._genetic_cfg.generations
except:
generations = 400
logger.error("Generations not configured. Using {}.", generations)
@@ -3610,7 +3650,7 @@ class GeneticOptimization(OptimizationBase):
home_appliances = [
HomeAppliance(
parameters=appliance_params,
optimization_hours=self.config.optimization.genetic.horizon_hours,
optimization_hours=self._genetic_cfg.horizon_hours,
prediction_hours=self.control_end_slot,
slot_duration_h=self.slot_duration_h,
)
@@ -3643,7 +3683,7 @@ class GeneticOptimization(OptimizationBase):
# Prepare device simulation
self.simulation.prepare(
parameters=parameters.ems,
optimization_hours=self.config.optimization.genetic.horizon_hours,
optimization_hours=self._genetic_cfg.horizon_hours,
prediction_hours=self.control_end_slot,
inverter=inverter, # battery is part of inverter
ev=eauto,
@@ -3762,7 +3802,7 @@ class GeneticOptimization(OptimizationBase):
return GeneticSolution(
**{
"parameters": parameters,
"interval_seconds": self.config.optimization.genetic.interval_sec,
"interval_seconds": self._genetic_cfg.interval_sec,
"start_hour": start_hour,
"extra_data": extra_data,
"fitness_history": self.fitness_history,
+116
View File
@@ -0,0 +1,116 @@
"""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