mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
fix: return only completed optimization results per run
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user