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
+86
View File
@@ -1,6 +1,8 @@
"""Abstract and base classes for devices."""
import math
from enum import StrEnum
from typing import Optional
from pydantic import Field
@@ -87,6 +89,90 @@ class BatteryOperationMode(StrEnum):
FAULT = "FAULT"
def validate_home_appliance_load_definition(
*,
load_profile_power_w: Optional[list[float]],
load_profile_interval_seconds: Optional[int],
consumption_wh: Optional[float],
duration_h: Optional[float],
) -> None:
"""Validate the load definition of a flexible consumer / home appliance.
A consumer's load must be given **either** as a full explicit power profile
(``load_profile_power_w``) **or** as the complete flat fallback
(``consumption_wh`` together with ``duration_h``). Providing both, or only a
part of the fallback, is rejected. Profile values must be finite and
non-negative and the profile interval, if given, must be positive.
Args:
load_profile_power_w: Explicit per-step power values [W], or None.
load_profile_interval_seconds: Duration of one profile step [s], or None.
consumption_wh: Fallback total energy of one run [Wh], or None.
duration_h: Fallback run duration [h], or None.
Raises:
ValueError: If the definition is conflicting, incomplete, or contains
invalid profile values.
"""
profile_given = load_profile_power_w is not None
fallback_fields = (consumption_wh, duration_h)
fallback_partial = any(field is not None for field in fallback_fields)
fallback_given = all(field is not None for field in fallback_fields)
if profile_given and fallback_partial:
raise ValueError(
"Conflicting home appliance load definition: provide either "
"load_profile_power_w or consumption_wh together with duration_h, "
"not both."
)
if not profile_given:
if not fallback_given:
raise ValueError(
"Incomplete home appliance load definition: provide a full "
"load_profile_power_w or both consumption_wh and duration_h."
)
# Value ranges of the fallback fields are enforced by their Field
# constraints (gt=0); nothing more to check here.
return
# Explicit profile path.
if load_profile_interval_seconds is not None and load_profile_interval_seconds <= 0:
raise ValueError("load_profile_interval_seconds must be greater than zero.")
if len(load_profile_power_w) == 0:
raise ValueError("load_profile_power_w must not be empty.")
for value in load_profile_power_w:
if value is None or math.isnan(value) or math.isinf(value):
raise ValueError(
"load_profile_power_w must contain only finite values "
"(no NaN or infinity)."
)
if value < 0:
raise ValueError("load_profile_power_w must not contain negative values.")
class ConsumerScheduleMode(StrEnum):
"""Schedule mode of a flexible consumer (home appliance).
Determines how often a consumer's load profile is scheduled within the
optimization horizon.
Modes
-----
- ONCE:
The consumer runs exactly once somewhere within the optimization
horizon ("fire and forget"). The optimizer picks the start.
- DAILY:
The consumer runs once per local calendar day, but only on days for
which at least one complete, allowed run still fits into the remaining
horizon. The optimizer picks one start per eligible day.
"""
ONCE = "ONCE"
DAILY = "DAILY"
class ApplianceOperationMode(StrEnum):
"""Appliance operation modes.