feat(optimization): port tested terminal and tail value primitives

Source d2e2d58237. 22 primitive tests pass; integration with the optimizer, forecast horizon and API is still pending.

Co-authored-by: Andreas <drbacke@gmx.de>

Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
Andreas
2026-09-16 12:17:26 +02:00
co-authored by Christin
parent c0796e1b8f
commit f70a786d7c
5 changed files with 1033 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
"""Economic tail scenarios and hard control/forecast boundaries."""
from unittest.mock import patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.config.config import SettingsEOSDefaults
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import Inverter
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.devices.genetic.inverter import InverterParameters
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
bat = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=1000,
max_charge_power_w=power,
charging_efficiency=efficiency,
discharging_efficiency=efficiency,
initial_soc_percentage=50,
levelized_cost_of_storage_kwh=lcos,
charge_rates=[0, 0.5, 1],
),
prediction_hours=1,
)
inv = Inverter(
InverterParameters(
device_id="inverter1",
battery_id="battery1",
max_power_wh=export_power,
dc_to_ac_efficiency=1,
ac_to_dc_efficiency=1,
max_ac_charge_power_w=ac_limit,
),
battery=bat,
)
return bat, inv
def curve(
prices=(-0.1, 0.3),
tariffs=(0, 0.3),
direct=True,
continuation=None,
load=None,
pv=None,
**kwargs,
):
bat, inv = devices(**kwargs)
return build_tail_value_curve(
battery=bat,
inverter=inv,
prices_euro_per_wh=np.array(prices) / 1000,
feed_in_euro_per_wh=np.array(tariffs) / 1000,
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
continuation=continuation or TerminalValueCurve(),
charge_rates=[0.5, 1],
export_rates=[1],
direct_marketing=direct,
)
def test_headroom_has_value_and_empty_state_can_earn():
c = curve()
assert c.value(0) == pytest.approx(0.4)
tail, continuation = c.component_values(0)
assert tail == pytest.approx(0.4)
assert continuation == pytest.approx(0.0)
assert c.value(0) == pytest.approx(tail + continuation)
assert c.value(500) > c.value(1000)
assert any(v < 0 for v in c.marginal_euro_per_kwh)
def test_chronology_changes_arbitrage():
forward = curve()
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
assert forward.value(0) > reverse.value(0)
def test_discharge_and_ac_power_limits():
limited = curve(prices=(1,), tariffs=(1,), power=100)
assert limited.value(1000) == pytest.approx(0.1)
limited_ac = curve(ac_limit=100)
assert limited_ac.value(0) == pytest.approx(0.04)
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
assert limited_inverter.value(1000) == pytest.approx(0.05)
def test_losses_and_lcos_reduce_arbitrage():
ideal = curve(prices=(0.1, 0.3))
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
assert 0 < lossy.value(0) < ideal.value(0)
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
def test_no_battery_export_without_permission():
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
def test_pv_surplus_can_be_stored_for_local_load():
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
assert c.value(0) == pytest.approx(0)
# Without PV the same empty battery must buy energy to serve the load.
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
0
) < c.value(0)
def test_continuation_survives_tail_end():
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
assert c.value(1000) == pytest.approx(0.2)
tail, continuation_credit = c.component_values(1000)
assert tail == pytest.approx(0.0)
assert continuation_credit == pytest.approx(0.2)
def test_tail_diagnostic_plan_explains_the_selected_path():
c = curve()
plan = c.diagnostic_plan(0, control_horizon_hours=24)
assert len(plan) == 2
assert plan[0].hour_from_start == 24
assert plan[0].action == "GRID_CHARGE"
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
assert plan[0].grid_import_wh > 0
assert plan[1].action == "BATTERY_EXPORT"
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
assert plan[1].grid_export_wh > 0
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
def test_provider_values_are_not_extrapolated():
from types import SimpleNamespace
start = to_datetime("2026-09-05T00:00:00Z")
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
provider = SimpleNamespace(key_to_series=lambda *a, **kw: series)
result = bounded_forecast_array(
provider,
key="price",
start_datetime=start,
end_datetime=start.add(hours=3),
interval=to_duration("15 minutes"),
)
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
assert np.isnan(result[8:]).all()
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
bat, inv = devices()
inv.parameters.ac_to_dc_efficiency = 0
c = build_tail_value_curve(
battery=bat,
inverter=inv,
prices_euro_per_wh=np.array([-0.001, 0.001]),
feed_in_euro_per_wh=np.array([0.0, 0.001]),
load_wh=np.zeros(2),
pv_wh=np.zeros(2),
continuation=TerminalValueCurve(),
charge_rates=[1],
export_rates=[1],
direct_marketing=True,
)
assert c.value(0) == pytest.approx(0)
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
def test_missing_provider_key_stays_missing():
from types import SimpleNamespace
def unavailable(*a, **kw):
raise KeyError("price unavailable")
start = to_datetime("2026-09-05T00:00:00Z")
result = bounded_forecast_array(
SimpleNamespace(key_to_series=unavailable),
key="price",
start_datetime=start,
end_datetime=start.add(hours=2),
interval=to_duration("1 hour"),
)
assert np.isnan(result).all()
assert len(result) == 2
+127
View File
@@ -0,0 +1,127 @@
"""Tests for the concave terminal value of the energy left in the battery."""
import numpy as np
import pytest
from akkudoktoreos.optimization.genetic.terminalvalue import (
build_terminal_value_curve,
trailing_window,
)
def _curve(**overrides):
"""Two expensive slots, one cheap one, no PV, 10 kWh of usable battery."""
params = dict(
prices_euro_per_wh=np.array([0.0004, 0.0003, 0.0001]),
load_wh=np.array([1000.0, 1000.0, 1000.0]),
pv_wh=np.array([0.0, 0.0, 0.0]),
feed_in_euro_per_wh=np.array([0.00008, 0.00008, 0.00008]),
max_energy_wh=10000.0,
lcos_euro_per_kwh=0.0,
dc_to_ac_efficiency=1.0,
grid_export_allowed=False,
)
params.update(overrides)
return build_terminal_value_curve(**params)
def test_marginal_value_follows_the_most_expensive_hours_first():
"""The first stored kWh replaces the most expensive slot, then the next."""
curve = _curve()
# 0.40, 0.30 and 0.10 EUR/kWh, in that order.
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.3, 0.1])
assert curve.energy_wh == pytest.approx([0.0, 1000.0, 2000.0, 3000.0])
assert curve.value_euro == pytest.approx([0.0, 0.4, 0.7, 0.8])
def test_curve_is_concave_and_saturates():
"""Marginal values only decrease, and beyond the last breakpoint nothing is added."""
curve = _curve()
marginals = curve.marginal_euro_per_kwh
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
# The residual load of the window is 3 kWh - more energy replaces nothing.
assert curve.value(3000.0) == pytest.approx(0.8)
assert curve.value(9000.0) == pytest.approx(0.8)
def test_value_interpolates_within_a_segment():
"""Half of the first slot is worth half of the first segment."""
curve = _curve()
assert curve.value(500.0) == pytest.approx(0.2)
def test_pv_reduces_the_residual_load():
"""Only load that PV cannot cover can be replaced by stored energy."""
curve = _curve(pv_wh=np.array([600.0, 1000.0, 0.0]))
# Slot 0 keeps 400 Wh, slot 1 is fully covered by PV, slot 2 keeps 1000 Wh.
assert curve.energy_wh == pytest.approx([0.0, 400.0, 1400.0])
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.1])
def test_lcos_is_subtracted_from_the_marginal_value():
"""Storage cost is already charged on discharge and must not be credited twice."""
curve = _curve(lcos_euro_per_kwh=0.05, dc_to_ac_efficiency=1.0)
assert curve.marginal_euro_per_kwh == pytest.approx([0.35, 0.25, 0.05])
def test_negative_prices_do_not_create_value():
"""Storing energy for an hour that pays nothing is not worth anything."""
curve = _curve(prices_euro_per_wh=np.array([0.0004, -0.0001, 0.0]))
assert curve.marginal_euro_per_kwh == pytest.approx([0.4])
assert curve.value(5000.0) == pytest.approx(0.4)
def test_export_tail_only_with_direct_marketing():
"""Surplus beyond the residual load is worth an export - if export is allowed."""
without = _curve(grid_export_allowed=False)
with_export = _curve(grid_export_allowed=True)
assert without.value(10000.0) == pytest.approx(0.8)
# 7 kWh beyond the residual load at the median feed-in tariff of 0.08 EUR/kWh.
assert with_export.value(10000.0) == pytest.approx(0.8 + 7.0 * 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():
"""A battery smaller than the residual load ends the curve early."""
curve = _curve(max_energy_wh=1500.0)
assert curve.energy_wh[-1] == pytest.approx(1500.0)
assert curve.value(5000.0) == pytest.approx(0.4 + 0.5 * 0.3)
def test_empty_window_yields_an_empty_curve():
"""Without data there is no curve, and no credit."""
curve = build_terminal_value_curve(
prices_euro_per_wh=np.zeros(0),
load_wh=np.zeros(0),
pv_wh=np.zeros(0),
feed_in_euro_per_wh=np.zeros(0),
max_energy_wh=10000.0,
)
assert curve.energy_wh == []
assert curve.value(5000.0) == 0.0
def test_trailing_window_takes_the_end_of_the_horizon():
values = np.arange(10, dtype=float)
assert list(trailing_window(values, end_slot=8, window_slots=3)) == [5.0, 6.0, 7.0]
# A window longer than the horizon yields what there is.
assert list(trailing_window(values, end_slot=2, window_slots=5)) == [0.0, 1.0]
assert list(trailing_window(None, end_slot=8, window_slots=3)) == []