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]
+320 -100
View File
@@ -2,6 +2,8 @@
import random
import time
from collections import OrderedDict, defaultdict
from dataclasses import dataclass, field
from typing import Any, Optional
import numpy as np
@@ -11,6 +13,7 @@ from numpydantic import NDArray, Shape
from pydantic import ConfigDict, Field
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.devices.devicesabc import ConsumerScheduleMode
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
from akkudoktoreos.devices.genetic.inverter import Inverter
@@ -25,6 +28,54 @@ from akkudoktoreos.optimization.genetic.geneticsolution import (
from akkudoktoreos.optimization.optimizationabc import OptimizationBase
@dataclass
class ApplianceGeneSlot:
"""One appliance start gene in the genome.
The gene value is an **index into ``allowed_start_slots``**, not an absolute
slot. This guarantees every gene value maps to a genuinely valid start and
keeps all allowed starts equally reachable by mutation/crossover.
"""
gene_index: int
appliance_index: int
device_id: str
run_index: int
# Local calendar date of the run for DAILY appliances; None for ONCE.
run_date: Optional[Any]
allowed_start_slots: list[int]
@dataclass
class ApplianceGeneLayout:
"""Ordered descriptor of the appliance part of the genome.
Every genome-building step (create/split/merge/mutate/decode) consumes only
this descriptor, so the appliance gene block can vary in length with the
number of devices and DAILY run days without any hard-coded gene positions.
"""
genes: list[ApplianceGeneSlot] = field(default_factory=list)
@property
def n_genes(self) -> int:
"""Number of appliance start genes."""
return len(self.genes)
def signature(self) -> tuple:
"""Stable identity of the layout for start-solution compatibility.
Two layouts with the same length can still describe different schedules;
the signature captures device, run date and the allowed-start list so a
cached start solution built for a different layout is not silently
reused.
"""
return tuple(
(gene.device_id, str(gene.run_date), tuple(gene.allowed_start_slots))
for gene in self.genes
)
class GeneticSimulation(PydanticBaseModel):
"""Device simulation for GENETIC optimization algorithm."""
@@ -84,8 +135,9 @@ class GeneticSimulation(PydanticBaseModel):
)
battery: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
ev: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."})
home_appliance: Optional[HomeAppliance] = Field(
default=None, json_schema_extra={"description": "TBD."}
home_appliances: list[HomeAppliance] = Field(
default_factory=list,
json_schema_extra={"description": "Flexible consumers scheduled by the optimizer."},
)
inverter: Optional[Inverter] = Field(default=None, json_schema_extra={"description": "TBD."})
@@ -108,18 +160,13 @@ class GeneticSimulation(PydanticBaseModel):
ev_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, json_schema_extra={"description": "TBD"}
)
home_appliance_start_hour: Optional[int] = Field(
default=None,
json_schema_extra={"description": "Home appliance start hour - None denotes no start."},
)
def prepare(
self,
parameters: GeneticEnergyManagementParameters,
optimization_hours: int,
prediction_hours: int,
ev: Optional[Battery] = None,
home_appliance: Optional[HomeAppliance] = None,
home_appliances: Optional[list[HomeAppliance]] = None,
inverter: Optional[Inverter] = None,
direct_marketing_enabled: bool = False,
) -> None:
@@ -149,7 +196,7 @@ class GeneticSimulation(PydanticBaseModel):
else:
self.battery = None
self.ev = ev
self.home_appliance = home_appliance
self.home_appliances = home_appliances or []
self.inverter = inverter
# Initialize per-hour action arrays for the prediction horizon
@@ -159,14 +206,12 @@ class GeneticSimulation(PydanticBaseModel):
self.bat_grid_export_hours = np.full(self.prediction_hours, 0.0)
self.ev_charge_hours = np.full(self.prediction_hours, 0.0)
self.ev_discharge_hours = np.full(self.prediction_hours, 0.0)
self.home_appliance_start_hour = None
def reset(self) -> None:
if self.ev:
self.ev.reset()
if self.battery:
self.battery.reset()
self.home_appliance_start_hour = None
def simulate(self, start_hour: int) -> dict[str, Any]:
"""Simulate energy usage and costs for the given start hour.
@@ -190,7 +235,7 @@ class GeneticSimulation(PydanticBaseModel):
pv_prediction_wh_fast = self.pv_prediction_wh
battery_fast = self.battery
ev_fast = self.ev
home_appliance_fast = self.home_appliance
home_appliances_fast = self.home_appliances
inverter_fast = self.inverter
direct_marketing_enabled_fast = self.direct_marketing_enabled
@@ -327,14 +372,12 @@ class GeneticSimulation(PydanticBaseModel):
# Default return if no electric vehicle is available
soc_ev_per_hour = np.full((total_hours), 0)
if home_appliance_fast and self.home_appliance_start_hour is not None:
if home_appliances_fast:
home_appliance_enabled = True
# Pre-allocate arrays for the results, optimized for speed
# Pre-allocate the aggregate appliance load array (sum over all
# devices). Each appliance already carries its own resampled load
# curve, built from the decoded start(s) before this call.
home_appliance_wh_per_hour = np.full((total_hours), np.nan)
self.home_appliance_start_hour = home_appliance_fast.set_starting_time(
self.home_appliance_start_hour, start_hour
)
else:
home_appliance_enabled = False
# Default return if no home appliance is available
@@ -347,9 +390,11 @@ class GeneticSimulation(PydanticBaseModel):
consumption = load_energy_array_fast[hour]
losses_wh_per_hour[hour_idx] = 0.0
# Home appliances
# Home appliances (sum the per-slot load of all flexible consumers)
if home_appliance_enabled:
ha_load = home_appliance_fast.get_load_for_hour(hour) # type: ignore[union-attr]
ha_load = 0.0
for appliance in home_appliances_fast:
ha_load += appliance.get_load_for_hour(hour)
consumption += ha_load
home_appliance_wh_per_hour[hour_idx] = ha_load
@@ -575,6 +620,14 @@ class GeneticOptimization(OptimizationBase):
# Per-run cache for the AC-charge break-even penalty (see evaluate()).
self._ac_break_even_best_prices: Optional[list[float]] = None
# Appliance genome layout, built once per optimization run in
# optimierung_ems(). Empty by default so setup_deap_environment() can be
# exercised standalone (e.g. in tests) without appliances.
self.appliance_layout: ApplianceGeneLayout = ApplianceGeneLayout([])
# Local datetime of slot index 0 (midnight of the start day), needed to
# turn decoded start slots into absolute local timestamps.
self._slot0_datetime: Optional[Any] = None
# Create Simulation
self.simulation = GeneticSimulation()
@@ -585,6 +638,129 @@ class GeneticOptimization(OptimizationBase):
except Exception:
return False
def _appliance_horizon_end_slot(self) -> int:
"""Exclusive upper slot bound for appliance runs (end of horizon).
A run must complete within the optimization horizon. The horizon starts
at the current slot and lasts ``horizon_hours``; the bound is capped to
the total slot grid.
"""
start_slot = self._start_day_slot()
horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour
return min(self.total_slots, start_slot + horizon_slots)
def _build_appliance_layout(
self, appliances: list[HomeAppliance], slot0_datetime: Any
) -> ApplianceGeneLayout:
"""Compute the appliance genome layout from the configured consumers.
For each appliance the allowed start slots are computed once. ONCE
appliances get a single gene; DAILY appliances get one gene per local
calendar day that still has at least one complete allowed run.
Raises:
ValueError: If a ONCE appliance has no valid start within the horizon.
"""
start_slot = self._start_day_slot()
horizon_end_slot = self._appliance_horizon_end_slot()
genes: list[ApplianceGeneSlot] = []
gene_index = 0
for appliance_index, appliance in enumerate(appliances):
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0_datetime,
earliest_slot=start_slot,
horizon_end_slot=horizon_end_slot,
)
if appliance.schedule_mode == ConsumerScheduleMode.ONCE:
if not allowed:
raise ValueError(
f"Home appliance '{appliance.device_id}' (ONCE) has no valid "
f"start slot within the optimization horizon and its time windows."
)
genes.append(
ApplianceGeneSlot(
gene_index=gene_index,
appliance_index=appliance_index,
device_id=appliance.device_id,
run_index=0,
run_date=None,
allowed_start_slots=allowed,
)
)
gene_index += 1
else: # DAILY
by_date: "OrderedDict[Any, list[int]]" = OrderedDict()
for slot in allowed:
run_date = slot0_datetime.add(
seconds=slot * appliance.slot_interval_seconds
).date()
by_date.setdefault(run_date, []).append(slot)
if not by_date:
logger.warning(
"Home appliance '{}' (DAILY) has no valid start slot within the "
"horizon; no runs are scheduled.",
appliance.device_id,
)
for run_index, (run_date, slots) in enumerate(by_date.items()):
genes.append(
ApplianceGeneSlot(
gene_index=gene_index,
appliance_index=appliance_index,
device_id=appliance.device_id,
run_index=run_index,
run_date=run_date,
allowed_start_slots=slots,
)
)
gene_index += 1
return ApplianceGeneLayout(genes)
def _decode_appliance_starts(
self, appliance_gene_values: list[int]
) -> dict[int, list[int]]:
"""Map appliance gene values to absolute start slots per appliance.
Each gene value is an index into its gene's ``allowed_start_slots``; it is
clamped defensively so crossover artefacts can never index out of range.
"""
starts_per_appliance: dict[int, list[int]] = defaultdict(list)
for position, gene in enumerate(self.appliance_layout.genes):
allowed = gene.allowed_start_slots
if not allowed:
continue
value = int(appliance_gene_values[position])
value = min(max(value, 0), len(allowed) - 1)
starts_per_appliance[gene.appliance_index].append(allowed[value])
return starts_per_appliance
def _apply_appliance_starts(self, appliance_gene_values: list[int]) -> None:
"""Build every appliance's load curve from the decoded starts."""
if not self.simulation.home_appliances:
return
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
appliance.build_load_curve(starts_per_appliance.get(appliance_index, []))
def _start_solution_matches_layout(self, start_solution: list[float]) -> bool:
"""Check that a start solution's appliance tail fits the current layout.
A length match alone is insufficient (two different layouts can share a
length), so every appliance gene value must be a valid index into its
gene's ``allowed_start_slots``.
"""
n_genes = self.appliance_layout.n_genes
if n_genes == 0:
return True
if len(start_solution) < n_genes:
return False
tail = start_solution[-n_genes:]
for value, gene in zip(tail, self.appliance_layout.genes):
if not gene.allowed_start_slots:
return False
if not (0 <= int(value) < len(gene.allowed_start_slots)):
return False
return True
def _ac_break_even_prices(
self,
prices_arr: Any,
@@ -716,15 +892,19 @@ class GeneticOptimization(OptimizationBase):
deep=True,
)
def _start_solution_for_slot_grid(
self, start_solution: list[float], *, has_appliance: bool
) -> list[float]:
"""Expand a legacy hourly genome to the configured slot grid when possible."""
expected_length = self.total_slots * (2 if self.optimize_ev else 1)
hourly_length = self.config.prediction.hours * (2 if self.optimize_ev else 1)
if has_appliance:
expected_length += 1
hourly_length += 1
def _start_solution_for_slot_grid(self, start_solution: list[float]) -> list[float]:
"""Expand a legacy hourly genome to the configured slot grid when possible.
Only the battery and EV parts are grid-expanded. The appliance start
genes are indices into interval-dependent allowed-start lists, so they
are copied verbatim and validated later against the current layout
(incompatible tails cause the whole start solution to be discarded).
"""
n_appliance_genes = self.appliance_layout.n_genes
expected_length = self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes
hourly_length = (
self.config.prediction.hours * (2 if self.optimize_ev else 1) + n_appliance_genes
)
if len(start_solution) == expected_length or self.slots_per_hour == 1:
return list(start_solution)
@@ -738,8 +918,8 @@ class GeneticOptimization(OptimizationBase):
migrated.extend(
np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist()
)
if has_appliance:
migrated.append(start_solution[-1])
if n_appliance_genes > 0:
migrated.extend(list(start_solution[-n_appliance_genes:]))
logger.info(
"Expanded hourly start_solution from {} to {} slot values.",
hourly_length,
@@ -826,11 +1006,16 @@ class GeneticOptimization(OptimizationBase):
] * self.fixed_eauto_hours
individual[self.total_slots : self.total_slots * 2] = ev_charge_part_mutated
# 3. Mutating the appliance start time, if applicable
if self.opti_param["home_appliance"] > 0:
appliance_part = [individual[-1]]
(appliance_part_mutated,) = self.toolbox.mutate_hour(appliance_part)
individual[-1] = appliance_part_mutated[0]
# 3. Mutating the appliance start genes. Each gene is an index into its
# own allowed_start_slots list, so the redraw stays within valid range.
n_appliance_genes = self.appliance_layout.n_genes
if n_appliance_genes > 0:
base = len(individual) - n_appliance_genes
appliance_mutation_probability = 0.2
for position, gene in enumerate(self.appliance_layout.genes):
if random.random() < appliance_mutation_probability: # noqa: S311
upper = len(gene.allowed_start_slots) - 1
individual[base + position] = random.randint(0, upper) # noqa: S311
return (individual,)
@@ -847,9 +1032,11 @@ class GeneticOptimization(OptimizationBase):
self.toolbox.attr_ev_charge_index() for _ in range(self.total_slots)
]
# Add the start time of the household appliance if it's being optimized
if self.opti_param["home_appliance"] > 0:
individual_components += [self.toolbox.attr_int()]
# Add one appliance start gene per scheduled run (index into that run's
# allowed_start_slots). No draws happen when there are no appliances, so
# the battery/EV-only genome is unchanged.
for gene in self.appliance_layout.genes:
individual_components.append(random.randint(0, len(gene.allowed_start_slots) - 1)) # noqa: S311
return creator.Individual(individual_components)
@@ -857,14 +1044,15 @@ class GeneticOptimization(OptimizationBase):
self,
discharge_hours_bin: np.ndarray,
eautocharge_hours_index: Optional[np.ndarray],
washingstart_int: Optional[int],
appliance_gene_values: Optional[list[int]],
) -> list[int]:
"""Merge the individual components back into a single solution list.
Parameters:
discharge_hours_bin (np.ndarray): Binary discharge hours.
eautocharge_hours_index (Optional[np.ndarray]): EV charge hours as integers, or None.
washingstart_int (Optional[int]): Dishwasher start time as integer, or None.
appliance_gene_values (Optional[list[int]]): One index per appliance
start gene (into the gene's allowed_start_slots), or None.
Returns:
list[int]: The merged individual solution as a list of integers.
@@ -876,27 +1064,28 @@ class GeneticOptimization(OptimizationBase):
if self.optimize_ev and eautocharge_hours_index is not None:
individual.extend(eautocharge_hours_index.tolist())
elif self.optimize_ev:
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
# optimize_ev active but no EV data present: pad with zeros
individual.extend([0] * self.total_slots)
# Add dishwasher start time if applicable
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
individual.append(washingstart_int)
elif self.opti_param.get("home_appliance", 0) > 0:
# Falls ein Haushaltsgerät optimiert wird, aber kein Startzeitpunkt vorhanden ist
individual.append(0)
# Add appliance start genes (one index per scheduled run).
n_appliance_genes = self.appliance_layout.n_genes
if n_appliance_genes > 0:
if appliance_gene_values is not None:
individual.extend(int(value) for value in appliance_gene_values)
else:
individual.extend([0] * n_appliance_genes)
return individual
def split_individual(
self, individual: list[int]
) -> tuple[np.ndarray, Optional[np.ndarray], Optional[int]]:
) -> tuple[np.ndarray, Optional[np.ndarray], list[int]]:
"""Split the individual solution into its components.
Components:
1. Discharge hours (binary as int NumPy array),
2. Electric vehicle charge hours (float as int NumPy array, if applicable),
3. Dishwasher start time (integer if applicable).
3. Appliance start genes (list of indices, one per scheduled run).
"""
# Discharge hours as a NumPy array of ints
discharge_hours_bin = np.array(individual[: self.total_slots], dtype=int)
@@ -912,14 +1101,14 @@ class GeneticOptimization(OptimizationBase):
else None
)
# Washing machine start time as an integer (if applicable)
washingstart_int = (
int(individual[-1])
if self.opti_param and self.opti_param.get("home_appliance", 0) > 0
else None
)
# Appliance start genes are the trailing entries of the genome.
n_appliance_genes = self.appliance_layout.n_genes
if n_appliance_genes > 0:
appliance_gene_values = [int(value) for value in individual[-n_appliance_genes:]]
else:
appliance_gene_values = []
return discharge_hours_bin, eautocharge_hours_index, washingstart_int
return discharge_hours_bin, eautocharge_hours_index, appliance_gene_values
def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None:
"""Set up the DEAP environment with fitness and individual creation rules."""
@@ -963,9 +1152,6 @@ class GeneticOptimization(OptimizationBase):
len_ev - 1,
)
# Household appliance start time
self.toolbox.register("attr_int", random.randint, start_hour, 23)
self.toolbox.register("individual", self.create_individual)
self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
self.toolbox.register("mate", tools.cxTwoPoint)
@@ -991,9 +1177,6 @@ class GeneticOptimization(OptimizationBase):
indpb=mutation_probability,
)
# Mutation for household appliance
self.toolbox.register("mutate_hour", tools.mutUniformInt, low=start_hour, up=23, indpb=0.2)
# Custom mutate function remains unchanged
self.toolbox.register("mutate", self.mutate)
self.toolbox.register("select", tools.selTournament, tournsize=3)
@@ -1004,13 +1187,13 @@ class GeneticOptimization(OptimizationBase):
This is an internal function.
"""
self.simulation.reset()
discharge_hours_bin, eautocharge_hours_index, washingstart_int = self.split_individual(
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = self.split_individual(
individual
)
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int:
# Set start hour for appliance
self.simulation.home_appliance_start_hour = washingstart_int
# Decode the appliance start genes and (re)build each appliance's load
# curve for this candidate solution.
self._apply_appliance_starts(appliance_gene_values)
ac_charge_hours, dc_charge_hours, discharge, battery_grid_export = (
self.decode_charge_discharge(discharge_hours_bin)
@@ -1092,8 +1275,8 @@ class GeneticOptimization(OptimizationBase):
# EV 100% & charge not allowed
if self.optimize_ev:
discharge_hours_bin, eautocharge_hours_index, washingstart_int = self.split_individual(
individual
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = (
self.split_individual(individual)
)
eauto_soc_per_hour = np.array(
@@ -1119,7 +1302,7 @@ class GeneticOptimization(OptimizationBase):
eautocharge_hours_index[-min_length:] = eautocharge_hours_index_tail.tolist()
adjusted_individual = self.merge_individual(
discharge_hours_bin, eautocharge_hours_index, washingstart_int
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values
)
individual[:] = adjusted_individual
@@ -1330,23 +1513,26 @@ class GeneticOptimization(OptimizationBase):
# currently active genome layout. EV optimization adds one gene per prediction slot,
# so a cached solution from a previous run without EV optimization must not be reused.
if start_solution is not None:
has_appliance = self.opti_param.get("home_appliance", 0) > 0
expected_length = self.total_slots * (2 if self.optimize_ev else 1)
if has_appliance:
expected_length += 1
start_solution = self._start_solution_for_slot_grid(
start_solution, has_appliance=has_appliance
n_appliance_genes = self.appliance_layout.n_genes
expected_length = (
self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes
)
start_solution = self._start_solution_for_slot_grid(start_solution)
if len(start_solution) == expected_length:
for _ in range(10):
population.insert(0, creator.Individual(start_solution))
else:
if len(start_solution) != expected_length:
logger.warning(
"Ignoring start_solution with incompatible length {} (expected {}).",
len(start_solution),
expected_length,
)
elif not self._start_solution_matches_layout(start_solution):
logger.warning(
"Ignoring start_solution: appliance genes do not match the current "
"appliance layout."
)
else:
for _ in range(10):
population.insert(0, creator.Individual(start_solution))
# Run the evolutionary algorithm
pop, log = algorithms.eaMuPlusLambda(
@@ -1391,11 +1577,9 @@ class GeneticOptimization(OptimizationBase):
direct_marketing_enabled = self._direct_marketing_enabled()
parameters = self._parameters_for_config(parameters)
parameters = self._parameters_for_slot_grid(parameters)
if self.slots_per_hour > 1 and parameters.dishwasher is not None:
raise ValueError(
"Home-appliance scheduling is not yet supported for sub-hourly "
"optimization intervals."
)
# Home-appliance scheduling now supports sub-hourly intervals via the
# energy-preserving per-slot run profile.
home_appliance_params = parameters.resolved_home_appliances()
self.optimize_dc_charge = direct_marketing_enabled
self.optimize_battery_grid_export = direct_marketing_enabled
@@ -1496,16 +1680,23 @@ class GeneticOptimization(OptimizationBase):
self.bat_possible_charge_values = [1.0]
logger.debug("Battery AC charge levels: {}", self.bat_possible_charge_values)
# Initialize household appliance if applicable
dishwasher = (
# Initialize the flexible consumers (home appliances) and their genome
# layout. slot0_datetime (midnight of the start day) turns decoded start
# slots into absolute local timestamps and drives DAILY day grouping.
self._slot0_datetime = self.ems.start_datetime.set(
hour=0, minute=0, second=0, microsecond=0
)
home_appliances = [
HomeAppliance(
parameters=parameters.dishwasher,
parameters=appliance_params,
optimization_hours=self.config.optimization.horizon_hours,
prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
)
if parameters.dishwasher is not None
else None
for appliance_params in home_appliance_params
]
self.appliance_layout = self._build_appliance_layout(
home_appliances, self._slot0_datetime
)
# Initialize the inverter and energy management system. slot_duration_h
@@ -1525,14 +1716,16 @@ class GeneticOptimization(OptimizationBase):
prediction_hours=self.total_slots,
inverter=inverter, # battery is part of inverter
ev=eauto,
home_appliance=dishwasher,
home_appliances=home_appliances,
direct_marketing_enabled=direct_marketing_enabled,
)
# Setup the DEAP environment and optimization process. setup_deap gets
# the hour-of-day (appliance gene bounds); evaluate gets the slot index
# (its break-even loop walks the slot arrays from "now").
self.setup_deap_environment({"home_appliance": 1 if dishwasher else 0}, start_hour)
# Setup the DEAP environment and optimization process. The appliance
# genome layout (built above) drives the appliance gene block; evaluate
# gets the slot index (its break-even loop walks the slot arrays from "now").
self.setup_deap_environment(
{"home_appliance": self.appliance_layout.n_genes}, start_hour
)
self.toolbox.register(
"evaluate",
lambda ind: self.evaluate(ind, parameters, start_slot, worst_case),
@@ -1547,12 +1740,38 @@ class GeneticOptimization(OptimizationBase):
simulation_result = self.evaluate_inner(start_solution)
# Prepare results
discharge_hours_bin, eautocharge_hours_index, washingstart_int = self.split_individual(
start_solution
discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = (
self.split_individual(start_solution)
)
# home appliance may have choosen a different appliance start hour
if self.simulation.home_appliance:
washingstart_int = self.simulation.home_appliance_start_hour
# Materialize the per-device appliance results only for the final best
# solution. Each appliance's load curve (already built by the final
# evaluate_inner above) starts at slot 0; slice it to the simulation
# window so it aligns with the other per-slot result arrays.
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
home_appliance_energy_wh: dict[str, list[float]] = {}
appliance_starts: dict[str, list[Any]] = {}
timezone = self.config.general.timezone
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
device_id = appliance.device_id
home_appliance_energy_wh[device_id] = appliance.get_load_curve()[start_slot:].tolist()
starts = sorted(starts_per_appliance.get(appliance_index, []))
appliance_starts[device_id] = [
self._slot0_datetime.add(
seconds=start * appliance.slot_interval_seconds
).in_timezone(timezone)
for start in starts
]
simulation_result["home_appliance_energy_wh"] = home_appliance_energy_wh
# Deprecated single-device hourly start (kept for backward compatibility).
# Only meaningful for the legacy case: exactly one appliance on the hourly
# grid. Otherwise None; use appliance_starts instead.
washingstart_int: Optional[int] = None
if self.slots_per_hour == 1 and len(self.simulation.home_appliances) == 1:
single_starts = starts_per_appliance.get(0, [])
if single_starts:
washingstart_int = int(min(single_starts))
eautocharge_hours_float = None
if eautocharge_hours_index is not None and self.simulation.ev is not None:
@@ -1619,5 +1838,6 @@ class GeneticOptimization(OptimizationBase):
"eauto_obj": self.simulation.ev,
"start_solution": start_solution,
"washingstart": washingstart_int,
"appliance_starts": appliance_starts,
}
)
@@ -2,9 +2,14 @@
from typing import Optional
from pydantic import Field
from pydantic import Field, model_validator
from typing_extensions import Self
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.devices.devicesabc import (
ConsumerScheduleMode,
validate_home_appliance_load_definition,
)
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
@@ -125,22 +130,69 @@ class ElectricVehicleParameters(BaseBatteryParameters):
class HomeApplianceParameters(DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
"""Flexible consumer (home appliance) device simulation configuration.
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.
"""
device_id: str = Field(
json_schema_extra={"description": "ID of home appliance", "examples": ["dishwasher"]}
json_schema_extra={"description": "ID of home appliance", "examples": ["dishwasher1"]}
)
consumption_wh: int = Field(
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. Each value "
"covers 'load_profile_interval_seconds'. Mutually exclusive with "
"consumption_wh/duration_h."
),
"examples": [[200.0, 2000.0, 1800.0, 100.0]],
},
)
load_profile_interval_seconds: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": "An integer representing the energy consumption of a household device in watt-hours.",
"description": (
"Duration of one 'load_profile_power_w' step in seconds. Defaults "
"to the configured optimization interval when a profile is given."
),
"examples": [900, 3600],
},
)
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 in watt-hours. "
"Used only when no load_profile_power_w is given."
),
"examples": [2000],
},
)
duration_h: int = Field(
duration_h: Optional[int] = Field(
default=None,
gt=0,
json_schema_extra={
"description": "An integer representing the usage duration of a household device in hours.",
"description": (
"Flat fallback: run duration in hours. Used only when no "
"load_profile_power_w is given."
),
"examples": [3],
},
)
@@ -156,6 +208,17 @@ class HomeApplianceParameters(DeviceParameters):
},
)
@model_validator(mode="after")
def validate_load_definition(self) -> Self:
"""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
class InverterParameters(DeviceParameters):
"""Inverter Device Simulation Configuration."""
@@ -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:
@@ -25,7 +25,7 @@ from akkudoktoreos.devices.devicesabc import (
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.optimization.genetic.geneticdevices import GeneticParametersBaseModel
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
from akkudoktoreos.utils.utils import NumpyEncoder
@@ -107,9 +107,22 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
Gesamtkosten_Euro: float = Field(json_schema_extra={"description": "The total costs in euros."})
Home_appliance_wh_per_hour: list[Optional[float]] = Field(
json_schema_extra={
"description": "The energy consumption of a household appliance in watt-hours per hour."
"description": (
"Deprecated: aggregated energy consumption of all household "
"appliances in watt-hours per slot. Use 'home_appliance_energy_wh' "
"for per-device values."
)
}
)
home_appliance_energy_wh: dict[str, list[float]] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Per-device appliance energy in watt-hours per optimization slot, "
"keyed by device_id."
)
},
)
Kosten_Euro_pro_Stunde: list[float] = Field(
json_schema_extra={"description": "The costs in euros per hour."}
)
@@ -154,6 +167,15 @@ class GeneticSimulationResult(GeneticParametersBaseModel):
def convert_numpy(cls, field: Any) -> Any:
return NumpyEncoder.convert_numpy(field)[0]
@field_validator("home_appliance_energy_wh", mode="before")
def convert_numpy_appliance_energy(cls, field: Any) -> Any:
if isinstance(field, dict):
return {
device_id: NumpyEncoder.convert_numpy(values)[0]
for device_id, values in field.items()
}
return field
class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"""**Note**: The first value of "Last_Wh_per_hour", "Netzeinspeisung_Wh_per_hour", and "Netzbezug_Wh_per_hour", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged."""
@@ -191,7 +213,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
washingstart: Optional[int] = Field(
default=None,
json_schema_extra={
"description": "Can be `null` or contain an object representing the start of washing (if applicable)."
"description": (
"Deprecated: start slot of a single home appliance on the hourly "
"grid (legacy single-device case). Use 'appliance_starts' for the "
"general, ID-based start times."
)
},
)
appliance_starts: dict[str, list[DateTime]] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Scheduled run start times per appliance device_id as absolute "
"local datetimes."
)
},
)
@@ -573,32 +608,27 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
raise ValueError(error_msg)
solution[key] = operation[key]
# Add home appliance data
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
# Use config and not self.washingstart as washingstart may be None (no start)
# even if configured to be started.
homeappliance_device_id = self._homeappliance_device_id()
# result starts at start_day_slot
solution[f"{homeappliance_device_id}_energy_wh"] = (
self.result.Home_appliance_wh_per_hour[:n_points]
)
# Add home appliance data, one block of columns per device. Per-device
# energy arrays start at start_day_slot, like the other result arrays.
for device_id, energy_wh in self.result.home_appliance_energy_wh.items():
solution[f"{device_id}_energy_wh"] = energy_wh[:n_points]
operation = {
f"{homeappliance_device_id}_run_op_mode": [],
f"{homeappliance_device_id}_run_op_factor": [],
f"{homeappliance_device_id}_off_op_mode": [],
f"{homeappliance_device_id}_off_op_factor": [],
f"{device_id}_run_op_mode": [],
f"{device_id}_run_op_factor": [],
f"{device_id}_off_op_mode": [],
f"{device_id}_off_op_factor": [],
}
for hour_idx, energy in enumerate(solution[f"{homeappliance_device_id}_energy_wh"]):
if energy > 0.0:
operation[f"{homeappliance_device_id}_run_op_mode"].append(1.0)
operation[f"{homeappliance_device_id}_run_op_factor"].append(1.0)
operation[f"{homeappliance_device_id}_off_op_mode"].append(0.0)
operation[f"{homeappliance_device_id}_off_op_factor"].append(0.0)
for hour_idx, energy in enumerate(solution[f"{device_id}_energy_wh"]):
if energy and energy > 0.0:
operation[f"{device_id}_run_op_mode"].append(1.0)
operation[f"{device_id}_run_op_factor"].append(1.0)
operation[f"{device_id}_off_op_mode"].append(0.0)
operation[f"{device_id}_off_op_factor"].append(0.0)
else:
operation[f"{homeappliance_device_id}_run_op_mode"].append(0.0)
operation[f"{homeappliance_device_id}_run_op_factor"].append(0.0)
operation[f"{homeappliance_device_id}_off_op_mode"].append(1.0)
operation[f"{homeappliance_device_id}_off_op_factor"].append(1.0)
operation[f"{device_id}_run_op_mode"].append(0.0)
operation[f"{device_id}_run_op_factor"].append(0.0)
operation[f"{device_id}_off_op_mode"].append(1.0)
operation[f"{device_id}_off_op_factor"].append(1.0)
for key in operation.keys():
if len(operation[key]) != n_points:
error_msg = f"instruction {key} has invalid length {len(operation[key])} - expected {n_points}"
@@ -820,24 +850,23 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
)
# Add home appliance instructions (demand driven based control)
if self.config.devices.max_home_appliances and self.config.devices.max_home_appliances > 0:
# Use config and not self.washingstart as washingstart may be None (no start)
# even if configured to be started.
resource_id = self._homeappliance_device_id()
last_energy: Optional[float] = None
for hours, energy in enumerate(self.result.Home_appliance_wh_per_hour):
# Add home appliance instructions (demand driven based control), one
# stream of instructions per device. A new instruction is only emitted on
# a transition between OFF (energy == 0) and RUN (energy > 0); a mere
# power change within a running profile does not add an instruction.
for resource_id, energy_wh in self.result.home_appliance_energy_wh.items():
last_state: Optional[bool] = None
for hours, energy in enumerate(energy_wh):
# hours starts at start_datetime with 0
if energy is None:
raise ValueError(
f"Unexpected value {energy} in {self.result.Home_appliance_wh_per_hour}"
f"Unexpected value {energy} in home_appliance_energy_wh[{resource_id}]"
)
running = energy > 0.0
if last_state is None or running != last_state:
operation_mode = (
ApplianceOperationMode.RUN if running else ApplianceOperationMode.OFF
)
if last_energy is None or energy != last_energy:
if energy > 0.0:
operation_mode = ApplianceOperationMode.RUN # type: ignore[assignment]
else:
operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment]
operation_mode_factor = 1.0
execution_time = start_datetime.add(seconds=interval_s * hours)
plan.add_instruction(
DDBCInstruction(
@@ -845,9 +874,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
execution_time=execution_time,
actuator_id=resource_id,
operation_mode_id=operation_mode,
operation_mode_factor=operation_mode_factor,
operation_mode_factor=1.0,
)
)
last_energy = energy
last_state = running
return plan