From 3074018bedaa45f48770a097c97f8e48fb95e5ea Mon Sep 17 00:00:00 2001 From: Andreas Date: Fri, 4 Sep 2026 11:23:35 +0200 Subject: [PATCH] fix(optimization): say why the terminal value fell back to the fixed scalar A run whose price forecast is all zeros produces no priced residual load, so AUTO cannot derive a curve and quietly credits the request scalar instead. The reported mode was then "FIXED" - indistinguishable from a run actually configured that way. The solution now carries a reason, and the fallback is logged as a warning instead of passing unnoticed. --- docs/akkudoktoreos/optimpost.md | 3 +++ .../optimization/genetic/genetic.py | 20 +++++++++++++- .../optimization/genetic/terminalvalue.py | 11 ++++++++ tests/test_geneticoptimize.py | 26 ++++++++++++++++--- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/akkudoktoreos/optimpost.md b/docs/akkudoktoreos/optimpost.md index e1d5dd11..e6e76823 100644 --- a/docs/akkudoktoreos/optimpost.md +++ b/docs/akkudoktoreos/optimpost.md @@ -511,6 +511,9 @@ be unreachable. `deadline_policy` decides what happens then: - `curve.energy_wh` / `curve.value_euro`: breakpoints of the value curve - `curve.marginal_euro_per_kwh`: slope of each segment, monotonically decreasing - `curve.window_slots`: how many trailing horizon slots the curve was derived from + - `reason`: why that mode applied. Empty in `AUTO` mode. In `FIXED` mode it distinguishes a + configured `FIXED` from an `AUTO` run that found no priced residual load in its window - the + latter is nearly always an all-zero price forecast in the request. With direct marketing enabled, `dc_charge = 1` and `discharge_allowed = 1` may occur together. This is the normal self-consumption mode: within a coarse optimization slot, the battery may cover diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 14657d3d..d252878f 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -682,6 +682,9 @@ class GeneticOptimization(OptimizationBase): # Concave value of the energy left in the battery at the end of the # horizon. None means the fixed scalar terminal value is used instead. self._terminal_value_curve: Optional[TerminalValueCurve] = None + # Why that is - reported with the solution, because a run that silently + # falls back to the scalar looks exactly like a run configured for it. + self._terminal_value_reason: str = "" self.verbose = verbose self.fix_seed = fixed_seed self.optimize_ev = True @@ -838,13 +841,16 @@ class GeneticOptimization(OptimizationBase): The curve, or None when the fixed scalar terminal value applies. """ if battery is None: + self._terminal_value_reason = "no battery in this optimization" return None try: mode = self.config.optimization.terminal_value_mode window_hours = self.config.optimization.terminal_value_window_hours except Exception: + self._terminal_value_reason = "terminal value configuration unavailable" return None if str(mode) != "AUTO": + self._terminal_value_reason = "terminal_value_mode is FIXED" return None dc_to_ac = inverter.dc_to_ac_efficiency if inverter else 1.0 @@ -876,6 +882,7 @@ class GeneticOptimization(OptimizationBase): grid_export_allowed=self.optimize_battery_grid_export, ) if curve.energy_wh: + self._terminal_value_reason = "" logger.debug( "Terminal value curve: {} segments, first {:.3f} EUR/kWh, last {:.3f} EUR/kWh, " "knee at {:.0f} Wh.", @@ -884,6 +891,16 @@ class GeneticOptimization(OptimizationBase): curve.marginal_euro_per_kwh[-1], curve.energy_wh[-1], ) + else: + # Almost always an input problem: an all-zero price forecast, or a + # window whose load is fully covered by PV. Falling back to the + # scalar is quiet, so say it out loud. + self._terminal_value_reason = ( + "AUTO could not derive a curve: the last " + f"{window_slots} slots of the horizon carry no priced residual load " + "(check the electricity price forecast) - falling back to the fixed value" + ) + logger.warning(self._terminal_value_reason) return curve def _terminal_value( @@ -899,7 +916,7 @@ class GeneticOptimization(OptimizationBase): """ battery = self.simulation.battery if battery is None: - return 0.0, TerminalValueResult(mode="FIXED") + return 0.0, TerminalValueResult(mode="FIXED", reason="no battery in this optimization") # Usable DC energy, converted to the AC energy that can serve a load. energy_wh = battery.current_energy_content() @@ -921,6 +938,7 @@ class GeneticOptimization(OptimizationBase): mode="FIXED", battery_energy_wh=energy_wh, credited_euro=credit, + reason=getattr(self, "_terminal_value_reason", "") or "terminal_value_mode is FIXED", ) def _build_appliance_layout( diff --git a/src/akkudoktoreos/optimization/genetic/terminalvalue.py b/src/akkudoktoreos/optimization/genetic/terminalvalue.py index 1ee3d605..aed5c54a 100644 --- a/src/akkudoktoreos/optimization/genetic/terminalvalue.py +++ b/src/akkudoktoreos/optimization/genetic/terminalvalue.py @@ -108,6 +108,17 @@ class TerminalValueResult(PydanticBaseModel): "description": "The value curve the credit was read from; None in FIXED mode." }, ) + reason: str = Field( + default="", + json_schema_extra={ + "description": ( + "Why this mode applied. Empty in AUTO mode; in FIXED mode it " + "says whether FIXED was configured or whether AUTO fell back " + "because no curve could be derived." + ), + "examples": ["", "terminal_value_mode is FIXED"], + }, + ) def build_terminal_value_curve( diff --git a/tests/test_geneticoptimize.py b/tests/test_geneticoptimize.py index 9e16b38e..5ff11437 100644 --- a/tests/test_geneticoptimize.py +++ b/tests/test_geneticoptimize.py @@ -1,7 +1,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, Optional from unittest.mock import patch import pytest @@ -381,7 +381,9 @@ def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS): assert soc_per_hour[6] >= 60.0 -def _terminal_value_run(config_eos: ConfigEOS, mode: str) -> GeneticSolution: +def _terminal_value_run( + config_eos: ConfigEOS, mode: str, prices: Optional[list[float]] = None +) -> GeneticSolution: """48 h with expensive energy and two dirt-cheap slots at the very end. Charging in those last slots only pays off when the stored energy keeps a @@ -403,7 +405,8 @@ def _terminal_value_run(config_eos: ConfigEOS, mode: str) -> GeneticSolution: ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0)) CacheEnergyManagementStore().clear() - prices = [0.0004] * (hours - 2) + [0.00002] * 2 + if prices is None: + prices = [0.0004] * (hours - 2) + [0.00002] * 2 parameters = GeneticOptimizationParameters( ems={ "pv_prognose_wh": [0.0] * hours, @@ -469,3 +472,20 @@ def test_terminal_value_curve_is_concave_and_reported(config_eos: ConfigEOS): # The credit is the curve evaluated at the energy left in the battery. expected = curve.value(solution.terminal_value.battery_energy_wh) assert solution.terminal_value.credited_euro == pytest.approx(expected) + + +def test_terminal_value_reports_why_it_fell_back_to_fixed(config_eos: ConfigEOS): + """AUTO without any prices cannot build a curve - and has to say so. + + A request whose price forecast is all zeros used to be indistinguishable + from a run configured for FIXED. + """ + hours = 48 + solution = _terminal_value_run(config_eos, "AUTO", prices=[0.0] * hours) + + assert solution.terminal_value.mode == "FIXED" + assert solution.terminal_value.curve is None + assert "no priced residual load" in solution.terminal_value.reason + + configured = _terminal_value_run(config_eos, "FIXED") + assert configured.terminal_value.reason == "terminal_value_mode is FIXED"