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.
This commit is contained in:
Andreas
2026-09-04 11:23:35 +02:00
parent 1c10ab83ad
commit 3074018bed
4 changed files with 56 additions and 4 deletions
+3
View File
@@ -511,6 +511,9 @@ be unreachable. `deadline_policy` decides what happens then:
- `curve.energy_wh` / `curve.value_euro`: breakpoints of the value curve - `curve.energy_wh` / `curve.value_euro`: breakpoints of the value curve
- `curve.marginal_euro_per_kwh`: slope of each segment, monotonically decreasing - `curve.marginal_euro_per_kwh`: slope of each segment, monotonically decreasing
- `curve.window_slots`: how many trailing horizon slots the curve was derived from - `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 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 is the normal self-consumption mode: within a coarse optimization slot, the battery may cover
@@ -682,6 +682,9 @@ class GeneticOptimization(OptimizationBase):
# Concave value of the energy left in the battery at the end of the # 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. # horizon. None means the fixed scalar terminal value is used instead.
self._terminal_value_curve: Optional[TerminalValueCurve] = None 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.verbose = verbose
self.fix_seed = fixed_seed self.fix_seed = fixed_seed
self.optimize_ev = True self.optimize_ev = True
@@ -838,13 +841,16 @@ class GeneticOptimization(OptimizationBase):
The curve, or None when the fixed scalar terminal value applies. The curve, or None when the fixed scalar terminal value applies.
""" """
if battery is None: if battery is None:
self._terminal_value_reason = "no battery in this optimization"
return None return None
try: try:
mode = self.config.optimization.terminal_value_mode mode = self.config.optimization.terminal_value_mode
window_hours = self.config.optimization.terminal_value_window_hours window_hours = self.config.optimization.terminal_value_window_hours
except Exception: except Exception:
self._terminal_value_reason = "terminal value configuration unavailable"
return None return None
if str(mode) != "AUTO": if str(mode) != "AUTO":
self._terminal_value_reason = "terminal_value_mode is FIXED"
return None return None
dc_to_ac = inverter.dc_to_ac_efficiency if inverter else 1.0 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, grid_export_allowed=self.optimize_battery_grid_export,
) )
if curve.energy_wh: if curve.energy_wh:
self._terminal_value_reason = ""
logger.debug( logger.debug(
"Terminal value curve: {} segments, first {:.3f} EUR/kWh, last {:.3f} EUR/kWh, " "Terminal value curve: {} segments, first {:.3f} EUR/kWh, last {:.3f} EUR/kWh, "
"knee at {:.0f} Wh.", "knee at {:.0f} Wh.",
@@ -884,6 +891,16 @@ class GeneticOptimization(OptimizationBase):
curve.marginal_euro_per_kwh[-1], curve.marginal_euro_per_kwh[-1],
curve.energy_wh[-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 return curve
def _terminal_value( def _terminal_value(
@@ -899,7 +916,7 @@ class GeneticOptimization(OptimizationBase):
""" """
battery = self.simulation.battery battery = self.simulation.battery
if battery is None: 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. # Usable DC energy, converted to the AC energy that can serve a load.
energy_wh = battery.current_energy_content() energy_wh = battery.current_energy_content()
@@ -921,6 +938,7 @@ class GeneticOptimization(OptimizationBase):
mode="FIXED", mode="FIXED",
battery_energy_wh=energy_wh, battery_energy_wh=energy_wh,
credited_euro=credit, credited_euro=credit,
reason=getattr(self, "_terminal_value_reason", "") or "terminal_value_mode is FIXED",
) )
def _build_appliance_layout( def _build_appliance_layout(
@@ -108,6 +108,17 @@ class TerminalValueResult(PydanticBaseModel):
"description": "The value curve the credit was read from; None in FIXED mode." "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( def build_terminal_value_curve(
+23 -3
View File
@@ -1,7 +1,7 @@
import json import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Optional
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -381,7 +381,9 @@ def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
assert soc_per_hour[6] >= 60.0 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. """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 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)) ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear() CacheEnergyManagementStore().clear()
prices = [0.0004] * (hours - 2) + [0.00002] * 2 if prices is None:
prices = [0.0004] * (hours - 2) + [0.00002] * 2
parameters = GeneticOptimizationParameters( parameters = GeneticOptimizationParameters(
ems={ ems={
"pv_prognose_wh": [0.0] * hours, "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. # The credit is the curve evaluated at the energy left in the battery.
expected = curve.value(solution.terminal_value.battery_energy_wh) expected = curve.value(solution.terminal_value.battery_energy_wh)
assert solution.terminal_value.credited_euro == pytest.approx(expected) 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"