fix: return only completed optimization results per run

This commit is contained in:
Andreas
2026-09-17 13:53:10 +02:00
committed by Andreas
parent 1e0579f6b6
commit cfa245b41a
3 changed files with 343 additions and 29 deletions
+30 -27
View File
@@ -180,7 +180,7 @@ class EnergyManagement(
genetic0_seed: Optional[int] = None, genetic0_seed: Optional[int] = None,
force_enable: Optional[bool] = False, force_enable: Optional[bool] = False,
force_update: Optional[bool] = False, force_update: Optional[bool] = False,
) -> None: ) -> Optional[GeneticSolution | Genetic0Solution]:
"""Run the energy management. """Run the energy management.
This method initializes the energy management run by setting its This method initializes the energy management run by setting its
@@ -224,7 +224,9 @@ class EnergyManagement(
even if a cached version is still valid. even if a cached version is still valid.
Returns: Returns:
None The native optimization solution produced by this run, or None when
no optimization completed. Previous successful results remain available
through the solution getters after an aborted run.
""" """
async with EnergyManagement._run_lock: async with EnergyManagement._run_lock:
if mode is None: if mode is None:
@@ -234,7 +236,7 @@ class EnergyManagement(
raise ValueError(f"Unknown energy management mode {mode}.") raise ValueError(f"Unknown energy management mode {mode}.")
if mode == EnergyManagementMode.DISABLED: if mode == EnergyManagementMode.DISABLED:
logger.info("Energy management run disabled.") logger.info("Energy management run disabled.")
return return None
logger.info("Starting energy management run.") logger.info("Starting energy management run.")
@@ -277,7 +279,7 @@ class EnergyManagement(
if mode == EnergyManagementMode.PREDICTION: if mode == EnergyManagementMode.PREDICTION:
logger.info("Energy management run done (predictions updated)") logger.info("Energy management run done (predictions updated)")
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
# --- Optimization --- # --- Optimization ---
EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION
@@ -287,6 +289,8 @@ class EnergyManagement(
if algorithm is None: if algorithm is None:
algorithm = self.config.optimization.algorithm algorithm = self.config.optimization.algorithm
run_solution: GeneticSolution | Genetic0Solution
# --- GENETIC algorithm --- # --- GENETIC algorithm ---
if algorithm == OptimizationAlgorithm.GENETIC: if algorithm == OptimizationAlgorithm.GENETIC:
# Prepare optimization parameters # Prepare optimization parameters
@@ -300,7 +304,7 @@ class EnergyManagement(
"Could not prepare optimisation parameters." "Could not prepare optimisation parameters."
) )
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
# Take values from config if not given # Take values from config if not given
if genetic_generations is None: if genetic_generations is None:
@@ -331,21 +335,20 @@ class EnergyManagement(
), ),
) )
# Build all representations before publishing any of them.
optimization_solution = await genetic_solution.optimization_solution()
plan = genetic_solution.energy_management_plan()
except Exception: except Exception:
logger.exception(f"{algorithm}: Energy management optimization failed.") logger.exception(f"{algorithm}: Energy management optimization failed.")
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
# Make genetic solution public # Publish a consistent set without yielding to another coroutine.
EnergyManagement._genetic_solution = genetic_solution EnergyManagement._genetic_solution = genetic_solution
EnergyManagement._optimization_solution = optimization_solution
# Make optimization solution public EnergyManagement._plan = plan
EnergyManagement._optimization_solution = ( run_solution = genetic_solution
await genetic_solution.optimization_solution()
)
# Make plan public
EnergyManagement._plan = genetic_solution.energy_management_plan()
logger.debug( logger.debug(
"{}: Energy management genetic solution:\n{}", "{}: Energy management genetic solution:\n{}",
@@ -366,7 +369,7 @@ class EnergyManagement(
"Could not prepare optimisation parameters." "Could not prepare optimisation parameters."
) )
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
# Take values from config if not given # Take values from config if not given
if genetic0_generations is None: if genetic0_generations is None:
@@ -397,21 +400,20 @@ class EnergyManagement(
), ),
) )
# Build all representations before publishing any of them.
optimization_solution = await genetic0_solution.optimization_solution()
plan = genetic0_solution.energy_management_plan()
except Exception: except Exception:
logger.exception(f"{algorithm}: Energy management optimization failed.") logger.exception(f"{algorithm}: Energy management optimization failed.")
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
# Make genetic0 solution public # Publish a consistent set without yielding to another coroutine.
EnergyManagement._genetic0_solution = genetic0_solution EnergyManagement._genetic0_solution = genetic0_solution
EnergyManagement._optimization_solution = optimization_solution
# Make optimization solution public EnergyManagement._plan = plan
EnergyManagement._optimization_solution = ( run_solution = genetic0_solution
await genetic0_solution.optimization_solution()
)
# Make plan public
EnergyManagement._plan = genetic0_solution.energy_management_plan()
logger.debug( logger.debug(
"{}: Energy management genetic solution:\n{}", "{}: Energy management genetic solution:\n{}",
@@ -422,7 +424,7 @@ class EnergyManagement(
else: else:
logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.") logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.")
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return return None
optimization_duration = to_datetime() - optimization_start optimization_duration = to_datetime() - optimization_start
logger.info( logger.info(
@@ -459,3 +461,4 @@ class EnergyManagement(
# energy management run finished # energy management run finished
EnergyManagement._stage = EnergyManagementStage.IDLE EnergyManagement._stage = EnergyManagementStage.IDLE
return run_solution
+1 -2
View File
@@ -2197,7 +2197,7 @@ async def fastapi_optimize(
start_datetime = to_datetime().set(hour=start_hour) start_datetime = to_datetime().set(hour=start_hour)
# Ensure there is only one optimization/ energy management run at a time # Ensure there is only one optimization/ energy management run at a time
await get_ems().run( solution = await get_ems().run(
start_datetime=start_datetime, start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION, mode=EnergyManagementMode.OPTIMIZATION,
algorithm=OptimizationAlgorithm.GENETIC0, algorithm=OptimizationAlgorithm.GENETIC0,
@@ -2205,7 +2205,6 @@ async def fastapi_optimize(
genetic0_generations=ngen, genetic0_generations=ngen,
) )
solution = get_ems().genetic0_solution()
if solution is None: if solution is None:
raise EOSProblem( raise EOSProblem(
status=404, status=404,
+312
View File
@@ -0,0 +1,312 @@
"""Offline regression tests for per-run optimization results and atomic publication."""
from asyncio import Lock
from types import SimpleNamespace
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.genetic0params import (
Genetic0OptimizationParameters,
)
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.utils.datetimeutil import to_datetime
@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",
"_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(
ems=SimpleNamespace(mode=EnergyManagementMode.OPTIMIZATION),
optimization=SimpleNamespace(
algorithm=OptimizationAlgorithm.GENETIC,
genetic=SimpleNamespace(generations=3, seed=17),
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("selection", ["configured", "explicit"])
@pytest.mark.parametrize("supplied", [False, True])
async def test_optimization_routes_only_selected_algorithm(
monkeypatch, offline_ems, algorithm, selection, supplied
):
"""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
monkeypatch.setattr(ems_module, prefix + "Optimization", constructor)
prepare = AsyncMock(return_value=sentinel_parameters)
preparers[prefix] = prepare
monkeypatch.setattr(
getattr(ems_module, prefix + "OptimizationParameters"), "prepare", prepare
)
kwargs = {"start_datetime": to_datetime("2026-09-16T10:00:00+02:00")}
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
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
)
selected.return_value.optimize_ems.assert_called_once_with(
start_hour=10,
parameters=sentinel_parameters,
ngen=7 if supplied else expected_config.generations,
)
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()]
monkeypatch.setattr(ems_module, "GeneticOptimization", constructors[0])
monkeypatch.setattr(ems_module, "Genetic0Optimization", 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()
monkeypatch.setattr(ems_module, prefix + "Optimization", 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
monkeypatch.setattr(ems_module, "Genetic0Optimization", 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
monkeypatch.setattr(ems_module, prefix + "Optimization", constructor)
result = await cls.run(
offline_ems,
algorithm=OptimizationAlgorithm(prefix.upper()),
**{suffix + "_parameters": object()},
)
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()