mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 15:26:38 +00:00
fix(optimization): shift the warm start by the slots elapsed since it was computed
A planned battery export kept moving 15 minutes later with every run. At 07:49 the plan exported at 08:00 and 08:15; the run at 08:00 exported at 08:15 and 08:30, although nothing in the forecasts had changed. Genomes are run-relative: gene 0 controls the slot the run starts in. Clients such as Node-RED send the previous `start_solution` back unchanged, so after a slot boundary every decision in it is read one slot too late. The warm start is seeded as exact copies and local neighbours, and when an export 15 minutes later scores almost the same, the shifted genome survives and becomes the next warm start. The internal energy management run reused its last solution the same way. Solutions now carry `start_solution_datetime`, the start of the slot gene 0 controls, and requests accept it back. Before seeding, the battery and EV blocks drop the elapsed slots and repeat their last gene. A warm start that starts after the run, or whose control slots have all elapsed, is ignored. When a request omits the datetime but its `start_solution` equals the last solution of this server, that solution's start is used, so existing clients are fixed without changes. Appliance genes index per-run start slot lists that cannot be rebuilt for the earlier run and stay as they are, validated as before.
This commit is contained in:
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user