diff --git a/openapi.json b/openapi.json index e5ee0e2b..3573ef48 100644 --- a/openapi.json +++ b/openapi.json @@ -5385,6 +5385,23 @@ ], "title": "Start Solution", "description": "Can be `null` or contain a previous solution (if available)." + }, + "start_solution_datetime": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start Solution Datetime", + "description": "Start of the slot that gene 0 of 'start_solution' controls, as returned with the previous solution. The warm start is shifted by the slots that have elapsed until this run. Without it, a 'start_solution' identical to the last solution of this server uses that solution's start; any other one is used unshifted.", + "examples": [ + null, + "2026-09-14T07:45:00+02:00" + ] } }, "additionalProperties": false, @@ -5650,6 +5667,23 @@ "title": "Start Solution", "description": "An array of binary values (0 or 1) representing a possible starting solution for the simulation." }, + "start_solution_datetime": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start Solution Datetime", + "description": "Start of the slot that gene 0 of 'start_solution' controls. Send it back together with 'start_solution' so the next run can shift the warm start by the slots that have elapsed since.", + "examples": [ + null, + "2026-09-14T07:45:00+02:00" + ] + }, "washingstart": { "anyOf": [ { diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 27475afd..e92fbd9d 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -35,6 +35,7 @@ from akkudoktoreos.optimization.genetic.terminalvalue import ( trailing_window, ) from akkudoktoreos.optimization.optimizationabc import OptimizationBase +from akkudoktoreos.utils.datetimeutil import DateTime @dataclass @@ -1369,6 +1370,85 @@ class GeneticOptimization(OptimizationBase): ) return migrated + def _resolve_start_solution_datetime( + self, parameters: GeneticOptimizationParameters + ) -> Optional[DateTime]: + """Start of the slot that gene 0 of the supplied warm start controls. + + An explicit ``start_solution_datetime`` wins. Clients that only echo + ``start_solution`` get the start of this server's last solution when the + genomes are identical; for any other genome the start is unknown. + """ + if parameters.start_solution_datetime is not None: + return parameters.start_solution_datetime + if parameters.start_solution is None: + return None + last_solution = self.ems.genetic_solution() + if ( + last_solution is not None + and last_solution.start_solution is not None + and list(last_solution.start_solution) == list(parameters.start_solution) + ): + return last_solution.start_solution_datetime + return None + + def _start_solution_for_run_start( + self, + start_solution: Optional[list[float]], + start_solution_datetime: Optional[DateTime], + ) -> Optional[list[float]]: + """Align a warm start from an earlier run with the slot this run starts in. + + Genomes are run-relative, so a solution returned one slot ago describes + every battery and EV decision one slot too late. Reused unchanged, a + search that keeps the seed postpones each planned action by one slot per + run. The battery and EV blocks therefore drop the elapsed slots and + repeat their last gene to refill the horizon. + + Appliance genes index into per-run lists of allowed start slots that + cannot be rebuilt for the earlier run; they are kept and validated + against the current layout as before. + """ + if ( + start_solution is None + or start_solution_datetime is None + or self._slot0_datetime is None + ): + return start_solution + start_solution = self._start_solution_for_slot_grid(start_solution) + blocks = 2 if self.optimize_ev else 1 + if len(start_solution) != self.control_end_slot * blocks + self.appliance_layout.n_genes: + # optimize() rejects the length and logs why. + return start_solution + + elapsed_s = (self._slot0_datetime - start_solution_datetime).total_seconds() + if elapsed_s < 0: + logger.warning( + "Ignoring start_solution from {}: it starts after this run ({}).", + start_solution_datetime, + self._slot0_datetime, + ) + return None + elapsed_slots = int(elapsed_s // (self.slot_duration_h * 3600)) + if elapsed_slots == 0: + return start_solution + if elapsed_slots >= self.control_slots: + logger.info( + "Ignoring start_solution from {}: all {} control slots have elapsed.", + start_solution_datetime, + self.control_slots, + ) + return None + + aligned = list(start_solution) + for block in range(blocks): + begin = self._control_start_slot() + block * self.control_end_slot + end = begin + self.control_slots + genes = aligned[begin:end] + aligned[begin:end] = genes[elapsed_slots:] + [genes[-1]] * elapsed_slots + logger.debug("Shifted start_solution by {} elapsed slots.", elapsed_slots) + return aligned + def decode_charge_discharge( self, discharge_hours_bin: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @@ -3340,8 +3420,9 @@ class GeneticOptimization(OptimizationBase): ) start_time = time.time() + start_solution_datetime = self._resolve_start_solution_datetime(parameters) start_solution, extra_data = self.optimize( - parameters.start_solution, + self._start_solution_for_run_start(parameters.start_solution, start_solution_datetime), ngen=generations, individuals=individuals, ) @@ -3459,6 +3540,7 @@ class GeneticOptimization(OptimizationBase): "result": GeneticSimulationResult(**simulation_result), "eauto_obj": self.simulation.ev, "start_solution": start_solution, + "start_solution_datetime": self._slot0_datetime, "washingstart": washingstart_int, "appliance_starts": appliance_starts, "appliance_deadline_missed": appliance_deadline_missed, diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index d0530614..2890388d 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -8,7 +8,7 @@ It also provides a method to assemble these parameters from predictions, forecasts, and fallback defaults, preparing them for optimization runs. """ -from typing import Optional, Union +from typing import Any, Optional, Union from loguru import logger from pydantic import Field, field_validator, model_validator @@ -28,7 +28,7 @@ from akkudoktoreos.optimization.genetic.geneticdevices import ( InverterParameters, SolarPanelBatteryParameters, ) -from akkudoktoreos.utils.datetimeutil import to_duration +from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS = frozenset( {"FeedInTariffAkkudoktor", "FeedInTariffEnergyCharts", "FeedInTariffTibber"} @@ -133,6 +133,27 @@ class GeneticOptimizationParameters( "description": "Can be `null` or contain a previous solution (if available)." }, ) + start_solution_datetime: Optional[DateTime] = Field( + default=None, + json_schema_extra={ + "description": ( + "Start of the slot that gene 0 of 'start_solution' controls, as " + "returned with the previous solution. The warm start is shifted by " + "the slots that have elapsed until this run. Without it, a " + "'start_solution' identical to the last solution of this server " + "uses that solution's start; any other one is used unshifted." + ), + "examples": [None, "2026-09-14T07:45:00+02:00"], + }, + ) + + @field_validator("start_solution_datetime", mode="before") + @classmethod + def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]: + """Accept the usual date time representations, naive input is local time.""" + if value is None: + return None + return to_datetime(value) @model_validator(mode="after") def validate_list_length(self) -> Self: @@ -271,9 +292,11 @@ class GeneticOptimizationParameters( # Get start solution from last run start_solution = None + start_solution_datetime = None last_solution = ems.genetic_solution() if last_solution and last_solution.start_solution: start_solution = last_solution.start_solution + start_solution_datetime = last_solution.start_solution_datetime # Add forecast and device data interval = to_duration(cls.config.optimization.interval) @@ -545,6 +568,7 @@ class GeneticOptimizationParameters( inverter=inverter_params, home_appliances=home_appliance_params, start_solution=start_solution, + start_solution_datetime=start_solution_datetime, ) except: logger.info( diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index 56798347..f6d13199 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -237,6 +237,17 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): "description": "An array of binary values (0 or 1) representing a possible starting solution for the simulation." }, ) + start_solution_datetime: Optional[DateTime] = Field( + default=None, + json_schema_extra={ + "description": ( + "Start of the slot that gene 0 of 'start_solution' controls. Send it " + "back together with 'start_solution' so the next run can shift the " + "warm start by the slots that have elapsed since." + ), + "examples": [None, "2026-09-14T07:45:00+02:00"], + }, + ) washingstart: Optional[int] = Field( default=None, json_schema_extra={ @@ -276,6 +287,14 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): def convert_numpy(cls, field: Any) -> Any: return NumpyEncoder.convert_numpy(field)[0] + @field_validator("start_solution_datetime", mode="before") + @classmethod + def transform_start_solution_datetime(cls, value: Any) -> Optional[DateTime]: + """Accept the usual date time representations, naive input is local time.""" + if value is None: + return None + return to_datetime(value) + @field_validator( "eauto_obj", mode="before", diff --git a/tests/test_genetic_warm_start_alignment.py b/tests/test_genetic_warm_start_alignment.py new file mode 100644 index 00000000..3055f02c --- /dev/null +++ b/tests/test_genetic_warm_start_alignment.py @@ -0,0 +1,140 @@ +"""A warm start from an earlier run is aligned with the slot this run starts in. + +Genomes are run-relative: gene 0 controls the slot the run starts in. Reusing +the previous solution unchanged after a slot boundary describes every decision +one slot too late, and a search that keeps the seed postpones a planned action +by one slot per run. +""" + +from types import SimpleNamespace + +import pytest + +from akkudoktoreos.config.config import ConfigEOS +from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization +from akkudoktoreos.optimization.genetic.geneticparams import ( + GeneticEnergyManagementParameters, + GeneticOptimizationParameters, +) +from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime + + +def _optimizer( + config_eos: ConfigEOS, + *, + interval: int, + optimize_ev: bool = False, + n_appliance_genes: int = 0, +) -> tuple[GeneticOptimization, DateTime]: + config_eos.merge_settings_from_dict( + { + "prediction": {"hours": 48}, + "optimization": {"tail_horizon_hours": 0, "horizon_hours": 24, "interval": interval}, + } + ) + slot0 = get_ems(init=True).set_start_datetime(to_datetime().set(hour=8, minute=0, second=0)) + opt = GeneticOptimization(fixed_seed=42) + opt.optimize_ev = optimize_ev + opt.appliance_layout = SimpleNamespace(n_genes=n_appliance_genes, genes=[]) # type: ignore[assignment] + opt._slot0_datetime = slot0 + return opt, slot0 + + +def _genes(start: int, stop: int) -> list[float]: + return [float(value) for value in range(start, stop)] + + +def test_quarter_hour_warm_start_moves_one_slot_forward(config_eos: ConfigEOS): + # 07:45 run: export genes (32) at 08:00 and 08:15. Unshifted, the 08:00 run + # would read them as 08:15 and 08:30. + opt, slot0 = _optimizer(config_eos, interval=900) + previous = [19.0, 32.0, 32.0, 14.0] + [36.0] * (opt.control_slots - 4) + + aligned = opt._start_solution_for_run_start(previous, slot0.subtract(minutes=15)) + + assert aligned == [32, 32, 14] + [36] * (opt.control_slots - 3) + + +def test_elapsed_slots_are_dropped_per_block_and_appliance_genes_kept(config_eos: ConfigEOS): + opt, slot0 = _optimizer(config_eos, interval=3600, optimize_ev=True, n_appliance_genes=1) + slots = opt.control_slots + battery = _genes(0, slots) + ev = _genes(100, 100 + slots) + appliance = [3.0] + + aligned = opt._start_solution_for_run_start(battery + ev + appliance, slot0.subtract(hours=3)) + + assert aligned == ( + _genes(3, slots) + + [slots - 1.0] * 3 + + _genes(103, 100 + slots) + + [100 + slots - 1.0] * 3 + + appliance + ) + + +def test_same_slot_or_unknown_start_keeps_warm_start(config_eos: ConfigEOS): + opt, slot0 = _optimizer(config_eos, interval=900) + previous = _genes(0, opt.control_slots) + + assert opt._start_solution_for_run_start(previous, slot0) == previous + assert opt._start_solution_for_run_start(previous, None) == previous + assert opt._start_solution_for_run_start(None, slot0) is None + + +@pytest.mark.parametrize( + "offset_minutes", + [ + pytest.param(-24 * 60, id="all-control-slots-elapsed"), + pytest.param(15, id="starts-after-this-run"), + ], +) +def test_unusable_warm_start_is_dropped(config_eos: ConfigEOS, offset_minutes: int): + opt, slot0 = _optimizer(config_eos, interval=900) + previous = _genes(0, opt.control_slots) + + assert opt._start_solution_for_run_start(previous, slot0.add(minutes=offset_minutes)) is None + + +def test_start_datetime_falls_back_to_last_solution_of_this_server( + config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch +): + opt, slot0 = _optimizer(config_eos, interval=900) + last_start = slot0.subtract(minutes=15) + monkeypatch.setattr( + type(get_ems()), + "_genetic_solution", + SimpleNamespace(start_solution=[19.0, 32.0, 14.0], start_solution_datetime=last_start), + ) + + same = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=None) + other = SimpleNamespace(start_solution=[19, 32, 15], start_solution_datetime=None) + explicit_start = slot0.subtract(minutes=30) + explicit = SimpleNamespace(start_solution=[19, 32, 14], start_solution_datetime=explicit_start) + + assert opt._resolve_start_solution_datetime(same) == last_start # type: ignore[arg-type] + assert opt._resolve_start_solution_datetime(other) is None # type: ignore[arg-type] + assert opt._resolve_start_solution_datetime(explicit) == explicit_start # type: ignore[arg-type] + + +def test_parameters_accept_iso_start_solution_datetime(config_eos: ConfigEOS): + parameters = GeneticOptimizationParameters( + ems=GeneticEnergyManagementParameters( + pv_prognose_wh=[0.0, 0.0], + strompreis_euro_pro_wh=[0.0, 0.0], + einspeiseverguetung_euro_pro_wh=0.0, + preis_euro_pro_wh_akku=0.0, + gesamtlast=[0.0, 0.0], + ), + pv_akku=None, + inverter=None, + eauto=None, + start_solution=[1.0, 2.0], + start_solution_datetime="2026-09-14T07:45:00+02:00", # type: ignore[arg-type] + ) + + assert parameters.start_solution_datetime is not None + assert compare_datetimes( + parameters.start_solution_datetime, to_datetime("2026-09-14T07:45:00+02:00") + ).equal