Files
EOS/tests/test_homeappliance.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

541 lines
20 KiB
Python

"""Tests for flexible consumers (home appliances).
Covers the energy-preserving load profile, allowed start computation, the
appliance genome layout (ONCE/DAILY), multi-device scheduling and the
deprecated single-appliance compatibility path.
"""
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.devices.genetic.homeappliance import (
HomeAppliance,
resample_power_to_slot_energy,
)
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time
ems_eos = get_ems(init=True)
def _appliance(prediction_hours: int, slot_duration_h: float, **params) -> HomeAppliance:
return HomeAppliance(
HomeApplianceParameters(**params),
optimization_hours=prediction_hours,
prediction_hours=prediction_hours,
slot_duration_h=slot_duration_h,
)
def _ems(n: int, load: float = 500.0) -> dict:
return {
"pv_prognose_wh": [0.0] * n,
"strompreis_euro_pro_wh": [0.0003] * n,
"einspeiseverguetung_euro_pro_wh": 0.00007,
"preis_euro_pro_wh_akku": 0.0001,
"gesamtlast": [load] * n,
}
# --------------------------------------------------------------------------- #
# Energy-preserving resampling
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"input_interval, slot_interval",
[(3600, 900), (900, 3600), (600, 900), (1200, 900), (1800, 900), (3600, 3600)],
)
def test_resample_conserves_energy(input_interval: int, slot_interval: int):
"""Energy is conserved for integer and non-integer interval ratios."""
power = [1000.0, 0.0, 500.0, 2500.0, 750.0]
energy = resample_power_to_slot_energy(power, input_interval, slot_interval)
expected = sum(p * input_interval / 3600 for p in power)
assert energy.sum() == pytest.approx(expected)
assert (energy >= 0).all()
def test_flat_fallback_hourly_matches_legacy():
"""The flat consumption_wh/duration_h fallback reproduces the legacy curve."""
appliance = _appliance(48, 1.0, device_id="dw", consumption_wh=2000, duration_h=2)
assert appliance.run_slots == 2
assert list(appliance.run_energy_wh) == [1000.0, 1000.0]
appliance.build_load_curve([5])
curve = appliance.get_load_curve()
assert curve[5] == 1000.0 and curve[6] == 1000.0
assert curve.sum() == 2000.0
def test_flat_fallback_15min_grid():
"""The flat fallback resamples onto the quarter-hour grid, conserving energy."""
appliance = _appliance(192, 0.25, device_id="dw", consumption_wh=2000, duration_h=2)
assert appliance.run_slots == 8 # 2 h -> 8 quarter-hours
assert appliance.run_energy_wh.sum() == pytest.approx(2000.0)
assert all(value == pytest.approx(250.0) for value in appliance.run_energy_wh)
def test_build_load_curve_overlapping_runs_add():
"""Overlapping runs of one appliance sum their per-slot energy."""
appliance = _appliance(
10, 1.0, device_id="d", load_profile_power_w=[3600.0, 3600.0], load_profile_interval_seconds=3600
)
appliance.build_load_curve([2, 3]) # runs occupy [2,3] and [3,4] -> overlap at 3
curve = appliance.get_load_curve()
assert curve[2] == pytest.approx(3600.0)
assert curve[3] == pytest.approx(7200.0)
assert curve[4] == pytest.approx(3600.0)
# --------------------------------------------------------------------------- #
# Allowed start slots and time windows
# --------------------------------------------------------------------------- #
def test_allowed_start_slots_time_window_and_horizon():
"""Only starts whose full run fits a window and the horizon are allowed."""
slot0 = to_datetime("2026-07-15 00:00:00")
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("3 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
# window 10:00-13:00, run 1 h -> starts 10,11,12 each day (+24 on day 1)
assert allowed == [10, 11, 12, 34, 35, 36]
def test_allowed_start_slots_window_over_midnight():
"""A window crossing midnight yields starts on both sides of midnight."""
slot0 = to_datetime("2026-07-15 00:00:00")
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("23:00"), duration=to_duration("3 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
# 23:00-02:00 window: a run starting at 23:00 crosses midnight (ends 00:00).
# TimeWindow evaluates the window on the start's own calendar day, so the
# only allowed start per day is 23:00 (slot 23 on day 0, slot 47 on day 1).
assert allowed == [23, 47]
def test_allowed_start_slots_weekday_restriction():
"""A weekday-restricted window only allows starts on that weekday."""
slot0 = to_datetime("2026-07-15 00:00:00") # Wednesday
weekday = slot0.day_of_week
windows = TimeWindowSequence(
windows=[
TimeWindow(
start_time=to_time("10:00"), duration=to_duration("2 hours"), day_of_week=weekday
)
]
)
appliance = _appliance(
72, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=72
)
# Only day 0 (the Wednesday) matches: starts 10, 11
assert allowed == [10, 11]
# --------------------------------------------------------------------------- #
# Genome layout (ONCE / DAILY)
# --------------------------------------------------------------------------- #
def _optimizer(config_eos, *, prediction_hours: int, horizon_hours: int, interval: int, hour: int):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": prediction_hours},
"optimization": {"tail_horizon_hours": 0, "horizon_hours": horizon_hours, "interval": interval},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=hour, minute=0))
return GeneticOptimization(fixed_seed=1)
def test_once_layout_single_gene(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10)
slot0 = opt.ems.start_datetime
appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=2)
layout = opt._build_appliance_layout([appliance], slot0)
assert layout.n_genes == 1
assert layout.genes[0].run_date is None
assert layout.genes[0].allowed_start_slots[0] == 0
def test_once_no_valid_start_raises(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=10, interval=3600, hour=10)
slot0 = opt.ems.start_datetime
# 02:00 window is in the past (start slot 10) and day 1 is beyond the 10 h horizon.
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("02:00"), duration=to_duration("1 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=500, duration_h=1, time_windows=windows
)
with pytest.raises(ValueError, match="no valid start"):
opt._build_appliance_layout([appliance], slot0)
def test_daily_layout_one_gene_per_calendar_day(config_eos):
opt = _optimizer(config_eos, prediction_hours=72, horizon_hours=72, interval=3600, hour=0)
slot0 = opt.ems.start_datetime
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))]
)
appliance = _appliance(
72,
1.0,
device_id="d",
consumption_wh=500,
duration_h=1,
schedule_mode="DAILY",
time_windows=windows,
)
layout = opt._build_appliance_layout([appliance], slot0)
assert layout.n_genes == 3 # 3 calendar days in the 72 h horizon
assert len({gene.run_date for gene in layout.genes}) == 3
for gene in layout.genes:
assert len(gene.allowed_start_slots) == 2 # starts 10 and 11 on each day
def test_daily_layout_partial_first_day(config_eos):
"""A partial first day (start after the window) produces no gene for that day."""
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=14)
slot0 = opt.ems.start_datetime
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))]
)
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=500,
duration_h=1,
schedule_mode="DAILY",
time_windows=windows,
)
layout = opt._build_appliance_layout([appliance], slot0)
# Day 0 window (10:00-12:00) is already in the past at start hour 14 -> days 1 and 2 are inside the 48 hours from now.
assert layout.n_genes == 2
assert all(slot >= 0 for slot in layout.genes[0].allowed_start_slots)
# --------------------------------------------------------------------------- #
# Multiple devices, aggregate and deprecated compatibility (integration)
# --------------------------------------------------------------------------- #
def test_multiple_appliances_scheduled_and_aggregate(config_eos):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"tail_horizon_hours": 0,
"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()
parameters = GeneticOptimizationParameters(
ems=_ems(48),
pv_akku=None,
inverter=None,
eauto=None,
home_appliances=[
HomeApplianceParameters(device_id="dw", consumption_wh=1000, duration_h=1),
HomeApplianceParameters(device_id="wm", consumption_wh=2000, duration_h=2),
],
)
solution = GeneticOptimization(fixed_seed=7).optimierung_ems(
parameters=parameters, start_hour=0, ngen=3
)
per_device = solution.result.home_appliance_energy_wh
assert set(per_device) == {"dw", "wm"}
assert sum(per_device["dw"]) == pytest.approx(1000.0)
assert sum(per_device["wm"]) == pytest.approx(2000.0)
# Per-device energy sums exactly to the deprecated aggregate.
aggregate = [
(per_device["dw"][i] or 0.0) + (per_device["wm"][i] or 0.0)
for i in range(len(per_device["dw"]))
]
reported = [value or 0.0 for value in solution.result.Home_appliance_wh_per_hour]
assert reported == pytest.approx(aggregate)
# Each device has an absolute start datetime.
assert set(solution.appliance_starts) == {"dw", "wm"}
assert len(solution.appliance_starts["dw"]) == 1
# DDBC instructions are only emitted on RUN/OFF transitions.
plan = solution.energy_management_plan()
dw_instructions = [i for i in plan.instructions if i.resource_id == "dw"]
modes = [str(i.operation_mode_id) for i in dw_instructions]
# A single 1 h run yields an OFF/RUN/OFF sequence (no repeated RUN).
assert modes.count("RUN") == 1
def test_duplicate_device_id_rejected():
with pytest.raises(ValidationError, match="unique"):
GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
home_appliances=[
HomeApplianceParameters(device_id="x", consumption_wh=1000, duration_h=1),
HomeApplianceParameters(device_id="x", consumption_wh=1000, duration_h=1),
],
)
def test_dishwasher_and_home_appliances_conflict_rejected():
with pytest.raises(ValidationError, match="either"):
GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
dishwasher=HomeApplianceParameters(device_id="d", consumption_wh=1000, duration_h=1),
home_appliances=[
HomeApplianceParameters(device_id="e", consumption_wh=1000, duration_h=1)
],
)
def test_deprecated_dishwasher_maps_to_list():
parameters = GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
dishwasher=HomeApplianceParameters(device_id="d", consumption_wh=1000, duration_h=1),
)
resolved = parameters.resolved_home_appliances()
assert [appliance.device_id for appliance in resolved] == ["d"]
def test_max_home_appliances_is_upper_bound():
with pytest.raises(ValidationError, match="exceeds max_home_appliances"):
DevicesCommonSettings(
max_home_appliances=1,
home_appliances=[
{"device_id": "a", "consumption_wh": 1000, "duration_h": 1},
{"device_id": "b", "consumption_wh": 1000, "duration_h": 1},
],
)
def test_start_solution_layout_mismatch_is_ignored(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10)
slot0 = opt.ems.start_datetime
appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=1)
opt.appliance_layout = opt._build_appliance_layout([appliance], slot0)
opt.optimize_ev = False
valid_index_count = len(opt.appliance_layout.genes[0].allowed_start_slots)
# A tail index beyond the allowed range must be rejected.
bad_solution = [0] * opt.control_slots + [valid_index_count + 5]
assert opt._start_solution_matches_layout(bad_solution) is False
good_solution = [0] * opt.control_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
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=2000,
duration_h=2,
deadline_datetime=slot0.set(hour=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": {"tail_horizon_hours": 0,
"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}