mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
feat(optimization): report the knee of the terminal value curve
Everything up to the knee is backed by residual load, everything beyond it only by an export at the median feed-in tariff - the two halves have very different confidence. The breakpoint list alone does not say where the split is, so consumers had to guess it from the segment lengths.
This commit is contained in:
@@ -510,6 +510,8 @@ be unreachable. `deadline_policy` decides what happens then:
|
|||||||
- `credited_euro`: the credit applied to the total balance
|
- `credited_euro`: the credit applied to the total balance
|
||||||
- `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.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
|
- `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
|
- `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
|
configured `FIXED` from an `AUTO` run that found no priced residual load in its window - the
|
||||||
|
|||||||
@@ -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(
|
window_slots: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
json_schema_extra={
|
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
|
# 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
|
# tariff rather than the best one: exporting all of it in the single best
|
||||||
# slot is not something the horizon can promise.
|
# slot is not something the horizon can promise.
|
||||||
|
residual_energy_wh = cumulative_energy
|
||||||
if grid_export_allowed and cumulative_energy < max_energy_wh:
|
if grid_export_allowed and cumulative_energy < max_energy_wh:
|
||||||
positive_feed_in = [
|
positive_feed_in = [
|
||||||
float(value) for value in feed_in_euro_per_wh[:window] if float(value) > 0.0
|
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,
|
energy_wh=energy_points,
|
||||||
value_euro=value_points,
|
value_euro=value_points,
|
||||||
marginal_euro_per_kwh=marginals,
|
marginal_euro_per_kwh=marginals,
|
||||||
|
residual_energy_wh=residual_energy_wh,
|
||||||
window_slots=window,
|
window_slots=window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,19 @@ def test_export_tail_only_with_direct_marketing():
|
|||||||
assert with_export.marginal_euro_per_kwh[-1] == pytest.approx(0.08)
|
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():
|
def test_curve_is_capped_by_the_usable_battery_energy():
|
||||||
"""A battery smaller than the residual load ends the curve early."""
|
"""A battery smaller than the residual load ends the curve early."""
|
||||||
curve = _curve(max_energy_wh=1500.0)
|
curve = _curve(max_energy_wh=1500.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user