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:
Andreas
2026-07-15 14:19:46 +02:00
co-authored by Claude Opus 4.8
parent c59bf1b486
commit 67cf6f7d8a
21 changed files with 2505 additions and 1094 deletions
+20 -10
View File
@@ -221,7 +221,7 @@ def test_hourly_start_solution_is_expanded_to_slots(config_eos: ConfigEOS):
opt.optimize_ev = False
hourly = list(range(48))
migrated = opt._start_solution_for_slot_grid(hourly, has_appliance=False)
migrated = opt._start_solution_for_slot_grid(hourly)
assert len(migrated) == 192
assert migrated[:8] == [0, 0, 0, 0, 1, 1, 1, 1]
@@ -242,8 +242,8 @@ def test_quarter_hour_mutation_probability_preserves_hourly_rate(config_eos: Con
assert opt.toolbox.mutate_charge_discharge.keywords["indpb"] == pytest.approx(0.05)
def test_sub_hourly_home_appliance_is_rejected(config_eos: ConfigEOS):
"""An hourly appliance model must not silently run on slot indices."""
def test_sub_hourly_home_appliance_is_scheduled(config_eos: ConfigEOS):
"""A home appliance is scheduled on the 15-min slot grid and delivers its energy."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
@@ -252,18 +252,28 @@ def test_sub_hourly_home_appliance_is_rejected(config_eos: ConfigEOS):
)
parameters = load_hourly_parameters().model_copy(
update={
"dishwasher": HomeApplianceParameters(
device_id="dishwasher", consumption_wh=1200, duration_h=2
)
"home_appliances": [
HomeApplianceParameters(
device_id="dishwasher1", consumption_wh=1200, duration_h=2
)
]
},
deep=True,
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
CacheEnergyManagementStore().clear()
with pytest.raises(ValueError, match="Home-appliance scheduling"):
GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=10, ngen=1
)
genetic_solution = GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=10, ngen=3
)
# The appliance runs exactly once and delivers its full energy on the 15-min grid.
energy = genetic_solution.result.home_appliance_energy_wh["dishwasher1"]
assert sum(energy) == pytest.approx(1200.0)
# The run occupies 2 h = 8 quarter-hour slots at 1200/2 = 600 W -> 150 Wh/slot.
assert max(energy) == pytest.approx(150.0)
# A single start time is reported as an absolute datetime.
assert len(genetic_solution.appliance_starts["dishwasher1"]) == 1
def test_optimize_15min_slot_grid(config_eos: ConfigEOS):