feat(optimization): deadlines for consumers and EV, graded grid export

Three related scheduling improvements, all opt-in and behaviour-preserving
when the new fields are not set.

Flexible consumers get absolute time bounds next to the recurring
time_windows: earliest_start_datetime and deadline_datetime, where the
deadline requires the complete run to have *finished* before that moment
("clean dishes by 03:00 tonight"). When no start can meet it,
deadline_policy decides between BEST_EFFORT (run as early as possible, so
the delay rather than the cost is minimized) and STRICT (keep the
deadline; a ONCE consumer then fails the optimization). The solution
reports appliance_deadline_missed per device.

The EV charging target can be given the same kind of deadline, as an
absolute min_soc_deadline_datetime and/or a relative min_soc_max_duration_h
("full in 6 hours"), the earlier of the two winning. The ev_soc_miss
penalty is then evaluated at that slot instead of at the end of the
horizon, and the seeding heuristic only proposes charge slots before it.

Battery-to-grid export under direct marketing is no longer all-or-nothing:
grid_export_rates configures the selectable export levels as a factor of
the rated discharge power (default [0.25, 0.5, 0.75, 1.0]). Each rate is
its own optimizer state, with the full-power state keeping its previous
index so existing seeds and heuristics are unaffected. The chosen level
per slot is reported in battery_grid_export_factor and as the
GRID_SUPPORT_EXPORT operation factor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Andreas
2026-09-03 17:53:33 +02:00
co-authored by Claude Opus 5
parent 8926cc7ae0
commit f24d9ea0eb
20 changed files with 1469 additions and 39 deletions
+40
View File
@@ -1,6 +1,8 @@
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.devices.devices import BatteriesCommonSettings
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
@@ -343,3 +345,41 @@ def test_quarter_hour_discharge_calls_share_one_power_budget():
battery.reset()
assert battery.discharged_energy_wh(0) == 0.0
def test_grid_export_rates_are_sorted_and_deduplicated():
"""Export rates are normalized like the charge rates."""
settings = BatteriesCommonSettings(
device_id="battery1", grid_export_rates=[1.0, 0.5, 0.5, 0.25]
)
assert list(settings.grid_export_rates) == [0.25, 0.5, 1.0]
def test_grid_export_rates_default_and_override():
"""None falls back to the defaults; [1.0] restores all-or-nothing export."""
assert list(BatteriesCommonSettings(device_id="battery1").grid_export_rates) == [
0.25,
0.5,
0.75,
1.0,
]
assert list(
BatteriesCommonSettings(device_id="battery1", grid_export_rates=None).grid_export_rates
) == [0.25, 0.5, 0.75, 1.0]
assert list(
BatteriesCommonSettings(device_id="battery1", grid_export_rates=[1.0]).grid_export_rates
) == [1.0]
@pytest.mark.parametrize("rates", [[0.0, 0.5], [1.5], [-0.25], []])
def test_grid_export_rates_reject_invalid_values(rates):
"""0.0 is not an export level, and rates above the rated power are rejected."""
with pytest.raises(ValidationError):
BatteriesCommonSettings(device_id="battery1", grid_export_rates=rates)
def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery):
"""The rate reference is the rated discharge energy of one slot."""
battery = setup_pv_battery
expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency
assert battery.rated_discharge_energy_wh() == pytest.approx(expected)
+179 -1
View File
@@ -54,7 +54,9 @@ def test_direct_marketing_uses_market_price_as_feed_in_tariff(config_eos: Config
"gesamtlast": [0.0, 0.0],
},
pv_akku=None,
inverter=None,
# Without an inverter the simulation books no grid energy at all, so the
# price signal would never reach the fitness.
inverter={"device_id": "inverter1", "max_power_wh": 20000},
eauto=None,
)
@@ -86,6 +88,63 @@ def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS):
"""Configured export rates end up as per-slot export levels in the solution."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 24},
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"genetic": {"individuals": 40, "generations": 10},
},
"feedintariff": {"direct_marketing_enabled": True},
"devices": {
"max_batteries": 1,
"batteries": [{"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}],
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
hours = 24
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
"strompreis_euro_pro_wh": [0.0003] * hours,
# A pronounced tariff peak makes exporting worthwhile at all.
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [200.0] * hours,
},
pv_akku={
"device_id": "battery1",
"capacity_wh": 10000,
"initial_soc_percentage": 100,
"min_soc_percentage": 0,
"max_charge_power_w": 5000,
},
inverter={
"device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1",
},
eauto=None,
)
optimization = GeneticOptimization(fixed_seed=42)
solution = optimization.optimierung_ems(parameters=parameters, start_hour=0, ngen=3)
# Full power first, so the full-power state keeps the lowest export index.
assert optimization.bat_possible_grid_export_values == [1.0, 0.5]
assert len(solution.battery_grid_export_factor) == len(solution.battery_grid_export_allowed)
assert set(solution.battery_grid_export_factor) <= {0.0, 0.5, 1.0}
assert [
1 if factor > 0.0 else 0 for factor in solution.battery_grid_export_factor
] == solution.battery_grid_export_allowed
@pytest.mark.parametrize(
"fn_in, fn_out, ngen, break_even",
[
@@ -201,3 +260,122 @@ def test_optimize(
# Check the correct generic energy management plan is created
plan = genetic_solution.energy_management_plan()
# @TODO
def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParameters:
"""Optimization parameters with an EV that has to be charged."""
return GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
# Expensive for the first six hours, dirt cheap afterwards: without a
# deadline the optimizer would always wait for the cheap slots.
"strompreis_euro_pro_wh": [0.0009] * 6 + [0.00001] * (hours - 6),
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [300.0] * hours,
},
pv_akku=None,
inverter=None,
eauto={
"device_id": "ev1",
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"max_charge_power_w": 11040,
"initial_soc_percentage": 20,
"min_soc_percentage": 60,
**ev_extra,
},
)
def test_ev_deadline_slot_resolution(config_eos: ConfigEOS):
"""Datetime and maximum duration resolve to a slot; the earlier one wins."""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
optimization = GeneticOptimization(fixed_seed=1)
optimization._slot0_datetime = optimization.ems.start_datetime.set(
hour=0, minute=0, second=0, microsecond=0
)
slot0 = optimization._slot0_datetime
# Duration only: 6 h after the start hour 10.
parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6)
assert optimization._ev_deadline_slot(parameters) == 16
# Datetime only.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14))
assert optimization._ev_deadline_slot(parameters) == 14
# Both: the earlier one wins.
parameters = _ev_deadline_parameters(
48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6
)
assert optimization._ev_deadline_slot(parameters) == 16
# Beyond the horizon: no deadline, the end-of-horizon target already covers it.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100))
assert optimization._ev_deadline_slot(parameters) is None
# In the past: due right now.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=2))
assert optimization._ev_deadline_slot(parameters) == optimization._start_day_slot()
# No deadline at all.
assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None
def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS):
"""With a deadline the penalty checks the SoC at that slot, not at the end."""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
optimization = GeneticOptimization(fixed_seed=1)
simulation_result = {"EAuto_SoC_pro_Stunde": [20.0, 35.0, 50.0, 80.0]}
class _Ev:
def current_soc_percentage(self):
return 80.0
optimization.simulation.ev = _Ev()
# Without a deadline the final SoC counts.
optimization._ev_soc_deadline_slot = None
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
# With one, the SoC at the beginning of the deadline slot counts.
optimization._ev_soc_deadline_slot = 12
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 50.0
# A deadline beyond the reported slots falls back to the final SoC.
optimization._ev_soc_deadline_slot = 99
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
"""The EV reaches its target before the deadline even when energy is cheaper later."""
hours = 24
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": hours},
"optimization": {
"horizon_hours": hours,
"interval": 3600,
"genetic": {"individuals": 100, "generations": 40},
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
parameters = _ev_deadline_parameters(hours, min_soc_max_duration_h=6)
solution = GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=0, ngen=40
)
soc_per_hour = solution.result.EAuto_SoC_pro_Stunde
# Slot 6 is the first slot at or after the deadline, so its start-of-slot SoC
# is what the target is checked against.
assert soc_per_hour[6] >= 60.0
+14
View File
@@ -528,6 +528,20 @@ def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos):
assert simulation.battery.current_soc_percentage() == 50.0
def test_direct_marketing_grid_export_rate_limits_exported_energy(config_eos):
"""A partial export level exports that share of the rated discharge power."""
simulation = _direct_marketing_battery_export_simulation(config_eos)
assert simulation.bat_grid_export_hours is not None
# 500 W rated discharge power over a one hour slot -> 500 Wh at rate 1.0.
simulation.bat_grid_export_hours[0] = 0.5
result = simulation.simulate(start_hour=0)
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(250.0)
assert simulation.battery is not None
assert simulation.battery.current_soc_percentage() == 75.0
def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos):
simulation = _direct_marketing_battery_export_simulation(
config_eos,
+52
View File
@@ -74,3 +74,55 @@ def test_decode_charge_discharge_has_self_consumption_state_after_legacy_export(
assert dc_charge.tolist() == [1]
assert discharge.tolist() == [1]
assert battery_grid_export.tolist() == [0]
def test_graded_grid_export_states_decode_to_rates():
"""Each configured export rate gets its own state; state 5 stays full power."""
optimization = GeneticOptimization()
optimization.bat_possible_charge_values = [1.0]
optimization.bat_possible_grid_export_values = [1.0, 0.5, 0.25]
optimization.optimize_dc_charge = True
optimization.optimize_battery_grid_export = True
layout = optimization._battery_state_layout()
assert layout.grid_export_states == (5, 6, 7)
# The full-power state keeps its index, so existing seeds stay valid.
assert layout.grid_export_state == 5
assert layout.self_consumption_state == 8
assert layout.total_states == 9
_, _, _, battery_grid_export = optimization.decode_charge_discharge(
np.array([0, 5, 6, 7])
)
assert battery_grid_export.tolist() == [0.0, 1.0, 0.5, 0.25]
def test_single_export_rate_keeps_all_or_nothing_layout():
"""Without configured rates the state space is the one from before grading."""
optimization = GeneticOptimization()
optimization.bat_possible_charge_values = [1.0]
optimization.optimize_dc_charge = True
optimization.optimize_battery_grid_export = True
layout = optimization._battery_state_layout()
assert layout.grid_export_states == (5,)
assert layout.total_states == 7
def test_battery_grid_export_factor_becomes_operation_factor(config_eos):
"""A partial export level is reported as the GRID_SUPPORT_EXPORT factor."""
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
solution = GeneticSolution.model_construct()
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
ac_charge=0.0,
dc_charge=0.0,
discharge_allowed=False,
battery_grid_export_allowed=True,
battery_grid_export_factor=0.25,
)
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
assert operation_mode_factor == 0.25
+185
View File
@@ -353,3 +353,188 @@ def test_start_solution_layout_mismatch_is_ignored(config_eos):
assert opt._start_solution_matches_layout(bad_solution) is False
good_solution = [0] * opt.total_slots + [0]
assert opt._start_solution_matches_layout(good_solution) is True
# --------------------------------------------------------------------------- #
# Absolute bounds: earliest start and deadline
# --------------------------------------------------------------------------- #
def test_deadline_limits_starts_to_completed_runs():
"""A run has to be finished at (not just started before) the deadline."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=2000,
duration_h=2,
deadline_datetime=slot0.add(hours=10),
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
# 2 h run, deadline 10:00 -> last start 08:00
assert allowed == list(range(0, 9))
assert appliance.deadline_relaxed is False
def test_deadline_on_quarter_hour_grid():
"""Deadlines are honoured slot-exact on a sub-hourly grid."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48 * 4,
0.25,
device_id="d",
load_profile_power_w=[1000.0, 1000.0, 1000.0],
load_profile_interval_seconds=900,
deadline_datetime=slot0.add(hours=3),
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48 * 4
)
# 45 min run, deadline 03:00 (slot 12) -> last start slot 9 (02:15-03:00)
assert allowed[-1] == 9
def test_earliest_start_datetime_limits_starts():
"""An absolute earliest start pushes the first allowed slot back."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=1000,
duration_h=1,
earliest_start_datetime=slot0.add(hours=20),
deadline_datetime=slot0.add(hours=27),
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
assert allowed == list(range(20, 27))
def test_deadline_best_effort_runs_as_early_as_possible():
"""An unreachable BEST_EFFORT deadline schedules the run with minimal delay."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=2000,
duration_h=2,
# "now" is 12:00, so a 10:00 deadline can not be met any more.
deadline_datetime=slot0.add(hours=10),
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=12, horizon_end_slot=48
)
# Deadline already missed -> the only offered start is the earliest one.
assert allowed == [12]
assert appliance.deadline_relaxed is True
assert appliance.deadline_missed([12], slot0) is True
def test_deadline_strict_keeps_empty_result():
"""A STRICT deadline that can not be met yields no allowed start."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=2000,
duration_h=2,
deadline_datetime=slot0.add(hours=10),
deadline_policy="STRICT",
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=12, horizon_end_slot=48
)
assert allowed == []
assert appliance.deadline_relaxed is False
def test_deadline_strict_once_raises(config_eos):
"""A ONCE consumer with an unreachable STRICT deadline fails the layout."""
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=12)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=2000,
duration_h=2,
deadline_datetime=slot0.add(hours=10),
deadline_policy="STRICT",
)
with pytest.raises(ValueError, match="no valid start"):
opt._build_appliance_layout([appliance], slot0)
def test_deadline_scheduled_run_reported_as_kept(config_eos):
"""A met deadline is reported as not missed."""
slot0 = to_datetime("2026-07-15 00:00:00")
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=1000,
duration_h=1,
deadline_datetime=slot0.add(hours=10),
)
assert appliance.deadline_missed([9], slot0) is False
assert appliance.deadline_missed([], slot0) is True
def test_deadline_before_earliest_start_rejected():
with pytest.raises(ValidationError, match="must be after"):
HomeApplianceParameters(
device_id="d",
consumption_wh=1000,
duration_h=1,
earliest_start_datetime="2026-07-15 20:00:00",
deadline_datetime="2026-07-15 18:00:00",
)
def test_deadline_end_to_end_optimization(config_eos):
"""The optimizer only picks starts whose run finishes before the deadline."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"horizon_hours": 48,
"interval": 3600,
"genetic": {
"individuals": 60,
"generations": 10,
"penalties": {"ev_soc_miss": 10, "ac_charge_break_even": 0},
},
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
deadline = ems_eos.start_datetime.set(hour=0, minute=0).add(hours=8)
parameters = GeneticOptimizationParameters(
ems=_ems(48),
pv_akku=None,
inverter=None,
eauto=None,
home_appliances=[
HomeApplianceParameters(
device_id="dw",
consumption_wh=1000,
duration_h=2,
deadline_datetime=deadline,
)
],
)
solution = GeneticOptimization(fixed_seed=7).optimierung_ems(
parameters=parameters, start_hour=0, ngen=3
)
starts = solution.appliance_starts["dw"]
assert len(starts) == 1
# 2 h run has to be complete at the deadline.
assert starts[0].add(hours=2) <= deadline
assert solution.appliance_deadline_missed == {"dw": False}
+23
View File
@@ -15,6 +15,9 @@ def mock_battery() -> Mock:
mock_battery = Mock()
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
# Rated discharge energy of one slot - the reference a grid-export rate is
# applied to. Large enough to never bind at the default factor of 1.0.
mock_battery.rated_discharge_energy_wh = Mock(return_value=1e9)
mock_battery.parameters.device_id = "battery1"
return mock_battery
@@ -243,6 +246,26 @@ def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
def test_process_energy_grid_export_rate_limits_export(inverter, mock_battery):
"""An export rate caps the export at that share of the rated discharge power."""
mock_battery.max_charge_power_w = 300.0
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
mock_battery.rated_discharge_energy_wh.return_value = 300.0
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (150.0, 0.0)]
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation=0.0,
consumption=100.0,
hour=12,
allow_battery_grid_export=True,
battery_grid_export_factor=0.5,
)
# 0.5 * 300 Wh rated = 150 Wh, below the 200 Wh the battery could still give.
assert grid_export == pytest.approx(150.0)
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(150.0, 12)])
def test_process_energy_battery_empty(inverter, mock_battery):
# Battery is empty, so no energy can be discharged
mock_battery.discharge_energy.return_value = (0.0, 0.0)