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
@@ -104,7 +104,25 @@ class GeneticOptimizationParameters(
pv_akku: Optional[SolarPanelBatteryParameters]
inverter: Optional[InverterParameters]
eauto: Optional[ElectricVehicleParameters]
dishwasher: Optional[HomeApplianceParameters] = None
home_appliances: Optional[list[HomeApplianceParameters]] = Field(
default=None,
json_schema_extra={
"description": "List of flexible consumers (home appliances) to schedule."
},
)
dishwasher: Optional[HomeApplianceParameters] = Field(
default=None,
deprecated=(
"Deprecated: use 'home_appliances' (a list). A single 'dishwasher' is "
"mapped to a one-element 'home_appliances' list."
),
json_schema_extra={
"description": (
"Deprecated single home appliance. Use 'home_appliances' instead. "
"Mutually exclusive with 'home_appliances'."
)
},
)
temperature_forecast: Optional[list[Optional[float]]] = Field(
default=None,
json_schema_extra={
@@ -130,6 +148,41 @@ class GeneticOptimizationParameters(
raise ValueError("Input lists have different lengths")
return self
@model_validator(mode="after")
def validate_home_appliances(self) -> Self:
"""Reject conflicting home appliance definitions.
The deprecated ``dishwasher`` field and the new ``home_appliances`` list
must not be set at the same time; nothing is silently overwritten.
Device ids within ``home_appliances`` must be unique.
"""
# Read the deprecated field via __dict__ to avoid emitting a deprecation
# warning on every internal validation.
dishwasher = self.__dict__.get("dishwasher")
if dishwasher is not None and self.home_appliances is not None:
raise ValueError(
"Provide either 'home_appliances' or the deprecated 'dishwasher', "
"not both."
)
appliances = self.home_appliances or []
device_ids = [appliance.device_id for appliance in appliances]
if len(device_ids) != len(set(device_ids)):
raise ValueError("home_appliances device_id values must be unique.")
return self
def resolved_home_appliances(self) -> list[HomeApplianceParameters]:
"""Return the effective home appliance list.
Maps the deprecated single ``dishwasher`` onto a one-element list so the
optimizer only ever deals with the list form.
"""
if self.home_appliances is not None:
return list(self.home_appliances)
dishwasher = self.__dict__.get("dishwasher")
if dishwasher is not None:
return [dishwasher]
return []
@field_validator("start_solution")
def validate_start_solution(
cls, start_solution: Optional[list[float]]
@@ -583,65 +636,36 @@ class GeneticOptimizationParameters(
# Retry
continue
# Home Appliances
# ---------------
if cls.config.devices.max_home_appliances is None:
default_home_appliances = 0 if cls.config.optimization.interval < 3600 else 1
logger.info(
"Number of home appliance devices not configured - defaulting to {}.",
default_home_appliances,
# Home Appliances (flexible consumers)
# ------------------------------------
# max_home_appliances is purely an upper bound. No demo consumer is
# created when the list is missing; an empty/absent list simply means
# there is nothing to schedule.
appliances_config = cls.config.devices.home_appliances or []
max_home_appliances = cls.config.devices.max_home_appliances
if max_home_appliances is not None and len(appliances_config) > max_home_appliances:
raise ValueError(
f"Configured {len(appliances_config)} home appliances exceeds "
f"max_home_appliances = {max_home_appliances}."
)
cls.config.devices.max_home_appliances = default_home_appliances
if cls.config.devices.max_home_appliances == 0:
home_appliance_params = None
else:
home_appliance_params = None
if cls.config.devices.home_appliances is None:
logger.info(
"No home appliance device data available - defaulting to demo data."
home_appliance_params: Optional[list[HomeApplianceParameters]] = None
if appliances_config:
# Construction errors here are configuration errors (conflicting
# or incomplete load definitions) and must surface, not retry.
home_appliance_params = [
HomeApplianceParameters(
device_id=appliance_config.device_id,
load_profile_power_w=appliance_config.load_profile_power_w,
load_profile_interval_seconds=(
appliance_config.load_profile_interval_seconds
),
schedule_mode=appliance_config.schedule_mode,
consumption_wh=appliance_config.consumption_wh,
duration_h=appliance_config.duration_h,
time_windows=appliance_config.time_windows,
)
cls.config.devices.home_appliances = [
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3.0,
"time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours",
},
{
"start_time": "15:00",
"duration": "3 hours",
},
],
},
}
]
try:
home_appliance_config = cls.config.devices.home_appliances[0]
home_appliance_params = HomeApplianceParameters(
device_id=home_appliance_config.device_id,
consumption_wh=home_appliance_config.consumption_wh,
duration_h=home_appliance_config.duration_h,
time_windows=home_appliance_config.time_windows,
)
except:
logger.info(
"No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}.",
attempt,
)
cls.config.devices.home_appliances = [
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3.0,
"time_windows": None,
}
]
# Retry
continue
for appliance_config in appliances_config
]
# We got all parameter data
try:
@@ -659,7 +683,7 @@ class GeneticOptimizationParameters(
pv_akku=battery_params,
eauto=electric_vehicle_params,
inverter=inverter_params,
dishwasher=home_appliance_params,
home_appliances=home_appliance_params,
start_solution=start_solution,
)
except: