Files
EOS/tests/test_optimize_run_result.py
T
3651d33915 perf: import heavy optional dependencies on first use (#1377)
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: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:40:43 +02:00

371 lines
16 KiB
Python

"""Offline regression tests for per-run optimization results and atomic publication."""
from asyncio import Lock
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from akkudoktoreos.core import ems as ems_module
from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic0 import genetic0 as genetic0_module
from akkudoktoreos.optimization.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.utils.datetimeutil import to_datetime
def _patch_optimizer(monkeypatch, prefix: str, constructor: Any) -> None:
"""Replace the optimizer class that EnergyManagement.run() instantiates.
GENETIC0 is imported on first use inside run(), so it is patched at its source.
"""
module = genetic0_module if prefix == "Genetic0" else ems_module
monkeypatch.setattr(module, prefix + "Optimization", constructor)
@pytest.fixture
def offline_ems(monkeypatch):
"""Run the actual EMS orchestration with no real adapters or prediction IO."""
cls = ems_module.EnergyManagement
for name in (
"_start_datetime",
"_observation_datetime",
"_last_run_datetime",
"_plan",
"_optimization_solution",
"_genetic_solution",
"_genetic0_solution",
):
monkeypatch.setattr(cls, name, None)
monkeypatch.setattr(cls, "_stage", ems_module.EnergyManagementStage.IDLE)
monkeypatch.setattr(cls, "_run_lock", Lock())
monkeypatch.setattr(ems_module, "CacheEnergyManagementStore", Mock())
return SimpleNamespace(
config=SimpleNamespace(
general=SimpleNamespace(timezone="Europe/Berlin"),
ems=SimpleNamespace(mode=EnergyManagementMode.OPTIMIZATION),
optimization=SimpleNamespace(
algorithm=OptimizationAlgorithm.GENETIC,
genetic=SimpleNamespace(generations=3, seed=17, interval_sec=3600, individuals=31),
genetic0=SimpleNamespace(generations=5, seed=29),
),
server=SimpleNamespace(verbose=False),
),
prediction=SimpleNamespace(update_data=AsyncMock()),
adapter=SimpleNamespace(update_data=AsyncMock()),
set_start_datetime=cls.set_start_datetime,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
@pytest.mark.parametrize("explicit_start", [False, True])
async def test_implicit_genetic_start_uses_site_timezone(
offline_ems, monkeypatch, set_other_timezone, algorithm, explicit_start
):
set_other_timezone("UTC")
now = to_datetime("2026-09-16T22:47:23Z", in_timezone="UTC")
def frozen_datetime(value=None, **kwargs):
return to_datetime(now if value is None else value, **kwargs)
monkeypatch.setattr(ems_module, "to_datetime", frozen_datetime)
offline_ems.config.optimization.genetic.interval_sec = 900
await ems_module.EnergyManagement.run(
offline_ems,
mode=EnergyManagementMode.PREDICTION,
algorithm=algorithm,
start_datetime=now if explicit_start else None,
)
start = ems_module.EnergyManagement._start_datetime
observed = ems_module.EnergyManagement._observation_datetime
assert start is not None and observed is not None
assert observed.timestamp() == now.timestamp()
if algorithm == OptimizationAlgorithm.GENETIC:
assert start.minute == 45
assert start.hour == (22 if explicit_start else 0)
assert start.day == (16 if explicit_start else 17)
assert start.timezone_name == ("UTC" if explicit_start else "Europe/Berlin")
else:
assert start.minute == 0
assert start.hour == 22
assert start.day == 16
assert start.timezone_name == "UTC"
@pytest.mark.asyncio
@pytest.mark.parametrize("algorithm", list(OptimizationAlgorithm))
@pytest.mark.parametrize("selection", ["configured", "explicit"])
@pytest.mark.parametrize("supplied", [False, True])
@pytest.mark.parametrize("timezone, expected_hour", [("UTC", 8), ("Europe/Berlin", 10)])
async def test_optimization_routes_only_selected_algorithm(
monkeypatch, offline_ems, algorithm, selection, supplied, timezone, expected_hour
):
"""Configuration selection and explicit overrides retain isolated async paths."""
selected_name = "Genetic" if algorithm == OptimizationAlgorithm.GENETIC else "Genetic0"
suffix = "genetic" if algorithm == OptimizationAlgorithm.GENETIC else "genetic0"
other_suffix = "genetic0" if suffix == "genetic" else "genetic"
sentinel_parameters = object()
sentinel_result, sentinel_plan = object(), object()
solution = SimpleNamespace(
optimization_solution=AsyncMock(return_value=sentinel_result),
energy_management_plan=Mock(return_value=sentinel_plan),
)
constructors = {}
preparers = {}
for prefix in ("Genetic", "Genetic0"):
constructor = Mock()
constructor.return_value.optimize_ems.return_value = solution
constructors[prefix] = constructor
_patch_optimizer(monkeypatch, prefix, constructor)
prepare = AsyncMock(return_value=sentinel_parameters)
preparers[prefix] = prepare
monkeypatch.setattr(
getattr(ems_module, prefix + "OptimizationParameters"), "prepare", prepare
)
kwargs: dict[str, Any] = {
"start_datetime": to_datetime("2026-09-16T10:00:00+02:00", in_timezone=timezone)
}
if selection == "configured":
offline_ems.config.optimization.algorithm = algorithm
else:
offline_ems.config.optimization.algorithm = OptimizationAlgorithm(other_suffix.upper())
kwargs["algorithm"] = algorithm
if supplied:
kwargs[suffix + "_parameters"] = sentinel_parameters
kwargs[suffix + "_generations"] = 7
kwargs[suffix + "_seed"] = 43
if algorithm == OptimizationAlgorithm.GENETIC:
kwargs["genetic_individuals"] = 11
run_result = await ems_module.EnergyManagement.run(offline_ems, **kwargs)
assert run_result is solution
selected = constructors[selected_name]
expected_config = getattr(offline_ems.config.optimization, suffix)
selected.assert_called_once_with(
verbose=False, fixed_seed=43 if supplied else expected_config.seed
)
expected_arguments: dict[str, Any] = {
"start_hour": expected_hour,
"parameters": sentinel_parameters,
"ngen": 7 if supplied else expected_config.generations,
}
if algorithm == OptimizationAlgorithm.GENETIC:
expected_arguments["individuals"] = 11 if supplied else None
selected.return_value.optimize_ems.assert_called_once_with(**expected_arguments)
other_name = "Genetic0" if selected_name == "Genetic" else "Genetic"
constructors[other_name].assert_not_called()
preparers[other_name].assert_not_awaited()
if supplied:
preparers[selected_name].assert_not_awaited()
else:
preparers[selected_name].assert_awaited_once_with()
solution.optimization_solution.assert_awaited_once_with()
solution.energy_management_plan.assert_called_once_with()
cls = ems_module.EnergyManagement
assert getattr(cls, "_" + suffix + "_solution") is solution
assert getattr(cls, "_" + other_suffix + "_solution") is None
assert cls.optimization_solution() is sentinel_result
assert cls.plan() is sentinel_plan
assert cls.stage() == ems_module.EnergyManagementStage.IDLE
offline_ems.prediction.update_data.assert_awaited_once_with(
force_enable=False, force_update=False
)
assert offline_ems.adapter.update_data.await_count == 2
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", [EnergyManagementMode.DISABLED, EnergyManagementMode.PREDICTION])
async def test_non_optimization_modes_never_optimize(monkeypatch, offline_ems, mode):
constructors = [Mock(), Mock()]
_patch_optimizer(monkeypatch, "Genetic", constructors[0])
_patch_optimizer(monkeypatch, "Genetic0", constructors[1])
offline_ems.config.ems.mode = mode
await ems_module.EnergyManagement.run(offline_ems)
for constructor in constructors:
constructor.assert_not_called()
assert offline_ems.prediction.update_data.await_count == (
mode == EnergyManagementMode.PREDICTION
)
assert offline_ems.adapter.update_data.await_count == (mode == EnergyManagementMode.PREDICTION)
assert ems_module.EnergyManagement.plan() is None
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", ["Genetic", "Genetic0"])
async def test_missing_preparation_does_not_dispatch_controls(monkeypatch, offline_ems, prefix):
constructor = Mock()
_patch_optimizer(monkeypatch, prefix, constructor)
prepare = AsyncMock(return_value=None)
monkeypatch.setattr(getattr(ems_module, prefix + "OptimizationParameters"), "prepare", prepare)
await ems_module.EnergyManagement.run(
offline_ems, algorithm=OptimizationAlgorithm(prefix.upper())
)
prepare.assert_awaited_once_with()
constructor.assert_not_called()
assert offline_ems.adapter.update_data.await_count == 1 # acquisition only
assert ems_module.EnergyManagement.plan() is None
assert ems_module.EnergyManagement.stage() == ems_module.EnergyManagementStage.IDLE
@pytest.mark.asyncio
@pytest.mark.parametrize("start_hour", [None, 11])
async def test_legacy_optimize_explicitly_uses_genetic0(monkeypatch, start_hour):
"""Even with GENETIC configured, /optimize must never become the new optimizer."""
from akkudoktoreos.server import eos
from akkudoktoreos.server.rest.error import EOSProblem
fake = SimpleNamespace(
run=AsyncMock(return_value=None), genetic0_solution=Mock(return_value=None)
)
monkeypatch.setattr(eos, "get_ems", lambda: fake)
parameters = Genetic0OptimizationParameters.model_validate(
{
"ems": {
"pv_prognose_wh": [0.0, 100.0],
"gesamtlast": [100.0, 100.0],
"strompreis_euro_pro_wh": [0.0003, 0.0003],
"einspeiseverguetung_euro_pro_wh": 0.00008,
"preis_euro_pro_wh_akku": 0.0,
},
"pv_akku": None,
"eauto": None,
"inverter": None,
}
)
with pytest.raises(EOSProblem):
await eos.fastapi_optimize(parameters=parameters, start_hour=start_hour, ngen=2)
kwargs = fake.run.await_args.kwargs
assert kwargs["mode"] == EnergyManagementMode.OPTIMIZATION
assert kwargs["algorithm"] == OptimizationAlgorithm.GENETIC0
assert kwargs["genetic0_parameters"] is parameters
assert kwargs["genetic0_generations"] == 2
assert "genetic_parameters" not in kwargs
assert (
kwargs["start_datetime"] is None
if start_hour is None
else kwargs["start_datetime"].hour == 11
)
@pytest.mark.asyncio
@pytest.mark.parametrize("phase", ["optimizer", "conversion", "plan"])
async def test_failed_legacy_http_run_never_reports_previous_solution(
monkeypatch, offline_ems, phase
):
import json
from pathlib import Path
from types import MethodType
from httpx import ASGITransport, AsyncClient
from akkudoktoreos.optimization.genetic0.genetic0solution import Genetic0Solution
from akkudoktoreos.server import eos
cls = ems_module.EnergyManagement
data = json.loads(
(Path(__file__).parent / "testdata/genetic0/optimize_result_1.json").read_text()
)
previous = Genetic0Solution.model_validate(data)
monkeypatch.setattr(cls, "_genetic0_solution", previous)
constructor = Mock()
error = RuntimeError("synthetic " + phase + " failure")
native = SimpleNamespace(
optimization_solution=AsyncMock(return_value=object()),
energy_management_plan=Mock(return_value=object()),
)
constructor.return_value.optimize_ems.return_value = native
if phase == "optimizer":
constructor.return_value.optimize_ems.side_effect = error
elif phase == "conversion":
native.optimization_solution.side_effect = error
else:
native.energy_management_plan.side_effect = error
_patch_optimizer(monkeypatch, "Genetic0", constructor)
offline_ems.run = MethodType(cls.run, offline_ems)
offline_ems.genetic0_solution = cls.genetic0_solution
monkeypatch.setattr(eos, "get_ems", lambda: offline_ems)
async with AsyncClient(
transport=ASGITransport(app=eos.app, raise_app_exceptions=False), base_url="http://test"
) as client:
response = await client.post("/optimize?ngen=1", json=data["parameters"])
constructor.return_value.optimize_ems.assert_called_once()
# This diagnostic proves a failure is the stale-success bug, not invalid input.
if response.status_code == 200:
assert response.json()["start_solution"] == previous.start_solution
assert response.json()["result"]["total_balance"] == previous.result.total_balance
assert response.status_code >= 400, (
"The failing optimizer returned HTTP 200 with the previous solution"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", ["Genetic", "Genetic0"])
@pytest.mark.parametrize("phase", ["conversion", "plan"])
async def test_conversion_failure_preserves_consistent_previous_results(
monkeypatch, offline_ems, prefix, phase
):
cls = ems_module.EnergyManagement
previous_specific, previous_generic, previous_plan = object(), object(), object()
suffix = prefix.lower()
monkeypatch.setattr(cls, "_" + suffix + "_solution", previous_specific)
monkeypatch.setattr(cls, "_optimization_solution", previous_generic)
monkeypatch.setattr(cls, "_plan", previous_plan)
conversion = AsyncMock(return_value=object())
solution = SimpleNamespace(optimization_solution=conversion, energy_management_plan=Mock())
if phase == "conversion":
conversion.side_effect = RuntimeError("synthetic conversion failure")
else:
solution.energy_management_plan.side_effect = RuntimeError("synthetic plan failure")
constructor = Mock()
constructor.return_value.optimize_ems.return_value = solution
_patch_optimizer(monkeypatch, prefix, constructor)
supplied_parameters: dict[str, Any] = {suffix + "_parameters": object()}
result = await cls.run(
offline_ems,
algorithm=OptimizationAlgorithm(prefix.upper()),
**supplied_parameters,
)
assert result is None
conversion.assert_awaited_once_with()
assert solution.energy_management_plan.call_count == (phase == "plan")
assert offline_ems.adapter.update_data.await_count == 1
assert (
getattr(cls, "_" + suffix + "_solution"),
cls.optimization_solution(),
cls.plan(),
cls.stage(),
) == (
previous_specific,
previous_generic,
previous_plan,
ems_module.EnergyManagementStage.IDLE,
), (
"Failed conversion published a new algorithm result beside the old plan and left EMS in OPTIMIZATION"
)
@pytest.mark.asyncio
async def test_legacy_endpoint_uses_run_return_value_not_last_cached_solution(monkeypatch):
import json
from pathlib import Path
from akkudoktoreos.optimization.genetic0.genetic0solution import Genetic0Solution
from akkudoktoreos.server import eos
data = json.loads(
(Path(__file__).parent / "testdata/genetic0/optimize_result_1.json").read_text()
)
produced = Genetic0Solution.model_validate(data)
fake = SimpleNamespace(
run=AsyncMock(return_value=produced),
genetic0_solution=Mock(side_effect=AssertionError("must use this run's result")),
)
monkeypatch.setattr(eos, "get_ems", lambda: fake)
result = await eos.fastapi_optimize(parameters=produced.parameters, ngen=1)
assert result.start_solution == produced.start_solution
assert result.result.total_balance == produced.result.total_balance
fake.genetic0_solution.assert_not_called()