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
+100 -6
View File
@@ -14,7 +14,11 @@ from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
from akkudoktoreos.core.emplan import ResourceStatus
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
from akkudoktoreos.devices.devicesabc import DevicesBaseSettings
from akkudoktoreos.devices.devicesabc import (
ConsumerScheduleMode,
DevicesBaseSettings,
validate_home_appliance_load_definition,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# Default charge rates for battery
@@ -244,16 +248,77 @@ class InverterCommonSettings(DevicesBaseSettings):
class HomeApplianceCommonSettings(DevicesBaseSettings):
"""Home Appliance devices base settings."""
"""Flexible consumer (home appliance) devices base settings.
consumption_wh: int = Field(
gt=0, json_schema_extra={"description": "Energy consumption [Wh].", "examples": [2000]}
A consumer's load is defined **either** by an explicit power profile
(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)
**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one
of the two must be provided.
"""
load_profile_power_w: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Explicit load profile describing a single complete run as a "
"sequence of non-negative power values in watts (e.g. "
"[200.0, 2000.0, 1800.0, 100.0]). Each value covers "
"'load_profile_interval_seconds'. Mutually exclusive with "
"consumption_wh/duration_h."
),
# None-first so the auto-generated config example uses the flat
# consumption_wh/duration_h fallback (the two definitions are
# mutually exclusive and cannot be shown together).
"examples": [None],
},
)
duration_h: int = Field(
load_profile_interval_seconds: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
"to the configured optimization interval when a profile is given."
),
"examples": [None],
},
)
schedule_mode: ConsumerScheduleMode = Field(
default=ConsumerScheduleMode.ONCE,
json_schema_extra={
"description": (
"Scheduling mode: ONCE (a single run within the horizon) or DAILY "
"(one run per local calendar day with a feasible full run)."
),
"examples": ["ONCE", "DAILY"],
},
)
consumption_wh: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Flat fallback: total energy consumption of one run [Wh]. Used "
"only when no load_profile_power_w is given."
),
"examples": [2000],
},
)
duration_h: Optional[int] = Field(
default=None,
gt=0,
le=24,
json_schema_extra={"description": "Usage duration in hours [0 ... 24].", "examples": [1]},
json_schema_extra={
"description": (
"Flat fallback: run duration in hours [0 ... 24]. Used only when "
"no load_profile_power_w is given."
),
"examples": [1],
},
)
time_windows: Optional[TimeWindowSequence] = Field(
@@ -270,6 +335,17 @@ class HomeApplianceCommonSettings(DevicesBaseSettings):
},
)
@model_validator(mode="after")
def validate_load_definition(self) -> "HomeApplianceCommonSettings":
"""Ensure exactly one complete, valid load definition is provided."""
validate_home_appliance_load_definition(
load_profile_power_w=self.load_profile_power_w,
load_profile_interval_seconds=self.load_profile_interval_seconds,
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
)
return self
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
@@ -341,6 +417,24 @@ class DevicesCommonSettings(SettingsBaseModel):
},
)
@model_validator(mode="after")
def validate_max_home_appliances(self) -> "DevicesCommonSettings":
"""Enforce max_home_appliances purely as an upper bound.
No demo appliance is created and no on/off behaviour is implied; the
limit is only rejected when more appliances are configured than allowed.
"""
if (
self.max_home_appliances is not None
and self.home_appliances is not None
and len(self.home_appliances) > self.max_home_appliances
):
raise ValueError(
f"Configured {len(self.home_appliances)} home appliances exceeds "
f"max_home_appliances = {self.max_home_appliances}."
)
return self
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
+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.
@@ -1,11 +1,75 @@
"""Flexible consumer (home appliance) device model for genetic optimization.
A consumer is described by the energy of a **single complete run** resampled onto
the optimization slot grid. The optimizer decides, per run, at which slot the run
starts; :meth:`HomeAppliance.build_load_curve` then places the resampled run
energy at the chosen start(s). Several runs (DAILY mode) and several devices may
overlap; their energies simply add up.
"""
from typing import Optional
import numpy as np
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.devices.devicesabc import ConsumerScheduleMode
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
def resample_power_to_slot_energy(
power_w: list[float],
input_interval_seconds: float,
slot_interval_seconds: float,
) -> np.ndarray:
"""Resample a piecewise-constant power profile to per-slot energy.
Each input value ``power_w[i]`` is interpreted as a constant power [W] over
the interval ``[i * input_interval_seconds, (i + 1) * input_interval_seconds)``.
The energy of every output slot is the time-weighted integral of the input
power over that slot::
E_j = sum_i P_i * overlap(i, j) / 3600 [Wh]
where ``overlap(i, j)`` is the temporal overlap (in seconds) between input
interval ``i`` and output slot ``j``. This is exact for arbitrary (including
non-integer) ratios such as 10 -> 15 or 20 -> 15 minutes and conserves
energy within numerical tolerance::
sum_j E_j == sum_i P_i * input_interval_seconds / 3600
Args:
power_w: Piecewise-constant power values [W] of a single run.
input_interval_seconds: Duration of one input step [s] (> 0).
slot_interval_seconds: Duration of one output slot [s] (> 0).
Returns:
1-D array of per-slot energy [Wh]; length is the number of slots the run
occupies (ceil of the total run duration divided by the slot duration).
"""
n_in = len(power_w)
total_seconds = n_in * input_interval_seconds
n_slots = int(np.ceil(total_seconds / slot_interval_seconds - 1e-9))
out = np.zeros(max(n_slots, 0), dtype=float)
for i, power in enumerate(power_w):
if power == 0.0:
continue
seg_start = i * input_interval_seconds
seg_end = seg_start + input_interval_seconds
first = int(seg_start // slot_interval_seconds)
last = int((seg_end - 1e-9) // slot_interval_seconds)
for j in range(first, last + 1):
slot_start = j * slot_interval_seconds
slot_end = slot_start + slot_interval_seconds
overlap = min(seg_end, slot_end) - max(seg_start, slot_start)
if overlap > 0:
out[j] += power * overlap / 3600.0
return out
class HomeAppliance:
"""A flexible consumer scheduled onto the optimization slot grid."""
def __init__(
self,
parameters: HomeApplianceParameters,
@@ -13,94 +77,129 @@ class HomeAppliance:
prediction_hours: int,
slot_duration_h: float = 1.0,
):
# slot_duration_h is a forward-compatibility hook. Full sub-hourly home
# appliance scheduling additionally requires converting the start hour to
# a slot index and the duration to a slot count; the default of 1.0 keeps
# the hourly behaviour for the default optimization interval of 3600 s.
self.parameters: HomeApplianceParameters = parameters
self.prediction_hours = prediction_hours
self.slot_duration_h = slot_duration_h
self._setup()
"""Initialize the appliance and precompute its per-slot run energy.
def _setup(self) -> None:
"""Sets up the home appliance parameters based provided parameters."""
self.load_curve = np.zeros(self.prediction_hours) # Initialize the load curve with zeros
self.duration_h = self.parameters.duration_h
self.consumption_wh = self.parameters.consumption_wh
# setup possible start times
if self.parameters.time_windows is None:
self.parameters.time_windows = TimeWindowSequence(
windows=[
TimeWindow(
start_time=to_time("00:00"),
duration=to_duration(f"{self.prediction_hours} hours"),
),
]
)
start_datetime = to_datetime().set(hour=0, minute=0, second=0)
duration = to_duration(f"{self.duration_h} hours")
self.start_allowed: list[bool] = []
for hour in range(0, self.prediction_hours):
self.start_allowed.append(
self.parameters.time_windows.contains(
start_datetime.add(hours=hour), duration=duration
)
)
start_earliest = self.parameters.time_windows.earliest_start_time(duration, start_datetime)
if start_earliest:
self.start_earliest = start_earliest.hour
else:
self.start_earliest = 0
start_latest = self.parameters.time_windows.latest_start_time(duration, start_datetime)
if start_latest:
self.start_latest = start_latest.hour
else:
self.start_latest = 23
def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> int:
"""Sets the start time of the device and generates the corresponding load curve.
:param start_hour: The hour at which the device should start.
Args:
parameters: Appliance configuration (load definition, schedule mode,
allowed time windows).
optimization_hours: Optimization horizon in hours (informational).
prediction_hours: Total number of optimization slots of the run grid.
slot_duration_h: Length of one optimization slot in hours (1.0 hourly,
0.25 at 15 min).
"""
if not self.start_allowed[start_hour]:
# It is not allowed (by the time windows) to start the application at this time
if global_start_hour <= self.start_latest:
# There is a time window left to start the appliance. Use it
start_hour = self.start_latest
else:
# There is no time window left to run the application
# Set the start into tomorrow
start_hour = self.start_earliest + 24
self.parameters: HomeApplianceParameters = parameters
self.optimization_hours = optimization_hours
self.total_slots = int(prediction_hours)
self.slot_duration_h = slot_duration_h
self.slot_interval_seconds = int(round(slot_duration_h * 3600))
self.device_id: str = parameters.device_id
self.schedule_mode: ConsumerScheduleMode = parameters.schedule_mode
self.time_windows: Optional[TimeWindowSequence] = parameters.time_windows
self._build_run_profile()
self.reset_load_curve()
# Calculate power per hour based on total consumption and duration
power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours
def _build_run_profile(self) -> None:
"""Build the per-slot energy [Wh] of a single complete run."""
if self.parameters.load_profile_power_w is not None:
power = [float(value) for value in self.parameters.load_profile_power_w]
input_interval = (
self.parameters.load_profile_interval_seconds or self.slot_interval_seconds
)
else:
# Flat fallback: constant power over duration_h hours. Route it through
# the same resampling path so hourly and sub-hourly grids behave
# identically. Power [W] = energy per hour = consumption_wh / duration_h.
duration_h = self.parameters.duration_h
consumption_wh = self.parameters.consumption_wh
power = [consumption_wh / duration_h]
input_interval = duration_h * 3600
# Set the power for the duration of use in the load curve array
if start_hour < len(self.load_curve):
end_hour = min(start_hour + self.duration_h, self.prediction_hours)
self.load_curve[start_hour:end_hour] = power_per_hour
self.run_energy_wh: np.ndarray = resample_power_to_slot_energy(
power, float(input_interval), float(self.slot_interval_seconds)
)
self.run_slots: int = int(len(self.run_energy_wh))
return start_hour
def allowed_start_slots(
self,
*,
slot0_datetime: DateTime,
earliest_slot: int,
horizon_end_slot: int,
) -> list[int]:
"""Return the sorted absolute start slots at which a full run is allowed.
A start slot ``s`` is allowed when the complete run fits both the
optimization horizon and (if configured) a single allowed time window:
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
- with ``time_windows`` set, the run's whole occupied span starting at
``s`` is contained in one window (respecting weekday/date constraints).
No snapping is performed: every returned slot is a genuinely valid start.
Args:
slot0_datetime: Local, timezone-aware datetime of slot index 0.
earliest_slot: First slot the optimizer may schedule at ("now").
horizon_end_slot: Exclusive upper bound; a run must end at or before.
Returns:
Sorted list of allowed absolute start slots (may be empty).
"""
run_slots = self.run_slots
if run_slots <= 0:
return []
last_start = min(horizon_end_slot, self.total_slots) - run_slots
first_start = max(earliest_slot, 0)
if last_start < first_start:
return []
if self.time_windows is None:
return list(range(first_start, last_start + 1))
run_duration = to_duration(f"{run_slots * self.slot_interval_seconds} seconds")
allowed: list[int] = []
for slot in range(first_start, last_start + 1):
start_dt = slot0_datetime.add(seconds=slot * self.slot_interval_seconds)
if self.time_windows.contains(start_dt, duration=run_duration):
allowed.append(slot)
return allowed
def build_load_curve(self, starts: list[int]) -> None:
"""Place the resampled run energy at each decoded start slot.
Multiple runs may overlap; their per-slot energies are summed.
Args:
starts: Absolute start slots of the scheduled runs.
"""
self.reset_load_curve()
for start in starts:
if start is None or start < 0:
continue
end = min(start + self.run_slots, self.total_slots)
length = end - start
if length > 0:
self.load_curve[start:end] += self.run_energy_wh[:length]
def reset_load_curve(self) -> None:
"""Resets the load curve."""
self.load_curve = np.zeros(self.prediction_hours)
"""Reset the load curve to all zeros."""
self.load_curve = np.zeros(self.total_slots)
def get_load_curve(self) -> np.ndarray:
"""Returns the current load curve."""
"""Return the current per-slot load curve [Wh]."""
return self.load_curve
def get_load_for_hour(self, hour: int) -> float:
"""Returns the load for a specific hour.
"""Return the load [Wh] for a specific slot.
:param hour: The hour for which the load is queried.
:return: The load in watts for the specified hour.
Args:
hour: The slot index for which the load is queried.
Returns:
The energy in watt-hours for the specified slot.
"""
if hour < 0 or hour >= self.prediction_hours:
if hour < 0 or hour >= self.total_slots:
raise ValueError(
f"The specified hour {hour} is outside the available time frame {self.prediction_hours}."
f"The specified slot {hour} is outside the available time frame {self.total_slots}."
)
return self.load_curve[hour]