diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 7d757cc7..53674757 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -180,7 +180,7 @@ class EnergyManagement( genetic0_seed: Optional[int] = None, force_enable: Optional[bool] = False, force_update: Optional[bool] = False, - ) -> None: + ) -> Optional[GeneticSolution | Genetic0Solution]: """Run the energy management. This method initializes the energy management run by setting its @@ -224,7 +224,9 @@ class EnergyManagement( even if a cached version is still valid. 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: if mode is None: @@ -234,7 +236,7 @@ class EnergyManagement( raise ValueError(f"Unknown energy management mode {mode}.") if mode == EnergyManagementMode.DISABLED: logger.info("Energy management run disabled.") - return + return None logger.info("Starting energy management run.") @@ -277,7 +279,7 @@ class EnergyManagement( if mode == EnergyManagementMode.PREDICTION: logger.info("Energy management run done (predictions updated)") EnergyManagement._stage = EnergyManagementStage.IDLE - return + return None # --- Optimization --- EnergyManagement._stage = EnergyManagementStage.OPTIMIZATION @@ -287,6 +289,8 @@ class EnergyManagement( if algorithm is None: algorithm = self.config.optimization.algorithm + run_solution: GeneticSolution | Genetic0Solution + # --- GENETIC algorithm --- if algorithm == OptimizationAlgorithm.GENETIC: # Prepare optimization parameters @@ -300,7 +304,7 @@ class EnergyManagement( "Could not prepare optimisation parameters." ) EnergyManagement._stage = EnergyManagementStage.IDLE - return + return None # Take values from config if not given 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: logger.exception(f"{algorithm}: Energy management optimization failed.") 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 - - # Make optimization solution public - EnergyManagement._optimization_solution = ( - await genetic_solution.optimization_solution() - ) - - # Make plan public - EnergyManagement._plan = genetic_solution.energy_management_plan() + EnergyManagement._optimization_solution = optimization_solution + EnergyManagement._plan = plan + run_solution = genetic_solution logger.debug( "{}: Energy management genetic solution:\n{}", @@ -366,7 +369,7 @@ class EnergyManagement( "Could not prepare optimisation parameters." ) EnergyManagement._stage = EnergyManagementStage.IDLE - return + return None # Take values from config if not given 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: logger.exception(f"{algorithm}: Energy management optimization failed.") 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 - - # Make optimization solution public - EnergyManagement._optimization_solution = ( - await genetic0_solution.optimization_solution() - ) - - # Make plan public - EnergyManagement._plan = genetic0_solution.energy_management_plan() + EnergyManagement._optimization_solution = optimization_solution + EnergyManagement._plan = plan + run_solution = genetic0_solution logger.debug( "{}: Energy management genetic solution:\n{}", @@ -422,7 +424,7 @@ class EnergyManagement( else: logger.error(f"Unknown optimization algorithm: '{algorithm}'. Skipping.") EnergyManagement._stage = EnergyManagementStage.IDLE - return + return None optimization_duration = to_datetime() - optimization_start logger.info( @@ -459,3 +461,4 @@ class EnergyManagement( # energy management run finished EnergyManagement._stage = EnergyManagementStage.IDLE + return run_solution diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 258ff2ba..5a02375c 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -2197,7 +2197,7 @@ async def fastapi_optimize( start_datetime = to_datetime().set(hour=start_hour) # 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, mode=EnergyManagementMode.OPTIMIZATION, algorithm=OptimizationAlgorithm.GENETIC0, @@ -2205,7 +2205,6 @@ async def fastapi_optimize( genetic0_generations=ngen, ) - solution = get_ems().genetic0_solution() if solution is None: raise EOSProblem( status=404, diff --git a/tests/test_optimize_run_result.py b/tests/test_optimize_run_result.py new file mode 100644 index 00000000..e4662787 --- /dev/null +++ b/tests/test_optimize_run_result.py @@ -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()