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,
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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