mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
356 lines
14 KiB
Python
356 lines
14 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": {"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.set(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
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] == opt._start_day_slot()
|
||
|
|
|
||
|
|
|
||
|
|
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.set(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
# 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.set(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
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.set(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
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 -> only day 1.
|
||
|
|
assert layout.n_genes == 1
|
||
|
|
assert all(slot >= opt._start_day_slot() 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": {
|
||
|
|
"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.set(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
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.total_slots + [valid_index_count + 5]
|
||
|
|
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
|