Files
EOS/tests/test_tailvalue.py
T
Andreas a2f4ef6f54 feat(optimization): split the control horizon from the forecast tail
The optimizer treated the end of `optimization.horizon_hours` as the end of the
world: energy left in the battery there was worth a single configured price per
kWh, so it either dumped the battery into the last hours or hoarded it,
depending on that one number.

The horizon is now two spans. `horizon_hours` still receives every control
command. The new `optimization.tail_horizon_hours` (default 48 h) is a pure
lookahead that never produces a command. In AUTO terminal-value mode a
deterministic dynamic program solves that tail backwards on a 101-point SoC
grid using the production battery and inverter models - SoC bounds, power caps,
conversion losses, configured charge and export rates, direct-marketing
permission and LCOS on delivered DC energy - and the existing AUTO proxy
supplies the continuation value at the tail end. Genetic fitness reads the
resulting curve. `tail_horizon_hours: 0` restores the plain proxy at the control
end, FIXED is unchanged.

The forecast budget is reported, never enforced by refusal: a tail that does not
fit is shortened to what the forecast covers and reported as
`effective_tail_hours`, and a control horizon that does not fit is warned about
at configuration time and rejected by the optimizer at run time, which knows
which series ran out. `prediction.hours` defaults to 72 so the new defaults fit
out of the box; existing shorter configurations keep starting.

Control arrays and warm-start genomes now begin at the run timestamp rather than
midnight, flagged by `controls_start_at_now` so the adapters still read older
solutions. `forecast_interval_seconds` declares the resolution of shortened
native quarter-hour inputs.

Required forecasts are no longer silently replaced by demo providers. A missing
PV, price, load, feed-in or weather forecast used to rewrite the configured
provider and retry, so a run could quietly optimize against invented data.
Missing values now stay missing, and provider values are held only within their
own source interval instead of being extended indefinitely.

Also fixes a config update that could leave EOS half-updated: the merged
candidate is validated before the singleton is reinitialized.

Four provider tests that hard-coded the old 48 h prediction default are rewritten
to derive their expectations from the configured horizon.
2026-09-09 07:56:38 +02:00

434 lines
16 KiB
Python

"""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.optimization.genetic.geneticdevices import (
InverterParameters,
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_central_config_invariant():
# Only the control horizon is mandatory. A prediction horizon that cannot
# cover the requested tail shortens the tail instead of failing the run,
# so existing configurations keep starting after an upgrade.
short = SettingsEOSDefaults(
prediction={"hours": 48}, optimization={"horizon_hours": 24, "tail_horizon_hours": 48}
)
assert short.prediction.hours == 48
assert short.optimization.tail_horizon_hours == 48
# A control horizon the forecast cannot serve is not rejected here either -
# prediction.hours also serves callers that never optimize. The optimizer
# rejects the run itself, naming the series that ran out.
undersized = SettingsEOSDefaults(
prediction={"hours": 48}, optimization={"horizon_hours": 72, "tail_horizon_hours": 0}
)
assert undersized.optimization.horizon_hours == 72
settings = SettingsEOSDefaults()
assert settings.prediction.hours == 72
assert settings.optimization.tail_horizon_hours == 48
def setup_run(config, interval=3600, start_hour=0, hours=72, prediction_hours=72):
config.merge_settings_from_dict(
{
"prediction": {"hours": prediction_hours},
"optimization": {
"horizon_hours": 24,
"tail_horizon_hours": 48,
"interval": interval,
"visualize_pdf": False,
},
"feedintariff": {"direct_marketing_enabled": True},
}
)
ems = get_ems(init=True)
ems.set_start_datetime(to_datetime("2026-09-05T00:00:00").set(hour=start_hour))
bat, inv = devices()
params = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
"gesamtlast": [0.0] * hours,
"strompreis_euro_pro_wh": [0.0002] * hours,
"einspeiseverguetung_euro_pro_wh": [0.0001] * hours,
"preis_euro_pro_wh_akku": 0,
},
pv_akku=bat.parameters,
inverter=inv.parameters,
eauto=None,
)
return GeneticOptimization(fixed_seed=42), params
@pytest.mark.parametrize("interval", [3600, 900])
@pytest.mark.parametrize("start_hour", [0, 10])
def test_genome_output_and_final_control_state(config_eos, interval, start_hour):
opt, params = setup_run(config_eos, interval, start_hour, hours=72 + start_hour)
def choose(*args, **kwargs):
# Discharge only in the last control slot. Its POST-slot SOC is credited.
genome = opt.create_individual()
genome[:] = [0] * opt.control_end_slot
genome[-1] = opt._battery_state_layout().grid_export_state
assert len(genome) == 24 * (3600 // interval)
return genome, {}
with (
patch.object(opt, "optimize", side_effect=choose),
patch(
"akkudoktoreos.optimization.genetic.genetic.build_tail_value_curve",
wraps=build_tail_value_curve,
) as builder,
):
result = opt.optimierung_ems(params)
assert builder.call_count == 1
assert len(result.ac_charge) == opt.control_slots
assert len(result.dc_charge) == opt.control_slots
assert len(result.discharge_allowed) == opt.control_slots
assert len(result.battery_grid_export_factor) == opt.control_slots
assert len(result.result.Kosten_Euro_pro_Stunde) == opt.control_slots
assert result.terminal_value.mode == "TAIL"
assert result.terminal_value.battery_energy_wh == pytest.approx(
max(500 - 1000 * opt.slot_duration_h, 0)
)
assert result.terminal_value.effective_tail_hours == 48
assert result.terminal_value.credited_euro == pytest.approx(
result.terminal_value.tail_operating_euro + result.terminal_value.continuation_value_euro
)
assert result.terminal_value.continuation_curve is not None
assert result.terminal_value.tail_diagnostics is not None
assert result.terminal_value.tail_diagnostics.slots == 48 * (3600 // interval)
assert result.terminal_value.tail_diagnostics.soc_grid_points == 101
assert len(result.terminal_value.tail_plan) == result.terminal_value.tail_diagnostics.slots
assert result.terminal_value.tail_plan[0].hour_from_start == 24
assert len(result.terminal_value.curve.operating_value_euro) == 101
assert len(result.terminal_value.curve.continuation_value_euro) == 101
assert len(result.optimization_solution().solution.to_dataframe()) == opt.control_slots
def test_short_tail_is_reported(config_eos, caplog):
opt, params = setup_run(config_eos, hours=52)
with patch.object(
opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_end_slot, {})
):
result = opt.optimierung_ems(params)
assert result.terminal_value.effective_tail_hours == 28
assert "Tail forecast shortened" in caplog.text
assert result.terminal_value.reason
def test_missing_control_is_rejected(config_eos):
opt, params = setup_run(config_eos, hours=23)
with pytest.raises(ValueError, match="Incomplete control forecast"):
opt.optimierung_ems(params)
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_future_opportunity_changes_optimal_control_soc(config_eos):
final_energy = []
for negative_price in (0.5, -1.0):
opt, params = setup_run(config_eos, hours=3)
config_eos.merge_settings_from_dict(
{"optimization": {"horizon_hours": 1, "tail_horizon_hours": 2}}
)
params.ems.strompreis_euro_pro_wh = [0.0005, negative_price / 1000, 0.0003]
params.ems.einspeiseverguetung_euro_pro_wh = [0.00002, 0, 0.0003]
result = opt.optimierung_ems(params, ngen=3, individuals=20)
final_energy.append(result.terminal_value.battery_energy_wh)
assert final_energy[0] > final_energy[1]
@pytest.mark.parametrize("control", [24, 48])
def test_continuation_prevents_emptying_at_moved_boundary(config_eos, control):
opt, params = setup_run(config_eos, hours=96)
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 96},
"optimization": {"horizon_hours": control},
}
)
# Zero control load, with the same future local demand visible to both tails.
params.ems.gesamtlast[60] = 1000
params.ems.einspeiseverguetung_euro_pro_wh = [0.0] * 96
config_eos.feedintariff.direct_marketing_enabled = False
def choose(*a, **kw):
from deap import creator
idle = creator.Individual([0] * control)
discharge = creator.Individual([len(opt.bat_possible_charge_values)] * control)
assert opt.toolbox.evaluate(idle)[0] <= opt.toolbox.evaluate(discharge)[0]
return idle, {}
with patch.object(opt, "optimize", side_effect=choose):
result = opt.optimierung_ems(params)
assert result.terminal_value.battery_energy_wh == pytest.approx(500)
assert result.terminal_value.continuation_mode == "AUTO"
def test_missing_price_inside_tail_stops_at_first_gap(config_eos):
opt, params = setup_run(config_eos)
params.ems.strompreis_euro_pro_wh[30] = float("nan")
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
result = opt.optimierung_ems(params)
assert result.terminal_value.effective_tail_hours == 6
def test_ev_genome_and_output_are_control_only(config_eos):
from akkudoktoreos.optimization.genetic.geneticdevices import ElectricVehicleParameters
opt, params = setup_run(config_eos, interval=900, start_hour=10, hours=82)
params.eauto = ElectricVehicleParameters(
device_id="ev1",
capacity_wh=5000,
initial_soc_percentage=0,
min_soc_percentage=50,
charge_rates=[0, 0.5, 1],
)
def choose(*a, **kw):
genome = opt.create_individual()
assert len(genome) == 2 * 96
return genome, {}
with patch.object(opt, "optimize", side_effect=choose):
result = opt.optimierung_ems(params)
assert len(result.eautocharge_hours_float) == 96
def test_rejected_config_update_is_atomic(config_eos):
# The candidate is validated before the singleton is reinitialized, so a
# rejected update must leave the running configuration untouched rather
# than half-applied. `hours` is constrained to be non-negative.
before = config_eos.prediction.hours
with pytest.raises(ValueError):
config_eos.merge_settings_from_dict({"prediction": {"hours": -1}})
assert config_eos.prediction.hours == before
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_short_native_forecast_declares_its_resolution(config_eos):
opt, params = setup_run(config_eos, interval=900, hours=52 * 4)
params.forecast_interval_seconds = 900
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
result = opt.optimierung_ems(params)
assert result.terminal_value.effective_tail_hours == 28
@pytest.mark.parametrize(
"field,reason",
[
("strompreis_euro_pro_wh", "import price"),
("einspeiseverguetung_euro_pro_wh", "feed-in tariff"),
],
)
def test_differing_provider_lengths_use_the_common_tail(config_eos, field, reason):
opt, params = setup_run(config_eos)
setattr(params.ems, field, getattr(params.ems, field)[:52])
with patch.object(opt, "optimize", side_effect=lambda *a, **k: ([0] * opt.control_slots, {})):
result = opt.optimierung_ems(params)
assert result.terminal_value.effective_tail_hours == 28
assert reason in result.terminal_value.reason
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
def test_short_prediction_horizon_shortens_the_tail(config_eos):
# The forecast budget cannot serve the full 48 h tail. The run keeps going
# with the 12 h that are left after the control horizon instead of failing.
opt, params = setup_run(config_eos, hours=36, prediction_hours=36)
assert opt.control_slots == 24
assert opt.tail_slots == 12
result = opt.optimierung_ems(params, ngen=2)
assert result.terminal_value.mode == "TAIL"
assert result.terminal_value.requested_tail_hours == 48
assert result.terminal_value.effective_tail_hours == 12