mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c59bf1b486
commit
67cf6f7d8a
@@ -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]]:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user