diff --git a/docs/akkudoktoreos/optimpost.md b/docs/akkudoktoreos/optimpost.md index e6e76823..98cf490a 100644 --- a/docs/akkudoktoreos/optimpost.md +++ b/docs/akkudoktoreos/optimpost.md @@ -510,6 +510,8 @@ be unreachable. `deadline_policy` decides what happens then: - `credited_euro`: the credit applied to the total balance - `curve.energy_wh` / `curve.value_euro`: breakpoints of the value curve - `curve.marginal_euro_per_kwh`: slope of each segment, monotonically decreasing + - `curve.residual_energy_wh`: the knee - up to here the value is backed by residual load, + beyond it only by an export - `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 diff --git a/src/akkudoktoreos/optimization/genetic/terminalvalue.py b/src/akkudoktoreos/optimization/genetic/terminalvalue.py index aed5c54a..3a13b3ff 100644 --- a/src/akkudoktoreos/optimization/genetic/terminalvalue.py +++ b/src/akkudoktoreos/optimization/genetic/terminalvalue.py @@ -59,6 +59,15 @@ class TerminalValueCurve(PydanticBaseModel): ) }, ) + residual_energy_wh: float = Field( + default=0.0, + json_schema_extra={ + "description": ( + "Energy up to which the curve is backed by residual load - the " + "knee. Everything beyond it is only worth an export." + ) + }, + ) window_slots: int = Field( default=0, json_schema_extra={ @@ -196,6 +205,7 @@ def build_terminal_value_curve( # Everything beyond the residual load can only be sold. A median feed-in # tariff rather than the best one: exporting all of it in the single best # slot is not something the horizon can promise. + residual_energy_wh = cumulative_energy if grid_export_allowed and cumulative_energy < max_energy_wh: positive_feed_in = [ float(value) for value in feed_in_euro_per_wh[:window] if float(value) > 0.0 @@ -222,6 +232,7 @@ def build_terminal_value_curve( energy_wh=energy_points, value_euro=value_points, marginal_euro_per_kwh=marginals, + residual_energy_wh=residual_energy_wh, window_slots=window, ) diff --git a/tests/test_terminalvalue.py b/tests/test_terminalvalue.py index a82b2157..cdcce97f 100644 --- a/tests/test_terminalvalue.py +++ b/tests/test_terminalvalue.py @@ -85,6 +85,19 @@ def test_export_tail_only_with_direct_marketing(): assert with_export.marginal_euro_per_kwh[-1] == pytest.approx(0.08) +def test_residual_energy_marks_the_knee(): + """The knee separates load-backed value from the export tail.""" + without = _curve(grid_export_allowed=False) + with_export = _curve(grid_export_allowed=True) + + # 3 kWh of residual load in the window, whether or not export is allowed. + assert without.residual_energy_wh == pytest.approx(3000.0) + assert with_export.residual_energy_wh == pytest.approx(3000.0) + # Only the export tail reaches beyond it. + assert without.energy_wh[-1] == pytest.approx(3000.0) + assert with_export.energy_wh[-1] == pytest.approx(10000.0) + + def test_curve_is_capped_by_the_usable_battery_energy(): """A battery smaller than the residual load ends the curve early.""" curve = _curve(max_energy_wh=1500.0)