feat: integrate device and runtime configuration foundation on main (#1328)

* feat: adapt configuration for multi optimization algorithms

Decouple configuration from optimization algorithm parameters. Add to_[algorithm]_param() methods
to the configuration that derive optimization algorithm specific parameters from the configuration.
Add x-scope tags to the configuration options that describe for which specific algorithms the
configuration option is for.

The whole device settings are restructured. There are now general settings for the device classes
with the afore mentioned to_[algorithm]_param() methods. The general device settings got their own
directory `devices/settings`. By this the parameter class also does not have to be a pydantic model
which can be used for future optimization/ simulations speed up.

Also the parameter class for a device is now part of the device module. This better decouples and
also is the natural place for parameters of a device.

Besides this feature there are also fixes and improvements:

* feat: extend home appliance time window settings and simulation

  Home appliance can now be configured for multiple runs with per-cycle allowed time windows. The
  number of remaining cycles to plan is determined at runtime by reading the
  ``cycles_completed_measurement_key`` from the measurement store.

* feat: specialiced CycleTimeWindowSequence for time window sequences

  Sequence of time windows associated to cycles.

  This model specializes ``ValueTimeWindowSequence`` so that the ``value``
  field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based
  integer) the window belongs to.

  Typical use: an appliance that must run ``n`` times per day, each run
  constrained to a distinct time window.  Assign ``value=0`` to windows
  for the first cycle, ``value=1`` for the second, and so on.  Multiple
  windows may share the same cycle index (their allowed regions are unioned).
  Windows with ``value=None`` are silently ignored by all cycle-aware methods.

* fix: Make test_configmigrate also regard the _ANY_SENTENIEL in key values

* chore: Make devices configurations a map instead of a list

  This makes config paths stable regardless of declaration order and lets each device settings
  class build its own config path from ``self.device_id`` without needing an external index.
  Tests are adapted likewise.

  Devices configurations are automatically migrated from lists to maps.

* chore: rename levelized_cost_of_storage_kwh to levelized_cost_of_storage_amt kwh

  This better fits in the naming scheme and also makes clear the costs are money.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>

* fix: runtime config update ignored by config file

Runtime settings were handed back to pydantic-settings as init settings,
which rank below the config file and the environment. Any key already
present in EOS.config.json or in the environment silently discarded the
update, so a bulk PUT /v1/config returned 200 without applying anything,
while the granular PUT /v1/config/{path} endpoint kept working.

Add a dedicated runtime settings source ranked directly below the command
line arguments and record granular updates there as well, so both
endpoints share one store that survives re-evaluation of the settings
sources. Environment variables keep precedence over the config file for
all keys that were not set at runtime.

Also repairs revert_settings() and update(), which passed their data
through the same init settings.

Closes #1303

* fix: env vars ignored on first config build

ConfigEOS.__init__ passed self as first positional argument to _setup,
which forwards it to pydantic_settings.BaseSettings.__init__. Its first
positional parameter is _case_sensitive, so the environment source
matched the upper case variable names against the lower case field names
and returned nothing. Environment settings only took effect after the
next configuration setup.

* docs: changelog for config priority fixes

* fix(config): preserve device identities and storage costs during migration

* fix(devices): preserve charge-rate typing and public import compatibility

* ruff format fix

* fix(config): satisfy typed device conversion and migration contracts

* docs(config): refresh validated configuration prerequisite schemas

---------

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: r0b2g1t <r0b2g1t@users.noreply.github.com>
This commit is contained in:
Andreas
2026-09-17 17:51:40 +02:00
committed by GitHub
co-authored by Bobby Noelte r0b2g1t
parent 431d7d57e5
commit 8224c64654
55 changed files with 5214 additions and 2170 deletions
+8
View File
@@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API. - `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API.
- `PVForecastSolcast` — forecasts from the Solcast rooftop-site API. - `PVForecastSolcast` — forecasts from the Solcast rooftop-site API.
### Fixed
- Configuration updates made at runtime, e.g. by `PUT /v1/config`, are no longer discarded when the
same configuration key is set in the EOS configuration file or in the environment
([#1303](https://github.com/Akkudoktor-EOS/EOS/issues/1303)).
- Environment variables are applied to the configuration on server startup instead of taking effect
only after the first configuration change.
## 0.3.0 (2026-03-17) ## 0.3.0 (2026-03-17)
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
+95 -419
View File
@@ -1,4 +1,9 @@
## Base configuration for devices simulation settings ## Configuration for all controllable devices in the simulation
Every device collection is a ``dict[str, <Settings>]`` keyed by
``device_id``. This makes config paths stable regardless of
declaration order and lets each device settings class build its own
config path from ``self.device_id`` without needing an external index.
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
:::{table} devices :::{table} devices
@@ -7,15 +12,15 @@
| Name | Environment Variable | Type | Read-Only | Default | Description | | Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- | | ---- | -------------------- | ---- | --------- | ------- | ----------- |
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices | | batteries | `EOS_DEVICES__BATTERIES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices | | electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances | | home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `dict[str, akkudoktoreos.devices.settings.homeappliancesettings.HomeApplianceCommonSettings]` | `rw` | `required` | Shiftable home appliance devices, keyed by device_id. |
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters | | inverters | `EOS_DEVICES__INVERTERS` | `Optional[dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings]]` | `rw` | `None` | Inverter devices, keyed by device_id. |
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set | | max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries allowed. |
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set | | max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of EVs allowed. |
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set | | max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home appliances allowed. |
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set | | max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters allowed. |
| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. | | measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
@@ -27,13 +32,13 @@
```json ```json
{ {
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -52,15 +57,15 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -79,12 +84,22 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1 "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null
}
},
"max_home_appliances": 3
} }
} }
``` ```
@@ -98,13 +113,13 @@
```json ```json
{ {
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -122,28 +137,28 @@
], ],
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100, "max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor", "measurement_key_soc_factor": "bat0-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w", "measurement_key_power_l1_w": "bat0-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w", "measurement_key_power_l2_w": "bat0-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w", "measurement_key_power_l3_w": "bat0-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w", "measurement_key_power_3_phase_sym_w": "bat0-power-3-phase-sym-w",
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "bat0-soc-factor",
"battery1-power-l1-w", "bat0-power-l1-w",
"battery1-power-l2-w", "bat0-power-l2-w",
"battery1-power-l3-w", "bat0-power-l3-w",
"battery1-power-3-phase-sym-w" "bat0-power-3-phase-sym-w"
] ]
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -161,387 +176,48 @@
], ],
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100, "max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor", "measurement_key_soc_factor": "ev0-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w", "measurement_key_power_l1_w": "ev0-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w", "measurement_key_power_l2_w": "ev0-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w", "measurement_key_power_l3_w": "ev0-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w", "measurement_key_power_3_phase_sym_w": "ev0-power-3-phase-sym-w",
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "ev0-soc-factor",
"battery1-power-l1-w", "ev0-power-l1-w",
"battery1-power-l2-w", "ev0-power-l2-w",
"battery1-power-l3-w", "ev0-power-l3-w",
"battery1-power-3-phase-sym-w" "ev0-power-3-phase-sym-w"
] ]
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1, "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null,
"effective_num_cycles": 1,
"measurement_keys": []
}
},
"max_home_appliances": 3,
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "bat0-soc-factor",
"battery1-power-l1-w", "bat0-power-l1-w",
"battery1-power-l2-w", "bat0-power-l2-w",
"battery1-power-l3-w", "bat0-power-l3-w",
"battery1-power-3-phase-sym-w", "bat0-power-3-phase-sym-w",
"battery1-soc-factor", "ev0-soc-factor",
"battery1-power-l1-w", "ev0-power-l1-w",
"battery1-power-l2-w", "ev0-power-l2-w",
"battery1-power-l3-w", "ev0-power-l3-w",
"battery1-power-3-phase-sym-w" "ev0-power-3-phase-sym-w"
]
}
}
```
<!-- pyml enable line-length -->
### Inverter devices base settings
<!-- pyml disable line-length -->
:::{table} devices::inverters::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
| battery_id | `Optional[str]` | `rw` | `None` | ID of battery controlled by this inverter. |
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
| device_id | `str` | `rw` | `required` | ID of device |
| max_ac_charge_power_w | `Optional[float]` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
| max_power_w | `Optional[float]` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null,
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Model defining a daily or date time window with optional localization support
Represents a time interval starting at `start_time` and lasting for `duration`.
Can restrict applicability to a specific day of the week or a specific calendar date.
Supports day names in multiple languages via locale-aware parsing.
Timezone contract:
``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a
local wall-clock time in whatever timezone the caller's ``date_time`` or
``reference_date`` carries. When those arguments are timezone-aware the
window boundaries are evaluated in that timezone; when they are naive,
arithmetic is performed as-is (no timezone conversion occurs).
``date``, being a calendar ``Date`` object, is inherently timezone-free.
This design avoids the ambiguity that arises when a stored ``start_time``
carries its own timezone that differs from the caller's timezone, and keeps
the model serialisable without timezone state.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Model representing a sequence of time windows with collective operations
Manages multiple TimeWindow objects and provides methods to work with them
as a cohesive unit for scheduling and availability checking.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.TimeWindow]` | `rw` | `required` | List of TimeWindow objects that make up this sequence. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": []
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Home Appliance devices base settings
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `required` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
},
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Battery devices base settings
<!-- pyml disable line-length -->
:::{table} devices::batteries::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `Optional[list[float]]` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `required` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | Measurement key for the L1 power the battery is charged or discharged with [W]. |
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | Measurement key for the L2 power the battery is charged or discharged with [W]. |
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | Measurement key for the L3 power the battery is charged or discharged with [W]. |
| measurement_key_soc_factor | `str` | `ro` | `N/A` | Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
| min_charge_power_w | `Optional[float]` | `rw` | `50` | Minimum charging power [W]. |
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
] ]
} }
} }
+24 -14
View File
@@ -36,13 +36,13 @@
"batch_size": 100 "batch_size": 100
}, },
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -61,15 +61,15 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -88,12 +88,22 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1 "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null
}
},
"max_home_appliances": 3
}, },
"elecfee": { "elecfee": {
"provider": "ElecFeeFixed", "provider": "ElecFeeFixed",
+1 -1
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.3.0.dev2609161710154693` **Version**: `v0.3.0.dev2609171139845429`
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
+9 -4
View File
@@ -40,10 +40,15 @@ Use endpoint `POST /v1/config/reset` to reset the configuration to the values in
The configuration sources and their priorities are as follows: The configuration sources and their priorities are as follows:
1. `Settings`: Provided during runtime by the REST interface 1. `Command Line Arguments`: Provided at startup of the REST server
2. `Environment Variables`: Defined at startup of the REST server and during runtime 2. `Settings`: Provided during runtime by the REST interface
3. `EOS Configuration File`: Read at startup of the REST server and on request 3. `Environment Variables`: Defined at startup of the REST server and during runtime
4. `Default Values` 4. `EOS Configuration File`: Read at startup of the REST server and on request
5. `Default Values`
Runtime settings are kept until they are reset by `POST /v1/config/reset`. All other sources are
re-evaluated on every configuration change, which keeps environment variable changes effective for
all configuration keys that were not set during runtime.
### Runtime Config Updates ### Runtime Config Updates
+94 -7
View File
@@ -198,18 +198,18 @@ horizon.
### Home Appliance Simulation ### Home Appliance Simulation
### Home Appliance Configuration ### GENETIC0 Home Appliance Configuration
Home appliance to run within the optimization horizon. Home appliance to run within the optimization horizon.
```json ```json
[ {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3 "duration_h": 3
} }
] }
``` ```
Home appliance to run within a time window of 5 hours starting at 8:00 every day and another time Home appliance to run within a time window of 5 hours starting at 8:00 every day and another time
@@ -217,8 +217,8 @@ window of 3 hours starting at 15:00 every day. See
[Time Window Sequence Configuration](configtimewindow-page) for more information. [Time Window Sequence Configuration](configtimewindow-page) for more information.
```json ```json
[ {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3, "duration_h": 3,
@@ -235,7 +235,7 @@ window of 3 hours starting at 15:00 every day. See
] ]
} }
} }
] }
``` ```
:::{admonition} Note :::{admonition} Note
@@ -244,6 +244,93 @@ The optimization algorithm always restricts to one start within the optimization
energy management run. energy management run.
::: :::
### GENETIC Home Appliance Configuration
Home appliance to run once, unconstrained, within the optimization horizon:
```json
{
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"num_cycles": 1
}
}
```
Home appliance to run within a time window of 5 hours starting at 8:00. Each window's `value`
field carries the 0-based cycle index it applies to -- here there is only one cycle (index `0`),
so `num_cycles` does not need to be set explicitly; it is derived from the distinct cycle indices
found in `cycle_time_windows`. See
[ime Window Sequence Configuration](configimewindow-page) for more information.
```json
{
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"cycle_time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0
}
]
}
}
}
```
Home appliance that must run **twice** per day, each run confined to its own time window --
cycle 0 in the morning (07:00, 5 hours), cycle 1 in the evening (17:00, 5 hours) -- with at least
1 hour idle between the two runs:
```json
{
"washing_machine1": {
"device_id": "washing_machine1",
"consumption_wh": 2000,
"duration_h": 2,
"min_cycle_gap_h": 1,
"cycle_time_windows": {
"windows": [
{
"start_time": "07:00",
"duration": "5 hours",
"value": 0
},
{
"start_time": "17:00",
"duration": "5 hours",
"value": 1
}
]
}
}
}
```
`min_cycle_gap_h` enforces a minimum idle time between the end of one cycle and the start of the
next; it defaults to `0`, which permits back-to-back runs. To let both cycles run anywhere in a
shared window instead of separate morning/evening slots, give both windows the same `start_time`
and `duration` but keep their distinct `value` (cycle index) -- the optimizer still places each
cycle independently within that shared window.
How many cycles remain to be scheduled on a given energy management run is read at runtime from
the measurement store, under the key configured in `cycles_completed_measurement_key` (defaults
to `{device_id}.cycles_completed`). This lets the optimizer skip cycles the appliance has already
completed earlier the same day.
:::{admonition} Note
:class: note
Unlike GENETIC0, the GENETIC algorithm can plan multiple cycles -- and therefore multiple starts
-- of the same appliance within a single energy management run, whenever `num_cycles` (or the
number of distinct cycle indices in `cycle_time_windows`) is greater than 1.
:::
### Home Appliance Instructions ### Home Appliance Instructions
The home appliance instructions assume an idealized home appliance model. Under this model, The home appliance instructions assume an idealized home appliance model. Under this model,
+1050 -199
View File
File diff suppressed because it is too large Load Diff
+69 -14
View File
@@ -13,6 +13,7 @@ import json
import os import os
import sys import sys
import tempfile import tempfile
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any, Callable, ClassVar, Optional, Type, Union from typing import Any, Callable, ClassVar, Optional, Type, Union
@@ -38,7 +39,7 @@ from akkudoktoreos.core.emsettings import (
) )
from akkudoktoreos.core.logabc import LOGGING_LEVELS from akkudoktoreos.core.logabc import LOGGING_LEVELS
from akkudoktoreos.core.logsettings import LoggingCommonSettings from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, deep_merge
from akkudoktoreos.core.version import __version__ from akkudoktoreos.core.version import __version__
from akkudoktoreos.devices.devices import DevicesCommonSettings from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
@@ -396,6 +397,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
} }
_config_file_path: ClassVar[Optional[Path]] = None _config_file_path: ClassVar[Optional[Path]] = None
_config_autosave: ClassVar[str] = "" _config_autosave: ClassVar[str] = ""
# Settings provided at runtime, e.g. by the REST interface. Highest priority after CLI.
_runtime_settings: ClassVar[dict[str, Any]] = {}
_force_documentation_mode = False _force_documentation_mode = False
def __hash__(self) -> int: def __hash__(self) -> int:
@@ -519,6 +522,14 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
return settings return settings
def lazy_runtime_settings() -> dict:
"""Runtime settings.
Settings provided during runtime, e.g. by the REST interface. They supersede any
setting from the environment or the configuration file until they are reset.
"""
return deepcopy(cls._runtime_settings)
def lazy_config_file_settings() -> dict: def lazy_config_file_settings() -> dict:
"""Config file settings. """Config file settings.
@@ -668,6 +679,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
# runtime configuration. # runtime configuration.
setting_sources = [ setting_sources = [
lazy_config_cli_settings, # Prio high lazy_config_cli_settings, # Prio high
lazy_runtime_settings, # settings provided during runtime
lazy_env_settings, lazy_env_settings,
lazy_dotenv_settings, lazy_dotenv_settings,
lazy_config_file_settings, # resolves/creates config file path lazy_config_file_settings, # resolves/creates config file path
@@ -705,7 +717,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
logger.debug("Config init called again with parameters {} {}", args, kwargs) logger.debug("Config init called again with parameters {} {}", args, kwargs)
return return
logger.debug("Config init with parameters {} {}", args, kwargs) logger.debug("Config init with parameters {} {}", args, kwargs)
self._setup(self, *args, **kwargs) # Do not pass self - the first positional argument of pydantic_settings.BaseSettings
# is _case_sensitive, which would make environment variable lookup case sensitive.
self._setup(*args, **kwargs)
def _setup(self, *args: Any, **kwargs: Any) -> None: def _setup(self, *args: Any, **kwargs: Any) -> None:
"""Re-initialize global settings.""" """Re-initialize global settings."""
@@ -741,11 +755,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
) )
def merge_settings_from_dict(self, data: dict) -> None: def merge_settings_from_dict(self, data: dict) -> None:
"""Merges the provided dictionary data into the current instance. """Merges the provided dictionary data into the runtime settings.
Creates a new settings instance, then applies the dictionary data through validation, The data is added to the runtime settings, which have priority over the environment and
and finally merges the validated settings into the current instance. None values the EOS configuration file. All configuration sources are re-evaluated afterwards.
are not merged.
Args: Args:
data (dict): Dictionary containing field values to merge into the data (dict): Dictionary containing field values to merge into the
@@ -762,18 +775,56 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
config.merge_settings_from_dict(new_data) config.merge_settings_from_dict(new_data)
""" """
merged = merge_models( previous_settings = ConfigEOS._runtime_settings
self, ConfigEOS._runtime_settings = deep_merge(previous_settings, deepcopy(data))
data, try:
) self._setup()
except Exception:
# Keep the runtime settings in sync with the actual configuration
ConfigEOS._runtime_settings = previous_settings
raise
self._setup(**merged) def set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested configuration value and remember it as runtime setting.
Args:
path (str): A '/'-separated path to the nested attribute (e.g. "server/port").
value (Any): The new value to set.
"""
super().set_nested_value(path, value)
# Remember as runtime setting to survive re-evaluation of the configuration sources.
# List indices can not be expressed by the settings dictionary - remember the whole list.
keys = []
for key in path.strip("/").split("/"):
if key.isdigit():
break
keys.append(key)
setting = self.get_nested_value("/".join(keys))
if isinstance(setting, SettingsBaseModel):
setting = setting.model_dump(
exclude_none=True, exclude_unset=True, exclude_computed_fields=True
)
elif isinstance(setting, list):
setting = [
item.model_dump(exclude_none=True, exclude_unset=True, exclude_computed_fields=True)
if isinstance(item, SettingsBaseModel)
else item
for item in setting
]
runtime_setting: dict[str, Any] = {}
node = runtime_setting
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = setting
ConfigEOS._runtime_settings = deep_merge(ConfigEOS._runtime_settings, runtime_setting)
def reset_settings(self) -> None: def reset_settings(self) -> None:
"""Reset all changed settings to environment/config file defaults. """Reset all changed settings to environment/config file defaults.
This functions basically deletes the settings provided before. This functions basically deletes the settings provided before.
""" """
ConfigEOS._runtime_settings = {}
self._setup() self._setup()
def revert_settings(self, backup_id: str) -> None: def revert_settings(self, backup_id: str) -> None:
@@ -812,7 +863,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
backup_data: dict[str, Any] = json.load(f) backup_data: dict[str, Any] = json.load(f)
backup_settings = migrate_config_data(backup_data) backup_settings = migrate_config_data(backup_data)
self._setup(**backup_settings.model_dump(exclude_none=True, exclude_unset=True)) # Backup settings are runtime settings - they supersede environment and config file.
ConfigEOS._runtime_settings = backup_settings.model_dump(
exclude_none=True, exclude_defaults=True, exclude_computed_fields=True
)
self._setup()
def list_backups(self) -> dict[str, dict[str, Any]]: def list_backups(self) -> dict[str, dict[str, Any]]:
"""List available configuration backup files and extract metadata. """List available configuration backup files and extract metadata.
@@ -1094,11 +1149,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Updates all configuration fields. """Updates all configuration fields.
This method updates all configuration fields using the following order for value retrieval: This method updates all configuration fields using the following order for value retrieval:
1. Current settings. 1. Runtime settings.
2. Environment variables. 2. Environment variables.
3. EOS configuration file. 3. EOS configuration file.
4. Field default constants. 4. Field default constants.
The first non None value in priority order is taken. The first non None value in priority order is taken.
""" """
self._setup(**self.model_dump()) self._setup()
+218
View File
@@ -59,6 +59,15 @@ def runtime_environment() -> str:
return f"Standalone Python (Python {python_version})" return f"Standalone Python (Python {python_version})"
class ConfigScope(StrEnum):
"""Configuration scope for x-scope json_schema_extra."""
GENERAL = "GENERAL"
GENETIC = "GENETIC"
GENETIC0 = "GENETIC0"
UNUSED = "UNUSED"
class SettingsBaseModel(PydanticBaseModel): class SettingsBaseModel(PydanticBaseModel):
"""Base model class for all settings configurations.""" """Base model class for all settings configurations."""
@@ -1037,3 +1046,212 @@ class ValueTimeWindowSequence(TimeWindowSequence[ValueTimeWindow]):
index=pd.DatetimeIndex(timestamps), index=pd.DatetimeIndex(timestamps),
dtype=np.float64, dtype=np.float64,
) )
class CycleTimeWindowSequence(ValueTimeWindowSequence):
"""Sequence of time windows associated to cycles.
This model specializes ``ValueTimeWindowSequence`` so that the ``value``
field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based
integer) the window belongs to.
Typical use: an appliance that must run ``n`` times per day, each run
constrained to a distinct time window. Assign ``value=0`` to windows
for the first cycle, ``value=1`` for the second, and so on. Multiple
windows may share the same cycle index (their allowed regions are unioned).
Windows with ``value=None`` are silently ignored by all cycle-aware methods.
"""
def num_cycles(self) -> int:
"""Return the number of distinct cycles defined in the sequence.
Cycles are derived from the integer part of the window values.
Windows without a value are ignored.
Returns:
int: Number of unique cycles.
"""
cycles = {int(window.value) for window in self.windows if window.value is not None}
return len(cycles)
def cycle_to_array(
self,
cycle: int,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
dropna: bool = True,
boundary: str = "context",
align_to_interval: bool = True,
) -> np.ndarray:
"""Return a binary 1-D array indicating when *cycle* is active.
The time grid and alignment semantics are identical to
``TimeWindowSequence.to_array``: ``start_datetime`` is floored to the
nearest interval boundary in wall-clock time when
``align_to_interval=True``, and the timezone (or naivety) of
``start_datetime`` is preserved without any UTC conversion.
The step count uses ``math.ceil`` so that a partially-covered final
interval is included, consistent with ``to_array``'s while-loop
termination condition.
Args:
cycle: Integer cycle index to query.
start_datetime: First step of the time grid (inclusive).
end_datetime: Upper bound of the time grid (exclusive).
interval: Fixed step size.
dropna: Accepted for signature compatibility; has no effect.
boundary: Accepted for signature compatibility; has no effect.
align_to_interval: When ``True`` (default), floor
``start_datetime`` to the nearest interval boundary in
wall-clock time before building the grid.
Returns:
``np.ndarray`` of shape ``(n_steps,)`` with ``dtype=float64``.
``1.0`` at step ``t`` means step ``t`` falls inside a window
belonging to ``cycle``; ``0.0`` otherwise.
"""
import math
interval_s = interval.total_seconds()
if align_to_interval and interval_s > 0:
# Floor purely in wall-clock seconds — identical to to_array's
# alignment so all three methods produce consistent grids.
wall_s = (
start_datetime.hour * 3600
+ start_datetime.minute * 60
+ start_datetime.second
+ start_datetime.microsecond / 1_000_000
)
remainder_s = wall_s % interval_s
if remainder_s:
start_datetime = start_datetime.subtract(seconds=remainder_s)
# Use ceil so a partially-covered final step is included, matching the
# while-loop semantics of to_array.
steps = (
math.ceil((end_datetime - start_datetime).total_seconds() / interval_s)
if interval_s > 0
else 0
)
result = np.zeros(steps, dtype=np.float64)
# Anchor window start times to the calendar day of the (aligned) grid start.
base_day = start_datetime.start_of("day")
for window in self.windows:
if window.value is None:
continue
if int(window.value) != cycle:
continue
t = window.start_time
win_start = base_day.replace(
hour=t.hour,
minute=t.minute,
second=t.second,
microsecond=t.microsecond,
)
win_end = win_start + window.duration
# Convert to step indices relative to the aligned grid start.
idx_start = int((win_start - start_datetime).total_seconds() / interval_s)
idx_end = math.ceil((win_end - start_datetime).total_seconds() / interval_s)
idx_start = max(idx_start, 0)
idx_end = min(idx_end, steps)
if idx_start < idx_end:
result[idx_start:idx_end] = 1.0
return result
def cycles_to_matrix(
self,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
) -> tuple[list[int], np.ndarray]:
"""Return a ``(cycle_indices, matrix)`` pair over the simulation horizon.
The matrix encodes, for each cycle and each time step, whether that
step falls inside a window belonging to that cycle. It is the
vectorised equivalent of calling ``cycle_to_array`` for every cycle.
Alignment and step-count semantics are identical to ``to_array`` and
``cycle_to_array``: ``start_datetime`` is floored to the nearest
interval boundary in wall-clock time, and the step count uses
``math.ceil`` for consistency with ``to_array``'s while-loop.
Args:
start_datetime: First step of the time grid (inclusive).
end_datetime: Upper bound of the time grid (exclusive).
interval: Fixed step size.
Returns:
``(cycle_indices, matrix)`` where
* ``cycle_indices`` is a sorted ``list[int]`` of the distinct
cycle numbers found in the sequence (e.g. ``[0, 1, 2]``).
* ``matrix`` is a ``np.ndarray`` of shape
``(len(cycle_indices), n_steps)`` with ``dtype=float64``.
``matrix[k, t] == 1.0`` iff step ``t`` falls inside a window
belonging to ``cycle_indices[k]``; ``0.0`` otherwise.
"""
import math
interval_s = interval.total_seconds()
if interval_s > 0:
# Apply the same wall-clock floor as to_array and cycle_to_array.
wall_s = (
start_datetime.hour * 3600
+ start_datetime.minute * 60
+ start_datetime.second
+ start_datetime.microsecond / 1_000_000
)
remainder_s = wall_s % interval_s
if remainder_s:
start_datetime = start_datetime.subtract(seconds=remainder_s)
cycles = sorted({int(w.value) for w in self.windows if w.value is not None})
steps = (
math.ceil((end_datetime - start_datetime).total_seconds() / interval_s)
if interval_s > 0
else 0
)
matrix = np.zeros((len(cycles), steps), dtype=np.float64)
cycle_index = {c: i for i, c in enumerate(cycles)}
# Anchor window start times to the calendar day of the aligned grid start.
base_day = start_datetime.start_of("day")
for window in self.windows:
if window.value is None:
continue
c = int(window.value)
row = cycle_index[c]
t = window.start_time
win_start = base_day.replace(
hour=t.hour,
minute=t.minute,
second=t.second,
microsecond=t.microsecond,
)
win_end = win_start + window.duration
idx_start = int((win_start - start_datetime).total_seconds() / interval_s)
idx_end = math.ceil((win_end - start_datetime).total_seconds() / interval_s)
idx_start = max(idx_start, 0)
idx_end = min(idx_end, steps)
if idx_start < idx_end:
matrix[row, idx_start:idx_end] = 1.0
return cycles, matrix
+76 -1
View File
@@ -30,6 +30,49 @@ if TYPE_CHECKING:
_KEEP_DEFAULT = object() _KEEP_DEFAULT = object()
# -----------------------------
# Migration helpers
# -----------------------------
def _list_to_device_dict(
prefix: str,
) -> Callable[[Any], Any]:
"""Return a transform that converts a list of device dicts to a keyed dict.
Each item must be a dict. The key is taken from the item's own
``device_id`` field when present; otherwise a key is synthesised
from *prefix* + the zero-based index (e.g. ``"bat0"``, ``"bat1"``).
"""
def _transform(value: Any) -> Any:
if not isinstance(value, (list, dict)):
return value
result: Dict[str, Any] = {}
entries = enumerate(value) if isinstance(value, list) else value.items()
for index, original in entries:
if not isinstance(original, dict):
raise ValueError("Device settings must be an object")
item = dict(original)
key = (
(item.get("device_id") or f"{prefix}{index}") if isinstance(value, list) else index
)
if not isinstance(key, str):
raise ValueError("Device identifiers must be strings")
if key in result:
raise ValueError(f"Duplicate device_id: {key!r}")
item.setdefault("device_id", key)
if "levelized_cost_of_storage_kwh" in item:
item.setdefault(
"levelized_cost_of_storage_amt_kwh", item.pop("levelized_cost_of_storage_kwh")
)
result[key] = item
return result
return _transform
# ----------------------------- # -----------------------------
# Global migration map constant # Global migration map constant
# ----------------------------- # -----------------------------
@@ -58,10 +101,31 @@ MIGRATION_MAP: Dict[
# - NodeRed # - NodeRed
# devices # devices
# ======= # =======
# List → dict migration (all device collections)
# These must come *before* any sub-path entries that reference the old list indices,
# so the whole collection is moved first; the sub-path None-drops clean up leftovers.
# - batteries # - batteries
"devices/batteries": (
"devices/batteries",
_list_to_device_dict("bat"),
),
"devices/batteries/0/initial_soc_percentage": None, "devices/batteries/0/initial_soc_percentage": None,
# - electric_vehicles # - electric_vehicles
"devices/electric_vehicles": (
"devices/electric_vehicles",
_list_to_device_dict("ev"),
),
"devices/electric_vehicles/0/initial_soc_percentage": None, "devices/electric_vehicles/0/initial_soc_percentage": None,
# - inverters
"devices/inverters": (
"devices/inverters",
_list_to_device_dict("inv"),
),
# - home_appliances
"devices/home_appliances": (
"devices/home_appliances",
_list_to_device_dict("appliance"),
),
# elecfee # elecfee
# ======= # =======
# - ElecFeeFixed # - ElecFeeFixed
@@ -137,7 +201,7 @@ MIGRATION_MAP: Dict[
"optimization/interval": None, "optimization/interval": None,
"optimization/horizon_hours": "optimization/genetic0/horizon_hours", "optimization/horizon_hours": "optimization/genetic0/horizon_hours",
"optimization/ev_available_charge_rates_percent": ( "optimization/ev_available_charge_rates_percent": (
"devices/electric_vehicles/0/charge_rates", "devices/electric_vehicles/ev0/charge_rates",
lambda v: [x / 100 for x in v], lambda v: [x / 100 for x in v],
), ),
"optimization/hours": "optimization/genetic0/horizon_hours", "optimization/hours": "optimization/genetic0/horizon_hours",
@@ -253,6 +317,17 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
try: try:
if transform: if transform:
old_value = transform(old_value) old_value = transform(old_value)
if old_path == "optimization/ev_available_charge_rates_percent":
from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings,
)
vehicles = new_config.devices.electric_vehicles
if not vehicles:
vehicles = {"ev0": BatteriesCommonSettings(device_id="ev0")}
new_config.devices.electric_vehicles = vehicles
device_id = next(iter(vehicles))
new_path = f"devices/electric_vehicles/{device_id}/charge_rates"
new_config.set_nested_value(new_path, old_value) new_config.set_nested_value(new_path, old_value)
migrated_source_paths.add(old_path.strip("/")) migrated_source_paths.add(old_path.strip("/"))
mapped_count += 1 mapped_count += 1
+31 -18
View File
@@ -65,6 +65,37 @@ from akkudoktoreos.utils.datetimeutil import (
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary() _model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
def deep_merge(source_data: Any, update_data: Any) -> Any:
"""Merge two data structures recursively.
Values in update_data (including None) override source values.
Nested dictionaries are merged recursively.
Lists in update_data replace source lists entirely.
Args:
source_data (Any): Data to merge into.
update_data (Any): Data to merge from.
Returns:
Any: The merged data.
"""
if isinstance(source_data, dict) and isinstance(update_data, dict):
merged = dict(source_data)
for key, update_value in update_data.items():
if key in merged:
merged[key] = deep_merge(merged[key], update_value)
else:
merged[key] = update_value
return merged
# If both are lists, replace source list with update list
if isinstance(source_data, list) and isinstance(update_data, list):
return update_data
# For other types or if update_data is None, override source_data
return update_data
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]: def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
"""Merge a Pydantic model instance with an update dictionary. """Merge a Pydantic model instance with an update dictionary.
@@ -82,24 +113,6 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An
Returns: Returns:
dict[str, Any]: Merged dictionary representing combined model data. dict[str, Any]: Merged dictionary representing combined model data.
""" """
def deep_merge(source_data: Any, update_data: Any) -> Any:
if isinstance(source_data, dict) and isinstance(update_data, dict):
merged = dict(source_data)
for key, update_value in update_data.items():
if key in merged:
merged[key] = deep_merge(merged[key], update_value)
else:
merged[key] = update_value
return merged
# If both are lists, replace source list with update list
if isinstance(source_data, list) and isinstance(update_data, list):
return update_data
# For other types or if update_data is None, override source_data
return update_data
source_dict = source.model_dump( source_dict = source.model_dump(
exclude_unset=True, exclude_unset=True,
exclude_computed_fields=True, exclude_computed_fields=True,
+96 -293
View File
@@ -1,354 +1,157 @@
"""General configuration settings for simulated devices for optimization.""" """General configuration settings for simulated devices for optimization."""
import json import json
import re
from typing import Any, Optional, TextIO, cast from typing import Any, Optional, TextIO, cast
import numpy as np
from loguru import logger from loguru import logger
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field, field_validator, model_validator from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel, TimeWindowSequence from akkudoktoreos.config.configabc import ConfigScope, SettingsBaseModel
from akkudoktoreos.core.cache import CacheFileStore from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
from akkudoktoreos.core.emplan import ResourceStatus from akkudoktoreos.core.emplan import ResourceStatus
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
from akkudoktoreos.devices.devicesabc import DevicesBaseSettings from akkudoktoreos.devices.settings.batterysettings import (
BATTERY_DEFAULT_CHARGE_RATES as BATTERY_DEFAULT_CHARGE_RATES,
)
from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings,
)
from akkudoktoreos.devices.settings.homeappliancesettings import (
HomeApplianceCommonSettings,
)
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# Default charge rates for battery
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
class BatteriesCommonSettings(DevicesBaseSettings):
"""Battery devices base settings."""
capacity_wh: int = Field(
default=8000, gt=0, json_schema_extra={"description": "Capacity [Wh].", "examples": [8000]}
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Charging efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
discharging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Discharge efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
levelized_cost_of_storage_kwh: float = Field(
default=0.0,
json_schema_extra={
"description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh].",
"examples": [0.12],
},
)
max_charge_power_w: Optional[float] = Field(
default=5000,
gt=0,
json_schema_extra={"description": "Maximum charging power [W].", "examples": [5000]},
)
min_charge_power_w: Optional[float] = Field(
default=50,
gt=0,
json_schema_extra={"description": "Minimum charging power [W].", "examples": [50]},
)
charge_rates: Optional[list[float]] = Field(
default=BATTERY_DEFAULT_CHARGE_RATES,
json_schema_extra={
"description": (
"Charge rates as factor of maximum charging power [0.00 ... 1.00]. "
"None triggers fallback to default charge-rates."
),
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": (
"Minimum state of charge (SOC) as percentage of capacity [%]. "
"This is the target SoC for charging"
),
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "Maximum state of charge (SOC) as percentage of capacity [%].",
"examples": [100],
},
)
@field_validator("charge_rates", mode="before")
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
# None means fallback to default values
if v is None:
return np.asarray(BATTERY_DEFAULT_CHARGE_RATES, dtype=float)
# Convert to numpy array
if isinstance(v, str):
# Remove brackets and split by comma or whitespace
numbers = re.split(r"[,\s]+", v.strip("[]"))
# Filter out any empty strings and convert to floats
arr = np.array([float(x) for x in numbers if x])
else:
arr = np.array(v, dtype=float)
# Must not be empty
if arr.size == 0:
raise ValueError("charge_rates must contain at least one value.")
# Enforce bounds: 0.0 ≤ x ≤ 1.0
if (arr < 0.0).any() or (arr > 1.0).any():
raise ValueError("charge_rates must be within [0.0, 1.0].")
# Remove duplicates + sort
arr = np.unique(arr)
arr.sort()
return arr
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_soc_factor(self) -> str:
"""Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]."""
return f"{self.device_id}-soc-factor"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l1_w(self) -> str:
"""Measurement key for the L1 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l1-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l2_w(self) -> str:
"""Measurement key for the L2 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l2-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l3_w(self) -> str:
"""Measurement key for the L3 power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-l3-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_3_phase_sym_w(self) -> str:
"""Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]."""
return f"{self.device_id}-power-3-phase-sym-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the battery stati that are measurements."""
keys: list[str] = [
self.measurement_key_soc_factor,
self.measurement_key_power_l1_w,
self.measurement_key_power_l2_w,
self.measurement_key_power_l3_w,
self.measurement_key_power_3_phase_sym_w,
]
return keys
class InverterCommonSettings(DevicesBaseSettings):
"""Inverter devices base settings."""
max_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={"description": "Maximum power [W].", "examples": [10000]},
)
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "ID of battery controlled by this inverter.",
"examples": [None, "battery1"],
},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). "
"Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss)."
),
"examples": [0.95, 1.0, 0.0],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). "
"Default 1.0 (no additional inverter loss)."
),
"examples": [0.95, 1.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power in watts. "
"null means no additional limit. Set to 0 to disable AC charging."
),
"examples": [None, 0, 5000],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the inverter stati that are measurements."""
keys: list[str] = []
return keys
class HomeApplianceCommonSettings(DevicesBaseSettings):
"""Home Appliance devices base settings."""
consumption_wh: int = Field(
gt=0, json_schema_extra={"description": "Energy consumption [Wh].", "examples": [2000]}
)
duration_h: int = Field(
gt=0,
le=24,
json_schema_extra={"description": "Usage duration in hours [0 ... 24].", "examples": [1]},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": "Sequence of allowed time windows. Defaults to optimization general time window.",
"examples": [
{
"windows": [
{"start_time": "10:00", "duration": "2 hours"},
],
},
],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the home appliance stati that are measurements."""
keys: list[str] = []
return keys
class DevicesCommonSettings(SettingsBaseModel): class DevicesCommonSettings(SettingsBaseModel):
"""Base configuration for devices simulation settings.""" """Configuration for all controllable devices in the simulation.
batteries: Optional[list[BatteriesCommonSettings]] = Field( Every device collection is a ``dict[str, <Settings>]`` keyed by
``device_id``. This makes config paths stable regardless of
declaration order and lets each device settings class build its own
config path from ``self.device_id`` without needing an external index.
"""
# ---- Batteries ----
batteries: Optional[dict[str, BatteriesCommonSettings]] = Field(
default=None, default=None,
json_schema_extra={ json_schema_extra={
"description": "List of battery devices", "description": "Stationary battery storage devices, keyed by device_id.",
"examples": [[{"device_id": "battery1", "capacity_wh": 8000}]], "examples": [{"bat0": {"device_id": "bat0", "capacity_wh": 8000, "ports": []}}],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
max_batteries: Optional[int] = Field( max_batteries: Optional[int] = Field(
default=None, default=None,
ge=0, ge=0,
json_schema_extra={ json_schema_extra={
"description": "Maximum number of batteries that can be set", "description": "Maximum number of batteries allowed.",
"examples": [1, 2], "examples": [1],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
electric_vehicles: Optional[list[BatteriesCommonSettings]] = Field( # ---- Electric vehicles ----
electric_vehicles: Optional[dict[str, BatteriesCommonSettings]] = Field(
default=None, default=None,
json_schema_extra={ json_schema_extra={
"description": "List of electric vehicle devices", "description": "Electric vehicle battery packs, keyed by device_id.",
"examples": [[{"device_id": "battery1", "capacity_wh": 8000}]], "examples": [{"ev0": {"device_id": "ev0", "capacity_wh": 60000, "ports": []}}],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
max_electric_vehicles: Optional[int] = Field( max_electric_vehicles: Optional[int] = Field(
default=None, default=None,
ge=0, ge=0,
json_schema_extra={ json_schema_extra={
"description": "Maximum number of electric vehicles that can be set", "description": "Maximum number of EVs allowed.",
"examples": [1, 2], "examples": [1],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
inverters: Optional[list[InverterCommonSettings]] = Field( # ---- Inverters ----
default=None, json_schema_extra={"description": "List of inverters", "examples": [[]]} inverters: Optional[dict[str, InverterCommonSettings]] = Field(
default=None,
json_schema_extra={
"description": "Inverter devices, keyed by device_id.",
"examples": [{}],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
) )
max_inverters: Optional[int] = Field( max_inverters: Optional[int] = Field(
default=None, default=None,
ge=0, ge=0,
json_schema_extra={ json_schema_extra={
"description": "Maximum number of inverters that can be set", "description": "Maximum number of inverters allowed.",
"examples": [1, 2], "examples": [1],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
home_appliances: Optional[list[HomeApplianceCommonSettings]] = Field( # ---- Controllable home appliances ----
default=None, json_schema_extra={"description": "List of home appliances", "examples": [[]]} home_appliances: dict[str, HomeApplianceCommonSettings] = Field(
default_factory=dict,
json_schema_extra={
"description": "Shiftable home appliance devices, keyed by device_id.",
"examples": [
{
"dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2.0, # required field
"ports": [{"bus_id": "bus_ac", "port_id": "p_ac", "direction": "sink"}],
},
},
],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
) )
max_home_appliances: Optional[int] = Field( max_home_appliances: Optional[int] = Field(
default=None, default=None,
ge=0, ge=0,
json_schema_extra={ json_schema_extra={
"description": "Maximum number of home_appliances that can be set", "description": "Maximum number of home appliances allowed.",
"examples": [1, 2], "examples": [3],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
}, },
) )
@field_validator(
"batteries", "electric_vehicles", "inverters", "home_appliances", mode="before"
)
@classmethod
def validate_device_ids(cls, value: Any) -> Any:
"""Keep map keys and device identities consistent without mutating callers."""
if not isinstance(value, dict):
return value
result = {}
for key, device in value.items():
if isinstance(device, dict):
device = dict(device)
device.setdefault("device_id", key)
device_id = device["device_id"]
else:
device_id = device.device_id
if device_id != key:
raise ValueError(f"device_id {device_id!r} must match map key {key!r}")
result[key] = device
return result
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def measurement_keys(self) -> Optional[list[str]]: def measurement_keys(self) -> list[str]:
"""Return the measurement keys for the resource/ device stati that are measurements.""" """All measurement keys across all configured devices."""
keys: list[str] = [] keys: list[str] = []
for device_dict in [
if self.max_batteries and self.batteries: self.batteries,
for battery in self.batteries: self.electric_vehicles,
keys.extend(battery.measurement_keys or []) self.inverters,
if self.max_electric_vehicles and self.electric_vehicles: self.home_appliances,
for electric_vehicle in self.electric_vehicles: ]:
keys.extend(electric_vehicle.measurement_keys or []) for device in (device_dict or {}).values():
keys.extend(device.measurement_keys or [])
return keys return keys
-25
View File
@@ -1,32 +1,7 @@
"""Abstract and base classes for devices.""" """Abstract and base classes for devices."""
import secrets
import string
from enum import StrEnum from enum import StrEnum
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
def device_default_id() -> str:
"""Provide random default device id."""
alphabet = string.ascii_letters + string.digits
device_id = "".join(secrets.choice(alphabet) for _ in range(10))
return device_id
class DevicesBaseSettings(SettingsBaseModel):
"""Base devices setting."""
device_id: str = Field(
default_factory=device_default_id,
json_schema_extra={
"description": "ID of device",
"examples": ["battery1", "ev1", "inverter1", "dishwasher"],
},
)
class BatteryOperationMode(StrEnum): class BatteryOperationMode(StrEnum):
"""Battery Operation Mode. """Battery Operation Mode.
+96 -5
View File
@@ -1,12 +1,103 @@
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
import numpy as np import numpy as np
from pydantic import Field
from akkudoktoreos.devices.devices import BATTERY_DEFAULT_CHARGE_RATES from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
from akkudoktoreos.optimization.genetic.geneticdevices import ( from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
BaseBatteryParameters,
SolarPanelBatteryParameters,
) def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
return Field(
default=0, ge=0, le=100, json_schema_extra={"description": description, "examples": [42]}
)
def discharging_efficiency_field(default_value: float) -> float:
return Field(
default=default_value,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the discharge efficiency of the battery."
},
)
class BaseBatteryParameters(DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of battery", "examples": ["battery1"]}
)
capacity_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the capacity of the battery in watt-hours.",
"examples": [8000],
},
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the charging efficiency of the battery."
},
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.",
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the maximum state of charge (SOC) of the battery in percentage."
},
)
charge_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": "Charge rates as factor of maximum charging power [0.00 ... 1.00]. None denotes all charge rates are available.",
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
class SolarPanelBatteryParameters(BaseBatteryParameters):
"""PV battery device simulation configuration."""
max_charge_power_w: Optional[float] = max_charging_power_field()
class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
)
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class Battery: class Battery:
@@ -1,100 +1,625 @@
import numpy as np """Simulation of a home appliance that runs one or more fixed-duration cycles.
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence This module models household devices such as dishwashers or washing
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters machines that must run for a fixed duration, possibly multiple times
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time (cycles), within one or more allowed time windows. Given a set of
requested start times, `HomeAppliance` repairs them into a
feasible, chronologically ordered schedule and produces the resulting
hourly load curve.
Time windows are always expressed as a `CycleTimeWindowSequence`
(see ``akkudoktoreos.config.configabc``): each contained window's
``value`` encodes the 0-based cycle index it applies to, so different
cycles of the same appliance can be constrained to different windows.
When no windows are configured, every cycle defaults to a single
window spanning the full prediction horizon (i.e. unconstrained).
Cycle start times are repaired according to the following rules:
1. Round and clip the requested start to the simulation horizon.
2. Snap each cycle's start to the nearest start allowed by that
cycle's own time window.
3. Sort all cycle starts chronologically, keeping each start paired
with the cycle (and therefore the allowed-start mask) it belongs
to.
4. Walk the sorted starts and push any cycle that starts too soon
after its predecessor to the next start allowed by its own
window, enforcing the appliance duration plus the configured
minimum idle gap.
5. Generate the combined hourly load curve from the final starts.
"""
from typing import Optional
import numpy as np
from pydantic import Field
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
to_datetime,
to_duration,
to_time,
)
class HomeApplianceParameters(DeviceParameters):
"""Configuration for a simulated home appliance device."""
device_id: str = Field(
json_schema_extra={
"description": "ID of home appliance",
"examples": ["dishwasher"],
}
)
consumption_wh: int = Field(
gt=0,
json_schema_extra={
"description": (
"An integer representing the energy consumption "
"of a household device in watt-hours."
),
"examples": [2000],
},
)
duration_h: int = Field(
gt=0,
json_schema_extra={
"description": (
"An integer representing the usage duration of a household device in hours."
),
"examples": [3],
},
)
num_cycles: int = Field(
default=1,
gt=0,
json_schema_extra={
"description": "Number of cycles the appliance must run.",
"examples": [2],
},
)
min_cycle_gap_h: int = Field(
default=0,
ge=0,
json_schema_extra={
"description": (
"Minimum idle time between the end of one cycle and the start of the next cycle."
),
"examples": [1],
},
)
time_windows: Optional[CycleTimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": (
"Allowed per-cycle time windows. Each window's `value` "
"encodes the 0-based cycle index it applies to; multiple "
"windows may share a cycle index. When omitted, every "
"cycle is unconstrained across the full prediction "
"horizon."
),
"examples": [
[
{
"start_time": "10:00",
"duration": "3 hours",
"value": 0,
},
],
],
},
)
class HomeAppliance: class HomeAppliance:
"""Non-vectorized simulation of a multi-cycle home appliance.
A home appliance may execute multiple fixed-duration cycles during
the simulation horizon. See the module docstring for the start-time
repair algorithm.
"""
def __init__( def __init__(
self, self,
parameters: HomeApplianceParameters, parameters: HomeApplianceParameters,
optimization_hours: int, optimization_hours: int,
prediction_hours: int, prediction_hours: int,
): ) -> None:
self.parameters: HomeApplianceParameters = parameters """Initializes the appliance and builds its allowed-start masks.
Args:
parameters: The appliance's configuration.
optimization_hours: Number of hours under active
optimization.
prediction_hours: Length of the simulation horizon, in
hours.
"""
self.parameters = parameters
self.optimization_hours = optimization_hours
self.prediction_hours = prediction_hours self.prediction_hours = prediction_hours
self.duration_h = parameters.duration_h
self.consumption_wh = parameters.consumption_wh
self.num_cycles = parameters.num_cycles
self.min_cycle_gap_h = parameters.min_cycle_gap_h
self.completed_cycles = 0
self.load_curve = np.zeros(prediction_hours)
# Start times for remaining cycles, in chronological order.
self.start_hours: list[int] = []
# Absolute cycle index corresponding to each remaining cycle.
#
# Example:
# num_cycles = 4
# completed_cycles = 2
#
# remaining_cycle_indices = [2, 3]
self.remaining_cycle_indices: list[int] = []
# start_allowed[k][hour]
#
# k is the index into remaining_cycle_indices (NOT into the
# chronologically-sorted self.start_hours).
self.start_allowed: list[np.ndarray] = []
self.start_earliest: list[int] = []
self.start_latest: list[int] = []
self._setup() self._setup()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
def _setup(self) -> None: def _setup(self) -> None:
"""Sets up the home appliance parameters based provided parameters.""" """Sets up appliance parameters and default time windows.
self.load_curve = np.zeros(self.prediction_hours) # Initialize the load curve with zeros
self.duration_h = self.parameters.duration_h When no ``time_windows`` are configured, builds a single
self.consumption_wh = self.parameters.consumption_wh placeholder window spanning the full prediction horizon with
# setup possible start times ``value`` left unset. ``CycleTimeWindowSequence.cycles_to_matrix``
ignores windows whose ``value`` is ``None``, so this window never
matches any cycle; every remaining cycle then falls through to
the "no window for this cycle" branch in
``_build_cycle_start_allowed``, which treats it as unconstrained.
The net effect is the same as having no windows at all, without
assigning cycles an explicit (and misleadingly meaningful)
``value``.
"""
if self.parameters.time_windows is None: if self.parameters.time_windows is None:
self.parameters.time_windows = TimeWindowSequence( self.parameters.time_windows = CycleTimeWindowSequence(
windows=[ windows=[
TimeWindow( ValueTimeWindow(
start_time=to_time("00:00"), start_time=to_time("00:00"),
duration=to_duration(f"{self.prediction_hours} hours"), 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: self._build_start_allowed()
"""Sets the start time of the device and generates the corresponding load curve.
:param start_hour: The hour at which the device should start. @property
def num_remaining_cycles(self) -> int:
"""int: Number of cycles which still have to be scheduled."""
return max(0, self.num_cycles - self.completed_cycles)
def set_completed_cycles(self, completed_cycles: int) -> None:
"""Sets the number of cycles already completed.
Clears any previously scheduled start times and load curve,
and rebuilds the allowed-start masks for the cycles that
remain.
Args:
completed_cycles: Number of cycles completed so far.
Clamped to ``[0, num_cycles]``.
""" """
if not self.start_allowed[start_hour]: self.completed_cycles = max(
# It is not allowed (by the time windows) to start the application at this time 0,
if global_start_hour <= self.start_latest: min(completed_cycles, self.num_cycles),
# 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.start_hours = []
self.reset_load_curve()
self._build_start_allowed()
# ------------------------------------------------------------------
# Time-window handling
# ------------------------------------------------------------------
def _build_start_allowed(self) -> None:
"""Builds allowed start positions for all remaining cycles."""
if self.parameters.time_windows is None:
raise ValueError("Expected time windows in parameters, got {self.parameters}.")
self.start_allowed = []
self.start_earliest = []
self.start_latest = []
self.remaining_cycle_indices = list(
range(
self.completed_cycles,
self.num_cycles,
)
)
if not self.remaining_cycle_indices:
return
start_datetime = to_datetime().set(
hour=0,
minute=0,
second=0,
microsecond=0,
)
end_datetime = start_datetime.add(
hours=self.prediction_hours,
)
interval = to_duration("1 hour")
self._build_cycle_start_allowed(
self.parameters.time_windows,
start_datetime,
end_datetime,
interval,
)
def _build_cycle_start_allowed(
self,
time_windows: CycleTimeWindowSequence,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
) -> None:
"""Builds allowed starts from per-cycle windows.
Args:
time_windows: Windows associated with individual cycles.
start_datetime: Start of the simulation horizon.
end_datetime: End of the simulation horizon.
interval: Step size used to sample the cycle windows.
"""
cycle_indices, matrix = time_windows.cycles_to_matrix(
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
)
cycle_to_row = {cycle: row for row, cycle in enumerate(cycle_indices)}
max_start = max(
0,
self.prediction_hours - self.duration_h,
)
# The matrix tells us which individual *steps* are inside
# the cycle window. We still have to check that the complete
# appliance duration fits inside the window.
for cycle in self.remaining_cycle_indices:
row_index = cycle_to_row.get(cycle)
if row_index is None:
# No window for this cycle -> unconstrained.
allowed = np.zeros(
self.prediction_hours,
dtype=bool,
)
allowed[: max_start + 1] = True
else:
allowed = self._build_duration_feasibility(
matrix[row_index],
)
self.start_allowed.append(allowed)
allowed_indices = np.flatnonzero(allowed)
if len(allowed_indices):
self.start_earliest.append(int(allowed_indices[0]))
self.start_latest.append(int(allowed_indices[-1]))
else:
self.start_earliest.append(0)
self.start_latest.append(max_start)
def _build_duration_feasibility(
self,
window_steps: np.ndarray,
) -> np.ndarray:
"""Returns starts where the complete appliance fits in a window.
A start ``s`` is feasible when every step in
``window_steps[s : s + duration_h]`` lies inside the cycle's
window (i.e. sums to ``duration_h``).
Args:
window_steps: Per-hour membership of the cycle's window
(1.0 inside the window, 0.0 outside), one value per
hour of the prediction horizon.
Returns:
Boolean mask, one entry per hour of the prediction
horizon, ``True`` where a cycle of length ``duration_h``
can start without leaving the window.
"""
horizon = len(window_steps)
max_start = max(
0,
horizon - self.duration_h,
)
allowed = np.zeros(
horizon,
dtype=bool,
)
if self.duration_h > horizon:
return allowed
# Rolling sum of `duration_h` consecutive steps, aligned so
# that window_sums[s] == sum(window_steps[s : s + duration_h]).
cumulative = np.concatenate(([0.0], np.cumsum(window_steps)))
window_sums = cumulative[self.duration_h :] - cumulative[: -self.duration_h]
allowed[: max_start + 1] = window_sums[: max_start + 1] == float(self.duration_h)
return allowed
# ------------------------------------------------------------------
# Scheduling / repair
# ------------------------------------------------------------------
def set_starting_times(
self,
start_hours: list[int],
) -> list[int]:
"""Sets and repairs the start times of all remaining cycles.
See the module docstring for the repair algorithm.
Args:
start_hours: Requested start hour for each remaining
cycle, in the same order as ``remaining_cycle_indices``
(i.e. matching ``self.start_allowed``).
Returns:
The repaired, chronologically ordered start hours.
Raises:
ValueError: If ``start_hours`` does not have exactly
``num_remaining_cycles`` entries.
"""
if len(start_hours) != self.num_remaining_cycles:
raise ValueError(
f"Expected {self.num_remaining_cycles} start times, got {len(start_hours)}."
)
if self.num_remaining_cycles == 0:
self.start_hours = []
self.reset_load_curve()
return []
max_start = max(
0,
self.prediction_hours - self.duration_h,
)
# 1. Round and clip.
starts = [
max(
0,
min(
int(round(start)),
max_start,
),
)
for start in start_hours
]
# 2. Snap each cycle to its nearest allowed start, keeping the
# start paired with the cycle (start_allowed index) it
# belongs to.
repaired = [
(
self._repair_start(
start,
cycle_index,
max_start,
),
cycle_index,
)
for cycle_index, start in enumerate(starts)
]
# 3. Sort by start time, keeping each cycle's own index
# attached so its allowed-start mask is still used
# correctly in step 4.
repaired.sort(key=lambda pair: pair[0])
# 4. Enforce duration + minimum idle gap. Each cycle is
# pushed forward, if needed, to the next start allowed by
# its *own* window.
min_next_start = self.duration_h + self.min_cycle_gap_h
final_starts = [repaired[0][0]]
for index in range(1, len(repaired)):
_, cycle_index = repaired[index]
earliest = final_starts[index - 1] + min_next_start
candidate = self._first_allowed_start_at_or_after(
cycle_index=cycle_index,
earliest=earliest,
)
if candidate is None:
# No valid start remains for this cycle.
final_starts.append(max_start)
else:
final_starts.append(candidate)
# 5. Reconstruct load curve from the final schedule.
self.start_hours = final_starts
self._build_load_curve()
return list(self.start_hours)
def _repair_start(
self,
start: int,
cycle_index: int,
max_start: int,
) -> int:
"""Snaps a start to the nearest allowed start.
Args:
start: Requested (already rounded and clipped) start
hour.
cycle_index: Index into ``self.start_allowed`` for the
cycle being repaired.
max_start: Latest hour at which any cycle may start
without exceeding the prediction horizon, used as a
fallback when the cycle has no allowed start at all.
Returns:
The nearest hour allowed for this cycle, or ``max_start``
if the cycle has no allowed start.
"""
allowed = self.start_allowed[cycle_index]
if not np.any(allowed):
# Same fallback as the vectorized implementation.
return max_start
if allowed[start]:
return start
allowed_indices = np.flatnonzero(allowed)
distances = np.abs(allowed_indices - start)
return int(allowed_indices[np.argmin(distances)])
def _first_allowed_start_at_or_after(
self,
cycle_index: int,
earliest: int,
) -> int | None:
"""Returns the first allowed start at or after ``earliest``.
Args:
cycle_index: Index into ``self.start_allowed`` for the
cycle being scheduled.
earliest: Earliest acceptable start hour.
Returns:
The first allowed hour ``>= earliest``, or ``None`` if no
such hour exists.
"""
allowed = self.start_allowed[cycle_index]
allowed_indices = np.flatnonzero(allowed)
if len(allowed_indices) == 0:
return None
position = np.searchsorted(
allowed_indices,
max(0, earliest),
side="left",
)
if position >= len(allowed_indices):
return None
return int(allowed_indices[position])
# ------------------------------------------------------------------
# Backwards-compatible single-cycle interface
# ------------------------------------------------------------------
def set_starting_time(
self,
start_hour: int,
global_start_hour: int = 0,
) -> int:
"""Sets the start time of the first remaining cycle.
Args:
start_hour: Requested start hour for the first remaining
cycle.
global_start_hour: Retained for API compatibility with
the old, single-cycle implementation. Unused.
Returns:
The repaired start hour of the first remaining cycle, or
``start_hour`` unchanged if there are no remaining
cycles.
"""
if self.num_remaining_cycles == 0:
self.reset_load_curve()
return start_hour
if self.start_hours:
starts = list(self.start_hours)
else:
starts = [self.start_earliest[index] for index in range(self.num_remaining_cycles)]
starts[0] = start_hour
repaired = self.set_starting_times(starts)
return repaired[0]
# ------------------------------------------------------------------
# Load curve
# ------------------------------------------------------------------
def _build_load_curve(self) -> None:
"""Builds the load curve from all scheduled cycles."""
self.reset_load_curve() self.reset_load_curve()
# Calculate power per hour based on total consumption and duration power_per_hour = self.consumption_wh / self.duration_h
power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours
# Set the power for the duration of use in the load curve array for start_hour in self.start_hours:
if start_hour < len(self.load_curve): if start_hour >= self.prediction_hours:
end_hour = min(start_hour + self.duration_h, self.prediction_hours) continue
self.load_curve[start_hour:end_hour] = power_per_hour
return start_hour end_hour = min(
start_hour + self.duration_h,
self.prediction_hours,
)
self.load_curve[start_hour:end_hour] += power_per_hour
def reset_load_curve(self) -> None: def reset_load_curve(self) -> None:
"""Resets the load curve.""" """Resets the load curve to all zeros."""
self.load_curve = np.zeros(self.prediction_hours) self.load_curve = np.zeros(self.prediction_hours)
def get_load_curve(self) -> np.ndarray: def get_load_curve(self) -> np.ndarray:
"""Returns the current load curve.""" """Returns the current hourly load curve, in watts."""
return self.load_curve return self.load_curve
def get_load_for_hour(self, hour: int) -> float: def get_load_for_hour(self, hour: int) -> float:
"""Returns the load for a specific hour. """Returns the load for a specific hour.
:param hour: The hour for which the load is queried. Args:
:return: The load in watts for the specified hour. hour: Hour of the prediction horizon to look up.
Returns:
The load, in watts, at ``hour``.
Raises:
ValueError: If ``hour`` is outside
``[0, prediction_hours)``.
""" """
if hour < 0 or hour >= self.prediction_hours: if hour < 0 or hour >= self.prediction_hours:
raise ValueError( raise ValueError(
f"The specified hour {hour} is outside the available time frame {self.prediction_hours}." f"The specified hour {hour} is outside the available "
f"time frame {self.prediction_hours}."
) )
return self.load_curve[hour] return float(self.load_curve[hour])
+52 -1
View File
@@ -1,12 +1,63 @@
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
from pydantic import Field
from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.optimization.genetic.geneticdevices import InverterParameters from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
class InverterParameters(DeviceParameters):
"""Inverter Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of inverter", "examples": ["inverter1"]}
)
max_power_wh: float = Field(gt=0, json_schema_extra={"examples": [10000]})
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={"description": "ID of battery", "examples": [None, "battery1"]},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC to DC conversion (for AC/grid charging of battery). "
"Set to 0 to disable AC charging via inverter. "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0, 0.0],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC to AC conversion (for battery discharging to AC load/grid). "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power in watts. "
"None means no additional limit (battery's own max_charge_power_w applies). "
"Set to 0 to disable AC charging."
),
"examples": [None, 0, 5000],
},
)
class Inverter: class Inverter:
def __init__( def __init__(
self, self,
@@ -1,12 +1,103 @@
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
import numpy as np import numpy as np
from pydantic import Field
from akkudoktoreos.devices.devices import BATTERY_DEFAULT_CHARGE_RATES from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
from akkudoktoreos.optimization.genetic0.genetic0devices import ( from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters
Genetic0BaseBatteryParameters,
Genetic0SolarPanelBatteryParameters,
) def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
return Field(
default=0, ge=0, le=100, json_schema_extra={"description": description, "examples": [42]}
)
def discharging_efficiency_field(default_value: float) -> float:
return Field(
default=default_value,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the discharge efficiency of the battery."
},
)
class Genetic0BaseBatteryParameters(Genetic0DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of battery", "examples": ["battery1"]}
)
capacity_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the capacity of the battery in watt-hours.",
"examples": [8000],
},
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the charging efficiency of the battery."
},
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.",
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the maximum state of charge (SOC) of the battery in percentage."
},
)
charge_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": "Charge rates as factor of maximum charging power [0.00 ... 1.00]. None denotes all charge rates are available.",
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
class Genetic0SolarPanelBatteryParameters(Genetic0BaseBatteryParameters):
"""PV battery device simulation configuration."""
max_charge_power_w: Optional[float] = max_charging_power_field()
class Genetic0ElectricVehicleParameters(Genetic0BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
)
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class Genetic0Battery: class Genetic0Battery:
@@ -1,12 +1,46 @@
from typing import Optional
import numpy as np import numpy as np
from pydantic import Field
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.optimization.genetic0.genetic0devices import ( from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters
Genetic0HomeApplianceParameters,
)
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time
class Genetic0HomeApplianceParameters(Genetic0DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of home appliance", "examples": ["dishwasher"]}
)
consumption_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the energy consumption of a household device in watt-hours.",
"examples": [2000],
},
)
duration_h: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the usage duration of a household device in hours.",
"examples": [3],
},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": "List of allowed time windows. Defaults to optimization general time window.",
"examples": [
[
{"start_time": "10:00", "duration": "3 hours"},
],
],
},
)
class Genetic0HomeAppliance: class Genetic0HomeAppliance:
def __init__( def __init__(
self, self,
@@ -1,16 +1,65 @@
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
from pydantic import Field
from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery
from akkudoktoreos.optimization.genetic0.genetic0devices import ( from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters
Genetic0InverterParameters,
)
from akkudoktoreos.optimization.genetic0.genetic0loadinterpolator import ( from akkudoktoreos.optimization.genetic0.genetic0loadinterpolator import (
get_genetic0_load_interpolator, get_genetic0_load_interpolator,
) )
class Genetic0InverterParameters(Genetic0DeviceParameters):
"""Inverter Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of inverter", "examples": ["inverter1"]}
)
max_power_wh: float = Field(gt=0, json_schema_extra={"examples": [10000]})
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={"description": "ID of battery", "examples": [None, "battery1"]},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC to DC conversion (for AC/grid charging of battery). "
"Set to 0 to disable AC charging via inverter. "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0, 0.0],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC to AC conversion (for battery discharging to AC load/grid). "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power in watts. "
"None means no additional limit (battery's own max_charge_power_w applies). "
"Set to 0 to disable AC charging."
),
"examples": [None, 0, 5000],
},
)
class Genetic0Inverter: class Genetic0Inverter:
def __init__( def __init__(
self, self,
@@ -0,0 +1,271 @@
"""Battery and electric vehicle device settings.
Note: Used for the GENETIC and GENETIC0 algorithm.
"""
import re
from typing import TYPE_CHECKING, Any, Optional
import numpy as np
from numpydantic import NDArray, Shape
from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0battery import (
Genetic0ElectricVehicleParameters,
Genetic0SolarPanelBatteryParameters,
)
from akkudoktoreos.devices.genetic.battery import (
ElectricVehicleParameters,
SolarPanelBatteryParameters,
)
# Default charge rates for battery
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
class BatteriesCommonSettings(DevicesBaseSettings):
"""Battery and electric vehicle device settings.
Used for both stationary batteries and EV battery packs.
Note: Used for the GENETIC and GENETIC0 algorithm.
"""
capacity_wh: int = Field(
default=8000,
gt=0,
json_schema_extra={"description": "Capacity [Wh].", "examples": [8000]},
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Charging efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
discharging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "Discharge efficiency [0.01 ... 1.00].",
"examples": [0.88],
},
)
levelized_cost_of_storage_amt_kwh: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Levelized cost of storage (LCOS), the average lifetime cost "
"of delivering one kWh [€/kWh]."
),
"examples": [0.12],
},
)
max_charge_power_w: Optional[float] = Field(
default=5000,
gt=0,
json_schema_extra={
"description": "Maximum charging power [W].",
"examples": [5000],
},
)
min_charge_power_w: Optional[float] = Field(
default=50,
gt=0,
json_schema_extra={
"description": "Minimum charging power [W].",
"examples": [50],
},
)
charge_rates: Optional[list[float]] = Field(
default=BATTERY_DEFAULT_CHARGE_RATES,
json_schema_extra={
"description": (
"Charge rates as factor of maximum charging power [0.00 ... 1.00]. "
"None triggers fallback to default charge-rates."
),
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": (
"Minimum state of charge (SOC) as percentage of capacity [%]. "
"This is the target SoC for charging."
),
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "Maximum state of charge (SOC) as percentage of capacity [%].",
"examples": [100],
},
)
# ------------------------------------------------------------------
# GENETIC domain conversion
# ------------------------------------------------------------------
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
return SolarPanelBatteryParameters(
device_id=self.device_id,
capacity_wh=self.capacity_wh,
charging_efficiency=self.charging_efficiency,
discharging_efficiency=self.discharging_efficiency,
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
)
def to_genetic_ev_bat_param(self) -> "ElectricVehicleParameters":
"""Return ElectricVehicleParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.battery import ElectricVehicleParameters
return ElectricVehicleParameters(
device_id=self.device_id,
capacity_wh=self.capacity_wh,
charging_efficiency=self.charging_efficiency,
discharging_efficiency=self.discharging_efficiency,
charge_rates=self.charge_rates,
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
)
# ------------------------------------------------------------------
# GENETIC0 domain conversion
# ------------------------------------------------------------------
def to_genetic0_pv_bat_param(self) -> "Genetic0SolarPanelBatteryParameters":
"""Return Genetic0SolarPanelBatteryParameters for the GENETIC0 optimizer."""
from akkudoktoreos.devices.genetic0.genetic0battery import (
Genetic0SolarPanelBatteryParameters,
)
return Genetic0SolarPanelBatteryParameters(
device_id=self.device_id,
capacity_wh=self.capacity_wh,
charging_efficiency=self.charging_efficiency,
discharging_efficiency=self.discharging_efficiency,
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
)
def to_genetic0_ev_bat_param(self) -> "Genetic0ElectricVehicleParameters":
"""Return Genetic0ElectricVehicleParameters for the GENETI0C optimizer."""
from akkudoktoreos.devices.genetic0.genetic0battery import (
Genetic0ElectricVehicleParameters,
)
return Genetic0ElectricVehicleParameters(
device_id=self.device_id,
capacity_wh=self.capacity_wh,
charging_efficiency=self.charging_efficiency,
discharging_efficiency=self.discharging_efficiency,
charge_rates=self.charge_rates,
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
)
@field_validator("charge_rates", mode="before")
def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
# None means fallback to default values
if v is None:
return np.asarray(BATTERY_DEFAULT_CHARGE_RATES, dtype=float).copy()
# Convert to numpy array
if isinstance(v, str):
# Remove brackets and split by comma or whitespace
numbers = re.split(r"[,\s]+", v.strip("[]"))
# Filter out any empty strings and convert to floats
arr = np.array([float(x) for x in numbers if x])
else:
arr = np.array(v, dtype=float)
# Must not be empty
if arr.size == 0:
raise ValueError("charge_rates must contain at least one value.")
# Enforce bounds: 0.0 ≤ x ≤ 1.0
if (arr < 0.0).any() or (arr > 1.0).any():
raise ValueError("charge_rates must be within [0.0, 1.0].")
# Remove duplicates + sort
arr = np.unique(arr)
arr.sort()
return arr
@model_validator(mode="after")
def _validate_soc_range(self) -> "BatteriesCommonSettings":
if self.min_soc_percentage >= self.max_soc_percentage:
raise ValueError("min_soc_percentage must be < max_soc_percentage")
if (
self.min_charge_power_w is not None
and self.max_charge_power_w is not None
and self.min_charge_power_w > self.max_charge_power_w
):
raise ValueError("min_charge_power_w must be <= max_charge_power_w")
return self
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_soc_factor(self) -> str:
"""Measurement key for SoC as factor of total capacity [0.0 ... 1.0]."""
return f"{self.device_id}-soc-factor"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l1_w(self) -> str:
"""Measurement key for L1 power [W]."""
return f"{self.device_id}-power-l1-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l2_w(self) -> str:
"""Measurement key for L2 power [W]."""
return f"{self.device_id}-power-l2-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_l3_w(self) -> str:
"""Measurement key for L3 power [W]."""
return f"{self.device_id}-power-l3-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_power_3_phase_sym_w(self) -> str:
"""Measurement key for symmetric 3-phase power [W]."""
return f"{self.device_id}-power-3-phase-sym-w"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> list[str]:
"""All measurement keys for this battery."""
return [
self.measurement_key_soc_factor,
self.measurement_key_power_l1_w,
self.measurement_key_power_l2_w,
self.measurement_key_power_l3_w,
self.measurement_key_power_3_phase_sym_w,
]
@@ -0,0 +1,39 @@
"""Base classe for all device settings.
This module contains the building blocks that every device settings class
depends on.:
- ``DevicesBaseSettings``: common ``device_id`` field for all devices.
Nothing in this module is optimizer-specific.
"""
import secrets
import string
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
# ============================================================
# Base settings
# ============================================================
def device_default_id() -> str:
"""Provide random default device id."""
alphabet = string.ascii_letters + string.digits
device_id = "".join(secrets.choice(alphabet) for _ in range(10))
return device_id
class DevicesBaseSettings(SettingsBaseModel):
"""Base devices setting."""
device_id: str = Field(
default_factory=device_default_id,
json_schema_extra={
"description": "ID of device",
"examples": ["battery1", "ev1", "inverter1", "dishwasher"],
},
)
@@ -0,0 +1,258 @@
"""Controllable home appliance device settings.
Note: Used for the GENETIC and GENETIC0 algorithm.
"""
from typing import TYPE_CHECKING, Optional
from pydantic import Field, computed_field, model_validator
from akkudoktoreos.config.configabc import ConfigScope, CycleTimeWindowSequence
from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
Genetic0HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic.homeappliance import HomeApplianceParameters
class HomeApplianceCommonSettings(DevicesBaseSettings):
"""Controllable home appliance device settings.
Represents a shiftable load whose start time — and optionally the
start times for multiple sequential runs — can be deferred by the
optimiser within per-cycle allowed time windows.
The number of remaining cycles to plan is determined at runtime by
reading ``cycles_completed_measurement_key`` from the measurement
store inside ``HomeApplianceDevice.setup_run``.
``num_cycles`` is required when ``cycle_time_windows`` is ``None``
(unconstrained); when windows are provided it is derived from
``cycle_time_windows.num_cycles()``.
Single-cycle, unconstrained (start any time)
---------------------------------------------
::
device_id: dishwasher
consumption_wh: 1500
duration_h: 2
num_cycles: 1
ports:
- port_id: p_ac
bus_id: bus_ac
direction: sink
Single-cycle, constrained to one window
----------------------------------------
Each window's ``value`` field carries the **cycle index** (0-based).
Windows without a ``value`` are ignored by the optimizer::
device_id: dishwasher
consumption_wh: 1500
duration_h: 2
ports:
- port_id: p_ac
bus_id: bus_ac
direction: sink
cycle_time_windows:
windows:
- start_time: "10:00"
duration: "12 hours"
value: 0
Multi-cycle, per-cycle windows
--------------------------------
Two cycles, each with its own window. Cycle 0 runs in the morning,
cycle 1 in the evening::
device_id: washing_machine
consumption_wh: 2000
duration_h: 2
min_cycle_gap_h: 1
ports:
- port_id: p_ac
bus_id: bus_ac
direction: sink
cycle_time_windows:
windows:
- start_time: "07:00"
duration: "5 hours"
value: 0
- start_time: "17:00"
duration: "5 hours"
value: 1
Multi-cycle, shared window (both cycles may run any time 10:00-20:00)
-----------------------------------------------------------------------
Assign the same-shaped windows to distinct cycle indices so the
optimizer can place them independently::
cycle_time_windows:
windows:
- start_time: "10:00"
duration: "10 hours"
value: 0
- start_time: "10:00"
duration: "10 hours"
value: 1
"""
consumption_wh: int = Field(
default=3000,
gt=0,
json_schema_extra={
"description": "Energy consumption per run cycle [Wh].",
"examples": [2000],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
duration_h: int = Field(
default=3,
gt=0,
le=24,
json_schema_extra={
"description": "Run duration per cycle [h] (1-24).",
"examples": [2],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
num_cycles: int = Field(
default=1,
ge=1,
json_schema_extra={
"description": (
"Number of times the appliance must run within the horizon. "
"Required when cycle_time_windows is null (unconstrained). "
"Ignored when cycle_time_windows is provided -- the number "
"of distinct cycle indices in the windows defines num_cycles. "
"Defaults to 1."
),
"examples": [1, 2],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
cycle_time_windows: Optional[CycleTimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": (
"Per-cycle allowed scheduling time windows. "
"Each window's value field specifies the cycle index (0-based). "
"When null, the appliance may start at any step and num_cycles "
"must be set explicitly."
),
"examples": [
None,
{
"windows": [
{"start_time": "07:00", "duration": "5 hours", "value": 0},
{"start_time": "17:00", "duration": "5 hours", "value": 1},
]
},
],
"x-scope": [
str(ConfigScope.GENETIC),
],
},
)
min_cycle_gap_h: int = Field(
default=0,
ge=0,
json_schema_extra={
"description": (
"Minimum idle time between the end of one cycle and the start "
"of the next [h]. Applies uniformly between all consecutive cycles. "
"0 means back-to-back runs are permitted."
),
"examples": [0, 1, 4],
"x-scope": [
str(ConfigScope.GENETIC),
],
},
)
cycles_completed_measurement_key: Optional[str] = Field(
default=None,
json_schema_extra={
"description": (
"Measurement store key holding the number of cycles already "
"completed in the current planning day. Read by "
"HomeApplianceDevice.setup_run via context.resolve_measurement. "
"Defaults to '{device_id}.cycles_completed' when null."
),
"examples": ["dishwasher.cycles_completed", None],
"x-scope": [
str(ConfigScope.GENETIC),
],
},
)
@model_validator(mode="after")
def _validate_num_cycles_specified(self) -> "HomeApplianceCommonSettings":
"""Require num_cycles when windows are not provided."""
if self.cycle_time_windows is None and self.num_cycles is None:
raise ValueError(
"num_cycles must be set when cycle_time_windows is null. "
"Provide either cycle_time_windows (windows define cycle count) "
"or set num_cycles explicitly."
)
return self
@computed_field # type: ignore[prop-decorator]
@property
def effective_num_cycles(self) -> int:
"""Number of cycles as seen by the optimizer.
Derived from cycle_time_windows.num_cycles() when windows are
provided; falls back to the explicit num_cycles field otherwise.
"""
if self.cycle_time_windows is not None:
return self.cycle_time_windows.num_cycles()
return self.num_cycles
# ------------------------------------------------------------------
# GENETIC domain conversion
# ------------------------------------------------------------------
def to_genetic_param(self) -> "HomeApplianceParameters":
"""Return HomeApplianceParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.homeappliance import HomeApplianceParameters
return HomeApplianceParameters(
device_id=self.device_id,
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
num_cycles=self.effective_num_cycles,
min_cycle_gap_h=self.min_cycle_gap_h,
time_windows=self.cycle_time_windows,
)
# ------------------------------------------------------------------
# GENETIC0 domain conversion
# ------------------------------------------------------------------
def to_genetic0_param(self) -> "Genetic0HomeApplianceParameters":
"""Return Genetic0HomeApplianceParameters for the GENETIC0 optimizer."""
from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
Genetic0HomeApplianceParameters,
)
return Genetic0HomeApplianceParameters(
device_id=self.device_id,
consumption_wh=self.consumption_wh,
duration_h=self.duration_h,
time_windows=self.cycle_time_windows,
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> Optional[list[str]]:
"""Measurement keys for the home appliance stati that are measurements."""
keys: list[str] = []
return keys
@@ -0,0 +1,397 @@
"""Inverter device settings."""
from typing import TYPE_CHECKING, Optional
from pydantic import Field, computed_field, model_validator
from akkudoktoreos.config.configabc import ConfigScope
from akkudoktoreos.devices.settings.devicebasesettings import (
DevicesBaseSettings,
)
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0InverterParameters,
)
from akkudoktoreos.devices.genetic.inverter import InverterParameters
class InverterCommonSettings(DevicesBaseSettings):
"""Inverter device settings.
An inverter bridges a DC bus (PV / battery) and an AC bus (grid /
household). It must therefore have at least one DC port and one AC
port.
"""
# ------------------------------------------------------------------
# Shared fields (GENETIC + GENETIC0)
# ------------------------------------------------------------------
max_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": "Maximum AC output power [W].",
"examples": [10000],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC→DC conversion for grid-to-battery charging (0–1). "
"Set to 0 to disable AC charging. Default 1.0."
),
"examples": [0.95, 1.0, 0.0],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC→AC conversion for battery discharging (0–1). Default 1.0."
),
"examples": [0.95, 1.0],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power [W]. "
"null means no additional limit. 0 disables AC charging."
),
"examples": [None, 0, 5000],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={
"description": ("Device ID of the battery."),
"examples": [None],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
# ------------------------------------------------------------------
# UNUSED-only fields
# ------------------------------------------------------------------
# Auxiliary power consumption
off_state_power_consumption_w: float = Field(
default=0.0,
ge=0,
json_schema_extra={
"description": (
"Standby power consumed when the inverter is fully idle "
"(battery=0 and PV=0) [W]. Default 0.0."
),
"examples": [5.0, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
on_state_power_consumption_w: float = Field(
default=0.0,
ge=0,
json_schema_extra={
"description": (
"Auxiliary power consumed whenever the inverter is active "
"(non-zero AC power) [W]. Default 0.0."
),
"examples": [10.0, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
# PV parameters (used for SOLAR and HYBRID inverter types)
pv_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of PV DC→AC conversion (0–1). "
"Required when pv_power_w_key is set (SOLAR or HYBRID). Default 1.0."
),
"examples": [0.97, 1.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
pv_to_battery_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of PV DC→battery charging path (0–1). "
"Used for HYBRID inverters only. Default 1.0."
),
"examples": [0.98, 1.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
pv_max_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Maximum DC PV power fed into the inverter [W]. "
"Required when pv_power_w_key is set (SOLAR or HYBRID). "
"Values from pv_power_w_key are clipped to this limit."
),
"examples": [8000.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
pv_min_power_w: float = Field(
default=0.0,
ge=0,
json_schema_extra={
"description": (
"Minimum DC PV power threshold [W]. Steps with available PV "
"below this value are treated as zero. Default 0.0."
),
"examples": [50.0, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
pv_power_w_key: Optional[str] = Field(
default=None,
json_schema_extra={
"description": (
"SimulationContext prediction key resolving to a per-step PV "
"power forecast array [W] of shape (horizon,). "
"Set for SOLAR and HYBRID inverter types; leave None for BATTERY."
),
"examples": ["pv_forecast_w", None],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
# Battery parameters (used for BATTERY and HYBRID inverter types)
battery_capacity_wh: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Usable battery capacity [Wh]. Required for BATTERY and HYBRID inverter types."
),
"examples": [10000.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_charge_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Optional list of discrete charge rate fractions (each in (0, 1]). "
"When set, the battery is constrained to these specific fractions "
"of battery_max_charge_rate. null means continuous charging. "
"All values must be in (0, 1]."
),
"examples": [None, [0.25, 0.5, 1.0]],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_min_charge_rate: float = Field(
default=0.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Minimum non-zero charge rate as a fraction of the 1C rate "
"(1C = battery_capacity_wh W). "
"Charge commands below this threshold are rounded to zero. Default 0.0."
),
"examples": [0.1, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_max_charge_rate: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Maximum charge rate as a fraction of the 1C rate "
"(1C = battery_capacity_wh W). "
"bat_factor=+1 maps to this rate. Default 1.0."
),
"examples": [0.5, 1.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_min_discharge_rate: float = Field(
default=0.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Minimum discharge rate as a fraction of the 1C rate "
"(1C = battery_capacity_wh W). "
"Discharge commands below this threshold are rounded to zero. Default 0.0."
),
"examples": [0.1, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_max_discharge_rate: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Maximum discharge rate as a fraction of the 1C rate. "
"(1C = battery_capacity_wh W). "
"bat_factor=−1 maps to this rate. Default 1.0."
),
"examples": [0.5, 1.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_min_soc_factor: float = Field(
default=0.0,
ge=0,
lt=1,
json_schema_extra={
"description": (
"Minimum allowed state of charge as a fraction of battery_capacity_wh. "
"Must be < battery_max_soc_factor. Default 0.0."
),
"examples": [0.1, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_max_soc_factor: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Maximum allowed state of charge as a fraction of battery_capacity_wh. "
"Must be > battery_min_soc_factor. Default 1.0."
),
"examples": [0.9, 1.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_initial_soc_factor_key: str = Field(
default="",
json_schema_extra={
"description": (
"SimulationContext measurement key resolving to the initial battery "
"SoC as a fraction of battery_capacity_wh, in [min_soc_factor, max_soc_factor]. "
"An empty string means the device uses battery_min_soc_factor as the "
"initial SoC (fully depleted to the minimum)."
),
"examples": ["battery1_soc_factor", ""],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_lcos_amt_kwh: float = Field(
default=0.0,
ge=0,
json_schema_extra={
"description": (
"Levelized cost of battery storage [Amt./kWh cycled]. "
"Penalises unnecessary charging/discharging so the GA avoids "
"grid-charge→discharge cycles with no price-spread benefit. "
"Typical residential Li-ion value: 0.05 Amt./kWh. "
"Set to 0.0 to encourage the optimizer to use the battery. "
"Defaults to 0.0."
),
"examples": [0.05, 0.0],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
battery_discharge_reward_amt_kwh: float = Field(
default=0.02,
ge=0,
json_schema_extra={
"description": (
"Shadow price rewarding battery discharge [Amt./kWh discharged AC]. "
"Adds a direct fitness benefit per kWh the battery delivers, on top of "
"the grid import cost reduction already captured by GridConnectionDevice. "
"Helps the GA discover discharge when the load-matching rate is small "
"relative to mutation noise. "
"Suggested value: import_price - export_price - lcos "
"(e.g. 0.30 - 0.08 - 0.05 = 0.17). Set to 0.0 to disable."
),
"examples": [0.0, 0.17],
"x-scope": [str(ConfigScope.UNUSED)],
},
)
# ------------------------------------------------------------------
# Validators
# ------------------------------------------------------------------
@model_validator(mode="after")
def _validate_soc_factors(self) -> "InverterCommonSettings":
if self.battery_min_soc_factor >= self.battery_max_soc_factor:
raise ValueError(
"battery_min_soc_factor must be strictly less than battery_max_soc_factor"
)
return self
# ------------------------------------------------------------------
# GENETIC domain conversion
# ------------------------------------------------------------------
def to_genetic_param(self) -> "InverterParameters":
"""Return InverterParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.inverter import InverterParameters
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
return InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
max_ac_charge_power_w=self.max_ac_charge_power_w,
)
# ------------------------------------------------------------------
# GENETIC0 domain conversion
# ------------------------------------------------------------------
def to_genetic0_param(self) -> "Genetic0InverterParameters":
"""Return Genetic0InverterParameters for the GENETIC0 optimizer."""
from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0InverterParameters,
)
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
return Genetic0InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
max_ac_charge_power_w=self.max_ac_charge_power_w,
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> list[str]:
"""Measurement keys for this inverter.
Returns the ``battery_initial_soc_factor_key`` if non-empty, so
the EMS measurement store knows to watch for this key.
"""
if self.battery_initial_soc_factor_key:
return [self.battery_initial_soc_factor_key]
return []
@@ -1102,12 +1102,12 @@ class GeneticOptimization(OptimizationBase):
self.ev_possible_charge_values = parameters.ev.charge_rates self.ev_possible_charge_values = parameters.ev.charge_rates
elif ( elif (
self.config.devices.electric_vehicles self.config.devices.electric_vehicles
and self.config.devices.electric_vehicles[0] and len(self.config.devices.electric_vehicles) > 0
and self.config.devices.electric_vehicles[0].charge_rates is not None and list(self.config.devices.electric_vehicles.values())[0].charge_rates is not None
): ):
self.ev_possible_charge_values = self.config.devices.electric_vehicles[ self.ev_possible_charge_values = list(
0 self.config.devices.electric_vehicles.values()
].charge_rates )[0].charge_rates
else: else:
warning_msg = "No charge rates provided for electric vehicle - using default." warning_msg = "No charge rates provided for electric vehicle - using default."
logger.warning(warning_msg) logger.warning(warning_msg)
@@ -1136,11 +1136,11 @@ class GeneticOptimization(OptimizationBase):
] or [1.0] ] or [1.0]
elif ( elif (
self.config.devices.batteries self.config.devices.batteries
and self.config.devices.batteries[0] and len(self.config.devices.batteries) > 0
and self.config.devices.batteries[0].charge_rates and list(self.config.devices.batteries.values())[0].charge_rates
): ):
self.bat_possible_charge_values = [ self.bat_possible_charge_values = [
r for r in self.config.devices.batteries[0].charge_rates if r > 0.0 r for r in list(self.config.devices.batteries.values())[0].charge_rates if r > 0.0
] or [1.0] ] or [1.0]
else: else:
self.bat_possible_charge_values = [1.0] self.bat_possible_charge_values = [1.0]
@@ -4,7 +4,6 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
@@ -18,179 +17,3 @@ class DeviceParameters(GeneticParametersBaseModel):
"examples": [None], "examples": [None],
}, },
) )
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
return Field(
default=0, ge=0, le=100, json_schema_extra={"description": description, "examples": [42]}
)
def discharging_efficiency_field(default_value: float) -> float:
return Field(
default=default_value,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the discharge efficiency of the battery."
},
)
class BaseBatteryParameters(DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of battery", "examples": ["battery1"]}
)
capacity_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the capacity of the battery in watt-hours.",
"examples": [8000],
},
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the charging efficiency of the battery."
},
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.",
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the maximum state of charge (SOC) of the battery in percentage."
},
)
charge_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": "Charge rates as factor of maximum charging power [0.00 ... 1.00]. None denotes all charge rates are available.",
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
class SolarPanelBatteryParameters(BaseBatteryParameters):
"""PV battery device simulation configuration."""
max_charge_power_w: Optional[float] = max_charging_power_field()
class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
)
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class HomeApplianceParameters(DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of home appliance", "examples": ["dishwasher"]}
)
consumption_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the energy consumption of a household device in watt-hours.",
"examples": [2000],
},
)
duration_h: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the usage duration of a household device in hours.",
"examples": [3],
},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": "List of allowed time windows. Defaults to optimization general time window.",
"examples": [
[
{"start_time": "10:00", "duration": "3 hours"},
],
],
},
)
class InverterParameters(DeviceParameters):
"""Inverter Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of inverter", "examples": ["inverter1"]}
)
max_power_wh: float = Field(gt=0, json_schema_extra={"examples": [10000]})
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={"description": "ID of battery", "examples": [None, "battery1"]},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC to DC conversion (for AC/grid charging of battery). "
"Set to 0 to disable AC charging via inverter. "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0, 0.0],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC to AC conversion (for battery discharging to AC load/grid). "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power in watts. "
"None means no additional limit (battery's own max_charge_power_w applies). "
"Set to 0 to disable AC charging."
),
"examples": [None, 0, 5000],
},
)
@@ -28,13 +28,13 @@ from akkudoktoreos.core.coreabc import (
PredictionMixin, PredictionMixin,
get_ems, get_ems,
) )
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel from akkudoktoreos.devices.genetic.battery import (
from akkudoktoreos.optimization.genetic.geneticdevices import (
ElectricVehicleParameters, ElectricVehicleParameters,
HomeApplianceParameters,
InverterParameters,
SolarPanelBatteryParameters, SolarPanelBatteryParameters,
) )
from akkudoktoreos.devices.genetic.homeappliance import HomeApplianceParameters
from akkudoktoreos.devices.genetic.inverter import InverterParameters
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
# Do not import directly from akkudoktoreos.core.coreabc # Do not import directly from akkudoktoreos.core.coreabc
@@ -270,6 +270,11 @@ class GeneticOptimizationParameters(
if "ev_soc_miss" not in cls.config.optimization.genetic.penalties: if "ev_soc_miss" not in cls.config.optimization.genetic.penalties:
logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.") logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.")
cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10 cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10
# Setup some basic providers if not set
if not cls.config.weather.provider:
cls.config.weather.provider = "OpenMeteo"
if not cls.config.load.provider:
cls.config.load.provider = "LoadAkkudoktor"
# Get start solution from last run # Get start solution from last run
start_solution = None start_solution = None
@@ -579,36 +584,38 @@ class GeneticOptimizationParameters(
else: else:
if cls.config.devices.batteries is None: if cls.config.devices.batteries is None:
logger.info("No battery device data available - defaulting to demo data.") logger.info("No battery device data available - defaulting to demo data.")
cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
try: try:
battery_config = cls.config.devices.batteries[0] # Take first battery
battery_params = SolarPanelBatteryParameters( battery_config = list(cls.config.devices.batteries.values())[0]
device_id=battery_config.device_id, battery_params = battery_config.to_genetic_pv_bat_param()
capacity_wh=battery_config.capacity_wh,
charging_efficiency=battery_config.charging_efficiency,
discharging_efficiency=battery_config.discharging_efficiency,
max_charge_power_w=battery_config.max_charge_power_w,
min_soc_percentage=battery_config.min_soc_percentage,
max_soc_percentage=battery_config.max_soc_percentage,
charge_rates=battery_config.charge_rates,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
# Retry # Retry
continue continue
# Levelized cost of ownership # Levelized cost of ownership
if battery_config.levelized_cost_of_storage_kwh is None: if battery_config.levelized_cost_of_storage_amt_kwh is None:
logger.info( logger.info(
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.", "No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
attempt, attempt,
) )
battery_config.levelized_cost_of_storage_kwh = 0 battery_config.levelized_cost_of_storage_amt_kwh = 0
battery_lcos_kwh = battery_config.levelized_cost_of_storage_kwh battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC # Initial SOC
try: try:
initial_soc_factor = await cls.measurement.key_to_value( initial_soc_factor = await cls.measurement.key_to_value(
@@ -645,26 +652,18 @@ class GeneticOptimizationParameters(
"No electric vehicle device data available - defaulting to demo data." "No electric vehicle device data available - defaulting to demo data."
) )
cls.config.devices.max_electric_vehicles = 1 cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = [ cls.config.devices.electric_vehicles = {
{ "ev1": {
"device_id": "ev11", "device_id": "ev1",
"capacity_wh": 50000, "capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], "charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70, "min_soc_percentage": 70,
} },
] }
try: try:
electric_vehicle_config = cls.config.devices.electric_vehicles[0] # Take first electric_vehicle
electric_vehicle_params = ElectricVehicleParameters( electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
device_id=electric_vehicle_config.device_id, electric_vehicle_params = electric_vehicle_config.to_genetic_ev_bat_param()
capacity_wh=electric_vehicle_config.capacity_wh,
charging_efficiency=electric_vehicle_config.charging_efficiency,
discharging_efficiency=electric_vehicle_config.discharging_efficiency,
charge_rates=electric_vehicle_config.charge_rates,
max_charge_power_w=electric_vehicle_config.max_charge_power_w,
min_soc_percentage=electric_vehicle_config.min_soc_percentage,
max_soc_percentage=electric_vehicle_config.max_soc_percentage,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
@@ -672,14 +671,14 @@ class GeneticOptimizationParameters(
e, e,
) )
cls.config.devices.max_electric_vehicles = 1 cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = [ cls.config.devices.electric_vehicles = {
{ "ev1": {
"device_id": "ev12", "device_id": "ev1",
"capacity_wh": 50000, "capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], "charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70, "min_soc_percentage": 70,
} },
] }
# Retry # Retry
continue continue
# Initial SOC # Initial SOC
@@ -715,36 +714,30 @@ class GeneticOptimizationParameters(
else: else:
if cls.config.devices.inverters is None: if cls.config.devices.inverters is None:
logger.info("No inverter device data available - defaulting to demo data.") logger.info("No inverter device data available - defaulting to demo data.")
cls.config.devices.inverters = [ cls.config.devices.inverters = {
{ "inverter1": {
"device_id": "inverter1", "device_id": "inverter1",
"max_power_w": 10000, "max_power_w": 10000,
"battery_id": battery_config.device_id, "battery_id": battery_config.device_id,
} },
] }
try: try:
inverter_config = cls.config.devices.inverters[0] # Take first inverter
inverter_params = InverterParameters( inverter_config = list(cls.config.devices.inverters.values())[0]
device_id=inverter_config.device_id, inverter_params = inverter_config.to_genetic_param()
max_power_wh=inverter_config.max_power_w,
battery_id=inverter_config.battery_id,
ac_to_dc_efficiency=inverter_config.ac_to_dc_efficiency,
dc_to_ac_efficiency=inverter_config.dc_to_ac_efficiency,
max_ac_charge_power_w=inverter_config.max_ac_charge_power_w,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.inverters = [ cls.config.devices.inverters = {
{ "inverter1": {
"device_id": "inverter1", "device_id": "inverter1",
"max_power_w": 10000, "max_power_w": 10000,
"battery_id": battery_config.device_id, "battery_id": battery_config.device_id,
} },
] }
# Retry # Retry
continue continue
@@ -761,12 +754,12 @@ class GeneticOptimizationParameters(
logger.info( logger.info(
"No home appliance device data available - defaulting to demo data." "No home appliance device data available - defaulting to demo data."
) )
cls.config.devices.home_appliances = [ cls.config.devices.home_appliances = {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3.0, "duration_h": 3.0,
"time_windows": { "cycle_time_windows": {
"windows": [ "windows": [
{ {
"start_time": "08:00", "start_time": "08:00",
@@ -778,30 +771,26 @@ class GeneticOptimizationParameters(
}, },
], ],
}, },
} },
] }
try: try:
home_appliance_config = cls.config.devices.home_appliances[0] # Take first appliance
home_appliance_params = HomeApplianceParameters( home_appliance_config = list(cls.config.devices.home_appliances.values())[0]
device_id=home_appliance_config.device_id, home_appliance_params = home_appliance_config.to_genetic_param()
consumption_wh=home_appliance_config.consumption_wh,
duration_h=home_appliance_config.duration_h,
time_windows=home_appliance_config.time_windows,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.home_appliances = [ cls.config.devices.home_appliances = {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3.0, "duration_h": 3.0,
"time_windows": None, "cycle_time_windows": None,
} },
] }
# Retry # Retry
continue continue
@@ -350,21 +350,21 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
def _battery_device_id(self) -> str: def _battery_device_id(self) -> str:
"""Get battery device id.""" """Get battery device id."""
try: try:
return self.config.devices.batteries[0].device_id return list(self.config.devices.batteries.values())[0].device_id
except Exception: except Exception:
return "battery1" return "battery1"
def _ev_device_id(self) -> str: def _ev_device_id(self) -> str:
"""Get electric vehicle device id.""" """Get electric vehicle device id."""
try: try:
return self.config.devices.electric_vehicles[0].device_id return self.config.devices.electric_vehicles.values()[0].device_id
except Exception: except Exception:
return "ev1" return "ev1"
def _homeappliance_device_id(self) -> str: def _homeappliance_device_id(self) -> str:
"""Get home appliance device id.""" """Get home appliance device id."""
try: try:
return self.config.devices.home_appliances[0].device_id return self.config.devices.home_appliances.values()[0].device_id
except Exception: except Exception:
return "homeappliance1" return "homeappliance1"
@@ -453,11 +453,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
(the inverter curtails automatically, but this makes intent clear). (the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC. - Discharge: blocked when SOC is at or below min SOC.
""" """
bat_list = self.config.devices.batteries bat_dict = self.config.devices.batteries
if not bat_list: if bat_dict is None or len(bat_dict) <= 0:
return ac_charge, dc_charge, discharge_allowed return ac_charge, dc_charge, discharge_allowed
bat = bat_list[0] bat = list(bat_dict.values())[0]
min_soc = float(bat.min_soc_percentage) min_soc = float(bat.min_soc_percentage)
max_soc = float(bat.max_soc_percentage) max_soc = float(bat.max_soc_percentage)
capacity_wh = float(bat.capacity_wh) capacity_wh = float(bat.capacity_wh)
@@ -1106,12 +1106,12 @@ class Genetic0Optimization(OptimizationBase):
self.ev_possible_charge_values = parameters.ev.charge_rates self.ev_possible_charge_values = parameters.ev.charge_rates
elif ( elif (
self.config.devices.electric_vehicles self.config.devices.electric_vehicles
and self.config.devices.electric_vehicles[0] and len(self.config.devices.electric_vehicles) > 0
and self.config.devices.electric_vehicles[0].charge_rates is not None and list(self.config.devices.electric_vehicles.values())[0].charge_rates is not None
): ):
self.ev_possible_charge_values = self.config.devices.electric_vehicles[ self.ev_possible_charge_values = list(
0 self.config.devices.electric_vehicles.values()
].charge_rates )[0].charge_rates
else: else:
warning_msg = "No charge rates provided for electric vehicle - using default." warning_msg = "No charge rates provided for electric vehicle - using default."
logger.warning(warning_msg) logger.warning(warning_msg)
@@ -1140,11 +1140,11 @@ class Genetic0Optimization(OptimizationBase):
] or [1.0] ] or [1.0]
elif ( elif (
self.config.devices.batteries self.config.devices.batteries
and self.config.devices.batteries[0] and len(self.config.devices.batteries) > 0
and self.config.devices.batteries[0].charge_rates and list(self.config.devices.batteries.values())[0].charge_rates
): ):
self.bat_possible_charge_values = [ self.bat_possible_charge_values = [
r for r in self.config.devices.batteries[0].charge_rates if r > 0.0 r for r in list(self.config.devices.batteries.values())[0].charge_rates if r > 0.0
] or [1.0] ] or [1.0]
else: else:
self.bat_possible_charge_values = [1.0] self.bat_possible_charge_values = [1.0]
@@ -4,7 +4,6 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
@@ -18,179 +17,3 @@ class Genetic0DeviceParameters(Genetic0ParametersBaseModel):
"examples": [None], "examples": [None],
}, },
) )
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
return Field(
default=0, ge=0, le=100, json_schema_extra={"description": description, "examples": [42]}
)
def discharging_efficiency_field(default_value: float) -> float:
return Field(
default=default_value,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the discharge efficiency of the battery."
},
)
class Genetic0BaseBatteryParameters(Genetic0DeviceParameters):
"""Battery Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of battery", "examples": ["battery1"]}
)
capacity_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the capacity of the battery in watt-hours.",
"examples": [8000],
},
)
charging_efficiency: float = Field(
default=0.88,
gt=0,
le=1,
json_schema_extra={
"description": "A float representing the charging efficiency of the battery."
},
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
min_soc_percentage: int = Field(
default=0,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the minimum state of charge (SOC) of the battery in percentage.",
"examples": [10],
},
)
max_soc_percentage: int = Field(
default=100,
ge=0,
le=100,
json_schema_extra={
"description": "An integer representing the maximum state of charge (SOC) of the battery in percentage."
},
)
charge_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": "Charge rates as factor of maximum charging power [0.00 ... 1.00]. None denotes all charge rates are available.",
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
class Genetic0SolarPanelBatteryParameters(Genetic0BaseBatteryParameters):
"""PV battery device simulation configuration."""
max_charge_power_w: Optional[float] = max_charging_power_field()
class Genetic0ElectricVehicleParameters(Genetic0BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
)
discharging_efficiency: float = discharging_efficiency_field(1.0)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
class Genetic0HomeApplianceParameters(Genetic0DeviceParameters):
"""Home Appliance Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of home appliance", "examples": ["dishwasher"]}
)
consumption_wh: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the energy consumption of a household device in watt-hours.",
"examples": [2000],
},
)
duration_h: int = Field(
gt=0,
json_schema_extra={
"description": "An integer representing the usage duration of a household device in hours.",
"examples": [3],
},
)
time_windows: Optional[TimeWindowSequence] = Field(
default=None,
json_schema_extra={
"description": "List of allowed time windows. Defaults to optimization general time window.",
"examples": [
[
{"start_time": "10:00", "duration": "3 hours"},
],
],
},
)
class Genetic0InverterParameters(Genetic0DeviceParameters):
"""Inverter Device Simulation Configuration."""
device_id: str = Field(
json_schema_extra={"description": "ID of inverter", "examples": ["inverter1"]}
)
max_power_wh: float = Field(gt=0, json_schema_extra={"examples": [10000]})
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={"description": "ID of battery", "examples": [None, "battery1"]},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of AC to DC conversion (for AC/grid charging of battery). "
"Set to 0 to disable AC charging via inverter. "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0, 0.0],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC to AC conversion (for battery discharging to AC load/grid). "
"Default 1.0 for backward compatibility (no additional inverter loss)."
),
"examples": [0.95, 1.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power in watts. "
"None means no additional limit (battery's own max_charge_power_w applies). "
"Set to 0 to disable AC charging."
),
"examples": [None, 0, 5000],
},
)
@@ -27,13 +27,15 @@ from akkudoktoreos.core.coreabc import (
PredictionMixin, PredictionMixin,
get_ems, get_ems,
) )
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel from akkudoktoreos.devices.genetic0.genetic0battery import (
from akkudoktoreos.optimization.genetic0.genetic0devices import (
Genetic0ElectricVehicleParameters, Genetic0ElectricVehicleParameters,
Genetic0HomeApplianceParameters,
Genetic0InverterParameters,
Genetic0SolarPanelBatteryParameters, Genetic0SolarPanelBatteryParameters,
) )
from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
Genetic0HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
# Do not import directly from akkudoktoreos.core.coreabc # Do not import directly from akkudoktoreos.core.coreabc
@@ -260,6 +262,11 @@ class Genetic0OptimizationParameters(
if "ev_soc_miss" not in cls.config.optimization.genetic0.penalties: if "ev_soc_miss" not in cls.config.optimization.genetic0.penalties:
logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.") logger.info("Genetic penalties unknown - defaulting to ev_soc_miss = 10.")
cls.config.optimization.genetic0.penalties["ev_soc_miss"] = 10 cls.config.optimization.genetic0.penalties["ev_soc_miss"] = 10
# Setup some basic providers if not set
if not cls.config.weather.provider:
cls.config.weather.provider = "OpenMeteo"
if not cls.config.load.provider:
cls.config.load.provider = "LoadAkkudoktor"
# Get start solution from last run # Get start solution from last run
start_solution = None start_solution = None
@@ -548,36 +555,38 @@ class Genetic0OptimizationParameters(
else: else:
if cls.config.devices.batteries is None: if cls.config.devices.batteries is None:
logger.info("No battery device data available - defaulting to demo data.") logger.info("No battery device data available - defaulting to demo data.")
cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
try: try:
battery_config = cls.config.devices.batteries[0] # Take first battery
battery_params = Genetic0SolarPanelBatteryParameters( battery_config = list(cls.config.devices.batteries.values())[0]
device_id=battery_config.device_id, battery_params = battery_config.to_genetic0_pv_bat_param()
capacity_wh=battery_config.capacity_wh,
charging_efficiency=battery_config.charging_efficiency,
discharging_efficiency=battery_config.discharging_efficiency,
max_charge_power_w=battery_config.max_charge_power_w,
min_soc_percentage=battery_config.min_soc_percentage,
max_soc_percentage=battery_config.max_soc_percentage,
charge_rates=battery_config.charge_rates,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
# Retry # Retry
continue continue
# Levelized cost of ownership # Levelized cost of ownership
if battery_config.levelized_cost_of_storage_kwh is None: if battery_config.levelized_cost_of_storage_amt_kwh is None:
logger.info( logger.info(
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.", "No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
attempt, attempt,
) )
battery_config.levelized_cost_of_storage_kwh = 0 battery_config.levelized_cost_of_storage_amt_kwh = 0
battery_lcos_kwh = battery_config.levelized_cost_of_storage_kwh battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC # Initial SOC
try: try:
initial_soc_factor = await cls.measurement.key_to_value( initial_soc_factor = await cls.measurement.key_to_value(
@@ -614,26 +623,18 @@ class Genetic0OptimizationParameters(
"No electric vehicle device data available - defaulting to demo data." "No electric vehicle device data available - defaulting to demo data."
) )
cls.config.devices.max_electric_vehicles = 1 cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = [ cls.config.devices.electric_vehicles = {
{ "ev1": {
"device_id": "ev11", "device_id": "ev1",
"capacity_wh": 50000, "capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], "charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70, "min_soc_percentage": 70,
} },
] }
try: try:
electric_vehicle_config = cls.config.devices.electric_vehicles[0] # Take first electric_vehicle
electric_vehicle_params = Genetic0ElectricVehicleParameters( electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
device_id=electric_vehicle_config.device_id, electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param()
capacity_wh=electric_vehicle_config.capacity_wh,
charging_efficiency=electric_vehicle_config.charging_efficiency,
discharging_efficiency=electric_vehicle_config.discharging_efficiency,
charge_rates=electric_vehicle_config.charge_rates,
max_charge_power_w=electric_vehicle_config.max_charge_power_w,
min_soc_percentage=electric_vehicle_config.min_soc_percentage,
max_soc_percentage=electric_vehicle_config.max_soc_percentage,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
@@ -641,14 +642,14 @@ class Genetic0OptimizationParameters(
e, e,
) )
cls.config.devices.max_electric_vehicles = 1 cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = [ cls.config.devices.electric_vehicles = {
{ "ev1": {
"device_id": "ev12", "device_id": "ev1",
"capacity_wh": 50000, "capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], "charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70, "min_soc_percentage": 70,
} },
] }
# Retry # Retry
continue continue
# Initial SOC # Initial SOC
@@ -684,36 +685,30 @@ class Genetic0OptimizationParameters(
else: else:
if cls.config.devices.inverters is None: if cls.config.devices.inverters is None:
logger.info("No inverter device data available - defaulting to demo data.") logger.info("No inverter device data available - defaulting to demo data.")
cls.config.devices.inverters = [ cls.config.devices.inverters = {
{ "inverter1": {
"device_id": "inverter1", "device_id": "inverter1",
"max_power_w": 10000, "max_power_w": 10000,
"battery_id": battery_config.device_id, "battery_id": battery_config.device_id,
} },
] }
try: try:
inverter_config = cls.config.devices.inverters[0] # Take first inverter
inverter_params = Genetic0InverterParameters( inverter_config = list(cls.config.devices.inverters.values())[0]
device_id=inverter_config.device_id, inverter_params = inverter_config.to_genetic0_param()
max_power_wh=inverter_config.max_power_w,
battery_id=inverter_config.battery_id,
ac_to_dc_efficiency=inverter_config.ac_to_dc_efficiency,
dc_to_ac_efficiency=inverter_config.dc_to_ac_efficiency,
max_ac_charge_power_w=inverter_config.max_ac_charge_power_w,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.inverters = [ cls.config.devices.inverters = {
{ "inverter1": {
"device_id": "inverter1", "device_id": "inverter1",
"max_power_w": 10000, "max_power_w": 10000,
"battery_id": battery_config.device_id, "battery_id": battery_config.device_id,
} },
] }
# Retry # Retry
continue continue
@@ -730,12 +725,12 @@ class Genetic0OptimizationParameters(
logger.info( logger.info(
"No home appliance device data available - defaulting to demo data." "No home appliance device data available - defaulting to demo data."
) )
cls.config.devices.home_appliances = [ cls.config.devices.home_appliances = {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3.0, "duration_h": 3.0,
"time_windows": { "cycle_time_windows": {
"windows": [ "windows": [
{ {
"start_time": "08:00", "start_time": "08:00",
@@ -747,30 +742,26 @@ class Genetic0OptimizationParameters(
}, },
], ],
}, },
} },
] }
try: try:
home_appliance_config = cls.config.devices.home_appliances[0] # Take first appliance
home_appliance_params = Genetic0HomeApplianceParameters( home_appliance_config = list(cls.config.devices.home_appliances.values())[0]
device_id=home_appliance_config.device_id, home_appliance_params = home_appliance_config.to_genetic0_param()
consumption_wh=home_appliance_config.consumption_wh,
duration_h=home_appliance_config.duration_h,
time_windows=home_appliance_config.time_windows,
)
except Exception as e: except Exception as e:
logger.info( logger.info(
"No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}", "No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt, attempt,
e, e,
) )
cls.config.devices.home_appliances = [ cls.config.devices.home_appliances = {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000,
"duration_h": 3.0, "duration_h": 3.0,
"time_windows": None, "cycle_time_windows": None,
} },
] }
# Retry # Retry
continue continue
@@ -427,21 +427,21 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
def _battery_device_id(self) -> str: def _battery_device_id(self) -> str:
"""Get battery device id.""" """Get battery device id."""
try: try:
return self.config.devices.batteries[0].device_id return list(self.config.devices.batteries.values())[0].device_id
except Exception: except Exception:
return "battery1" return "battery1"
def _ev_device_id(self) -> str: def _ev_device_id(self) -> str:
"""Get electric vehicle device id.""" """Get electric vehicle device id."""
try: try:
return self.config.devices.electric_vehicles[0].device_id return self.config.devices.electric_vehicles.values()[0].device_id
except Exception: except Exception:
return "ev1" return "ev1"
def _homeappliance_device_id(self) -> str: def _homeappliance_device_id(self) -> str:
"""Get home appliance device id.""" """Get home appliance device id."""
try: try:
return self.config.devices.home_appliances[0].device_id return self.config.devices.home_appliances.values()[0].device_id
except Exception: except Exception:
return "homeappliance1" return "homeappliance1"
@@ -530,11 +530,11 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
(the inverter curtails automatically, but this makes intent clear). (the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC. - Discharge: blocked when SOC is at or below min SOC.
""" """
bat_list = self.config.devices.batteries bat_dict = self.config.devices.batteries
if not bat_list: if bat_dict is None or len(bat_dict) <= 0:
return ac_charge, dc_charge, discharge_allowed return ac_charge, dc_charge, discharge_allowed
bat = bat_list[0] bat = list(bat_dict.values())[0]
min_soc = float(bat.min_soc_percentage) min_soc = float(bat.min_soc_percentage)
max_soc = float(bat.max_soc_percentage) max_soc = float(bat.max_soc_percentage)
capacity_wh = float(bat.capacity_wh) capacity_wh = float(bat.capacity_wh)
+3 -1
View File
@@ -445,9 +445,11 @@ def ConfigItemsCard(
description = config["description"] description = config["description"]
item_model = resolve_item_model(hint) item_model = resolve_item_model(hint)
if item_model is None:
raise ValueError(f"Hint for {config_name} needs item_model to be listed. Got {hint}")
item_path = hint.item_path # e.g. "pvforecast.planes" item_path = hint.item_path # e.g. "pvforecast.planes"
if item_path is None: if item_path is None:
raise ValueError(f"Hint needs item_path to be listed. Got {hint}") raise ValueError(f"Hint for {config_name} needs item_path to be listed. Got {hint}")
path_parts = item_path.split(".") # e.g. ["pvforecast", "planes"] path_parts = item_path.split(".") # e.g. ["pvforecast", "planes"]
items_list = json.loads(value) or [] items_list = json.loads(value) or []
+3 -1
View File
@@ -453,9 +453,11 @@ def ConfigMapCard(
config_id = config_name.lower().replace(".", "-") config_id = config_name.lower().replace(".", "-")
item_model = resolve_item_model(hint) item_model = resolve_item_model(hint)
if item_model is None:
raise ValueError(f"Hint for {config_name} needs item_model to be listed. Got {hint}")
item_path = hint.item_path # e.g. "devices.batteries" item_path = hint.item_path # e.g. "devices.batteries"
if item_path is None: if item_path is None:
raise ValueError(f"Hint needs item_path to be mapped. Got {hint}") raise ValueError(f"Hint for {config_name} needs item_path to be listed. Got {hint}")
path_parts = item_path.split(".") # e.g. ["devices", "batteries"] path_parts = item_path.split(".") # e.g. ["devices", "batteries"]
items_map = json.loads(value) or {} items_map = json.loads(value) or {}
+4 -3
View File
@@ -535,7 +535,8 @@ def InstructionCard(
config.devices config.devices
and config.devices.batteries and config.devices.batteries
and any( and any(
battery_config.device_id == resource_id for battery_config in config.devices.batteries battery_config.device_id == resource_id
for battery_config in config.devices.batteries.values()
) )
): ):
# This is a battery # This is a battery
@@ -548,7 +549,7 @@ def InstructionCard(
and config.devices.electric_vehicles and config.devices.electric_vehicles
and any( and any(
electric_vehicle_config.device_id == resource_id electric_vehicle_config.device_id == resource_id
for electric_vehicle_config in config.devices.electric_vehicles for electric_vehicle_config in config.devices.electric_vehicles.values()
) )
): ):
# This is a car battery # This is a car battery
@@ -558,7 +559,7 @@ def InstructionCard(
and config.devices.home_appliances and config.devices.home_appliances
and any( and any(
home_appliance.device_id == resource_id home_appliance.device_id == resource_id
for home_appliance in config.devices.home_appliances for home_appliance in config.devices.home_appliances.values()
) )
): ):
# This is a home appliance # This is a home appliance
+28 -6
View File
@@ -207,16 +207,18 @@ UI_HINTS: dict[str, UiHint] = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Devices # Devices
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# - batteries
"devices.batteries": UiHint( "devices.batteries": UiHint(
form="items", form="map_items",
item_path="devices.batteries", item_path="devices.batteries",
), ),
"devices.electric_vehicles": UiHint( "devices.electric_vehicles": UiHint(
form="items", form="map_items",
item_path="devices.electric_vehicles", item_path="devices.electric_vehicles",
), ),
# - home_appliances
"devices.home_appliances": UiHint( "devices.home_appliances": UiHint(
form="items", form="map_items",
item_path="devices.home_appliances", item_path="devices.home_appliances",
), ),
# Sub-field hint for the time_windows field inside each appliance entry # Sub-field hint for the time_windows field inside each appliance entry
@@ -224,6 +226,19 @@ UI_HINTS: dict[str, UiHint] = {
form="time_windows", form="time_windows",
value_description="cycle index (0-based)", value_description="cycle index (0-based)",
), ),
# - inverters
"devices.inverters": UiHint(
form="map_items",
item_path="devices.inverters",
),
# Sub-field hint for the load_power_w_key field inside each inverter entry
"devices.inverters.load_power_w_key": UiHint(
form="select", options=["loadforecast_power_w", "null"]
),
# Sub-field hint for the pv_power_w_key field inside each inverter entry
"devices.inverters.pv_power_w_key": UiHint(
form="select", options=["pvforecast_dc_power_w", "null"]
),
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Electricity fee # Electricity fee
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -362,26 +377,33 @@ def _ensure_item_models() -> None:
UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting
if UI_HINTS["devices.batteries"].item_model is None: if UI_HINTS["devices.batteries"].item_model is None:
from akkudoktoreos.devices.devices import ( from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings, BatteriesCommonSettings,
) )
UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings
if UI_HINTS["devices.electric_vehicles"].item_model is None: if UI_HINTS["devices.electric_vehicles"].item_model is None:
from akkudoktoreos.devices.devices import ( from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings, BatteriesCommonSettings,
) )
UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings
if UI_HINTS["devices.home_appliances"].item_model is None: if UI_HINTS["devices.home_appliances"].item_model is None:
from akkudoktoreos.devices.devices import ( from akkudoktoreos.devices.settings.homeappliancesettings import (
HomeApplianceCommonSettings, HomeApplianceCommonSettings,
) )
UI_HINTS["devices.home_appliances"].item_model = HomeApplianceCommonSettings UI_HINTS["devices.home_appliances"].item_model = HomeApplianceCommonSettings
if UI_HINTS["devices.inverters"].item_model is None:
from akkudoktoreos.devices.settings.invertersettings import (
InverterCommonSettings,
)
UI_HINTS["devices.inverters"].item_model = InverterCommonSettings
def resolve_item_model(hint: UiHint) -> Optional[Any]: def resolve_item_model(hint: UiHint) -> Optional[Any]:
"""Return the ``item_model`` for an ``"items"`` hint, resolving lazily. """Return the ``item_model`` for an ``"items"`` hint, resolving lazily.
+1 -2
View File
@@ -81,8 +81,7 @@ def validate_ip_or_hostname(value: str) -> str:
raise ValueError(f"Not a valid hostname: {value}") raise ValueError(f"Not a valid hostname: {value}")
hostname_regex = re.compile( hostname_regex = re.compile(
r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(?<!-)" r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(?<!-)" r"(?:\.(?!-)[A-Z\d-]{1,63}(?<!-))*\.?$",
r"(?:\.(?!-)[A-Z\d-]{1,63}(?<!-))*\.?$",
re.IGNORECASE, re.IGNORECASE,
) )
if not bool(hostname_regex.fullmatch(value)): if not bool(hostname_regex.fullmatch(value)):
+2 -2
View File
@@ -370,8 +370,8 @@ def config_eos_factory(
assert not config_file_cwd.exists() assert not config_file_cwd.exists()
config_eos = get_config(init=init) config_eos = get_config(init=init)
# Ensure newly created configurations are respected # Ensure newly created configurations are respected and runtime settings of
# Note: Workaround for pydantic_settings and pytest # previous tests are dropped
config_eos.reset_settings() config_eos.reset_settings()
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user) # Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
+241 -34
View File
@@ -1,3 +1,4 @@
import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Union from typing import Any, Optional, Union
@@ -9,7 +10,7 @@ from loguru import logger
from pydantic import IPvAnyAddress, ValidationError from pydantic import IPvAnyAddress, ValidationError
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings from akkudoktoreos.config.config import ConfigEOS, GeneralSettings
from akkudoktoreos.devices.devices import BATTERY_DEFAULT_CHARGE_RATES from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
def assert_values_equal(actual, expected): def assert_values_equal(actual, expected):
@@ -286,7 +287,6 @@ def test_config_common_settings_timezone_none_when_coordinates_missing():
assert config_no_coords.timezone is None assert config_no_coords.timezone is None
# Test partial assignments and possible side effects # Test partial assignments and possible side effects
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path, value, expected, exception", "path, value, expected, exception",
@@ -349,70 +349,120 @@ def test_config_common_settings_timezone_none_when_coordinates_missing():
), ),
# Correct value assignment - preparation for list # Correct value assignment - preparation for list
( (
"devices/max_electric_vehicles", "devices/max_home_appliances",
1, 1,
[("devices.max_electric_vehicles", 1), ], [("devices.max_home_appliances", 1), ],
None, None,
), ),
# Correct value for list # Correct value to generate a windows list
( (
"devices/electric_vehicles/0/charge_rates", "devices/home_appliances",
[0.1, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], {
"dishwasher1": {
"device_id": "dishwasher1",
"consumption_wh": 2000,
"duration_h": 3,
"cycle_time_windows": {
"windows": [
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
{
"start_time": "15:00",
"duration": "3 hours",
"value": 0,
},
],
},
},
},
[ [
( ("devices.home_appliances['dishwasher1'].device_id", "dishwasher1"),
"devices.electric_vehicles[0].charge_rates", ("devices.home_appliances['dishwasher1'].consumption_wh", 2000),
[0.1, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], ("devices.home_appliances['dishwasher1'].duration_h", 3),
)
], ],
None, None,
), ),
# Invalid value for list # Invalid value for list
( (
"devices/electric_vehicles/0/charge_rates", "devices/home_appliances/dishwasher1/cycle_time_windows/windows",
"invalid", "invalid",
[ [
( (
"devices.electric_vehicles[0].charge_rates", "devices.home_appliances['dishwasher1'].cycle_time_windows",
BATTERY_DEFAULT_CHARGE_RATES, [
{
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
{
"start_time": "15:00",
"duration": "3 hours",
"value": 0,
},
],
) )
], ],
ValueError, ValueError,
), ),
# Invalid index (out of bound) # Invalid index (out of bound)
( (
"devices/electric_vehicles/0/charge_rates/10", "devices/home_appliances/dishwasher1/cycle_time_windows/windows/10",
0, {
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
[ [
( (
"devices.electric_vehicles[0].charge_rates", "devices.home_appliances['dishwasher1'].cycle_time_windows.windows[10]",
BATTERY_DEFAULT_CHARGE_RATES, {
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
) )
], ],
TypeError, TypeError,
), ),
# Invalid index (no number) # Invalid index (no number)
( (
"devices/electric_vehicles/0/charge_rates/test", "devices/home_appliances/dishwasher1/cycle_time_windows/windows/test",
0, {
"start_time": "17:00",
"duration": "1 hour",
"value": 0,
},
[ [
( (
"devices.electric_vehicles[0].charge_rates", "devices.home_appliances['dishwasher1'].cycle_time_windows.windows[0]",
BATTERY_DEFAULT_CHARGE_RATES, {
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
) )
], ],
IndexError, IndexError,
), ),
# Unset value (set None) # Unset value (set None)
( (
"devices/electric_vehicles/0/charge_rates", "devices/home_appliances/dishwasher1/cycle_time_windows/windows/0",
None, None,
[ [
( (
"devices.electric_vehicles[0].charge_rates", "devices.home_appliances['dishwasher1'].cycle_time_windows.windows[0]",
BATTERY_DEFAULT_CHARGE_RATES, {
"start_time": "08:00",
"duration": "5 hours",
"value": 0,
},
) )
], ],
None, TypeError,
), ),
], ],
) )
@@ -514,30 +564,28 @@ def test_merge_settings_partial(config_eos):
partial_settings = { partial_settings = {
"devices": { "devices": {
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev1": {"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],}
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], },
}
],
} }
} }
config_eos.merge_settings_from_dict(partial_settings) config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.devices.max_electric_vehicles == 1 assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1 assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles[0].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]) assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
# Assure re-apply generates the same config # Assure re-apply generates the same config
config_eos.merge_settings_from_dict(partial_settings) config_eos.merge_settings_from_dict(partial_settings)
assert config_eos.devices.max_electric_vehicles == 1 assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1 assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles[0].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]) assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
# Assure update keeps same values # Assure update keeps same values
config_eos.update() config_eos.update()
assert config_eos.devices.max_electric_vehicles == 1 assert config_eos.devices.max_electric_vehicles == 1
assert len(config_eos.devices.electric_vehicles) == 1 assert len(config_eos.devices.electric_vehicles) == 1
assert_values_equal(config_eos.devices.electric_vehicles[0].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]) assert_values_equal(config_eos.devices.electric_vehicles["ev1"].charge_rates, [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0])
def test_merge_settings_empty(config_eos): def test_merge_settings_empty(config_eos):
@@ -547,3 +595,162 @@ def test_merge_settings_empty(config_eos):
config_eos.merge_settings_from_dict({}) # No changes config_eos.merge_settings_from_dict({}) # No changes
assert config_eos.general.latitude == original_latitude # Should remain unchanged assert config_eos.general.latitude == original_latitude # Should remain unchanged
# ------------------------------------
# Runtime settings priority (issue #1303)
# ------------------------------------
@pytest.fixture
def config_eos_file(config_eos_factory) -> ConfigEOS:
"""ConfigEOS with the EOS configuration file as an active settings source."""
return config_eos_factory(
init={
"with_init_settings": True,
"with_env_settings": True,
"with_dotenv_settings": False,
"with_file_settings": True,
"with_file_secret_settings": False,
}
)
def write_config_file(config_eos: ConfigEOS, settings: dict[str, Any]) -> None:
"""Write settings to the EOS configuration file and load them."""
settings = {"general": {"version": config_eos.general.version}, **settings}
config_file_path = config_eos.general.config_file_path
assert config_file_path is not None
config_file_path.write_text(json.dumps(settings), encoding="utf-8")
config_eos.reset_settings()
def test_merge_settings_overrides_config_file(config_eos_file):
"""Runtime settings take precedence over the EOS configuration file."""
write_config_file(
config_eos_file,
{
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0, 180.0, 270.0)
]
},
},
)
assert config_eos_file.optimization.genetic.individuals == 200
assert len(config_eos_file.pvforecast.planes) == 4
config_eos_file.merge_settings_from_dict(
{
"optimization": {"genetic": {"individuals": 300}},
"pvforecast": {
"planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}]
},
}
)
assert config_eos_file.optimization.genetic.individuals == 300
assert len(config_eos_file.pvforecast.planes) == 1
def test_merge_settings_overrides_env(config_eos_file, monkeypatch):
"""Runtime settings take precedence over environment variables."""
monkeypatch.setenv("EOS_OPTIMIZATION__GENETIC__INDIVIDUALS", "150")
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 150
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
def test_env_overrides_config_file_after_merge(config_eos_file, monkeypatch):
"""Environment variables keep precedence over the config file for untouched keys."""
write_config_file(config_eos_file, {"server": {"port": 9000}})
monkeypatch.setenv("EOS_SERVER__PORT", "9500")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9500
# A runtime update of an unrelated key must not freeze the env value
config_eos_file.merge_settings_from_dict({"general": {"latitude": 51.1657}})
assert config_eos_file.general.latitude == 51.1657
assert config_eos_file.server.port == 9500
monkeypatch.setenv("EOS_SERVER__PORT", "9600")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9600
def test_reset_settings_drops_runtime_settings(config_eos_file):
"""Reset drops runtime settings and falls back to the config file."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 200
def test_set_nested_value_survives_merge(config_eos_file):
"""Granular updates are not lost by a later bulk update."""
write_config_file(
config_eos_file,
{
"general": {"latitude": 48.0},
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0)
]
},
},
)
config_eos_file.set_nested_value("optimization/genetic/individuals", 400)
# A list index can not be expressed by the settings dictionary
config_eos_file.set_nested_value("pvforecast/planes/1/peakpower", 9.9)
# Clearing a value must not be reverted by the config file either
config_eos_file.set_nested_value("general/latitude", None)
config_eos_file.merge_settings_from_dict({"server": {"port": 8600}})
assert config_eos_file.server.port == 8600
assert config_eos_file.optimization.genetic.individuals == 400
assert config_eos_file.pvforecast.planes[1].peakpower == 9.9
assert config_eos_file.general.latitude is None
def test_revert_settings_restores_backup(config_eos_file):
"""Revert restores the backup values even if the config file differs."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_file_path = config_eos_file.general.config_file_path
assert config_file_path is not None
backup_path = config_file_path.with_suffix(".backup")
backup_path.write_text(
json.dumps(
{
"general": {"version": config_eos_file.general.version},
"optimization": {"genetic": {"individuals": 500}},
}
),
encoding="utf-8",
)
config_eos_file.revert_settings("backup")
assert config_eos_file.optimization.genetic.individuals == 500
def test_config_from_env_on_first_init(config_eos, config_default_dirs, monkeypatch):
"""Environment variables are applied on the first configuration build."""
config_eos.reset_instance()
monkeypatch.setenv("EOS_CONFIG_DIR", str(config_default_dirs[0]))
monkeypatch.setenv("EOS_SERVER__PORT", "8553")
assert ConfigEOS().server.port == 8553
+491
View File
@@ -28,6 +28,9 @@ import pendulum
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importable
CycleTimeWindowSequence,
)
from akkudoktoreos.config.configabc import TimeWindow from akkudoktoreos.config.configabc import TimeWindow
from akkudoktoreos.config.configabc import TimeWindow as _TW_check from akkudoktoreos.config.configabc import TimeWindow as _TW_check
from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importable from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importable
@@ -1504,3 +1507,491 @@ class TestAlignToIntervalTimezoneInvariance:
assert series.iloc[0] == pytest.approx(0.25) assert series.iloc[0] == pytest.approx(0.25)
assert series.iloc[1] == pytest.approx(0.25) assert series.iloc[1] == pytest.approx(0.25)
assert series.iloc[2] == pytest.approx(0.0) assert series.iloc[2] == pytest.approx(0.0)
# ===========================================================================
# CycleTimeWindowSequence
# ===========================================================================
class TestCycleTimeWindowSequence:
"""Tests for CycleTimeWindowSequence.
Window layout:
win1: 08:00–12:00 cycle=0
win2: 14:00–18:00 cycle=1
win3: 20:00–22:00 cycle=2
"""
def setup_method(self, method):
self.seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
]
)
# ------------------------------------------------------------------
# cycle detection
# ------------------------------------------------------------------
def test_num_cycles(self):
assert self.seq.num_cycles() == 3
def test_num_cycles_ignores_none(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
assert seq.num_cycles() == 1
def test_num_cycles_non_contiguous(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=2.0)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=5.0)),
]
)
assert seq.num_cycles() == 2
# ------------------------------------------------------------------
# cycle_to_array basic correctness
# ------------------------------------------------------------------
def test_cycle0_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
arr = self.seq.cycle_to_array(
0, start, end, pendulum.duration(hours=1)
)
assert arr.shape == (24,)
assert arr[8] == pytest.approx(1.0)
assert arr[11] == pytest.approx(1.0)
assert arr[12] == pytest.approx(0.0)
def test_cycle1_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
arr = self.seq.cycle_to_array(
1, start, end, pendulum.duration(hours=1)
)
assert arr[14] == pytest.approx(1.0)
assert arr[17] == pytest.approx(1.0)
assert arr[13] == pytest.approx(0.0)
def test_cycle2_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
arr = self.seq.cycle_to_array(
2, start, end, pendulum.duration(hours=1)
)
assert arr[20] == pytest.approx(1.0)
assert arr[21] == pytest.approx(1.0)
assert arr[22] == pytest.approx(0.0)
# ------------------------------------------------------------------
# cycle not present
# ------------------------------------------------------------------
def test_cycle_not_present_all_zero(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 6)
arr = self.seq.cycle_to_array(
5, start, end, pendulum.duration(hours=1)
)
assert np.all(arr == 0.0)
# ------------------------------------------------------------------
# aware datetime
# ------------------------------------------------------------------
def test_cycle_array_aware_datetime(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
arr = self.seq.cycle_to_array(
1, start, end, pendulum.duration(hours=1)
)
assert arr[14] == pytest.approx(1.0)
assert arr[13] == pytest.approx(0.0)
# ------------------------------------------------------------------
# dtype
# ------------------------------------------------------------------
def test_cycle_array_dtype(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 6)
arr = self.seq.cycle_to_array(
0, start, end, pendulum.duration(hours=1)
)
assert arr.dtype == np.float64
# ------------------------------------------------------------------
# dropna propagation
# ------------------------------------------------------------------
def test_cycle_array_dropna_false(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
start = naive_dt(2024, 6, 15, 8)
end = naive_dt(2024, 6, 15, 13)
arr = seq.cycle_to_array(
1, start, end, pendulum.duration(hours=1), dropna=False
)
assert arr[2] == pytest.approx(1.0)
assert arr[3] == pytest.approx(1.0)
# ===========================================================================
# CycleTimeWindowSequence.cycles_to_matrix
# ===========================================================================
class TestCyclesToMatrix:
"""Tests for CycleTimeWindowSequence.cycles_to_matrix.
The method returns ``(cycle_indices, matrix)`` where:
* ``cycle_indices`` — sorted list of distinct integer cycle indices
(derived from the integer part of each window's ``value``).
* ``matrix`` — shape ``(len(cycle_indices), n_steps)`` float64 array;
``matrix[k, t] == 1.0`` iff step ``t`` falls inside a window whose
cycle index equals ``cycle_indices[k]``, ``0.0`` otherwise.
Alignment contract (same as ``to_array`` and ``cycle_to_array``):
* ``start_datetime`` is floored to the nearest interval boundary in
wall-clock time before building the grid.
* The step count uses ``math.ceil`` so that a partially-covered final
step is included, consistent with ``to_array``'s while-loop.
Window layout used in ``setup_method`` unless overridden:
cycle 0: 08:00–12:00 (4 h)
cycle 1: 14:00–18:00 (4 h)
cycle 2: 20:00–22:00 (2 h)
All tests use 1-hour steps over a 24-hour horizon unless stated otherwise.
"""
def setup_method(self, method):
self.seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
]
)
self.start = naive_dt(2024, 6, 15, 0)
self.end = naive_dt(2024, 6, 16, 0)
self.interval = pendulum.duration(hours=1)
# ------------------------------------------------------------------
# return-value structure
# ------------------------------------------------------------------
def test_returns_tuple_of_two(self):
result = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert isinstance(result, tuple) and len(result) == 2
def test_cycle_indices_is_sorted_list(self):
indices, _ = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == sorted(indices)
assert isinstance(indices, list)
def test_cycle_indices_values(self):
indices, _ = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == [0, 1, 2]
def test_matrix_shape(self):
indices, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert matrix.shape == (len(indices), 24)
def test_matrix_dtype_float64(self):
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert matrix.dtype == np.float64
# ------------------------------------------------------------------
# correctness: cycle-row contents
# ------------------------------------------------------------------
def test_cycle0_row_marks_correct_steps(self):
# Cycle 0: 08:00–12:00 → steps 8, 9, 10, 11
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert matrix[0, 7] == pytest.approx(0.0)
assert matrix[0, 8] == pytest.approx(1.0)
assert matrix[0, 11] == pytest.approx(1.0)
assert matrix[0, 12] == pytest.approx(0.0)
def test_cycle1_row_marks_correct_steps(self):
# Cycle 1: 14:00–18:00 → steps 14, 15, 16, 17
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert matrix[1, 13] == pytest.approx(0.0)
assert matrix[1, 14] == pytest.approx(1.0)
assert matrix[1, 17] == pytest.approx(1.0)
assert matrix[1, 18] == pytest.approx(0.0)
def test_cycle2_row_marks_correct_steps(self):
# Cycle 2: 20:00–22:00 → steps 20, 21
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
assert matrix[2, 19] == pytest.approx(0.0)
assert matrix[2, 20] == pytest.approx(1.0)
assert matrix[2, 21] == pytest.approx(1.0)
assert matrix[2, 22] == pytest.approx(0.0)
def test_cycle_rows_are_mutually_exclusive(self):
# No step should be 1.0 in more than one row
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
overlap = (matrix > 0.5).sum(axis=0)
assert np.all(overlap <= 1)
def test_steps_outside_all_windows_are_zero_in_all_rows(self):
_, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
outside = list(range(0, 8)) + [12, 13, 18, 19] + list(range(22, 24))
for t in outside:
assert matrix[:, t].sum() == pytest.approx(0.0), f"step {t} should be all-zero"
# ------------------------------------------------------------------
# None value windows are skipped
# ------------------------------------------------------------------
def test_none_value_windows_skipped(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="10:00:00", duration="2 hours", value=1.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == [1]
assert matrix.shape == (1, 24)
# The None window (08:00–10:00) must not bleed into cycle 1's row
assert matrix[0, 8] == pytest.approx(0.0)
assert matrix[0, 10] == pytest.approx(1.0)
assert matrix[0, 11] == pytest.approx(1.0)
def test_all_none_returns_empty(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == []
assert matrix.shape == (0, 24)
# ------------------------------------------------------------------
# row ordering: sorted by cycle index regardless of window order
# ------------------------------------------------------------------
def test_row_order_independent_of_window_order(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=2.0)),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="14:00:00", duration="4 hours", value=1.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == [0, 1, 2]
# Row 0 must be cycle 0 (08:00–12:00)
assert matrix[0, 8] == pytest.approx(1.0)
assert matrix[0, 14] == pytest.approx(0.0)
# ------------------------------------------------------------------
# non-contiguous cycle indices
# ------------------------------------------------------------------
def test_non_contiguous_cycle_indices(self):
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="06:00:00", duration="2 hours", value=3.0)),
ValueTimeWindow.model_validate(dict(start_time="16:00:00", duration="2 hours", value=7.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == [3, 7]
assert matrix.shape == (2, 24)
assert matrix[0, 6] == pytest.approx(1.0)
assert matrix[0, 7] == pytest.approx(1.0)
assert matrix[1, 16] == pytest.approx(1.0)
assert matrix[1, 17] == pytest.approx(1.0)
# ------------------------------------------------------------------
# multiple windows for the same cycle index (union)
# ------------------------------------------------------------------
def test_same_cycle_multiple_windows_union(self):
# Cycle 0 appears twice: 06:00–08:00 and 20:00–22:00
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="06:00:00", duration="2 hours", value=0.0)),
ValueTimeWindow.model_validate(dict(start_time="20:00:00", duration="2 hours", value=0.0)),
]
)
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == [0]
assert matrix[0, 6] == pytest.approx(1.0)
assert matrix[0, 7] == pytest.approx(1.0)
assert matrix[0, 20] == pytest.approx(1.0)
assert matrix[0, 21] == pytest.approx(1.0)
assert matrix[0, 8] == pytest.approx(0.0)
# ------------------------------------------------------------------
# sub-hour steps
# ------------------------------------------------------------------
def test_30min_steps(self):
# Cycle 0: 08:00–12:00 → 8 half-hour steps starting at step 16 (08:00 / 0.5h)
seq = CycleTimeWindowSequence(
windows=[
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0)),
]
)
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
_, matrix = seq.cycles_to_matrix(start, end, pendulum.duration(minutes=30))
assert matrix.shape == (1, 48)
# Steps 16–23 (08:00–12:00 in 30-min slots)
assert matrix[0, 15] == pytest.approx(0.0)
assert matrix[0, 16] == pytest.approx(1.0)
assert matrix[0, 23] == pytest.approx(1.0)
assert matrix[0, 24] == pytest.approx(0.0)
# ------------------------------------------------------------------
# aware datetime
# ------------------------------------------------------------------
def test_aware_datetime_berlin(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
indices, matrix = self.seq.cycles_to_matrix(start, end, self.interval)
assert indices == [0, 1, 2]
assert matrix[0, 8] == pytest.approx(1.0)
assert matrix[0, 11] == pytest.approx(1.0)
assert matrix[0, 12] == pytest.approx(0.0)
def test_aware_datetime_utc(self):
start = aware_dt(2024, 6, 15, 0, tz="UTC")
end = aware_dt(2024, 6, 16, 0, tz="UTC")
indices, matrix = self.seq.cycles_to_matrix(start, end, self.interval)
assert matrix[0, 8] == pytest.approx(1.0)
assert matrix[1, 14] == pytest.approx(1.0)
# ------------------------------------------------------------------
# short horizon — window partially or fully outside
# ------------------------------------------------------------------
def test_window_fully_outside_horizon(self):
# Only first 6 hours — all windows are outside
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 6)
indices, matrix = self.seq.cycles_to_matrix(start, end, self.interval)
assert matrix.shape == (3, 6)
assert np.all(matrix == 0.0)
def test_window_partially_inside_horizon_clipped(self):
# Horizon ends at 10:00; cycle 0 window is 08:00–12:00 → only steps 8, 9 inside
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 10)
indices, matrix = self.seq.cycles_to_matrix(start, end, self.interval)
assert matrix.shape == (3, 10)
assert matrix[0, 8] == pytest.approx(1.0)
assert matrix[0, 9] == pytest.approx(1.0)
assert matrix[0, :8].sum() == pytest.approx(0.0)
# ------------------------------------------------------------------
# empty sequence
# ------------------------------------------------------------------
def test_empty_sequence(self):
seq = CycleTimeWindowSequence()
indices, matrix = seq.cycles_to_matrix(self.start, self.end, self.interval)
assert indices == []
assert matrix.shape == (0, 24)
assert matrix.dtype == np.float64
# ------------------------------------------------------------------
# alignment: misaligned start_datetime is floored (wall-clock floor)
# ------------------------------------------------------------------
def test_misaligned_start_floored_step_count(self):
# start=08:10, end=10:10, interval=1h
# floor(08:10) = 08:00 → steps: 08:00, 09:00, 10:00 = 3 steps
# (10:00 < 10:10, so it is included by ceil)
# Window 08:00–12:00 → all three steps are inside → all 1.0
seq = CycleTimeWindowSequence(
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.0))]
)
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
_, matrix = seq.cycles_to_matrix(start, end, pendulum.duration(hours=1))
assert matrix.shape == (1, 3)
np.testing.assert_array_equal(matrix[0], [1.0, 1.0, 1.0])
def test_misaligned_start_30min_steps(self):
# start=08:15, interval=30min → floor to 08:00
# Window 08:00–10:00 → steps 08:00(1), 08:30(1), 09:00(1), 09:30(1)
seq = CycleTimeWindowSequence(
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.0))]
)
start = naive_dt(2024, 6, 15, 8, 15)
end = naive_dt(2024, 6, 15, 10, 15)
_, matrix = seq.cycles_to_matrix(start, end, pendulum.duration(minutes=30))
# floor(08:15, 30min) = 08:00; steps: 08:00,08:30,09:00,09:30,10:00(ceil)
# 10:00 is outside [08:00,10:00) → 0.0
assert matrix.shape[1] >= 4
np.testing.assert_array_equal(matrix[0, :4], [1.0, 1.0, 1.0, 1.0])
assert matrix[0, 4] == pytest.approx(0.0) # 10:00 outside
# ------------------------------------------------------------------
# consistency with cycle_to_array
# ------------------------------------------------------------------
def test_matrix_rows_match_cycle_to_array(self):
"""Each matrix row must exactly match the corresponding cycle_to_array output.
Both methods now use the same wall-clock floor alignment and math.ceil
step count, so they must produce identical arrays for every cycle.
"""
indices, matrix = self.seq.cycles_to_matrix(self.start, self.end, self.interval)
for k, cycle_idx in enumerate(indices):
expected = self.seq.cycle_to_array(
cycle_idx, self.start, self.end, self.interval
)
np.testing.assert_array_equal(
matrix[k],
expected,
err_msg=f"Row {k} (cycle {cycle_idx}) differs from cycle_to_array",
)
def test_matrix_rows_match_cycle_to_array_misaligned(self):
"""Consistency holds even when start_datetime is not on an interval boundary."""
start = naive_dt(2024, 6, 15, 8, 20)
end = naive_dt(2024, 6, 15, 14, 20)
interval = pendulum.duration(hours=1)
indices, matrix = self.seq.cycles_to_matrix(start, end, interval)
for k, cycle_idx in enumerate(indices):
expected = self.seq.cycle_to_array(cycle_idx, start, end, interval)
np.testing.assert_array_equal(
matrix[k],
expected,
err_msg=f"Row {k} (cycle {cycle_idx}) differs from cycle_to_array (misaligned)",
)
+6 -4
View File
@@ -214,18 +214,20 @@ class TestConfigMigration:
# Verify the migrated value matches the expected one # Verify the migrated value matches the expected one
new_value = configmigrate._get_json_nested_value(new_data, new_path) new_value = configmigrate._get_json_nested_value(new_data, new_path)
if new_value != expected_value: value_errors = _dict_contains(new_value, expected_value, new_path)
if value_errors:
# Check if this mapping uses _KEEP_DEFAULT and the old value was None/missing # Check if this mapping uses _KEEP_DEFAULT and the old value was None/missing
old_value = configmigrate._get_json_nested_value(old_data, old_path) old_value = configmigrate._get_json_nested_value(old_data, old_path)
keep_default = ( keep_default = (
isinstance(mapping, tuple) isinstance(mapping, tuple)
and configmigrate._KEEP_DEFAULT in mapping and configmigrate._KEEP_DEFAULT in mapping
) )
if keep_default and old_value is None: if keep_default and old_value is None:
continue # acceptable: old was None, new model keeps its default continue # acceptable: old was None, new model keeps its default
mismatched_values.append(
f"{old_path} → {new_path}: expected {expected_value!r}, got {new_value!r}" mismatched_values.extend(value_errors)
)
assert not missing_migrations, ( assert not missing_migrations, (
"Some expected migration map entries were not migrated:\n" "Some expected migration map entries were not migrated:\n"
+44
View File
@@ -0,0 +1,44 @@
"""Contracts required by the combined EOS configuration."""
import copy
import pytest
from pydantic import ValidationError
from akkudoktoreos.config.configmigrate import migrate_config_data
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
def test_device_map_supplies_stable_identity():
raw = {"batteries": {"house": {"capacity_wh": 12000}}}
a = DevicesCommonSettings.model_validate(raw)
b = DevicesCommonSettings.model_validate(raw)
assert a.batteries is not None and b.batteries is not None
assert a.batteries["house"].device_id == b.batteries["house"].device_id == "house"
assert "house-soc-factor" in a.measurement_keys
def test_device_map_rejects_conflicting_identity():
with pytest.raises(ValidationError, match="device_id"):
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
@pytest.mark.parametrize("as_list", [False, True])
def test_migration_preserves_lcos_and_input(as_list):
battery = {"device_id": "house", "capacity_wh": 12000,
"levelized_cost_of_storage_kwh": 0.123}
raw = {"devices": {"batteries": [battery] if as_list else {"house": battery}}}
original = copy.deepcopy(raw)
migrated = migrate_config_data(raw)
assert migrated.devices.batteries is not None
assert migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123
assert raw == original
@pytest.mark.parametrize("converter", ["to_genetic_param", "to_genetic0_param"])
def test_inverter_conversion_requires_output_limit(converter):
settings = InverterCommonSettings(device_id="inverter")
with pytest.raises(ValueError, match="max_power_w"):
getattr(settings, converter)()
settings.max_power_w = 4200
assert getattr(settings, converter)().max_power_wh == 4200
+8 -8
View File
@@ -17,17 +17,17 @@ from unittest.mock import Mock, patch
import numpy as np import numpy as np
import pytest import pytest
from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery from akkudoktoreos.devices.genetic0.genetic0battery import (
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter Genetic0Battery,
from akkudoktoreos.optimization.genetic0.genetic0 import (
Genetic0Simulation,
)
from akkudoktoreos.optimization.genetic0.genetic0devices import (
Genetic0InverterParameters,
Genetic0SolarPanelBatteryParameters, Genetic0SolarPanelBatteryParameters,
) )
from akkudoktoreos.optimization.genetic0.genetic0params import ( from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0Inverter,
Genetic0InverterParameters,
)
from akkudoktoreos.optimization.genetic0.genetic0 import (
Genetic0EnergyManagementParameters, Genetic0EnergyManagementParameters,
Genetic0Simulation,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -2
View File
@@ -83,11 +83,11 @@ async def test_optimize(
}, },
"devices": { "devices": {
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"electric_vehicles": [ "electric_vehicles": { "ev1":
{ {
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], "charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
} }
], },
} }
} }
) )
+13 -11
View File
@@ -2,21 +2,23 @@ import numpy as np
import pytest import pytest
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery from akkudoktoreos.devices.genetic0.genetic0battery import (
from akkudoktoreos.devices.genetic0.genetic0homeappliance import Genetic0HomeAppliance Genetic0Battery,
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter
from akkudoktoreos.optimization.genetic0.genetic0 import (
Genetic0Simulation,
Genetic0SimulationResult,
)
from akkudoktoreos.optimization.genetic0.genetic0devices import (
Genetic0ElectricVehicleParameters, Genetic0ElectricVehicleParameters,
Genetic0HomeApplianceParameters,
Genetic0InverterParameters,
Genetic0SolarPanelBatteryParameters, Genetic0SolarPanelBatteryParameters,
) )
from akkudoktoreos.optimization.genetic0.genetic0params import ( from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
Genetic0HomeAppliance,
Genetic0HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0Inverter,
Genetic0InverterParameters,
)
from akkudoktoreos.optimization.genetic0.genetic0 import (
Genetic0EnergyManagementParameters, Genetic0EnergyManagementParameters,
Genetic0Simulation,
Genetic0SimulationResult,
) )
from akkudoktoreos.utils.datetimeutil import to_duration, to_time from akkudoktoreos.utils.datetimeutil import to_duration, to_time
+372
View File
@@ -0,0 +1,372 @@
"""Regression test suite for the repaired HomeAppliance module.
TODO: fix this import to match wherever HomeApplianceParameters / HomeAppliance
actually live in the repo.
"""
from typing import Any
from unittest.mock import Mock
import numpy as np
import pytest
from akkudoktoreos.config.configabc import CycleTimeWindowSequence, ValueTimeWindow
from akkudoktoreos.devices.genetic.homeappliance import (
HomeAppliance,
HomeApplianceParameters,
)
from akkudoktoreos.utils.datetimeutil import to_duration, to_time
# ---------------------------------------------------------------------------
# Fixtures / factories
# ---------------------------------------------------------------------------
def make_params(**overrides) -> HomeApplianceParameters:
defaults: dict[str, Any] = dict(
device_id="dishwasher",
consumption_wh=2000,
duration_h=2,
num_cycles=1,
min_cycle_gap_h=0,
time_windows=None,
)
defaults.update(overrides)
return HomeApplianceParameters(**defaults)
def make_appliance(
prediction_hours: int = 24,
optimization_hours: int = 24,
**param_overrides,
) -> HomeAppliance:
params = make_params(**param_overrides)
return HomeAppliance(
parameters=params,
optimization_hours=optimization_hours,
prediction_hours=prediction_hours,
)
def cycle_window(cycle: int, start: str, duration: str) -> CycleTimeWindowSequence:
"""Build a CycleTimeWindowSequence with a single window for one cycle."""
return CycleTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time=to_time(start),
duration=to_duration(duration),
value=float(cycle),
)
]
)
def mock_cycle_windows(cycle_matrix: dict[int, np.ndarray]) -> Mock:
"""Build a Mock standing in for CycleTimeWindowSequence.
``Mock(spec=CycleTimeWindowSequence)`` satisfies both the pydantic
field-type check on ``HomeApplianceParameters.time_windows`` and any
isinstance check in the module, without needing real windows/pendulum
datetimes -- useful for isolating _build_duration_feasibility and the
scheduling/repair logic from the real cycles_to_matrix() implementation.
"""
cycle_indices = list(cycle_matrix.keys())
matrix = np.array([cycle_matrix[c] for c in cycle_indices])
mock = Mock(spec=CycleTimeWindowSequence)
mock.cycles_to_matrix = Mock(return_value=(cycle_indices, matrix))
return mock
# ---------------------------------------------------------------------------
# Setup / defaults
# ---------------------------------------------------------------------------
class TestSetup:
def test_default_time_windows_created_when_none_given(self):
appliance = make_appliance(time_windows=None, num_cycles=1)
assert appliance.parameters.time_windows is not None
assert isinstance(appliance.parameters.time_windows, CycleTimeWindowSequence)
def test_default_time_window_value_left_unset(self):
# A None-valued window is invisible to cycles_to_matrix() and so
# never masquerades as a real per-cycle window; every remaining
# cycle instead falls through to the "unconstrained" fallback.
appliance = make_appliance(time_windows=None, num_cycles=3)
assert appliance.parameters.time_windows is not None
windows = appliance.parameters.time_windows.windows
assert len(windows) == 1
assert windows[0].value is None
def test_default_time_window_serializes_without_a_cycle_value(self):
appliance = make_appliance(time_windows=None, num_cycles=1)
assert appliance.parameters.time_windows is not None
dumped = appliance.parameters.time_windows.model_dump()
assert dumped["windows"][0]["value"] is None
def test_num_remaining_cycles_initial(self):
appliance = make_appliance(num_cycles=3)
assert appliance.num_remaining_cycles == 3
def test_num_remaining_cycles_never_negative(self):
appliance = make_appliance(num_cycles=2)
appliance.completed_cycles = 5
assert appliance.num_remaining_cycles == 0
# ---------------------------------------------------------------------------
# Allowed-start computation: default (unconstrained) per-cycle windows
# ---------------------------------------------------------------------------
class TestDefaultStartAllowed:
def test_starts_allowed_up_to_horizon_minus_duration(self):
appliance = make_appliance(prediction_hours=10, duration_h=3)
max_start = 10 - 3
allowed = appliance.start_allowed[0]
assert allowed[: max_start + 1].all()
def test_starts_beyond_horizon_minus_duration_forbidden(self):
appliance = make_appliance(prediction_hours=10, duration_h=3)
max_start = 10 - 3
allowed = appliance.start_allowed[0]
assert not allowed[max_start + 1 :].any()
def test_start_earliest_and_latest(self):
appliance = make_appliance(prediction_hours=10, duration_h=3)
assert appliance.start_earliest[0] == 0
assert appliance.start_latest[0] == 10 - 3
def test_each_cycle_gets_its_own_unconstrained_mask(self):
appliance = make_appliance(prediction_hours=10, duration_h=2, num_cycles=2)
max_start = 10 - 2
assert appliance.start_allowed[0][: max_start + 1].all()
assert appliance.start_allowed[1][: max_start + 1].all()
# ---------------------------------------------------------------------------
# Allowed-start computation: explicit single-cycle window
# ---------------------------------------------------------------------------
class TestExplicitCycleWindow:
def test_only_hours_inside_window_allowed(self):
appliance = make_appliance(
prediction_hours=24,
duration_h=2,
num_cycles=1,
time_windows=cycle_window(0, "10:00", "3 hours"),
)
allowed = appliance.start_allowed[0]
# Window is 10:00-13:00, appliance needs 2h -> valid starts 10, 11.
assert allowed[10] and allowed[11]
assert not allowed[9]
assert not allowed[12]
def test_earliest_latest_reflect_window(self):
appliance = make_appliance(
prediction_hours=24,
duration_h=2,
num_cycles=1,
time_windows=cycle_window(0, "10:00", "3 hours"),
)
assert appliance.start_earliest[0] == 10
assert appliance.start_latest[0] == 11
def test_window_with_no_valid_start_falls_back(self):
# Window shorter than the appliance's own duration -> nothing fits.
appliance = make_appliance(
prediction_hours=24,
duration_h=3,
num_cycles=1,
time_windows=cycle_window(0, "10:00", "1 hour"),
)
allowed = appliance.start_allowed[0]
assert not allowed.any()
assert appliance.start_earliest[0] == 0
assert appliance.start_latest[0] == 24 - 3
# ---------------------------------------------------------------------------
# Allowed-start computation: mocked per-cycle windows (CycleTimeWindowSequence)
# ---------------------------------------------------------------------------
class TestCycleStartAllowed:
def test_missing_cycle_row_is_unconstrained(self):
# num_cycles=2 but the mock only provides a window for cycle 0.
prediction_hours = 10
duration_h = 2
steps = np.zeros(prediction_hours)
steps[2:6] = 1.0
windows = mock_cycle_windows({0: steps})
appliance = make_appliance(
prediction_hours=prediction_hours,
duration_h=duration_h,
num_cycles=2,
time_windows=windows,
)
max_start = prediction_hours - duration_h
# Cycle 1 has no matrix row -> should be allowed everywhere it fits.
assert appliance.start_allowed[1][: max_start + 1].all()
def test_duration_feasibility_uses_correct_window(self):
# Steps 2,3,4,5 are inside the window (1.0); everything else is 0.
# duration_h=2 -> valid starts are 2, 3, 4 (each 2h block fully inside).
prediction_hours = 10
duration_h = 2
steps = np.zeros(prediction_hours)
steps[2:6] = 1.0
windows = mock_cycle_windows({0: steps})
appliance = make_appliance(
prediction_hours=prediction_hours,
duration_h=duration_h,
num_cycles=1,
time_windows=windows,
)
allowed = appliance.start_allowed[0]
expected = np.zeros(prediction_hours, dtype=bool)
expected[2:5] = True # starts 2, 3, 4
np.testing.assert_array_equal(allowed, expected)
# ---------------------------------------------------------------------------
# set_completed_cycles
# ---------------------------------------------------------------------------
class TestSetCompletedCycles:
def test_resets_start_hours_and_load_curve(self):
appliance = make_appliance(num_cycles=2)
appliance.start_hours = [1, 5]
appliance.load_curve[0] = 999
appliance.set_completed_cycles(1)
assert appliance.start_hours == []
assert (appliance.load_curve == 0).all()
def test_remaining_cycle_indices_updated(self):
appliance = make_appliance(num_cycles=4)
appliance.set_completed_cycles(2)
assert appliance.remaining_cycle_indices == [2, 3]
assert appliance.num_remaining_cycles == 2
def test_clamped_to_valid_range(self):
appliance = make_appliance(num_cycles=3)
appliance.set_completed_cycles(-5)
assert appliance.completed_cycles == 0
appliance.set_completed_cycles(99)
assert appliance.completed_cycles == 3
# ---------------------------------------------------------------------------
# set_starting_times -- the core scheduling / repair logic
# ---------------------------------------------------------------------------
class TestSetStartingTimes:
def test_single_cycle_schedule_returns_requested_start(self):
appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1)
result = appliance.set_starting_times([5])
assert result == [5]
def test_two_cycles_enforce_minimum_gap(self):
appliance = make_appliance(
prediction_hours=24, duration_h=2, num_cycles=2, min_cycle_gap_h=1
)
# Requested starts overlap; cycle 1 must be pushed to start >= 0+2+1=3.
result = appliance.set_starting_times([0, 1])
assert result[0] == 0
assert result[1] >= 3
def test_three_cycles_are_all_gap_repaired(self):
appliance = make_appliance(
prediction_hours=24, duration_h=1, num_cycles=3, min_cycle_gap_h=0
)
result = appliance.set_starting_times([0, 0, 0])
# Each cycle is 1h with no gap -> expect 0, 1, 2.
assert result == [0, 1, 2]
def test_load_curve_reflects_final_start_hours(self):
appliance = make_appliance(
prediction_hours=10, duration_h=2, consumption_wh=2000, num_cycles=1
)
appliance.set_starting_times([3])
expected = np.zeros(10)
expected[3:5] = 1000 # 2000 Wh over 2h
np.testing.assert_array_equal(appliance.get_load_curve(), expected)
def test_sorting_preserves_per_cycle_window_alignment(self):
prediction_hours = 24
duration_h = 1
# Cycle 0 only allowed late (hour 20), cycle 1 only allowed early (hour 2).
steps0 = np.zeros(prediction_hours)
steps0[20] = 1.0
steps1 = np.zeros(prediction_hours)
steps1[2] = 1.0
windows = mock_cycle_windows({0: steps0, 1: steps1})
appliance = make_appliance(
prediction_hours=prediction_hours,
duration_h=duration_h,
num_cycles=2,
time_windows=windows,
)
result = appliance.set_starting_times([20, 2])
# Cycle 0 must land at 20 (its only allowed hour), cycle 1 at 2,
# regardless of chronological sorting during repair.
assert 20 in result
assert 2 in result
def test_raises_on_wrong_number_of_start_times(self):
appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=2)
with pytest.raises(ValueError):
appliance.set_starting_times([5])
def test_no_remaining_cycles_returns_empty_list(self):
appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1)
appliance.completed_cycles = 1
result = appliance.set_starting_times([])
assert result == []
assert (appliance.load_curve == 0).all()
# ---------------------------------------------------------------------------
# Backwards-compatible single-cycle interface
# ---------------------------------------------------------------------------
class TestSetStartingTimeBackCompat:
def test_single_cycle_wrapper_returns_int(self):
appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1)
result = appliance.set_starting_time(5)
assert isinstance(result, int)
assert result == 5
def test_no_remaining_cycles_returns_input_unchanged(self):
appliance = make_appliance(prediction_hours=24, duration_h=2, num_cycles=1)
appliance.completed_cycles = 1 # nothing left to schedule
result = appliance.set_starting_time(7)
assert result == 7
assert (appliance.load_curve == 0).all()
# ---------------------------------------------------------------------------
# Load curve utilities
# ---------------------------------------------------------------------------
class TestLoadCurve:
def test_reset_load_curve_zeros_array(self):
appliance = make_appliance(prediction_hours=6)
appliance.load_curve[:] = 42
appliance.reset_load_curve()
assert (appliance.load_curve == 0).all()
assert len(appliance.load_curve) == 6
def test_get_load_for_hour_valid(self):
appliance = make_appliance(prediction_hours=6)
appliance.load_curve[2] = 123.0
assert appliance.get_load_for_hour(2) == 123.0
@pytest.mark.parametrize("hour", [-1, 6, 100])
def test_get_load_for_hour_out_of_range_raises(self, hour):
appliance = make_appliance(prediction_hours=6)
with pytest.raises(ValueError):
appliance.get_load_for_hour(hour)
+2 -2
View File
@@ -83,11 +83,11 @@ async def test_optimize(
}, },
"devices": { "devices": {
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"electric_vehicles": [ "electric_vehicles": { "ev1":
{ {
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], "charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
} }
], },
} }
} }
) )
+1 -2
View File
@@ -11,9 +11,8 @@ import numpy as np
import pandas as pd import pandas as pd
import pytest import pytest
from akkudoktoreos.devices.genetic.inverter import Inverter from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
from akkudoktoreos.optimization.genetic.geneticdevices import InverterParameters
from akkudoktoreos.optimization.genetic.geneticparams import ( from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters, GeneticOptimizationParameters,
) )
+13 -10
View File
@@ -2,21 +2,24 @@ import numpy as np
import pytest import pytest
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.battery import (
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance Battery,
from akkudoktoreos.devices.genetic.inverter import Inverter
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
from akkudoktoreos.optimization.genetic.geneticdevices import (
ElectricVehicleParameters, ElectricVehicleParameters,
HomeApplianceParameters,
InverterParameters,
SolarPanelBatteryParameters, SolarPanelBatteryParameters,
) )
from akkudoktoreos.optimization.genetic.geneticparams import ( from akkudoktoreos.devices.genetic.homeappliance import (
HomeAppliance,
HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic.inverter import (
Inverter,
InverterParameters,
)
from akkudoktoreos.optimization.genetic.genetic import (
GeneticEnergyManagementParameters, GeneticEnergyManagementParameters,
GeneticOptimizationParameters, GeneticSimulation,
GeneticSimulationResult,
) )
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
from akkudoktoreos.utils.datetimeutil import to_duration, to_time from akkudoktoreos.utils.datetimeutil import to_duration, to_time
start_hour = 1 start_hour = 1
+13 -10
View File
@@ -2,21 +2,24 @@ import numpy as np
import pytest import pytest
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.battery import (
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance Battery,
from akkudoktoreos.devices.genetic.inverter import Inverter
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
from akkudoktoreos.optimization.genetic.geneticdevices import (
ElectricVehicleParameters, ElectricVehicleParameters,
HomeApplianceParameters,
InverterParameters,
SolarPanelBatteryParameters, SolarPanelBatteryParameters,
) )
from akkudoktoreos.optimization.genetic.geneticparams import ( from akkudoktoreos.devices.genetic.homeappliance import (
HomeAppliance,
HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic.inverter import (
Inverter,
InverterParameters,
)
from akkudoktoreos.optimization.genetic.genetic import (
GeneticEnergyManagementParameters, GeneticEnergyManagementParameters,
GeneticOptimizationParameters, GeneticSimulation,
GeneticSimulationResult,
) )
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
from akkudoktoreos.utils.datetimeutil import to_duration, to_time from akkudoktoreos.utils.datetimeutil import to_duration, to_time
start_hour = 0 start_hour = 0
+6 -4
View File
@@ -17,12 +17,14 @@ from unittest.mock import Mock, patch
import numpy as np import numpy as np
import pytest import pytest
from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.battery import (
from akkudoktoreos.devices.genetic.inverter import Inverter Battery,
from akkudoktoreos.optimization.genetic.geneticdevices import (
InverterParameters,
SolarPanelBatteryParameters, SolarPanelBatteryParameters,
) )
from akkudoktoreos.devices.genetic.inverter import (
Inverter,
InverterParameters,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers / Fixtures # Helpers / Fixtures
+95 -419
View File
@@ -1,4 +1,9 @@
## Base configuration for devices simulation settings ## Configuration for all controllable devices in the simulation
Every device collection is a ``dict[str, <Settings>]`` keyed by
``device_id``. This makes config paths stable regardless of
declaration order and lets each device settings class build its own
config path from ``self.device_id`` without needing an external index.
<!-- pyml disable line-length --> <!-- pyml disable line-length -->
:::{table} devices :::{table} devices
@@ -7,15 +12,15 @@
| Name | Environment Variable | Type | Read-Only | Default | Description | | Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- | | ---- | -------------------- | ---- | --------- | ------- | ----------- |
| batteries | `EOS_DEVICES__BATTERIES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of battery devices | | batteries | `EOS_DEVICES__BATTERIES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Stationary battery storage devices, keyed by device_id. |
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of electric vehicle devices | | electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. |
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings] | None` | `rw` | `None` | List of home appliances | | home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `dict[str, akkudoktoreos.devices.settings.homeappliancesettings.HomeApplianceCommonSettings]` | `rw` | `required` | Shiftable home appliance devices, keyed by device_id. |
| inverters | `EOS_DEVICES__INVERTERS` | `list[akkudoktoreos.devices.devices.InverterCommonSettings] | None` | `rw` | `None` | List of inverters | | inverters | `EOS_DEVICES__INVERTERS` | `dict[str, akkudoktoreos.devices.settings.invertersettings.InverterCommonSettings] | None` | `rw` | `None` | Inverter devices, keyed by device_id. |
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries that can be set | | max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries allowed. |
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of electric vehicles that can be set | | max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of EVs allowed. |
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home_appliances that can be set | | max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home appliances allowed. |
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters that can be set | | max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters allowed. |
| measurement_keys | | `list[str] | None` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. | | measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
@@ -27,13 +32,13 @@
```json ```json
{ {
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -52,15 +57,15 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -79,12 +84,22 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1 "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null
}
},
"max_home_appliances": 3
} }
} }
``` ```
@@ -98,13 +113,13 @@
```json ```json
{ {
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -122,28 +137,28 @@
], ],
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100, "max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor", "measurement_key_soc_factor": "bat0-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w", "measurement_key_power_l1_w": "bat0-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w", "measurement_key_power_l2_w": "bat0-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w", "measurement_key_power_l3_w": "bat0-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w", "measurement_key_power_3_phase_sym_w": "bat0-power-3-phase-sym-w",
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "bat0-soc-factor",
"battery1-power-l1-w", "bat0-power-l1-w",
"battery1-power-l2-w", "bat0-power-l2-w",
"battery1-power-l3-w", "bat0-power-l3-w",
"battery1-power-3-phase-sym-w" "bat0-power-3-phase-sym-w"
] ]
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -161,387 +176,48 @@
], ],
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100, "max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor", "measurement_key_soc_factor": "ev0-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w", "measurement_key_power_l1_w": "ev0-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w", "measurement_key_power_l2_w": "ev0-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w", "measurement_key_power_l3_w": "ev0-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w", "measurement_key_power_3_phase_sym_w": "ev0-power-3-phase-sym-w",
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "ev0-soc-factor",
"battery1-power-l1-w", "ev0-power-l1-w",
"battery1-power-l2-w", "ev0-power-l2-w",
"battery1-power-l3-w", "ev0-power-l3-w",
"battery1-power-3-phase-sym-w" "ev0-power-3-phase-sym-w"
] ]
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1, "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null,
"effective_num_cycles": 1,
"measurement_keys": []
}
},
"max_home_appliances": 3,
"measurement_keys": [ "measurement_keys": [
"battery1-soc-factor", "bat0-soc-factor",
"battery1-power-l1-w", "bat0-power-l1-w",
"battery1-power-l2-w", "bat0-power-l2-w",
"battery1-power-l3-w", "bat0-power-l3-w",
"battery1-power-3-phase-sym-w", "bat0-power-3-phase-sym-w",
"battery1-soc-factor", "ev0-soc-factor",
"battery1-power-l1-w", "ev0-power-l1-w",
"battery1-power-l2-w", "ev0-power-l2-w",
"battery1-power-l3-w", "ev0-power-l3-w",
"battery1-power-3-phase-sym-w" "ev0-power-3-phase-sym-w"
]
}
}
```
<!-- pyml enable line-length -->
### Inverter devices base settings
<!-- pyml disable line-length -->
:::{table} devices::inverters::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
| battery_id | `str | None` | `rw` | `None` | ID of battery controlled by this inverter. |
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
| device_id | `str` | `rw` | `required` | ID of device |
| max_ac_charge_power_w | `float | None` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
| max_power_w | `float | None` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null,
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Model defining a daily or date time window with optional localization support
Represents a time interval starting at `start_time` and lasting for `duration`.
Can restrict applicability to a specific day of the week or a specific calendar date.
Supports day names in multiple languages via locale-aware parsing.
Timezone contract:
``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a
local wall-clock time in whatever timezone the caller's ``date_time`` or
``reference_date`` carries. When those arguments are timezone-aware the
window boundaries are evaluated in that timezone; when they are naive,
arithmetic is performed as-is (no timezone conversion occurs).
``date``, being a calendar ``Date`` object, is inherently timezone-free.
This design avoids the ambiguity that arises when a stored ``start_time``
carries its own timezone that differs from the caller's timezone, and keeps
the model serialisable without timezone state.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Model representing a sequence of time windows with collective operations
Manages multiple TimeWindow objects and provides methods to work with them
as a cohesive unit for scheduling and availability checking.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.TimeWindow]` | `rw` | `required` | List of TimeWindow objects that make up this sequence. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": []
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Home Appliance devices base settings
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `required` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| time_windows | `akkudoktoreos.config.configabc.TimeWindowSequence | None` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
},
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Battery devices base settings
<!-- pyml disable line-length -->
:::{table} devices::batteries::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `list[float] | None` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `required` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
| max_charge_power_w | `float | None` | `rw` | `5000` | Maximum charging power [W]. |
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | Measurement key for the L1 power the battery is charged or discharged with [W]. |
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | Measurement key for the L2 power the battery is charged or discharged with [W]. |
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | Measurement key for the L3 power the battery is charged or discharged with [W]. |
| measurement_key_soc_factor | `str` | `ro` | `N/A` | Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
| min_charge_power_w | `float | None` | `rw` | `50` | Minimum charging power [W]. |
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
] ]
} }
} }
+24 -14
View File
@@ -36,13 +36,13 @@
"batch_size": 100 "batch_size": 100
}, },
"devices": { "devices": {
"batteries": [ "batteries": {
{ "bat0": {
"device_id": "battery1", "device_id": "bat0",
"capacity_wh": 8000, "capacity_wh": 8000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -61,15 +61,15 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "battery1", "device_id": "ev0",
"capacity_wh": 8000, "capacity_wh": 60000,
"charging_efficiency": 0.88, "charging_efficiency": 0.88,
"discharging_efficiency": 0.88, "discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0, "levelized_cost_of_storage_amt_kwh": 0.0,
"max_charge_power_w": 5000, "max_charge_power_w": 5000,
"min_charge_power_w": 50, "min_charge_power_w": 50,
"charge_rates": [ "charge_rates": [
@@ -88,12 +88,22 @@
"min_soc_percentage": 0, "min_soc_percentage": 0,
"max_soc_percentage": 100 "max_soc_percentage": 100
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [], "inverters": {},
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [], "home_appliances": {
"max_home_appliances": 1 "dishwasher": {
"device_id": "dishwasher",
"consumption_wh": 1500,
"duration_h": 2,
"num_cycles": 1,
"cycle_time_windows": null,
"min_cycle_gap_h": 0,
"cycles_completed_measurement_key": null
}
},
"max_home_appliances": 3
}, },
"elecfee": { "elecfee": {
"provider": "ElecFeeFixed", "provider": "ElecFeeFixed",
+7 -6
View File
@@ -12,14 +12,15 @@
"console_level": "INFO" "console_level": "INFO"
}, },
"devices": { "devices": {
"batteries": [ "batteries": {
{ "pv_akku": {
"device_id": "pv_akku", "device_id": "pv_akku",
"capacity_wh": 30000 "capacity_wh": 30000
} }
], },
"electric_vehicles": [ "electric_vehicles": {
{ "ev0": {
"device_id": "ev0",
"charge_rates": [ "charge_rates": [
0.0, 0.0,
0.375, 0.375,
@@ -30,7 +31,7 @@
1.0 1.0
] ]
} }
] }
}, },
"measurement": { "measurement": {
"load_emr_keys": [ "load_emr_keys": [
+26 -28
View File
@@ -10,54 +10,52 @@
"mode": "OPTIMIZATION" "mode": "OPTIMIZATION"
}, },
"devices": { "devices": {
"batteries": [ "batteries": {
{ "battery1": {
"device_id": "battery1" "device_id": "battery1"
} }
], },
"max_batteries": 1, "max_batteries": 1,
"electric_vehicles": [ "electric_vehicles": {
{ "ev11": {
"device_id": "ev11", "device_id": "ev11",
"capacity_wh": 50000, "capacity_wh": 50000,
"min_soc_percentage": 70 "min_soc_percentage": 70
} }
], },
"max_electric_vehicles": 1, "max_electric_vehicles": 1,
"inverters": [ "inverters": {
{ "inverter1": {
"device_id": "inverter1", "device_id": "inverter1",
"max_power_w": 10000.0, "max_power_w": 10000.0,
"battery_id": "battery1" "battery_id": "battery1"
} }
], },
"max_inverters": 1, "max_inverters": 1,
"home_appliances": [ "home_appliances": {
{ "dishwasher1": {
"device_id": "dishwasher1", "device_id": "dishwasher1",
"consumption_wh": 2000, "consumption_wh": 2000
"duration_h": 3,
"time_windows": {
"windows": [
{
"start_time": "08:00:00.000000",
"duration": "5 hours"
},
{
"start_time": "15:00:00.000000",
"duration": "3 hours"
}
]
}
} }
], },
"max_home_appliances": 1 "max_home_appliances": 1
}, },
"elecprice": { "elecprice": {
"provider": "ElecPriceAkkudoktor" "provider": "ElecPriceAkkudoktor"
}, },
"feedintariff": { "feedintariff": {
"provider": "FeedInTariffFixed" "provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"value": 0.078
}
]
}
}
}, },
"load": { "load": {
"provider": "LoadAkkudoktorAdjusted", "provider": "LoadAkkudoktorAdjusted",
@@ -122,4 +120,4 @@
"weather": { "weather": {
"provider": "BrightSky" "provider": "BrightSky"
} }
} }