mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
feat(optimization): schedule any number of flexible consumers
Replace the single hourly "dishwasher" home appliance with a list of flexible consumers (home_appliances). Each consumer defines its load either as an explicit power profile (energy-preservingly resampled onto the optimization slot grid, incl. 15-min and non-integer interval ratios) or the flat consumption_wh/duration_h fallback, and runs ONCE or DAILY within its time windows and the optimization horizon. - ConsumerScheduleMode + shared load-definition validation (XOR of profile/fallback, reject negative/NaN/inf, unique device_id) - ApplianceGeneLayout: variable appliance gene block (index into allowed_start_slots), ONCE/DAILY calendar-day based, no snapping - per-device output: result.home_appliance_energy_wh, appliance_starts (absolute local times), per-device solution columns and DDBC RUN/OFF instructions on state transitions only - deprecate dishwasher/washingstart/Home_appliance_wh_per_hour with backward-compatible mapping and explicit conflict rejection - max_home_appliances is now an upper bound only; no demo appliance and no on/off behaviour - docs, openapi.json, CHANGELOG and optimize_result_2* fixtures updated; new tests/test_homeappliance.py covers the mandatory test matrix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c59bf1b486
commit
67cf6f7d8a
@@ -0,0 +1,355 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user