mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
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:
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user