mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 15:26:38 +00:00
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:
co-authored by
Bobby Noelte
r0b2g1t
parent
431d7d57e5
commit
8224c64654
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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],
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user