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
+24 -2
View File
@@ -7,6 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
### Added
- Flexible consumers (home appliances): schedule any number of consumers via
`devices.home_appliances`, each with a unique `device_id`. Every consumer defines its
load **either** as an explicit power profile (`load_profile_power_w` at
`load_profile_interval_seconds`, energy-preservingly resampled onto the optimization
slot grid, including the 15-minute interval and non-integer ratios such as 10→15 min)
**or** as the flat `consumption_wh` + `duration_h` fallback. A `schedule_mode` selects
`ONCE` (a single run in the horizon) or `DAILY` (one run per local calendar day that
still has a feasible full run). Allowed start times honour `time_windows` (including
weekday/date restrictions) and the horizon; ONCE without any valid start is rejected.
Results are reported per device (`result.home_appliance_energy_wh`, `appliance_starts`,
per-device solution columns and `DDBCInstruction`s emitted only on RUN/OFF transitions).
- EV Bug (wrong output in genetic.py / no senseful results)
- Direktvermarktung active / Battery discharge into grid (new state / action battery_grid_export_allowed) + (new simulation output Feed_in_tariff)
- New PV forecast providers giving operators more cloud forecast sources to choose from in
@@ -25,8 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
is distributed across four slots, prices are held constant, and hourly warm-start
solutions are expanded to slot controls. Native slot arrays are preserved and
ambiguous lengths are rejected.
- Home-appliance scheduling remains hourly and is therefore rejected for sub-hourly
optimization instead of being simulated with incorrect slot indices.
- Home-appliance (flexible consumer) scheduling now runs on the same slot grid and
supports the 15-minute interval (see the flexible consumers entry below).
- The Tibber electricity price provider now requests native 15-minute exchange prices
(`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native
resolution, so both the hourly and the 15-minute optimizer are fed the correct
@@ -45,6 +56,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
missing slots at the end of the optimization horizon are extended with weekly or daily seasonal
ETS forecasts. A median fallback is used when the available history is too short for ETS.
### Changed
- `max_home_appliances` is now purely an upper bound. No demo appliance is created when
no `home_appliances` are configured, and the number is no longer used as an on/off switch.
### Deprecated
- The single-appliance genetic optimization input `dishwasher` is deprecated in favour of
the `home_appliances` list; a lone `dishwasher` is mapped to a one-element list, and
setting both at once is rejected. In the solution, `washingstart` (start slot of a single
hourly appliance) and `result.Home_appliance_wh_per_hour` (aggregate over all appliances)
are deprecated in favour of `appliance_starts` and `result.home_appliance_energy_wh`.
## 0.3.0 (2026-03-17)
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
+17 -3
View File
@@ -362,7 +362,12 @@ as a cohesive unit for scheduling and availability checking.
```
<!-- pyml enable line-length -->
### Home Appliance devices base settings
### Flexible consumer (home appliance) devices base settings
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.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list
@@ -371,10 +376,13 @@ as a cohesive unit for scheduling and availability checking.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| consumption_wh | `Optional[int]` | `rw` | `None` | Flat fallback: total energy consumption of one run [Wh]. Used only when no load_profile_power_w is given. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| duration_h | `Optional[int]` | `rw` | `None` | Flat fallback: run duration in hours [0 ... 24]. Used only when no load_profile_power_w is given. |
| load_profile_interval_seconds | `Optional[int]` | `rw` | `None` | Duration of one 'load_profile_power_w' step in seconds. Defaults to the configured optimization interval when a profile is given. |
| load_profile_power_w | `Optional[list[float]]` | `rw` | `None` | 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. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| schedule_mode | `<enum 'ConsumerScheduleMode'>` | `rw` | `ONCE` | Scheduling mode: ONCE (a single run within the horizon) or DAILY (one run per local calendar day with a feasible full run). |
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
:::
<!-- pyml enable line-length -->
@@ -390,6 +398,9 @@ as a cohesive unit for scheduling and availability checking.
"home_appliances": [
{
"device_id": "battery1",
"load_profile_power_w": null,
"load_profile_interval_seconds": null,
"schedule_mode": "ONCE",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
@@ -421,6 +432,9 @@ as a cohesive unit for scheduling and availability checking.
"home_appliances": [
{
"device_id": "battery1",
"load_profile_power_w": null,
"load_profile_interval_seconds": null,
"schedule_mode": "ONCE",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
+5 -4
View File
@@ -257,13 +257,14 @@ The GENETIC algorithm supports 4 devices:
- **electric_vehicle**: An electric vehicle, basically the battery of an electric vehicle. The
The electrical vehicle is optional.
- **battery**: A battery that can be charged by the inverter. The battery is mandatory.
- **home_appliance**: A home appliance, like a washing machine or a dish washer. The home
appliance is optional.
- **home_appliance**: A flexible consumer, like a washing machine or a dish washer. Any number of
home appliances can be configured; each is scheduled independently. Home appliances are optional.
:::{admonition} Warning
:class: warning
The GENETIC algorithm can only use the first inverter, electrical vehicle, battery, home appliance
that is configured, even if more devices are configured.
The GENETIC algorithm can only use the first inverter, electrical vehicle and battery that is
configured, even if more devices are configured. Home appliances are the exception: all configured
home appliances are scheduled.
:::
#### Inverter simulation configuration
+9 -6
View File
@@ -93,12 +93,15 @@ to `DISABLED` in the configuration.
"initial_soc_percentage": 54,
"min_soc_percentage": 0
},
"dishwasher": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"time_windows": null
},
"home_appliances": [
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"schedule_mode": "ONCE",
"time_windows": null
}
],
"temperature_forecast": [
18.3, 17.8, 16.9, 16.2, 15.6, 15.1, 14.6, 14.2, 14.3, 14.8, 15.7, 16.7, 17.4,
18.0, 18.6, 19.2, 19.1, 18.7, 18.5, 17.7, 16.2, 14.6, 13.6, 13.0, 12.6, 12.2,
+33 -9
View File
@@ -193,28 +193,52 @@ indicates no power transfer. Intermediate values scale the power proportionally.
## Home Appliance
The optimization algorithm supports one start of the home appliance within the optimization
horizon.
The optimization algorithm schedules any number of flexible consumers (home appliances). Each
consumer has a unique `device_id` and is scheduled independently. The `schedule_mode` selects how
often a consumer runs:
- `ONCE` — a single run somewhere within the optimization horizon ("fire and forget").
- `DAILY` — one run per local calendar day that still has a feasible complete run.
### Home Appliance Simulation
Each consumer's load is described by the energy of a single complete run, resampled onto the
optimization slot grid (hourly or 15-minute). Multiple consumers and multiple daily runs may
overlap; their energy adds up.
### Home Appliance Configuration
Home appliance to run within the optimization horizon.
A consumer's load is defined **either** by an explicit power profile or by the flat
`consumption_wh` + `duration_h` fallback (exactly one of the two).
Two consumers, one defined by the flat fallback (runs once), one by an explicit 15-minute power
profile that runs once per day:
```json
[
{
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3
"duration_h": 3,
"schedule_mode": "ONCE"
},
{
"device_id": "washingmachine1",
"load_profile_power_w": [200, 2000, 1800, 100],
"load_profile_interval_seconds": 900,
"schedule_mode": "DAILY"
}
]
```
Home appliance to run within a time window of 5 hours starting at 8:00 every day and another time
window of 3 hours starting at 15:00 every day. See
[Time Window Sequence Configuration](configtimewindow-page) for more information.
- `load_profile_power_w`: non-negative power values in watts describing one complete run. Each value
covers `load_profile_interval_seconds` (default: the configured optimization interval). The profile
is resampled energy-preservingly onto the optimization slot grid.
- `consumption_wh` / `duration_h`: flat fallback used when no `load_profile_power_w` is given.
A consumer may be restricted to run within a time window of 5 hours starting at 8:00 every day and
another time window of 3 hours starting at 15:00 every day. The complete run must fit inside a
single window. See [Time Window Sequence Configuration](configtimewindow-page) for more information.
```json
[
@@ -240,8 +264,8 @@ window of 3 hours starting at 15:00 every day. See
:::{admonition} Note
:class: note
The optimization algorithm always restricts to one start within the optimization horizon per
energy management run.
A `ONCE` consumer without any valid start (given its time windows and the horizon) is rejected. For
`DAILY`, a calendar day without a feasible run simply gets no run for that day.
:::
### Home Appliance Instructions
+255 -37
View File
@@ -2643,6 +2643,15 @@
"title": "ConfigSaveMode",
"description": "Configuration file save mode."
},
"ConsumerScheduleMode": {
"type": "string",
"enum": [
"ONCE",
"DAILY"
],
"title": "ConsumerScheduleMode",
"description": "Schedule mode of a flexible consumer (home appliance).\n\nDetermines how often a consumer's load profile is scheduled within the\noptimization horizon.\n\nModes\n-----\n- ONCE:\n The consumer runs exactly once somewhere within the optimization\n horizon (\"fire and forget\"). The optimizer picks the start.\n\n- DAILY:\n The consumer runs once per local calendar day, but only on days for\n which at least one complete, allowed run still fits into the remaining\n horizon. The optimizer picks one start per eligible day."
},
"DDBCActuatorStatus": {
"properties": {
"type": {
@@ -4946,6 +4955,21 @@
}
]
},
"home_appliances": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/HomeApplianceParameters"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Home Appliances",
"description": "List of flexible consumers (home appliances) to schedule."
},
"dishwasher": {
"anyOf": [
{
@@ -4954,7 +4978,9 @@
{
"type": "null"
}
]
],
"description": "Deprecated single home appliance. Use 'home_appliances' instead. Mutually exclusive with 'home_appliances'.",
"deprecated": true
},
"temperature_forecast": {
"anyOf": [
@@ -5064,7 +5090,18 @@
},
"type": "array",
"title": "Home Appliance Wh Per Hour",
"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": {
"additionalProperties": {
"items": {
"type": "number"
},
"type": "array"
},
"type": "object",
"title": "Home Appliance Energy Wh",
"description": "Per-device appliance energy in watt-hours per optimization slot, keyed by device_id."
},
"Kosten_Euro_pro_Stunde": {
"items": {
@@ -5231,7 +5268,19 @@
}
],
"title": "Washingstart",
"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": {
"additionalProperties": {
"items": {
"type": "string",
"format": "date-time"
},
"type": "array"
},
"type": "object",
"title": "Appliance Starts",
"description": "Scheduled run start times per appliance device_id as absolute local datetimes."
}
},
"additionalProperties": false,
@@ -5301,21 +5350,78 @@
"dishwasher"
]
},
"load_profile_power_w": {
"anyOf": [
{
"items": {
"type": "number"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Load Profile Power W",
"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.",
"examples": [
null
]
},
"load_profile_interval_seconds": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Load Profile Interval Seconds",
"description": "Duration of one 'load_profile_power_w' step in seconds. Defaults to the configured optimization interval when a profile is given.",
"examples": [
null
]
},
"schedule_mode": {
"$ref": "#/components/schemas/ConsumerScheduleMode",
"description": "Scheduling mode: ONCE (a single run within the horizon) or DAILY (one run per local calendar day with a feasible full run).",
"default": "ONCE",
"examples": [
"ONCE",
"DAILY"
]
},
"consumption_wh": {
"type": "integer",
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Consumption Wh",
"description": "Energy consumption [Wh].",
"description": "Flat fallback: total energy consumption of one run [Wh]. Used only when no load_profile_power_w is given.",
"examples": [
2000
]
},
"duration_h": {
"type": "integer",
"maximum": 24.0,
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"maximum": 24.0,
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Duration H",
"description": "Usage duration in hours [0 ... 24].",
"description": "Flat fallback: run duration in hours [0 ... 24]. Used only when no load_profile_power_w is given.",
"examples": [
1
]
@@ -5343,12 +5449,8 @@
}
},
"type": "object",
"required": [
"consumption_wh",
"duration_h"
],
"title": "HomeApplianceCommonSettings",
"description": "Home Appliance devices base settings."
"description": "Flexible consumer (home appliance) devices base settings.\n\nA consumer's load is defined **either** by an explicit power profile\n(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)\n**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one\nof the two must be provided."
},
"HomeApplianceCommonSettings-Output": {
"properties": {
@@ -5364,21 +5466,78 @@
"dishwasher"
]
},
"load_profile_power_w": {
"anyOf": [
{
"items": {
"type": "number"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Load Profile Power W",
"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.",
"examples": [
null
]
},
"load_profile_interval_seconds": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Load Profile Interval Seconds",
"description": "Duration of one 'load_profile_power_w' step in seconds. Defaults to the configured optimization interval when a profile is given.",
"examples": [
null
]
},
"schedule_mode": {
"$ref": "#/components/schemas/ConsumerScheduleMode",
"description": "Scheduling mode: ONCE (a single run within the horizon) or DAILY (one run per local calendar day with a feasible full run).",
"default": "ONCE",
"examples": [
"ONCE",
"DAILY"
]
},
"consumption_wh": {
"type": "integer",
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Consumption Wh",
"description": "Energy consumption [Wh].",
"description": "Flat fallback: total energy consumption of one run [Wh]. Used only when no load_profile_power_w is given.",
"examples": [
2000
]
},
"duration_h": {
"type": "integer",
"maximum": 24.0,
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"maximum": 24.0,
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Duration H",
"description": "Usage duration in hours [0 ... 24].",
"description": "Flat fallback: run duration in hours [0 ... 24]. Used only when no load_profile_power_w is given.",
"examples": [
1
]
@@ -5423,12 +5582,10 @@
},
"type": "object",
"required": [
"consumption_wh",
"duration_h",
"measurement_keys"
],
"title": "HomeApplianceCommonSettings",
"description": "Home Appliance devices base settings."
"description": "Flexible consumer (home appliance) devices base settings.\n\nA consumer's load is defined **either** by an explicit power profile\n(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)\n**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one\nof the two must be provided."
},
"HomeApplianceParameters": {
"properties": {
@@ -5437,7 +5594,7 @@
"title": "Device Id",
"description": "ID of home appliance",
"examples": [
"dishwasher"
"dishwasher1"
]
},
"hours": {
@@ -5456,20 +5613,83 @@
null
]
},
"load_profile_power_w": {
"anyOf": [
{
"items": {
"type": "number"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Load Profile Power W",
"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": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Load Profile Interval Seconds",
"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": {
"$ref": "#/components/schemas/ConsumerScheduleMode",
"description": "Scheduling mode: ONCE (a single run within the horizon) or DAILY (one run per local calendar day with a feasible full run).",
"default": "ONCE",
"examples": [
"ONCE",
"DAILY"
]
},
"consumption_wh": {
"type": "integer",
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Consumption Wh",
"description": "An integer representing the energy consumption of a household device in watt-hours.",
"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": {
"type": "integer",
"exclusiveMinimum": 0.0,
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Duration H",
"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
]
@@ -5497,12 +5717,10 @@
"additionalProperties": false,
"type": "object",
"required": [
"device_id",
"consumption_wh",
"duration_h"
"device_id"
],
"title": "HomeApplianceParameters",
"description": "Home Appliance Device Simulation Configuration."
"description": "Flexible consumer (home appliance) device simulation configuration.\n\nA consumer's load is defined **either** by an explicit power profile\n(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)\n**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one\nof the two must be provided."
},
"HomeAssistantAdapterCommonSettings-Input": {
"properties": {
@@ -9525,4 +9743,4 @@
}
}
}
}
}
+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
+5 -5
View File
@@ -54,7 +54,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
battery=akku,
)
# Household device (currently not used, set to None)
# Flexible consumer (fixed start at slot 2 for this deterministic test)
home_appliance = HomeAppliance(
HomeApplianceParameters(
device_id="dishwasher1",
@@ -65,6 +65,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
optimization_hours=config_eos.optimization.horizon_hours,
prediction_hours=config_eos.prediction.hours,
)
home_appliance.build_load_curve([2])
# Example initialization of electric car battery
eauto = Battery(
@@ -246,7 +247,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
prediction_hours=config_eos.prediction.hours,
inverter=inverter,
ev=eauto,
home_appliance=home_appliance,
home_appliances=[home_appliance],
)
# Init for test
@@ -259,7 +260,6 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
simulation.dc_charge_hours[start_hour] = 1.0
simulation.bat_discharge_hours[start_hour] = 1.0
simulation.ev_charge_hours[start_hour] = 1.0
simulation.home_appliance_start_hour = 2
return simulation
@@ -362,8 +362,8 @@ def test_simulation(genetic_simulation):
# Check home appliances
assert (
sum(simulation.home_appliance.get_load_curve()) == 2000
), "The sum of 'simulation.home_appliance.get_load_curve()' should be 2000."
sum(simulation.home_appliances[0].get_load_curve()) == 2000
), "The sum of 'simulation.home_appliances[0].get_load_curve()' should be 2000."
assert (
np.nansum(
+3 -3
View File
@@ -50,7 +50,7 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation:
battery = akku,
)
# Household device (currently not used, set to None)
# Flexible consumer (fixed start at slot 2 for this deterministic test)
home_appliance = HomeAppliance(
HomeApplianceParameters(
device_id="dishwasher1",
@@ -61,6 +61,7 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation:
optimization_hours = config_eos.optimization.horizon_hours,
prediction_hours = config_eos.prediction.hours,
)
home_appliance.build_load_curve([2])
# Example initialization of electric car battery
eauto = Battery(
@@ -148,7 +149,7 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation:
prediction_hours = config_eos.prediction.hours,
inverter=inverter,
ev=eauto,
home_appliance=home_appliance,
home_appliances=[home_appliance],
)
ac = np.full(config_eos.prediction.hours, 0.0)
@@ -157,7 +158,6 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation:
dc = np.full(config_eos.prediction.hours, 0.0)
dc[11] = 1
simulation.dc_charge_hours = dc
simulation.home_appliance_start_hour = 2
return simulation
+355
View File
@@ -0,0 +1,355 @@
"""Tests for flexible consumers (home appliances).
Covers the energy-preserving load profile, allowed start computation, the
appliance genome layout (ONCE/DAILY), multi-device scheduling and the
deprecated single-appliance compatibility path.
"""
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.devices.genetic.homeappliance import (
HomeAppliance,
resample_power_to_slot_energy,
)
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time
ems_eos = get_ems(init=True)
def _appliance(prediction_hours: int, slot_duration_h: float, **params) -> HomeAppliance:
return HomeAppliance(
HomeApplianceParameters(**params),
optimization_hours=prediction_hours,
prediction_hours=prediction_hours,
slot_duration_h=slot_duration_h,
)
def _ems(n: int, load: float = 500.0) -> dict:
return {
"pv_prognose_wh": [0.0] * n,
"strompreis_euro_pro_wh": [0.0003] * n,
"einspeiseverguetung_euro_pro_wh": 0.00007,
"preis_euro_pro_wh_akku": 0.0001,
"gesamtlast": [load] * n,
}
# --------------------------------------------------------------------------- #
# Energy-preserving resampling
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"input_interval, slot_interval",
[(3600, 900), (900, 3600), (600, 900), (1200, 900), (1800, 900), (3600, 3600)],
)
def test_resample_conserves_energy(input_interval: int, slot_interval: int):
"""Energy is conserved for integer and non-integer interval ratios."""
power = [1000.0, 0.0, 500.0, 2500.0, 750.0]
energy = resample_power_to_slot_energy(power, input_interval, slot_interval)
expected = sum(p * input_interval / 3600 for p in power)
assert energy.sum() == pytest.approx(expected)
assert (energy >= 0).all()
def test_flat_fallback_hourly_matches_legacy():
"""The flat consumption_wh/duration_h fallback reproduces the legacy curve."""
appliance = _appliance(48, 1.0, device_id="dw", consumption_wh=2000, duration_h=2)
assert appliance.run_slots == 2
assert list(appliance.run_energy_wh) == [1000.0, 1000.0]
appliance.build_load_curve([5])
curve = appliance.get_load_curve()
assert curve[5] == 1000.0 and curve[6] == 1000.0
assert curve.sum() == 2000.0
def test_flat_fallback_15min_grid():
"""The flat fallback resamples onto the quarter-hour grid, conserving energy."""
appliance = _appliance(192, 0.25, device_id="dw", consumption_wh=2000, duration_h=2)
assert appliance.run_slots == 8 # 2 h -> 8 quarter-hours
assert appliance.run_energy_wh.sum() == pytest.approx(2000.0)
assert all(value == pytest.approx(250.0) for value in appliance.run_energy_wh)
def test_build_load_curve_overlapping_runs_add():
"""Overlapping runs of one appliance sum their per-slot energy."""
appliance = _appliance(
10, 1.0, device_id="d", load_profile_power_w=[3600.0, 3600.0], load_profile_interval_seconds=3600
)
appliance.build_load_curve([2, 3]) # runs occupy [2,3] and [3,4] -> overlap at 3
curve = appliance.get_load_curve()
assert curve[2] == pytest.approx(3600.0)
assert curve[3] == pytest.approx(7200.0)
assert curve[4] == pytest.approx(3600.0)
# --------------------------------------------------------------------------- #
# Allowed start slots and time windows
# --------------------------------------------------------------------------- #
def test_allowed_start_slots_time_window_and_horizon():
"""Only starts whose full run fits a window and the horizon are allowed."""
slot0 = to_datetime("2026-07-15 00:00:00")
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("3 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
# window 10:00-13:00, run 1 h -> starts 10,11,12 each day (+24 on day 1)
assert allowed == [10, 11, 12, 34, 35, 36]
def test_allowed_start_slots_window_over_midnight():
"""A window crossing midnight yields starts on both sides of midnight."""
slot0 = to_datetime("2026-07-15 00:00:00")
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("23:00"), duration=to_duration("3 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
)
# 23:00-02:00 window: a run starting at 23:00 crosses midnight (ends 00:00).
# TimeWindow evaluates the window on the start's own calendar day, so the
# only allowed start per day is 23:00 (slot 23 on day 0, slot 47 on day 1).
assert allowed == [23, 47]
def test_allowed_start_slots_weekday_restriction():
"""A weekday-restricted window only allows starts on that weekday."""
slot0 = to_datetime("2026-07-15 00:00:00") # Wednesday
weekday = slot0.day_of_week
windows = TimeWindowSequence(
windows=[
TimeWindow(
start_time=to_time("10:00"), duration=to_duration("2 hours"), day_of_week=weekday
)
]
)
appliance = _appliance(
72, 1.0, device_id="d", consumption_wh=1000, duration_h=1, time_windows=windows
)
allowed = appliance.allowed_start_slots(
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=72
)
# Only day 0 (the Wednesday) matches: starts 10, 11
assert allowed == [10, 11]
# --------------------------------------------------------------------------- #
# Genome layout (ONCE / DAILY)
# --------------------------------------------------------------------------- #
def _optimizer(config_eos, *, prediction_hours: int, horizon_hours: int, interval: int, hour: int):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": prediction_hours},
"optimization": {"horizon_hours": horizon_hours, "interval": interval},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=hour, minute=0))
return GeneticOptimization(fixed_seed=1)
def test_once_layout_single_gene(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=2)
layout = opt._build_appliance_layout([appliance], slot0)
assert layout.n_genes == 1
assert layout.genes[0].run_date is None
assert layout.genes[0].allowed_start_slots[0] == opt._start_day_slot()
def test_once_no_valid_start_raises(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=10, interval=3600, hour=10)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
# 02:00 window is in the past (start slot 10) and day 1 is beyond the 10 h horizon.
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("02:00"), duration=to_duration("1 hours"))]
)
appliance = _appliance(
48, 1.0, device_id="d", consumption_wh=500, duration_h=1, time_windows=windows
)
with pytest.raises(ValueError, match="no valid start"):
opt._build_appliance_layout([appliance], slot0)
def test_daily_layout_one_gene_per_calendar_day(config_eos):
opt = _optimizer(config_eos, prediction_hours=72, horizon_hours=72, interval=3600, hour=0)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))]
)
appliance = _appliance(
72,
1.0,
device_id="d",
consumption_wh=500,
duration_h=1,
schedule_mode="DAILY",
time_windows=windows,
)
layout = opt._build_appliance_layout([appliance], slot0)
assert layout.n_genes == 3 # 3 calendar days in the 72 h horizon
assert len({gene.run_date for gene in layout.genes}) == 3
for gene in layout.genes:
assert len(gene.allowed_start_slots) == 2 # starts 10 and 11 on each day
def test_daily_layout_partial_first_day(config_eos):
"""A partial first day (start after the window) produces no gene for that day."""
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=14)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
windows = TimeWindowSequence(
windows=[TimeWindow(start_time=to_time("10:00"), duration=to_duration("2 hours"))]
)
appliance = _appliance(
48,
1.0,
device_id="d",
consumption_wh=500,
duration_h=1,
schedule_mode="DAILY",
time_windows=windows,
)
layout = opt._build_appliance_layout([appliance], slot0)
# Day 0 window (10:00-12:00) is already in the past at start hour 14 -> only day 1.
assert layout.n_genes == 1
assert all(slot >= opt._start_day_slot() for slot in layout.genes[0].allowed_start_slots)
# --------------------------------------------------------------------------- #
# Multiple devices, aggregate and deprecated compatibility (integration)
# --------------------------------------------------------------------------- #
def test_multiple_appliances_scheduled_and_aggregate(config_eos):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"horizon_hours": 48,
"interval": 3600,
"genetic": {
"individuals": 60,
"generations": 10,
"penalties": {"ev_soc_miss": 10, "ac_charge_break_even": 0},
},
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
parameters = GeneticOptimizationParameters(
ems=_ems(48),
pv_akku=None,
inverter=None,
eauto=None,
home_appliances=[
HomeApplianceParameters(device_id="dw", consumption_wh=1000, duration_h=1),
HomeApplianceParameters(device_id="wm", consumption_wh=2000, duration_h=2),
],
)
solution = GeneticOptimization(fixed_seed=7).optimierung_ems(
parameters=parameters, start_hour=0, ngen=3
)
per_device = solution.result.home_appliance_energy_wh
assert set(per_device) == {"dw", "wm"}
assert sum(per_device["dw"]) == pytest.approx(1000.0)
assert sum(per_device["wm"]) == pytest.approx(2000.0)
# Per-device energy sums exactly to the deprecated aggregate.
aggregate = [
(per_device["dw"][i] or 0.0) + (per_device["wm"][i] or 0.0)
for i in range(len(per_device["dw"]))
]
reported = [value or 0.0 for value in solution.result.Home_appliance_wh_per_hour]
assert reported == pytest.approx(aggregate)
# Each device has an absolute start datetime.
assert set(solution.appliance_starts) == {"dw", "wm"}
assert len(solution.appliance_starts["dw"]) == 1
# DDBC instructions are only emitted on RUN/OFF transitions.
plan = solution.energy_management_plan()
dw_instructions = [i for i in plan.instructions if i.resource_id == "dw"]
modes = [str(i.operation_mode_id) for i in dw_instructions]
# A single 1 h run yields an OFF/RUN/OFF sequence (no repeated RUN).
assert modes.count("RUN") == 1
def test_duplicate_device_id_rejected():
with pytest.raises(ValidationError, match="unique"):
GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
home_appliances=[
HomeApplianceParameters(device_id="x", consumption_wh=1000, duration_h=1),
HomeApplianceParameters(device_id="x", consumption_wh=1000, duration_h=1),
],
)
def test_dishwasher_and_home_appliances_conflict_rejected():
with pytest.raises(ValidationError, match="either"):
GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
dishwasher=HomeApplianceParameters(device_id="d", consumption_wh=1000, duration_h=1),
home_appliances=[
HomeApplianceParameters(device_id="e", consumption_wh=1000, duration_h=1)
],
)
def test_deprecated_dishwasher_maps_to_list():
parameters = GeneticOptimizationParameters(
ems=_ems(2),
pv_akku=None,
inverter=None,
eauto=None,
dishwasher=HomeApplianceParameters(device_id="d", consumption_wh=1000, duration_h=1),
)
resolved = parameters.resolved_home_appliances()
assert [appliance.device_id for appliance in resolved] == ["d"]
def test_max_home_appliances_is_upper_bound():
with pytest.raises(ValidationError, match="exceeds max_home_appliances"):
DevicesCommonSettings(
max_home_appliances=1,
home_appliances=[
{"device_id": "a", "consumption_wh": 1000, "duration_h": 1},
{"device_id": "b", "consumption_wh": 1000, "duration_h": 1},
],
)
def test_start_solution_layout_mismatch_is_ignored(config_eos):
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=10)
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
appliance = _appliance(48, 1.0, device_id="d", consumption_wh=1000, duration_h=1)
opt.appliance_layout = opt._build_appliance_layout([appliance], slot0)
opt.optimize_ev = False
valid_index_count = len(opt.appliance_layout.genes[0].allowed_start_slots)
# A tail index beyond the allowed range must be rejected.
bad_solution = [0] * opt.total_slots + [valid_index_count + 5]
assert opt._start_solution_matches_layout(bad_solution) is False
good_solution = [0] * opt.total_slots + [0]
assert opt._start_solution_matches_layout(good_solution) is True
+11 -3
View File
@@ -277,7 +277,7 @@ class TestAcChargingInSimulation:
prediction_hours=prediction_hours,
inverter=inverter,
ev=None,
home_appliance=None,
home_appliances=None,
)
return sim, akku, inverter
@@ -553,7 +553,10 @@ def _run_evaluate_with_mocked_sim(
- self.simulation is replaced by mock_sim
Then call evaluate() and return the fitness tuple.
"""
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.genetic import (
ApplianceGeneLayout,
GeneticOptimization,
)
config_eos.merge_settings_from_dict(
{
@@ -572,6 +575,7 @@ def _run_evaluate_with_mocked_sim(
optim.optimize_ev = False
optim.verbose = False
optim.opti_param = {"home_appliance": 0}
optim.appliance_layout = ApplianceGeneLayout([])
optim.simulation = mock_sim
# evaluate_inner() just returns the base balance; we test the *additional* penalty
@@ -602,7 +606,10 @@ def _run_evaluate_with_mocked_sim(
def _run_evaluate_with_mocked_ev_soc(config_eos, ev_soc_percentage: float) -> float:
"""Return fitness for a mocked EV SoC while EV optimization is active."""
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.genetic import (
ApplianceGeneLayout,
GeneticOptimization,
)
config_eos.merge_settings_from_dict(
{
@@ -620,6 +627,7 @@ def _run_evaluate_with_mocked_ev_soc(config_eos, ev_soc_percentage: float) -> fl
optim.optimize_ev = True
optim.verbose = False
optim.opti_param = {"home_appliance": 0}
optim.appliance_layout = ApplianceGeneLayout([])
mock_ev = Mock()
mock_ev.current_soc_percentage.return_value = ev_soc_percentage
+20 -10
View File
@@ -221,7 +221,7 @@ def test_hourly_start_solution_is_expanded_to_slots(config_eos: ConfigEOS):
opt.optimize_ev = False
hourly = list(range(48))
migrated = opt._start_solution_for_slot_grid(hourly, has_appliance=False)
migrated = opt._start_solution_for_slot_grid(hourly)
assert len(migrated) == 192
assert migrated[:8] == [0, 0, 0, 0, 1, 1, 1, 1]
@@ -242,8 +242,8 @@ def test_quarter_hour_mutation_probability_preserves_hourly_rate(config_eos: Con
assert opt.toolbox.mutate_charge_discharge.keywords["indpb"] == pytest.approx(0.05)
def test_sub_hourly_home_appliance_is_rejected(config_eos: ConfigEOS):
"""An hourly appliance model must not silently run on slot indices."""
def test_sub_hourly_home_appliance_is_scheduled(config_eos: ConfigEOS):
"""A home appliance is scheduled on the 15-min slot grid and delivers its energy."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
@@ -252,18 +252,28 @@ def test_sub_hourly_home_appliance_is_rejected(config_eos: ConfigEOS):
)
parameters = load_hourly_parameters().model_copy(
update={
"dishwasher": HomeApplianceParameters(
device_id="dishwasher", consumption_wh=1200, duration_h=2
)
"home_appliances": [
HomeApplianceParameters(
device_id="dishwasher1", consumption_wh=1200, duration_h=2
)
]
},
deep=True,
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
CacheEnergyManagementStore().clear()
with pytest.raises(ValueError, match="Home-appliance scheduling"):
GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=10, ngen=1
)
genetic_solution = GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=10, ngen=3
)
# The appliance runs exactly once and delivers its full energy on the 15-min grid.
energy = genetic_solution.result.home_appliance_energy_wh["dishwasher1"]
assert sum(energy) == pytest.approx(1200.0)
# The run occupies 2 h = 8 quarter-hour slots at 1200/2 = 600 W -> 150 Wh/slot.
assert max(energy) == pytest.approx(150.0)
# A single start time is reported as an absolute datetime.
assert len(genetic_solution.appliance_starts["dishwasher1"]) == 1
def test_optimize_15min_slot_grid(config_eos: ConfigEOS):
+338 -291
View File
@@ -10,12 +10,24 @@
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
1.0,
0.0,
@@ -25,7 +37,6 @@
0.0,
1.0,
1.0,
1.0,
0.0,
0.0,
0.0,
@@ -36,17 +47,6 @@
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0
],
"dc_charge": [
@@ -110,7 +110,11 @@
0,
0,
0,
0,
1,
1,
0,
0,
0,
0,
0,
@@ -121,19 +125,18 @@
0,
0,
1,
1,
0,
0,
0,
0,
1,
0,
0,
0,
1,
1,
1,
1,
0,
1,
1,
1,
0,
0,
0,
1,
@@ -142,9 +145,6 @@
1,
1,
1,
1,
0,
0,
0,
1,
1
@@ -161,15 +161,15 @@
0.0,
0.0,
0.0,
0.875,
0.75,
0.0,
0.875,
0.75,
1.0,
0.625,
0.375,
1.0,
0.75,
0.875,
0.1,
0.75,
0.0,
0.0,
0.0,
0.0,
@@ -202,28 +202,28 @@
],
"result": {
"Last_Wh_pro_Stunde": [
1053.07,
10240.91,
14186.477801352086,
11620.03,
10686.11504084929,
7609.82,
9082.22,
10280.78,
2177.92,
15230.07,
8929.91,
1320.56,
10061.61912107894,
13077.21553917656,
9042.82,
10393.22,
8969.78,
1129.12,
1178.71,
1050.98,
1488.5587949275546,
988.56,
912.38,
2204.61,
704.61,
516.37,
868.05,
694.34,
608.79,
556.31,
2056.31,
488.89,
506.91,
804.89,
1304.8889978824886,
1141.98,
1056.97,
992.46,
@@ -235,51 +235,51 @@
860.88,
1158.03,
1222.72,
1221.04,
949.99,
3721.04,
3449.99,
987.01,
733.99,
592.97
],
"EAuto_SoC_pro_Stunde": [
5.0,
5.0,
20.294999999999998,
33.405,
50.885000000000005,
61.809999999999995,
68.365,
81.475,
96.77,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518
33.405,
39.96,
57.440000000000005,
70.55,
85.845,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955
],
"Einnahmen_Euro_pro_Stunde": [
0.0,
@@ -288,10 +288,12 @@
0.0,
0.0,
0.0,
0.005594705401588846,
0.0016437871377312284,
0.028240415856282213,
9.722458428505041e-05,
0.0023429436853348124,
0.0692592282596561,
0.023370695807696434,
0.0019327051509204403,
8.435507117427132e-07,
0.0,
0.0,
0.0,
@@ -305,27 +307,54 @@
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.09023487592359272,
0.03692581803776506,
0.09634325718089931,
0.038455087951794004,
0.31400739999999994,
0.2577866264025879,
0.15146384621553569,
0.09798107754792196,
0.029150936607659956,
0.020928608511559126,
0.003524558735906156,
0.0,
0.0,
0.0,
0.0,
0.0
],
"Gesamt_Verluste": 6008.882879266785,
"Gesamtbilanz_Euro": 13.081213953988335,
"Gesamteinnahmen_Euro": 0.7099201341480623,
"Gesamtkosten_Euro": 13.791134088136397,
"Gesamt_Verluste": 6850.393259430652,
"Gesamtbilanz_Euro": 13.198417631718888,
"Gesamteinnahmen_Euro": 1.1191176909827683,
"Gesamtkosten_Euro": 14.317535322701657,
"Home_appliance_wh_per_hour": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
@@ -334,113 +363,126 @@
2500.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
],
"home_appliance_energy_wh": {
"dishwasher1": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
2500.0,
2500.0,
0.0,
0.0,
0.0
]
},
"Kosten_Euro_pro_Stunde": [
3.26035212,
0.7712613792641736,
0.0,
2.034522392,
2.737606326,
1.9651220859999998,
0.9176848434271027,
0.5064650351737862,
1.1459236583154115,
1.6539907068609285,
0.1912938683161112,
1.672937586,
1.342948866431813,
0.770122611209644,
1.4257539978642364,
1.3586609716653002,
0.05258762370598476,
0.1614775630079859,
0.0,
0.4396171120740812,
0.0,
0.61288158,
0.174739608,
0.28801899,
0.2338232746714084,
0.29116746986009023,
0.26650619799999997,
0.19588158,
0.0,
0.0,
0.22802125600000003,
0.199865757,
0.676320359,
0.162995926,
0.0,
0.42921344809282075,
0.25364873699864443,
0.1306329312971816,
0.0,
0.16677339,
0.0,
0.0,
0.0,
0.07362195915902499,
0.060401289430882174,
0.009619897970888898,
0.07029121023060134,
4.179128154646605e-17,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.17784012884918773,
0.19011028252189552,
0.293043269,
0.0,
0.0
],
"Netzbezug_Wh_pro_Stunde": [
14299.789999999999,
3486.7150961309835,
0.0,
9197.66,
13079.82,
10458.34,
4992.84463235638,
2527.2706345997312,
5213.483431826258,
7286.3026733961615,
638.2845122326032,
8903.34,
7306.577075254695,
3842.9272016449304,
6486.596896561585,
5985.290624076212,
175.4675465665157,
505.40708296709204,
0.0,
1480.6908456520082,
0.0,
2204.61,
516.37,
868.05,
758.9200735845777,
980.6920507244535,
912.38,
704.61,
0.0,
0.0,
694.34,
608.79,
2056.31,
488.89,
0.0,
1299.8590190576037,
833.8222781020527,
537.58407941227,
0.0,
506.91,
0.0,
0.0,
0.0,
322.9033296448464,
273.0618871197205,
45.96224544141853,
374.088399311343,
2.2737367544323206e-13,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
556.6201215937018,
617.0408390843736,
987.01,
0.0,
0.0
@@ -452,10 +494,12 @@
0.0,
0.0,
0.0,
79.92436287984066,
23.482673396160408,
403.4345122326031,
1.3889226326435775,
33.47062407621161,
989.4175465665157,
333.86708296709196,
27.61007358457772,
0.012050724453467332,
0.0,
0.0,
0.0,
@@ -469,92 +513,68 @@
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1289.0696560513247,
527.5116862537866,
1376.3322454414188,
549.358399311343,
4485.82,
3682.6660914655417,
2163.76923165051,
1399.729679256028,
416.4419515379994,
298.9801215937018,
50.35083908437366,
0.0,
0.0,
0.0,
0.0,
0.0
],
"Verluste_Pro_Stunde": [
97.85621559422765,
483.0,
1014.0000000000001,
552.0,
440.15935588276557,
259.38247615196775,
416.54628827357027,
483.0,
55.200000000000045,
1083.0,
1014.0066115357181,
114.42806532440363,
806.9999999999999,
752.8472490305633,
452.3012641973916,
490.424156871473,
414.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
70.41409090909087,
118.37045454545455,
0.0,
0.0,
106.80230977350088,
60.001301478240954,
124.41545454545451,
180.0,
0.0,
69.12409090909085,
60.00108228691243,
3.2918733722463145,
22.312089529472388,
98.21987178167876,
23.32202410391207,
0.0,
0.0,
94.68272727272722,
83.01681818181817,
75.86045454545456,
66.66681818181814,
0.0,
109.07302361034766,
116.99491129525349,
95.61900944932736,
54.18759955738153,
86.6234264543665,
171.42744837680007,
116.9350623689079,
383.6100412738411,
0.03390853445803674,
2.900768349489402,
16.700320743972142,
81.2380484620021,
0.0,
0.0,
377.3215838283509,
418.18661420587983,
0.0,
100.08954545454549,
80.85954545454547
],
"akku_soc_pro_stunde": [
80.0,
79.16421888032488,
79.16421888032488,
95.83088554699157,
95.83088554699157,
98.47420098817949,
99.9292697701786,
100.0,
100.0,
100.0,
100.0,
96.82533215994886,
98.49203497878888,
94.56477946914701,
99.564779469147,
99.564779469147,
99.564779469147,
96.57605701735636,
93.95557664545554,
91.56099159035911,
89.45660970330677,
89.45660970330677,
86.01371946235848,
82.51604934381585,
80.82184855044719,
82.32705964926333,
84.7332659396624,
89.12319984732603,
89.34416552017109,
96.66666666666667,
77.72745638104269,
76.48409249723811,
93.15075916390477,
98.72984941475377,
99.79377342023686,
100.0,
100.0,
100.0,
@@ -563,7 +583,29 @@
100.0,
100.0,
100.0,
96.84060778236915
97.77733298898072,
94.04089187327823,
94.04089187327823,
94.04089187327823,
99.04089187327823,
99.04089187327823,
96.85894455922863,
98.52564128942065,
98.6170822164275,
99.23686248113506,
99.35216599711354,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
88.1251455158017,
74.92485531047642,
74.92485531047642,
71.76546309284556
],
"Electricity_price": [
0.000228,
@@ -660,15 +702,15 @@
0.0,
0.0,
0.0,
0.875,
0.75,
0.0,
0.875,
0.75,
1.0,
0.625,
0.375,
1.0,
0.75,
0.875,
0.1,
0.75,
0.0,
0.0,
0.0,
0.0,
@@ -753,44 +795,47 @@
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"max_charge_power_w": 11040,
"soc_wh": 59110.8,
"soc_wh": 59373.0,
"initial_soc_percentage": 5
},
"start_solution": [
2.0,
1.0,
1.0,
0.0,
1.0,
1.0,
0.0,
2.0,
2.0,
1.0,
2.0,
1.0,
1.0,
2.0,
2.0,
2.0,
2.0,
2.0,
2.0,
2.0,
2.0,
0.0,
2.0,
0.0,
1.0,
1.0,
0.0,
2.0,
1.0,
1.0,
1.0,
0.0,
2.0,
0.0,
2.0,
0.0,
2.0,
1.0,
2.0,
0.0,
2.0,
1.0,
2.0,
1.0,
2.0,
2.0,
2.0,
1.0,
1.0,
1.0,
1.0,
0.0,
1.0,
1.0,
1.0,
0.0,
0.0,
1.0,
@@ -799,61 +844,63 @@
1.0,
1.0,
1.0,
1.0,
2.0,
0.0,
2.0,
1.0,
1.0,
6.0,
2.0,
1.0,
1.0,
6.0,
4.0,
6.0,
4.0,
2.0,
5.0,
0.0,
5.0,
4.0,
6.0,
3.0,
1.0,
4.0,
5.0,
3.0,
6.0,
5.0,
3.0,
0.0,
5.0,
4.0,
0.0,
1.0,
6.0,
4.0,
5.0,
4.0,
1.0,
3.0,
3.0,
2.0,
3.0,
0.0,
1.0,
3.0,
4.0,
5.0,
1.0,
3.0,
0.0,
2.0,
2.0,
5.0,
5.0,
5.0,
6.0,
1.0,
5.0,
5.0,
0.0,
0.0,
0.0,
5.0,
3.0,
5.0,
2.0,
0.0,
0.0,
4.0,
5.0,
0.0,
0.0,
6.0,
1.0,
1.0,
6.0,
0.0,
5.0,
2.0,
6.0,
3.0,
4.0,
5.0,
6.0,
2.0,
2.0,
14.0
33.0
],
"washingstart": 14
"washingstart": 43,
"appliance_starts": {
"dishwasher1": [
"2026-07-16 19:00:00+02:00"
]
}
}
+276 -229
View File
@@ -11,18 +11,13 @@
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
@@ -42,7 +37,12 @@
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
@@ -113,7 +113,6 @@
0,
0,
0,
0,
1,
1,
0,
@@ -123,7 +122,7 @@
1,
0,
0,
1,
0,
0,
0,
1,
@@ -134,6 +133,11 @@
1,
1,
1,
1,
1,
0,
0,
1,
0,
0,
1,
@@ -142,10 +146,6 @@
1,
1,
1,
0,
1,
0,
1,
1,
0
],
@@ -161,17 +161,17 @@
0.0,
0.0,
0.0,
1.0,
0.75,
0.625,
0.5,
0.375,
0.875,
0.5,
0.0,
1.0,
0.0,
0.375,
0.375,
1.0,
0.0,
0.75,
0.0,
0.0,
0.0,
0.375,
0.6,
0.0,
0.0,
0.0,
@@ -202,24 +202,24 @@
],
"result": {
"Last_Wh_pro_Stunde": [
16541.07,
8929.91,
8375.540038207693,
6376.03,
5096.67,
12853.82,
8960.220000000001,
13936.309375923789,
1129.12,
1178.71,
1050.98,
15230.07,
1525.2526751616956,
11808.56,
1132.03,
7596.67,
7609.82,
13190.760676868524,
1103.78,
8995.119999999999,
5111.71,
7343.779999999999,
988.56,
1412.38,
5912.38,
704.61,
516.37,
868.05,
694.34,
2608.79,
608.79,
556.31,
488.89,
506.91,
@@ -243,55 +243,54 @@
],
"EAuto_SoC_pro_Stunde": [
5.0,
22.48,
35.589999999999996,
46.515,
55.254999999999995,
61.809999999999995,
77.105,
85.845,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955,
98.955
20.294999999999998,
20.294999999999998,
37.775,
37.775,
44.330000000000005,
50.885000000000005,
68.365,
68.365,
81.475,
88.03,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518,
98.518
],
"Einnahmen_Euro_pro_Stunde": [
0.0,
0.0,
0.0,
0.0,
0.024981227816847,
0.0,
0.0,
0.0,
0.038217249326498136,
0.023370695807696434,
0.10263928607047809,
0.0002638810165675977,
0.0,
0.0,
0.0,
@@ -310,29 +309,30 @@
0.0,
0.0,
0.0,
0.19904591069188757,
0.2577866264025879,
0.0,
0.035969913516850464,
0.2577971476677123,
0.15146384621553569,
0.09798107754792196,
0.05435777788576338,
0.029150936607659956,
0.0,
0.0,
0.0,
0.0,
0.0
],
"Gesamt_Verluste": 7095.270423117038,
"Gesamtbilanz_Euro": 14.155149367775268,
"Gesamteinnahmen_Euro": 0.847204411694738,
"Gesamtkosten_Euro": 15.002353779470006,
"Gesamt_Verluste": 7723.761220821238,
"Gesamtbilanz_Euro": 14.859522880677414,
"Gesamteinnahmen_Euro": 0.6752660886427261,
"Gesamtkosten_Euro": 15.53478896932014,
"Home_appliance_wh_per_hour": [
0.0,
0.0,
0.0,
0.0,
2500.0,
2500.0,
0.0,
2500.0,
2500.0,
0.0,
0.0,
0.0,
@@ -365,83 +365,125 @@
0.0,
0.0
],
"home_appliance_energy_wh": {
"dishwasher1": [
0.0,
0.0,
0.0,
0.0,
2500.0,
2500.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
]
},
"Kosten_Euro_pro_Stunde": [
3.55926012,
1.7445413792641737,
1.5214016280281666,
0.9799189133780641,
3.26035212,
0.1921289942880966,
2.2398909259999997,
0.0,
0.6146086578009714,
1.120219902993293,
2.4860631399999997,
0.05258762370598476,
0.1614775630079859,
0.0,
0.5064650351737862,
2.0366107701900296,
0.005881449073870462,
2.11462917272379,
0.0,
2.1641282909999995,
0.29116746986009023,
0.41255619800000004,
0.0,
1.727006198,
0.19588158,
0.174739608,
0.28801899,
0.0,
0.856465757,
0.22802125600000003,
0.0,
0.0,
0.162995926,
0.0,
0.0,
0.16677339,
0.0,
0.0,
0.0,
0.07362195915902499,
0.060401289430882174,
0.009619897970888898,
0.0,
4.179128154646605e-17,
2.3325608707865465e-05,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.08357424731947552,
0.0,
0.19011028252189552,
0.0,
0.0,
0.16484566
],
"Netzbezug_Wh_pro_Stunde": [
15610.789999999999,
7886.7150961309835,
7268.9996561307535,
5215.108639585227,
14299.789999999999,
868.5759235447405,
10701.82,
0.0,
3066.9094700647274,
5096.541869851197,
10951.82,
175.4675465665157,
505.40708296709204,
0.0,
2527.2706345997312,
9265.745087306777,
25.909467285772962,
7055.819728808107,
0.0,
7024.109999999999,
980.6920507244535,
1412.38,
0.0,
5912.38,
704.61,
516.37,
868.05,
0.0,
2608.79,
694.34,
0.0,
0.0,
488.89,
0.0,
0.0,
506.91,
0.0,
0.0,
0.0,
322.9033296448464,
273.0618871197205,
45.96224544141853,
0.0,
2.2737367544323206e-13,
0.11639525303326081,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
278.85968408233407,
0.0,
617.0408390843736,
0.0,
0.0,
592.97
],
@@ -450,12 +492,11 @@
0.0,
0.0,
0.0,
356.87468309781434,
0.0,
0.0,
0.0,
545.9607046642591,
333.86708296709196,
1466.2755152925442,
3.769728808108539,
0.0,
0.0,
0.0,
@@ -474,11 +515,12 @@
0.0,
0.0,
0.0,
2843.513009884108,
3682.6660914655417,
0.0,
513.8559073835781,
3682.816395253033,
2163.76923165051,
1399.729679256028,
776.5396840823341,
416.4419515379994,
0.0,
0.0,
0.0,
@@ -486,41 +528,41 @@
0.0
],
"Verluste_Pro_Stunde": [
1152.0,
414.0066115357181,
405.0215587356905,
276.0922367502273,
333.15830172751845,
1098.8591364077674,
288.74422438214356,
1013.9999999999997,
53.21482102827082,
0.0,
106.80230977350088,
1083.0,
101.74991082536883,
552.0,
106.67021307906845,
567.0367126720731,
259.38247615196775,
735.7686104768134,
56.857674239187475,
414.0,
766.976146106431,
331.2000000000007,
0.0014460869344160802,
60.0,
96.08318181818186,
600.0,
0.0,
0.0,
94.68272727272722,
240.0,
118.37045454545455,
0.0,
83.01681818181817,
75.86045454545456,
66.66681818181814,
0.0,
69.12409090909085,
109.07302361034766,
116.99491129525349,
95.61900944932736,
54.18759955738153,
98.21987178167876,
86.6234264543665,
171.42744837680007,
165.15986945297027,
116.9350623689079,
197.07683881390702,
0.03390853445803674,
476.63569111397055,
0.0,
2.900768349489402,
16.700320743972142,
0.0,
81.2380484620021,
111.78035844493081,
6.04210069012484,
90.18403329253945,
134.59227272727276,
100.08954545454549,
0.0
@@ -528,42 +570,42 @@
"akku_soc_pro_stunde": [
80.0,
96.66666666666667,
96.66685032043661,
98.33411584087247,
98.33667797282322,
100.0,
81.50113762748849,
81.85514386032581,
98.52181052699248,
99.49305307848245,
99.49305307848245,
99.20134772591024,
91.86086775366753,
93.31593653566664,
98.42062016002258,
100.0,
100.0,
96.82533215994886,
96.82537232903037,
98.49203899569704,
95.45911027668879,
95.45911027668879,
95.45911027668879,
92.47038782489815,
99.13705449156481,
96.7424694364684,
94.63808754941604,
94.63808754941604,
91.19519730846775,
87.69752718992511,
86.00332639655647,
87.50853749537262,
89.91474378577168,
94.30467769343532,
94.52564336628036,
82.33137823444534,
82.33137823444534,
82.33141840352684,
98.99808507019351,
98.99808507019351,
98.99808507019351,
95.26164395449102,
95.26164395449102,
92.6411635825902,
90.24657852749377,
90.24657852749377,
88.06463121344417,
84.6217409724959,
81.12407085395327,
79.4298700605846,
79.54517357656309,
81.95137986696216,
86.53915401843355,
86.76011969127859,
100.0,
100.0,
100.0,
100.0,
100.0,
98.60068046043587,
98.76851659071711,
94.52002313341684,
91.360630915786
96.11252124341868,
91.86402778611841,
88.70463556848756
],
"Electricity_price": [
0.000228,
@@ -660,17 +702,17 @@
0.0,
0.0,
0.0,
1.0,
0.75,
0.625,
0.5,
0.375,
0.875,
0.5,
0.0,
1.0,
0.0,
0.375,
0.375,
1.0,
0.0,
0.75,
0.0,
0.0,
0.0,
0.375,
0.6,
0.0,
0.0,
0.0,
@@ -753,44 +795,48 @@
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"max_charge_power_w": 11040,
"soc_wh": 59373.0,
"soc_wh": 59110.8,
"initial_soc_percentage": 5
},
"start_solution": [
0.0,
0.0,
2.0,
0.0,
1.0,
2.0,
1.0,
0.0,
2.0,
2.0,
2.0,
0.0,
2.0,
0.0,
1.0,
2.0,
2.0,
2.0,
1.0,
1.0,
0.0,
2.0,
0.0,
2.0,
0.0,
1.0,
0.0,
0.0,
2.0,
1.0,
0.0,
0.0,
1.0,
2.0,
0.0,
1.0,
1.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
0.0,
1.0,
0.0,
0.0,
1.0,
@@ -799,61 +845,62 @@
1.0,
1.0,
1.0,
2.0,
1.0,
0.0,
1.0,
1.0,
3.0,
0.0,
1.0,
6.0,
5.0,
3.0,
0.0,
0.0,
4.0,
6.0,
3.0,
2.0,
6.0,
4.0,
3.0,
0.0,
2.0,
1.0,
5.0,
2.0,
4.0,
4.0,
0.0,
6.0,
5.0,
0.0,
2.0,
2.0,
2.0,
4.0,
6.0,
0.0,
1.0,
4.0,
5.0,
3.0,
6.0,
3.0,
5.0,
3.0,
1.0,
6.0,
0.0,
4.0,
1.0,
5.0,
2.0,
0.0,
1.0,
4.0,
6.0,
6.0,
6.0,
6.0,
3.0,
6.0,
1.0,
5.0,
5.0,
5.0,
5.0,
2.0,
6.0,
4.0,
4.0,
5.0,
4.0,
3.0,
1.0,
6.0,
5.0,
1.0,
2.0,
3.0,
1.0,
3.0,
15.0
4.0
],
"washingstart": 15
"washingstart": 14,
"appliance_starts": {
"dishwasher1": [
"2026-07-15 14:00:00+02:00"
]
}
}
+250 -203
View File
@@ -111,12 +111,10 @@
0,
0,
0,
0,
1,
0,
0,
0,
1,
0,
1,
1,
@@ -147,6 +145,8 @@
1,
1,
1,
1,
1,
1
],
"battery_grid_export_allowed": [],
@@ -162,15 +162,15 @@
0.0,
0.0,
0.375,
0.375,
0.875,
0.5,
0.75,
0.75,
1.0,
1.0,
0.875,
0.625,
0.375,
0.375,
0.375,
0.1,
0.6,
0.0,
0.0,
0.0,
0.0,
0.0,
@@ -203,15 +203,15 @@
"result": {
"Last_Wh_pro_Stunde": [
4986.07,
4996.91,
12997.56,
14120.029999999999,
10340.67,
7731.82,
6307.91,
9186.56,
8998.03,
11651.67,
11664.82,
5149.22,
5036.78,
5062.12,
2227.51,
7396.579999999999,
1129.12,
1178.71,
1050.98,
988.56,
912.38,
@@ -236,22 +236,22 @@
1158.03,
1222.72,
1221.04,
949.99,
987.01,
3449.99,
3487.01,
733.99,
592.97
],
"EAuto_SoC_pro_Stunde": [
5.0,
11.555,
18.11,
20.294999999999998,
33.405,
50.885000000000005,
66.18,
77.105,
83.66,
90.215,
96.77,
46.515,
63.995000000000005,
81.475,
88.03,
98.518,
98.518,
98.518,
98.518,
98.518,
@@ -321,59 +321,101 @@
0.0,
0.0
],
"Gesamt_Verluste": 8404.44594732788,
"Gesamtbilanz_Euro": 7.836546975121494,
"Gesamt_Verluste": 9242.679077117135,
"Gesamtbilanz_Euro": 6.793576335409896,
"Gesamteinnahmen_Euro": 0.0,
"Gesamtkosten_Euro": 7.836546975121494,
"Gesamtkosten_Euro": 6.793576335409896,
"Home_appliance_wh_per_hour": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
2500.0,
2500.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
],
"home_appliance_energy_wh": {
"dishwasher1": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
2500.0,
2500.0,
0.0,
0.0
]
},
"Kosten_Euro_pro_Stunde": [
0.9248695241652183,
0.8749092776800845,
1.5678286259999998,
2.4348720859999995,
0.8528744919675278,
0.5282751491259897,
0.19137741085396395,
1.691123530177008,
1.4724745282943246,
1.0809335963311613,
1.2680155994326825,
0.0,
0.4965507655670841,
0.008761738662872717,
0.0,
0.0,
0.0,
@@ -389,14 +431,14 @@
0.0,
0.0,
0.0,
0.11338560853163265,
0.11522756438372463,
0.04079284310894048,
0.0,
0.0,
0.0,
4.179128154646605e-17,
0.0,
0.0,
0.0021886029750169123,
0.0,
0.0,
0.0,
@@ -407,13 +449,13 @@
],
"Netzbezug_Wh_pro_Stunde": [
4056.445281426396,
3955.2860654615033,
7490.82,
12958.339999999998,
4640.231185895146,
2636.103538552843,
865.1781684175585,
8079.902198647912,
7836.479660959684,
5881.031536078136,
6327.4231508616895,
0.0,
2187.4483064629258,
38.597967677853376,
0.0,
0.0,
0.0,
@@ -429,14 +471,14 @@
0.0,
0.0,
0.0,
466.6074425170068,
474.1875077519532,
178.91597854798454,
0.0,
0.0,
0.0,
2.2737367544323206e-13,
0.0,
0.0,
9.957247384062384,
0.0,
0.0,
0.0,
@@ -487,15 +529,15 @@
],
"Verluste_Pro_Stunde": [
207.07863377116755,
207.1951278553804,
1083.0,
552.0,
521.2057423074175,
395.8024246263412,
876.0621802101069,
414.00986383774955,
414.016759315162,
581.7817843293763,
573.8007781034028,
458.55183787620945,
227.23539677555112,
640.3849261442235,
253.88850337853552,
938.3973561213433,
142.6574983015977,
108.98319763338179,
106.80230977350088,
133.7321802766326,
124.41545454545451,
@@ -509,47 +551,47 @@
69.12409090909085,
109.07302361034766,
116.99491129525349,
31.990721833371907,
30.957076574061034,
73.82223834331725,
123.8591383343284,
171.42744837680007,
116.9350623689079,
538.2984000000001,
441.9538395103232,
261.1952696860876,
262.55307614755066,
184.66788225469543,
131.21108264656203,
111.78035844493081,
90.18403329253945,
134.59227272727276,
418.18661420587983,
475.50136363636375,
100.08954545454549,
80.85954545454547
],
"akku_soc_pro_stunde": [
80.0,
80.00218427142131,
80.00760448962633,
61.06821055023239,
61.06821055023239,
62.12948116988288,
63.5406596317257,
58.120614791285504,
58.682709146161926,
45.22651624410048,
39.85140827675753,
36.67674043670639,
32.45548217808278,
28.528226668440908,
25.495297949432643,
23.27263093841336,
19.53618982271088,
16.54746737092025,
13.926986999019423,
11.532401943923004,
9.428020056870663,
7.2460727428210765,
3.8031825018727887,
0.30551238333016156,
61.0645175600859,
61.06479155557894,
61.065257092111224,
61.892528879038345,
62.498106048577306,
57.07806120813711,
38.33859382766936,
40.88136845531483,
39.81878058549141,
36.64411274544027,
32.422854486816654,
28.495598977174787,
25.46267025816652,
23.240003247147236,
19.503562131444756,
16.514839679654123,
13.894359307753296,
11.499774252656877,
9.395392365604536,
7.21344505155495,
3.7705548106066624,
0.2728846920640356,
0.6197802647075666,
1.5052110988161542,
2.736047696034607,
@@ -557,13 +599,13 @@
7.346947276543289,
22.29968060987662,
34.57523424809509,
41.83065840604197,
46.49642400356206,
47.884563842022054,
46.48524430245792,
43.99708508544073,
39.74859162814045,
36.5891994105096
41.7877983535968,
46.45356395111689,
47.841703789576876,
46.44238425001274,
33.24209404468744,
18.23258130364061,
15.073189086009755
],
"Electricity_price": [
0.000228,
@@ -661,15 +703,15 @@
0.0,
0.0,
0.375,
0.375,
0.875,
0.5,
0.75,
0.75,
1.0,
1.0,
0.875,
0.625,
0.375,
0.375,
0.375,
0.1,
0.6,
0.0,
0.0,
0.0,
0.0,
0.0,
@@ -757,103 +799,108 @@
"initial_soc_percentage": 5
},
"start_solution": [
2.0,
2.0,
2.0,
1.0,
1.0,
0.0,
2.0,
2.0,
2.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
5.0,
2.0,
0.0,
2.0,
0.0,
1.0,
2.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
5.0,
6.0,
1.0,
0.0,
6.0,
1.0,
3.0,
5.0,
1.0,
1.0,
1.0,
5.0,
6.0,
5.0,
3.0,
1.0,
1.0,
1.0,
4.0,
1.0,
4.0,
2.0,
6.0,
5.0,
5.0,
3.0,
1.0,
2.0,
2.0,
6.0,
5.0,
2.0,
5.0,
4.0,
0.0,
4.0,
6.0,
6.0,
1.0,
4.0,
6.0,
3.0,
1.0,
4.0,
5.0,
2.0,
1.0,
6.0,
4.0,
2.0,
4.0,
1.0,
0.0,
0.0,
5.0,
0.0,
0.0,
2.0,
3.0,
4.0,
4.0,
2.0,
5.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
4.0,
6.0,
3.0,
12.0
1.0,
1.0,
3.0,
34.0
],
"washingstart": 12
"washingstart": 44,
"appliance_starts": {
"dishwasher1": [
"2026-07-16 20:00:00+02:00"
]
}
}