diff --git a/CHANGELOG.md b/CHANGELOG.md index d812d1fa..b1e5c755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - `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) Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. diff --git a/docs/_generated/configdevices.md b/docs/_generated/configdevices.md index c8eee9bf..3dfbd09d 100644 --- a/docs/_generated/configdevices.md +++ b/docs/_generated/configdevices.md @@ -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, ]`` 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. :::{table} devices @@ -7,15 +12,15 @@ | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | -| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices | -| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices | -| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances | -| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters | -| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set | -| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set | -| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set | -| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set | -| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. | +| 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[dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings]]` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. | +| 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[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 allowed. | +| 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 allowed. | +| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters allowed. | +| measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. | ::: @@ -27,13 +32,13 @@ ```json { "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -52,15 +57,15 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -79,12 +84,22 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1 + "home_appliances": { + "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 { "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -122,28 +137,28 @@ ], "min_soc_percentage": 0, "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_key_soc_factor": "bat0-soc-factor", + "measurement_key_power_l1_w": "bat0-power-l1-w", + "measurement_key_power_l2_w": "bat0-power-l2-w", + "measurement_key_power_l3_w": "bat0-power-l3-w", + "measurement_key_power_3_phase_sym_w": "bat0-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" + "bat0-soc-factor", + "bat0-power-l1-w", + "bat0-power-l2-w", + "bat0-power-l3-w", + "bat0-power-3-phase-sym-w" ] } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -161,387 +176,48 @@ ], "min_soc_percentage": 0, "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_key_soc_factor": "ev0-soc-factor", + "measurement_key_power_l1_w": "ev0-power-l1-w", + "measurement_key_power_l2_w": "ev0-power-l2-w", + "measurement_key_power_l3_w": "ev0-power-l3-w", + "measurement_key_power_3_phase_sym_w": "ev0-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" + "ev0-soc-factor", + "ev0-power-l1-w", + "ev0-power-l2-w", + "ev0-power-l3-w", + "ev0-power-3-phase-sym-w" ] } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1, + "home_appliances": { + "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": [ - "battery1-soc-factor", - "battery1-power-l1-w", - "battery1-power-l2-w", - "battery1-power-l3-w", - "battery1-power-3-phase-sym-w", - "battery1-soc-factor", - "battery1-power-l1-w", - "battery1-power-l2-w", - "battery1-power-l3-w", - "battery1-power-3-phase-sym-w" - ] - } - } -``` - - -### Inverter devices base settings - - -:::{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. | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } -``` - - - -**Example Output** - - - -```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": [] - } - ] - } - } -``` - - -### 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. - - -:::{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(). | -::: - - - -**Example Input/Output** - - - -```json - { - "devices": { - "home_appliances": [ - { - "time_windows": { - "windows": [ - { - "start_time": "00:00:00.000000", - "duration": "2 hours", - "day_of_week": null, - "date": null, - "locale": null - } - ] - } - } - ] - } - } -``` - - -### 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. - - -:::{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. | -::: - - - -**Example Input/Output** - - - -```json - { - "devices": { - "home_appliances": [ - { - "time_windows": { - "windows": [] - } - } - ] - } - } -``` - - -### Home Appliance devices base settings - - -:::{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. | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } - ] - } - } -``` - - - -**Example Output** - - - -```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": [] - } - ] - } - } -``` - - -### Battery devices base settings - - -:::{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 | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } -``` - - - -**Example Output** - - - -```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" - ] - } + "bat0-soc-factor", + "bat0-power-l1-w", + "bat0-power-l2-w", + "bat0-power-l3-w", + "bat0-power-3-phase-sym-w", + "ev0-soc-factor", + "ev0-power-l1-w", + "ev0-power-l2-w", + "ev0-power-l3-w", + "ev0-power-3-phase-sym-w" ] } } diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 234bb06b..2a5291f9 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -36,13 +36,13 @@ "batch_size": 100 }, "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -61,15 +61,15 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -88,12 +88,22 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1 + "home_appliances": { + "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": { "provider": "ElecFeeFixed", diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index c2c7b762..58fb563a 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2609161710154693` +**Version**: `v0.3.0.dev2609171139845429` **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. diff --git a/docs/akkudoktoreos/configuration.md b/docs/akkudoktoreos/configuration.md index 65933c54..9fc2cd3a 100644 --- a/docs/akkudoktoreos/configuration.md +++ b/docs/akkudoktoreos/configuration.md @@ -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: -1. `Settings`: Provided during runtime by the REST interface -2. `Environment Variables`: Defined at startup of the REST server and during runtime -3. `EOS Configuration File`: Read at startup of the REST server and on request -4. `Default Values` +1. `Command Line Arguments`: Provided at startup of the REST server +2. `Settings`: Provided during runtime by the REST interface +3. `Environment Variables`: Defined at startup of the REST server and during runtime +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 diff --git a/docs/akkudoktoreos/resource.md b/docs/akkudoktoreos/resource.md index 5d0982bb..38dd8605 100644 --- a/docs/akkudoktoreos/resource.md +++ b/docs/akkudoktoreos/resource.md @@ -198,18 +198,18 @@ horizon. ### Home Appliance Simulation -### Home Appliance Configuration +### GENETIC0 Home Appliance Configuration Home appliance to run within the optimization horizon. ```json -[ - { +{ + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3 } -] +} ``` 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. ```json -[ - { +{ + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3, @@ -235,7 +235,7 @@ window of 3 hours starting at 15:00 every day. See ] } } -] +} ``` :::{admonition} Note @@ -244,6 +244,93 @@ The optimization algorithm always restricts to one start within the optimization 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 The home appliance instructions assume an idealized home appliance model. Under this model, diff --git a/openapi.json b/openapi.json index 0cd3ea7d..a0047c51 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2609161710154693" + "version": "v0.3.0.dev2609171139845429" }, "paths": { "/v1/admin/cache/clear": { @@ -2918,10 +2918,10 @@ 0.88 ] }, - "levelized_cost_of_storage_kwh": { + "levelized_cost_of_storage_amt_kwh": { "type": "number", - "title": "Levelized Cost Of Storage Kwh", - "description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh].", + "title": "Levelized Cost Of Storage Amt Kwh", + "description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [\u20ac/kWh].", "default": 0.0, "examples": [ 0.12 @@ -3004,7 +3004,7 @@ "maximum": 100.0, "minimum": 0.0, "title": "Min Soc Percentage", - "description": "Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging", + "description": "Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging.", "default": 0, "examples": [ 10 @@ -3024,7 +3024,7 @@ }, "type": "object", "title": "BatteriesCommonSettings", - "description": "Battery devices base settings." + "description": "Battery and electric vehicle device settings.\n\nUsed for both stationary batteries and EV battery packs.\n\nNote: Used for the GENETIC and GENETIC0 algorithm." }, "BatteriesCommonSettings-Output": { "properties": { @@ -3071,10 +3071,10 @@ 0.88 ] }, - "levelized_cost_of_storage_kwh": { + "levelized_cost_of_storage_amt_kwh": { "type": "number", - "title": "Levelized Cost Of Storage Kwh", - "description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh].", + "title": "Levelized Cost Of Storage Amt Kwh", + "description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [\u20ac/kWh].", "default": 0.0, "examples": [ 0.12 @@ -3157,7 +3157,7 @@ "maximum": 100.0, "minimum": 0.0, "title": "Min Soc Percentage", - "description": "Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging", + "description": "Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging.", "default": 0, "examples": [ 10 @@ -3177,47 +3177,40 @@ "measurement_key_soc_factor": { "type": "string", "title": "Measurement Key Soc Factor", - "description": "Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0].", + "description": "Measurement key for SoC as factor of total capacity [0.0 ... 1.0].", "readOnly": true }, "measurement_key_power_l1_w": { "type": "string", "title": "Measurement Key Power L1 W", - "description": "Measurement key for the L1 power the battery is charged or discharged with [W].", + "description": "Measurement key for L1 power [W].", "readOnly": true }, "measurement_key_power_l2_w": { "type": "string", "title": "Measurement Key Power L2 W", - "description": "Measurement key for the L2 power the battery is charged or discharged with [W].", + "description": "Measurement key for L2 power [W].", "readOnly": true }, "measurement_key_power_l3_w": { "type": "string", "title": "Measurement Key Power L3 W", - "description": "Measurement key for the L3 power the battery is charged or discharged with [W].", + "description": "Measurement key for L3 power [W].", "readOnly": true }, "measurement_key_power_3_phase_sym_w": { "type": "string", "title": "Measurement Key Power 3 Phase Sym W", - "description": "Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W].", + "description": "Measurement key for symmetric 3-phase power [W].", "readOnly": true }, "measurement_keys": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], + "items": { + "type": "string" + }, + "type": "array", "title": "Measurement Keys", - "description": "Measurement keys for the battery stati that are measurements.", + "description": "All measurement keys for this battery.", "readOnly": true } }, @@ -3231,7 +3224,7 @@ "measurement_keys" ], "title": "BatteriesCommonSettings", - "description": "Battery devices base settings." + "description": "Battery and electric vehicle device settings.\n\nUsed for both stationary batteries and EV battery packs.\n\nNote: Used for the GENETIC and GENETIC0 algorithm." }, "CacheCommonSettings": { "properties": { @@ -3350,6 +3343,36 @@ "title": "ConfigSaveMode", "description": "Configuration file save mode." }, + "CycleTimeWindowSequence-Input": { + "properties": { + "windows": { + "items": { + "$ref": "#/components/schemas/ValueTimeWindow-Input" + }, + "type": "array", + "title": "Windows", + "description": "Ordered list of value time windows. Each window defines a time interval and an associated value." + } + }, + "type": "object", + "title": "CycleTimeWindowSequence", + "description": "Sequence of time windows associated to cycles.\n\nThis model specializes ``ValueTimeWindowSequence`` so that the ``value``\nfield of each ``ValueTimeWindow`` encodes the **cycle index** (0-based\ninteger) the window belongs to.\n\nTypical use: an appliance that must run ``n`` times per day, each run\nconstrained to a distinct time window. Assign ``value=0`` to windows\nfor the first cycle, ``value=1`` for the second, and so on. Multiple\nwindows may share the same cycle index (their allowed regions are unioned).\nWindows with ``value=None`` are silently ignored by all cycle-aware methods." + }, + "CycleTimeWindowSequence-Output": { + "properties": { + "windows": { + "items": { + "$ref": "#/components/schemas/ValueTimeWindow-Output" + }, + "type": "array", + "title": "Windows", + "description": "Ordered list of value time windows. Each window defines a time interval and an associated value." + } + }, + "type": "object", + "title": "CycleTimeWindowSequence", + "description": "Sequence of time windows associated to cycles.\n\nThis model specializes ``ValueTimeWindowSequence`` so that the ``value``\nfield of each ``ValueTimeWindow`` encodes the **cycle index** (0-based\ninteger) the window belongs to.\n\nTypical use: an appliance that must run ``n`` times per day, each run\nconstrained to a distinct time window. Assign ``value=0`` to windows\nfor the first cycle, ``value=1`` for the second, and so on. Multiple\nwindows may share the same cycle index (their allowed regions are unioned).\nWindows with ``value=None`` are silently ignored by all cycle-aware methods." + }, "DDBCActuatorStatus": { "properties": { "type": { @@ -3710,24 +3733,29 @@ "batteries": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/BatteriesCommonSettings-Input" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Batteries", - "description": "List of battery devices", + "description": "Stationary battery storage devices, keyed by device_id.", "examples": [ - [ - { + { + "bat0": { "capacity_wh": 8000, - "device_id": "battery1" + "device_id": "bat0", + "ports": [] } - ] + } + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_batteries": { @@ -3741,33 +3769,41 @@ } ], "title": "Max Batteries", - "description": "Maximum number of batteries that can be set", + "description": "Maximum number of batteries allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "electric_vehicles": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/BatteriesCommonSettings-Input" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Electric Vehicles", - "description": "List of electric vehicle devices", + "description": "Electric vehicle battery packs, keyed by device_id.", "examples": [ - [ - { - "capacity_wh": 8000, - "device_id": "battery1" + { + "ev0": { + "capacity_wh": 60000, + "device_id": "ev0", + "ports": [] } - ] + } + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_electric_vehicles": { @@ -3781,28 +3817,35 @@ } ], "title": "Max Electric Vehicles", - "description": "Maximum number of electric vehicles that can be set", + "description": "Maximum number of EVs allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "inverters": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/InverterCommonSettings-Input" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Inverters", - "description": "List of inverters", + "description": "Inverter devices, keyed by device_id.", "examples": [ - [] + {} + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_inverters": { @@ -3816,28 +3859,41 @@ } ], "title": "Max Inverters", - "description": "Maximum number of inverters that can be set", + "description": "Maximum number of inverters allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "home_appliances": { - "anyOf": [ + "additionalProperties": { + "$ref": "#/components/schemas/HomeApplianceCommonSettings-Input" + }, + "type": "object", + "title": "Home Appliances", + "description": "Shiftable home appliance devices, keyed by device_id.", + "examples": [ { - "items": { - "$ref": "#/components/schemas/HomeApplianceCommonSettings-Input" - }, - "type": "array" - }, - { - "type": "null" + "dishwasher": { + "consumption_wh": 1500, + "device_id": "dishwasher", + "duration_h": 2.0, + "ports": [ + { + "bus_id": "bus_ac", + "direction": "sink", + "port_id": "p_ac" + } + ] + } } ], - "title": "Home Appliances", - "description": "List of home appliances", - "examples": [ - [] + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_home_appliances": { @@ -3851,40 +3907,48 @@ } ], "title": "Max Home Appliances", - "description": "Maximum number of home_appliances that can be set", + "description": "Maximum number of home appliances allowed.", "examples": [ - 1, - 2 + 3 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] } }, "type": "object", "title": "DevicesCommonSettings", - "description": "Base configuration for devices simulation settings." + "description": "Configuration for all controllable devices in the simulation.\n\nEvery device collection is a ``dict[str, ]`` keyed by\n``device_id``. This makes config paths stable regardless of\ndeclaration order and lets each device settings class build its own\nconfig path from ``self.device_id`` without needing an external index." }, "DevicesCommonSettings-Output": { "properties": { "batteries": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/BatteriesCommonSettings-Output" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Batteries", - "description": "List of battery devices", + "description": "Stationary battery storage devices, keyed by device_id.", "examples": [ - [ - { + { + "bat0": { "capacity_wh": 8000, - "device_id": "battery1" + "device_id": "bat0", + "ports": [] } - ] + } + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_batteries": { @@ -3898,33 +3962,41 @@ } ], "title": "Max Batteries", - "description": "Maximum number of batteries that can be set", + "description": "Maximum number of batteries allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "electric_vehicles": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/BatteriesCommonSettings-Output" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Electric Vehicles", - "description": "List of electric vehicle devices", + "description": "Electric vehicle battery packs, keyed by device_id.", "examples": [ - [ - { - "capacity_wh": 8000, - "device_id": "battery1" + { + "ev0": { + "capacity_wh": 60000, + "device_id": "ev0", + "ports": [] } - ] + } + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_electric_vehicles": { @@ -3938,28 +4010,35 @@ } ], "title": "Max Electric Vehicles", - "description": "Maximum number of electric vehicles that can be set", + "description": "Maximum number of EVs allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "inverters": { "anyOf": [ { - "items": { + "additionalProperties": { "$ref": "#/components/schemas/InverterCommonSettings-Output" }, - "type": "array" + "type": "object" }, { "type": "null" } ], "title": "Inverters", - "description": "List of inverters", + "description": "Inverter devices, keyed by device_id.", "examples": [ - [] + {} + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_inverters": { @@ -3973,28 +4052,41 @@ } ], "title": "Max Inverters", - "description": "Maximum number of inverters that can be set", + "description": "Maximum number of inverters allowed.", "examples": [ - 1, - 2 + 1 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "home_appliances": { - "anyOf": [ + "additionalProperties": { + "$ref": "#/components/schemas/HomeApplianceCommonSettings-Output" + }, + "type": "object", + "title": "Home Appliances", + "description": "Shiftable home appliance devices, keyed by device_id.", + "examples": [ { - "items": { - "$ref": "#/components/schemas/HomeApplianceCommonSettings-Output" - }, - "type": "array" - }, - { - "type": "null" + "dishwasher": { + "consumption_wh": 1500, + "device_id": "dishwasher", + "duration_h": 2.0, + "ports": [ + { + "bus_id": "bus_ac", + "direction": "sink", + "port_id": "p_ac" + } + ] + } } ], - "title": "Home Appliances", - "description": "List of home appliances", - "examples": [ - [] + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_home_appliances": { @@ -4008,26 +4100,22 @@ } ], "title": "Max Home Appliances", - "description": "Maximum number of home_appliances that can be set", + "description": "Maximum number of home appliances allowed.", "examples": [ - 1, - 2 + 3 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "measurement_keys": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], + "items": { + "type": "string" + }, + "type": "array", "title": "Measurement Keys", - "description": "Return the measurement keys for the resource/ device stati that are measurements.", + "description": "All measurement keys across all configured devices.", "readOnly": true } }, @@ -4036,7 +4124,7 @@ "measurement_keys" ], "title": "DevicesCommonSettings", - "description": "Base configuration for devices simulation settings." + "description": "Configuration for all controllable devices in the simulation.\n\nEvery device collection is a ``dict[str, ]`` keyed by\n``device_id``. This makes config paths stable regardless of\ndeclaration order and lets each device settings class build its own\nconfig path from ``self.device_id`` without needing an external index." }, "ElecFeeCommonSettings-Input": { "properties": { @@ -8582,9 +8670,14 @@ "type": "integer", "exclusiveMinimum": 0.0, "title": "Consumption Wh", - "description": "Energy consumption [Wh].", + "description": "Energy consumption per run cycle [Wh].", + "default": 3000, "examples": [ 2000 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "duration_h": { @@ -8592,40 +8685,100 @@ "maximum": 24.0, "exclusiveMinimum": 0.0, "title": "Duration H", - "description": "Usage duration in hours [0 ... 24].", + "description": "Run duration per cycle [h] (1-24).", + "default": 3, "examples": [ - 1 + 2 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, - "time_windows": { + "num_cycles": { + "type": "integer", + "minimum": 1.0, + "title": "Num Cycles", + "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.", + "default": 1, + "examples": [ + 1, + 2 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" + ] + }, + "cycle_time_windows": { "anyOf": [ { - "$ref": "#/components/schemas/TimeWindowSequence-Input" + "$ref": "#/components/schemas/CycleTimeWindowSequence-Input" }, { "type": "null" } ], - "description": "Sequence of allowed time windows. Defaults to optimization general time window.", + "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": [ + null, { "windows": [ { - "duration": "2 hours", - "start_time": "10:00" + "duration": "5 hours", + "start_time": "07:00", + "value": 0 + }, + { + "duration": "5 hours", + "start_time": "17:00", + "value": 1 } ] } + ], + "x-scope": [ + "GENETIC" + ] + }, + "min_cycle_gap_h": { + "type": "integer", + "minimum": 0.0, + "title": "Min Cycle Gap H", + "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.", + "default": 0, + "examples": [ + 0, + 1, + 4 + ], + "x-scope": [ + "GENETIC" + ] + }, + "cycles_completed_measurement_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cycles Completed Measurement Key", + "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", + null + ], + "x-scope": [ + "GENETIC" ] } }, "type": "object", - "required": [ - "consumption_wh", - "duration_h" - ], "title": "HomeApplianceCommonSettings", - "description": "Home Appliance devices base settings." + "description": "Controllable home appliance device settings.\n\nRepresents a shiftable load whose start time \u2014 and optionally the\nstart times for multiple sequential runs \u2014 can be deferred by the\noptimiser within per-cycle allowed time windows.\n\nThe number of remaining cycles to plan is determined at runtime by\nreading ``cycles_completed_measurement_key`` from the measurement\nstore inside ``HomeApplianceDevice.setup_run``.\n\n``num_cycles`` is required when ``cycle_time_windows`` is ``None``\n(unconstrained); when windows are provided it is derived from\n``cycle_time_windows.num_cycles()``.\n\nSingle-cycle, unconstrained (start any time)\n---------------------------------------------\n\n::\n\n device_id: dishwasher\n consumption_wh: 1500\n duration_h: 2\n num_cycles: 1\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n\nSingle-cycle, constrained to one window\n----------------------------------------\n\nEach window's ``value`` field carries the **cycle index** (0-based).\nWindows without a ``value`` are ignored by the optimizer::\n\n device_id: dishwasher\n consumption_wh: 1500\n duration_h: 2\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n cycle_time_windows:\n windows:\n - start_time: \"10:00\"\n duration: \"12 hours\"\n value: 0\n\nMulti-cycle, per-cycle windows\n--------------------------------\n\nTwo cycles, each with its own window. Cycle 0 runs in the morning,\ncycle 1 in the evening::\n\n device_id: washing_machine\n consumption_wh: 2000\n duration_h: 2\n min_cycle_gap_h: 1\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n cycle_time_windows:\n windows:\n - start_time: \"07:00\"\n duration: \"5 hours\"\n value: 0\n - start_time: \"17:00\"\n duration: \"5 hours\"\n value: 1\n\nMulti-cycle, shared window (both cycles may run any time 10:00-20:00)\n-----------------------------------------------------------------------\n\nAssign the same-shaped windows to distinct cycle indices so the\noptimizer can place them independently::\n\n cycle_time_windows:\n windows:\n - start_time: \"10:00\"\n duration: \"10 hours\"\n value: 0\n - start_time: \"10:00\"\n duration: \"10 hours\"\n value: 1" }, "HomeApplianceCommonSettings-Output": { "properties": { @@ -8644,9 +8797,14 @@ "type": "integer", "exclusiveMinimum": 0.0, "title": "Consumption Wh", - "description": "Energy consumption [Wh].", + "description": "Energy consumption per run cycle [Wh].", + "default": 3000, "examples": [ 2000 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "duration_h": { @@ -8654,32 +8812,102 @@ "maximum": 24.0, "exclusiveMinimum": 0.0, "title": "Duration H", - "description": "Usage duration in hours [0 ... 24].", + "description": "Run duration per cycle [h] (1-24).", + "default": 3, "examples": [ - 1 + 2 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, - "time_windows": { + "num_cycles": { + "type": "integer", + "minimum": 1.0, + "title": "Num Cycles", + "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.", + "default": 1, + "examples": [ + 1, + 2 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" + ] + }, + "cycle_time_windows": { "anyOf": [ { - "$ref": "#/components/schemas/TimeWindowSequence-Output" + "$ref": "#/components/schemas/CycleTimeWindowSequence-Output" }, { "type": "null" } ], - "description": "Sequence of allowed time windows. Defaults to optimization general time window.", + "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": [ + null, { "windows": [ { - "duration": "2 hours", - "start_time": "10:00" + "duration": "5 hours", + "start_time": "07:00", + "value": 0 + }, + { + "duration": "5 hours", + "start_time": "17:00", + "value": 1 } ] } + ], + "x-scope": [ + "GENETIC" ] }, + "min_cycle_gap_h": { + "type": "integer", + "minimum": 0.0, + "title": "Min Cycle Gap H", + "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.", + "default": 0, + "examples": [ + 0, + 1, + 4 + ], + "x-scope": [ + "GENETIC" + ] + }, + "cycles_completed_measurement_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cycles Completed Measurement Key", + "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", + null + ], + "x-scope": [ + "GENETIC" + ] + }, + "effective_num_cycles": { + "type": "integer", + "title": "Effective Num Cycles", + "description": "Number of cycles as seen by the optimizer.\n\nDerived from cycle_time_windows.num_cycles() when windows are\nprovided; falls back to the explicit num_cycles field otherwise.", + "readOnly": true + }, "measurement_keys": { "anyOf": [ { @@ -8699,12 +8927,11 @@ }, "type": "object", "required": [ - "consumption_wh", - "duration_h", + "effective_num_cycles", "measurement_keys" ], "title": "HomeApplianceCommonSettings", - "description": "Home Appliance devices base settings." + "description": "Controllable home appliance device settings.\n\nRepresents a shiftable load whose start time \u2014 and optionally the\nstart times for multiple sequential runs \u2014 can be deferred by the\noptimiser within per-cycle allowed time windows.\n\nThe number of remaining cycles to plan is determined at runtime by\nreading ``cycles_completed_measurement_key`` from the measurement\nstore inside ``HomeApplianceDevice.setup_run``.\n\n``num_cycles`` is required when ``cycle_time_windows`` is ``None``\n(unconstrained); when windows are provided it is derived from\n``cycle_time_windows.num_cycles()``.\n\nSingle-cycle, unconstrained (start any time)\n---------------------------------------------\n\n::\n\n device_id: dishwasher\n consumption_wh: 1500\n duration_h: 2\n num_cycles: 1\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n\nSingle-cycle, constrained to one window\n----------------------------------------\n\nEach window's ``value`` field carries the **cycle index** (0-based).\nWindows without a ``value`` are ignored by the optimizer::\n\n device_id: dishwasher\n consumption_wh: 1500\n duration_h: 2\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n cycle_time_windows:\n windows:\n - start_time: \"10:00\"\n duration: \"12 hours\"\n value: 0\n\nMulti-cycle, per-cycle windows\n--------------------------------\n\nTwo cycles, each with its own window. Cycle 0 runs in the morning,\ncycle 1 in the evening::\n\n device_id: washing_machine\n consumption_wh: 2000\n duration_h: 2\n min_cycle_gap_h: 1\n ports:\n - port_id: p_ac\n bus_id: bus_ac\n direction: sink\n cycle_time_windows:\n windows:\n - start_time: \"07:00\"\n duration: \"5 hours\"\n value: 0\n - start_time: \"17:00\"\n duration: \"5 hours\"\n value: 1\n\nMulti-cycle, shared window (both cycles may run any time 10:00-20:00)\n-----------------------------------------------------------------------\n\nAssign the same-shaped windows to distinct cycle indices so the\noptimizer can place them independently::\n\n cycle_time_windows:\n windows:\n - start_time: \"10:00\"\n duration: \"10 hours\"\n value: 0\n - start_time: \"10:00\"\n duration: \"10 hours\"\n value: 1" }, "HomeApplianceParameters": { "properties": { @@ -8750,21 +8977,42 @@ 3 ] }, + "num_cycles": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Num Cycles", + "description": "Number of cycles the appliance must run.", + "default": 1, + "examples": [ + 2 + ] + }, + "min_cycle_gap_h": { + "type": "integer", + "minimum": 0.0, + "title": "Min Cycle Gap H", + "description": "Minimum idle time between the end of one cycle and the start of the next cycle.", + "default": 0, + "examples": [ + 1 + ] + }, "time_windows": { "anyOf": [ { - "$ref": "#/components/schemas/TimeWindowSequence-Output" + "$ref": "#/components/schemas/CycleTimeWindowSequence-Output" }, { "type": "null" } ], - "description": "List of allowed time windows. Defaults to optimization general time window.", + "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": [ [ { "duration": "3 hours", - "start_time": "10:00" + "start_time": "10:00", + "value": 0 } ] ] @@ -8778,7 +9026,7 @@ "duration_h" ], "title": "HomeApplianceParameters", - "description": "Home Appliance Device Simulation Configuration." + "description": "Configuration for a simulated home appliance device." }, "HomeAssistantAdapterCommonSettings-Input": { "properties": { @@ -9188,25 +9436,13 @@ } ], "title": "Max Power W", - "description": "Maximum power [W].", + "description": "Maximum AC output power [W].", "examples": [ 10000 - ] - }, - "battery_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } ], - "title": "Battery Id", - "description": "ID of battery controlled by this inverter.", - "examples": [ - null, - "battery1" + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "ac_to_dc_efficiency": { @@ -9214,12 +9450,16 @@ "maximum": 1.0, "minimum": 0.0, "title": "Ac To Dc Efficiency", - "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).", + "description": "Efficiency of AC\u2192DC conversion for grid-to-battery charging (0\u20131). Set to 0 to disable AC charging. Default 1.0.", "default": 1.0, "examples": [ 0.95, 1.0, 0.0 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "dc_to_ac_efficiency": { @@ -9227,11 +9467,15 @@ "maximum": 1.0, "exclusiveMinimum": 0.0, "title": "Dc To Ac Efficiency", - "description": "Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss).", + "description": "Efficiency of DC\u2192AC conversion for battery discharging (0\u20131). Default 1.0.", "default": 1.0, "examples": [ 0.95, 1.0 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_ac_charge_power_w": { @@ -9245,17 +9489,326 @@ } ], "title": "Max Ac Charge Power W", - "description": "Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging.", + "description": "Maximum AC charging power [W]. null means no additional limit. 0 disables AC charging.", "examples": [ null, 0, 5000 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" + ] + }, + "battery_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Battery Id", + "description": "Device ID of the battery.", + "examples": [ + null + ], + "x-scope": [ + "GENETIC", + "GENETIC0" + ] + }, + "off_state_power_consumption_w": { + "type": "number", + "minimum": 0.0, + "title": "Off State Power Consumption W", + "description": "Standby power consumed when the inverter is fully idle (battery=0 and PV=0) [W]. Default 0.0.", + "default": 0.0, + "examples": [ + 5.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "on_state_power_consumption_w": { + "type": "number", + "minimum": 0.0, + "title": "On State Power Consumption W", + "description": "Auxiliary power consumed whenever the inverter is active (non-zero AC power) [W]. Default 0.0.", + "default": 0.0, + "examples": [ + 10.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_to_ac_efficiency": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Pv To Ac Efficiency", + "description": "Efficiency of PV DC\u2192AC conversion (0\u20131). Required when pv_power_w_key is set (SOLAR or HYBRID). Default 1.0.", + "default": 1.0, + "examples": [ + 0.97, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_to_battery_efficiency": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Pv To Battery Efficiency", + "description": "Efficiency of PV DC\u2192battery charging path (0\u20131). Used for HYBRID inverters only. Default 1.0.", + "default": 1.0, + "examples": [ + 0.98, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_max_power_w": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Pv Max Power W", + "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": [ + "UNUSED" + ] + }, + "pv_min_power_w": { + "type": "number", + "minimum": 0.0, + "title": "Pv Min Power W", + "description": "Minimum DC PV power threshold [W]. Steps with available PV below this value are treated as zero. Default 0.0.", + "default": 0.0, + "examples": [ + 50.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_power_w_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pv Power W Key", + "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", + null + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_capacity_wh": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Battery Capacity Wh", + "description": "Usable battery capacity [Wh]. Required for BATTERY and HYBRID inverter types.", + "examples": [ + 10000.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_charge_rates": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Battery Charge Rates", + "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": [ + null, + [ + 0.25, + 0.5, + 1.0 + ] + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_charge_rate": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Charge Rate", + "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.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_charge_rate": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Charge Rate", + "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.", + "default": 1.0, + "examples": [ + 0.5, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_discharge_rate": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Discharge Rate", + "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.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_discharge_rate": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Discharge Rate", + "description": "Maximum discharge rate as a fraction of the 1C rate. (1C = battery_capacity_wh W). bat_factor=\u22121 maps to this rate. Default 1.0.", + "default": 1.0, + "examples": [ + 0.5, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_soc_factor": { + "type": "number", + "exclusiveMaximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Soc Factor", + "description": "Minimum allowed state of charge as a fraction of battery_capacity_wh. Must be < battery_max_soc_factor. Default 0.0.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_soc_factor": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Soc Factor", + "description": "Maximum allowed state of charge as a fraction of battery_capacity_wh. Must be > battery_min_soc_factor. Default 1.0.", + "default": 1.0, + "examples": [ + 0.9, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_initial_soc_factor_key": { + "type": "string", + "title": "Battery Initial Soc Factor Key", + "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).", + "default": "", + "examples": [ + "battery1_soc_factor", + "" + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_lcos_amt_kwh": { + "type": "number", + "minimum": 0.0, + "title": "Battery Lcos Amt Kwh", + "description": "Levelized cost of battery storage [Amt./kWh cycled]. Penalises unnecessary charging/discharging so the GA avoids grid-charge\u2192discharge 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.", + "default": 0.0, + "examples": [ + 0.05, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_discharge_reward_amt_kwh": { + "type": "number", + "minimum": 0.0, + "title": "Battery Discharge Reward Amt Kwh", + "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.", + "default": 0.02, + "examples": [ + 0.0, + 0.17 + ], + "x-scope": [ + "UNUSED" ] } }, "type": "object", "title": "InverterCommonSettings", - "description": "Inverter devices base settings." + "description": "Inverter device settings.\n\nAn inverter bridges a DC bus (PV / battery) and an AC bus (grid /\nhousehold). It must therefore have at least one DC port and one AC\nport." }, "InverterCommonSettings-Output": { "properties": { @@ -9281,25 +9834,13 @@ } ], "title": "Max Power W", - "description": "Maximum power [W].", + "description": "Maximum AC output power [W].", "examples": [ 10000 - ] - }, - "battery_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } ], - "title": "Battery Id", - "description": "ID of battery controlled by this inverter.", - "examples": [ - null, - "battery1" + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "ac_to_dc_efficiency": { @@ -9307,12 +9848,16 @@ "maximum": 1.0, "minimum": 0.0, "title": "Ac To Dc Efficiency", - "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).", + "description": "Efficiency of AC\u2192DC conversion for grid-to-battery charging (0\u20131). Set to 0 to disable AC charging. Default 1.0.", "default": 1.0, "examples": [ 0.95, 1.0, 0.0 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "dc_to_ac_efficiency": { @@ -9320,11 +9865,15 @@ "maximum": 1.0, "exclusiveMinimum": 0.0, "title": "Dc To Ac Efficiency", - "description": "Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss).", + "description": "Efficiency of DC\u2192AC conversion for battery discharging (0\u20131). Default 1.0.", "default": 1.0, "examples": [ 0.95, 1.0 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, "max_ac_charge_power_w": { @@ -9338,18 +9887,170 @@ } ], "title": "Max Ac Charge Power W", - "description": "Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging.", + "description": "Maximum AC charging power [W]. null means no additional limit. 0 disables AC charging.", "examples": [ null, 0, 5000 + ], + "x-scope": [ + "GENETIC", + "GENETIC0" ] }, - "measurement_keys": { + "battery_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Battery Id", + "description": "Device ID of the battery.", + "examples": [ + null + ], + "x-scope": [ + "GENETIC", + "GENETIC0" + ] + }, + "off_state_power_consumption_w": { + "type": "number", + "minimum": 0.0, + "title": "Off State Power Consumption W", + "description": "Standby power consumed when the inverter is fully idle (battery=0 and PV=0) [W]. Default 0.0.", + "default": 0.0, + "examples": [ + 5.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "on_state_power_consumption_w": { + "type": "number", + "minimum": 0.0, + "title": "On State Power Consumption W", + "description": "Auxiliary power consumed whenever the inverter is active (non-zero AC power) [W]. Default 0.0.", + "default": 0.0, + "examples": [ + 10.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_to_ac_efficiency": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Pv To Ac Efficiency", + "description": "Efficiency of PV DC\u2192AC conversion (0\u20131). Required when pv_power_w_key is set (SOLAR or HYBRID). Default 1.0.", + "default": 1.0, + "examples": [ + 0.97, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_to_battery_efficiency": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Pv To Battery Efficiency", + "description": "Efficiency of PV DC\u2192battery charging path (0\u20131). Used for HYBRID inverters only. Default 1.0.", + "default": 1.0, + "examples": [ + 0.98, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_max_power_w": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Pv Max Power W", + "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": [ + "UNUSED" + ] + }, + "pv_min_power_w": { + "type": "number", + "minimum": 0.0, + "title": "Pv Min Power W", + "description": "Minimum DC PV power threshold [W]. Steps with available PV below this value are treated as zero. Default 0.0.", + "default": 0.0, + "examples": [ + 50.0, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "pv_power_w_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pv Power W Key", + "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", + null + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_capacity_wh": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Battery Capacity Wh", + "description": "Usable battery capacity [Wh]. Required for BATTERY and HYBRID inverter types.", + "examples": [ + 10000.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_charge_rates": { "anyOf": [ { "items": { - "type": "string" + "type": "number" }, "type": "array" }, @@ -9357,8 +10058,158 @@ "type": "null" } ], + "title": "Battery Charge Rates", + "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": [ + null, + [ + 0.25, + 0.5, + 1.0 + ] + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_charge_rate": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Charge Rate", + "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.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_charge_rate": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Charge Rate", + "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.", + "default": 1.0, + "examples": [ + 0.5, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_discharge_rate": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Discharge Rate", + "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.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_discharge_rate": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Discharge Rate", + "description": "Maximum discharge rate as a fraction of the 1C rate. (1C = battery_capacity_wh W). bat_factor=\u22121 maps to this rate. Default 1.0.", + "default": 1.0, + "examples": [ + 0.5, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_min_soc_factor": { + "type": "number", + "exclusiveMaximum": 1.0, + "minimum": 0.0, + "title": "Battery Min Soc Factor", + "description": "Minimum allowed state of charge as a fraction of battery_capacity_wh. Must be < battery_max_soc_factor. Default 0.0.", + "default": 0.0, + "examples": [ + 0.1, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_max_soc_factor": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Battery Max Soc Factor", + "description": "Maximum allowed state of charge as a fraction of battery_capacity_wh. Must be > battery_min_soc_factor. Default 1.0.", + "default": 1.0, + "examples": [ + 0.9, + 1.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_initial_soc_factor_key": { + "type": "string", + "title": "Battery Initial Soc Factor Key", + "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).", + "default": "", + "examples": [ + "battery1_soc_factor", + "" + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_lcos_amt_kwh": { + "type": "number", + "minimum": 0.0, + "title": "Battery Lcos Amt Kwh", + "description": "Levelized cost of battery storage [Amt./kWh cycled]. Penalises unnecessary charging/discharging so the GA avoids grid-charge\u2192discharge 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.", + "default": 0.0, + "examples": [ + 0.05, + 0.0 + ], + "x-scope": [ + "UNUSED" + ] + }, + "battery_discharge_reward_amt_kwh": { + "type": "number", + "minimum": 0.0, + "title": "Battery Discharge Reward Amt Kwh", + "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.", + "default": 0.02, + "examples": [ + 0.0, + 0.17 + ], + "x-scope": [ + "UNUSED" + ] + }, + "measurement_keys": { + "items": { + "type": "string" + }, + "type": "array", "title": "Measurement Keys", - "description": "Measurement keys for the inverter stati that are measurements.", + "description": "Measurement keys for this inverter.\n\nReturns the ``battery_initial_soc_factor_key`` if non-empty, so\nthe EMS measurement store knows to watch for this key.", "readOnly": true } }, @@ -9367,7 +10218,7 @@ "measurement_keys" ], "title": "InverterCommonSettings", - "description": "Inverter devices base settings." + "description": "Inverter device settings.\n\nAn inverter bridges a DC bus (PV / battery) and an AC bus (grid /\nhousehold). It must therefore have at least one DC port and one AC\nport." }, "InverterParameters": { "properties": { diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index ad49f366..4e4dcd7e 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -13,6 +13,7 @@ import json import os import sys import tempfile +from copy import deepcopy from pathlib import Path 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.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.devices.devices import DevicesCommonSettings from akkudoktoreos.measurement.measurement import MeasurementCommonSettings @@ -396,6 +397,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): } _config_file_path: ClassVar[Optional[Path]] = None _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 def __hash__(self) -> int: @@ -519,6 +522,14 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): 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: """Config file settings. @@ -668,6 +679,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): # runtime configuration. setting_sources = [ lazy_config_cli_settings, # Prio high + lazy_runtime_settings, # settings provided during runtime lazy_env_settings, lazy_dotenv_settings, 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) return 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: """Re-initialize global settings.""" @@ -741,11 +755,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): ) 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, - and finally merges the validated settings into the current instance. None values - are not merged. + The data is added to the runtime settings, which have priority over the environment and + the EOS configuration file. All configuration sources are re-evaluated afterwards. Args: 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) """ - merged = merge_models( - self, - data, - ) + previous_settings = ConfigEOS._runtime_settings + ConfigEOS._runtime_settings = deep_merge(previous_settings, deepcopy(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: """Reset all changed settings to environment/config file defaults. This functions basically deletes the settings provided before. """ + ConfigEOS._runtime_settings = {} self._setup() 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_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]]: """List available configuration backup files and extract metadata. @@ -1094,11 +1149,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): """Updates all configuration fields. This method updates all configuration fields using the following order for value retrieval: - 1. Current settings. + 1. Runtime settings. 2. Environment variables. 3. EOS configuration file. 4. Field default constants. The first non None value in priority order is taken. """ - self._setup(**self.model_dump()) + self._setup() diff --git a/src/akkudoktoreos/config/configabc.py b/src/akkudoktoreos/config/configabc.py index afa8b8e2..d97d636e 100644 --- a/src/akkudoktoreos/config/configabc.py +++ b/src/akkudoktoreos/config/configabc.py @@ -59,6 +59,15 @@ def runtime_environment() -> str: 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): """Base model class for all settings configurations.""" @@ -1037,3 +1046,212 @@ class ValueTimeWindowSequence(TimeWindowSequence[ValueTimeWindow]): index=pd.DatetimeIndex(timestamps), 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 diff --git a/src/akkudoktoreos/config/configmigrate.py b/src/akkudoktoreos/config/configmigrate.py index 625ad6aa..252c32c8 100644 --- a/src/akkudoktoreos/config/configmigrate.py +++ b/src/akkudoktoreos/config/configmigrate.py @@ -30,6 +30,49 @@ if TYPE_CHECKING: _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 # ----------------------------- @@ -58,10 +101,31 @@ MIGRATION_MAP: Dict[ # - NodeRed # 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 + "devices/batteries": ( + "devices/batteries", + _list_to_device_dict("bat"), + ), "devices/batteries/0/initial_soc_percentage": None, # - electric_vehicles + "devices/electric_vehicles": ( + "devices/electric_vehicles", + _list_to_device_dict("ev"), + ), "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 # ======= # - ElecFeeFixed @@ -137,7 +201,7 @@ MIGRATION_MAP: Dict[ "optimization/interval": None, "optimization/horizon_hours": "optimization/genetic0/horizon_hours", "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], ), "optimization/hours": "optimization/genetic0/horizon_hours", @@ -253,6 +317,17 @@ def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults": try: if transform: 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) migrated_source_paths.add(old_path.strip("/")) mapped_count += 1 diff --git a/src/akkudoktoreos/core/pydantic.py b/src/akkudoktoreos/core/pydantic.py index 924d042c..6fe7afd0 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -65,6 +65,37 @@ from akkudoktoreos.utils.datetimeutil import ( _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]: """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: 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( exclude_unset=True, exclude_computed_fields=True, diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 664d9c76..1c2682b6 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -1,354 +1,157 @@ """General configuration settings for simulated devices for optimization.""" import json -import re from typing import Any, Optional, TextIO, cast -import numpy as np from loguru import logger -from numpydantic import NDArray, Shape 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.coreabc import ConfigMixin, SingletonMixin from akkudoktoreos.core.emplan import ResourceStatus from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel -from akkudoktoreos.devices.devicesabc import DevicesBaseSettings +from akkudoktoreos.devices.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 -# 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): - """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, ]`` 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, json_schema_extra={ - "description": "List of battery devices", - "examples": [[{"device_id": "battery1", "capacity_wh": 8000}]], + "description": "Stationary battery storage devices, keyed by device_id.", + "examples": [{"bat0": {"device_id": "bat0", "capacity_wh": 8000, "ports": []}}], + "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) - max_batteries: Optional[int] = Field( default=None, ge=0, json_schema_extra={ - "description": "Maximum number of batteries that can be set", - "examples": [1, 2], + "description": "Maximum number of batteries allowed.", + "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, json_schema_extra={ - "description": "List of electric vehicle devices", - "examples": [[{"device_id": "battery1", "capacity_wh": 8000}]], + "description": "Electric vehicle battery packs, keyed by device_id.", + "examples": [{"ev0": {"device_id": "ev0", "capacity_wh": 60000, "ports": []}}], + "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) - max_electric_vehicles: Optional[int] = Field( default=None, ge=0, json_schema_extra={ - "description": "Maximum number of electric vehicles that can be set", - "examples": [1, 2], + "description": "Maximum number of EVs allowed.", + "examples": [1], + "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) - inverters: Optional[list[InverterCommonSettings]] = Field( - default=None, json_schema_extra={"description": "List of inverters", "examples": [[]]} + # ---- Inverters ---- + 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( default=None, ge=0, json_schema_extra={ - "description": "Maximum number of inverters that can be set", - "examples": [1, 2], + "description": "Maximum number of inverters allowed.", + "examples": [1], + "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) - home_appliances: Optional[list[HomeApplianceCommonSettings]] = Field( - default=None, json_schema_extra={"description": "List of home appliances", "examples": [[]]} + # ---- Controllable home appliances ---- + 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( default=None, ge=0, json_schema_extra={ - "description": "Maximum number of home_appliances that can be set", - "examples": [1, 2], + "description": "Maximum number of home appliances allowed.", + "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] @property - def measurement_keys(self) -> Optional[list[str]]: - """Return the measurement keys for the resource/ device stati that are measurements.""" + def measurement_keys(self) -> list[str]: + """All measurement keys across all configured devices.""" keys: list[str] = [] - - if self.max_batteries and self.batteries: - for battery in self.batteries: - keys.extend(battery.measurement_keys or []) - if self.max_electric_vehicles and self.electric_vehicles: - for electric_vehicle in self.electric_vehicles: - keys.extend(electric_vehicle.measurement_keys or []) + for device_dict in [ + self.batteries, + self.electric_vehicles, + self.inverters, + self.home_appliances, + ]: + for device in (device_dict or {}).values(): + keys.extend(device.measurement_keys or []) return keys diff --git a/src/akkudoktoreos/devices/devicesabc.py b/src/akkudoktoreos/devices/devicesabc.py index f4a668e1..42a945c5 100644 --- a/src/akkudoktoreos/devices/devicesabc.py +++ b/src/akkudoktoreos/devices/devicesabc.py @@ -1,32 +1,7 @@ """Abstract and base classes for devices.""" -import secrets -import string 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): """Battery Operation Mode. diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index 88123c08..fe7009c1 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -1,12 +1,103 @@ from typing import Any, Iterator, Optional import numpy as np +from pydantic import Field -from akkudoktoreos.devices.devices import BATTERY_DEFAULT_CHARGE_RATES -from akkudoktoreos.optimization.genetic.geneticdevices import ( - BaseBatteryParameters, - SolarPanelBatteryParameters, -) +from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES +from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters + + +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: diff --git a/src/akkudoktoreos/devices/genetic/homeappliance.py b/src/akkudoktoreos/devices/genetic/homeappliance.py index 087d89e8..ebbc1bdf 100644 --- a/src/akkudoktoreos/devices/genetic/homeappliance.py +++ b/src/akkudoktoreos/devices/genetic/homeappliance.py @@ -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 -from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters -from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time +This module models household devices such as dishwashers or washing +machines that must run for a fixed duration, possibly multiple times +(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: + """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__( self, parameters: HomeApplianceParameters, optimization_hours: int, prediction_hours: int, - ): - self.parameters: HomeApplianceParameters = parameters + ) -> None: + """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.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() + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + def _setup(self) -> None: - """Sets up the home appliance parameters based provided parameters.""" - self.load_curve = np.zeros(self.prediction_hours) # Initialize the load curve with zeros - self.duration_h = self.parameters.duration_h - self.consumption_wh = self.parameters.consumption_wh - # setup possible start times + """Sets up appliance parameters and default time windows. + + When no ``time_windows`` are configured, builds a single + placeholder window spanning the full prediction horizon with + ``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: - self.parameters.time_windows = TimeWindowSequence( + self.parameters.time_windows = CycleTimeWindowSequence( windows=[ - TimeWindow( + ValueTimeWindow( start_time=to_time("00:00"), duration=to_duration(f"{self.prediction_hours} hours"), ), ] ) - start_datetime = to_datetime().set(hour=0, minute=0, second=0) - duration = to_duration(f"{self.duration_h} hours") - self.start_allowed: list[bool] = [] - for hour in range(0, self.prediction_hours): - self.start_allowed.append( - self.parameters.time_windows.contains( - start_datetime.add(hours=hour), duration=duration - ) - ) - start_earliest = self.parameters.time_windows.earliest_start_time(duration, start_datetime) - if start_earliest: - self.start_earliest = start_earliest.hour - else: - self.start_earliest = 0 - start_latest = self.parameters.time_windows.latest_start_time(duration, start_datetime) - if start_latest: - self.start_latest = start_latest.hour - else: - self.start_latest = 23 - def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> int: - """Sets the start time of the device and generates the corresponding load curve. + self._build_start_allowed() - :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]: - # It is not allowed (by the time windows) to start the application at this time - if global_start_hour <= self.start_latest: - # There is a time window left to start the appliance. Use it - start_hour = self.start_latest - else: - # There is no time window left to run the application - # Set the start into tomorrow - start_hour = self.start_earliest + 24 + self.completed_cycles = max( + 0, + min(completed_cycles, self.num_cycles), + ) + 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() - # Calculate power per hour based on total consumption and duration - power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours + power_per_hour = self.consumption_wh / self.duration_h - # Set the power for the duration of use in the load curve array - if start_hour < len(self.load_curve): - end_hour = min(start_hour + self.duration_h, self.prediction_hours) - self.load_curve[start_hour:end_hour] = power_per_hour + for start_hour in self.start_hours: + if start_hour >= self.prediction_hours: + continue - 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: - """Resets the load curve.""" + """Resets the load curve to all zeros.""" self.load_curve = np.zeros(self.prediction_hours) def get_load_curve(self) -> np.ndarray: - """Returns the current load curve.""" + """Returns the current hourly load curve, in watts.""" return self.load_curve def get_load_for_hour(self, hour: int) -> float: """Returns the load for a specific hour. - :param hour: The hour for which the load is queried. - :return: The load in watts for the specified hour. + Args: + 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: 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]) diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index 9781d4c4..977aa3df 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -1,12 +1,63 @@ from typing import Optional from loguru import logger +from pydantic import Field 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 +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: def __init__( self, diff --git a/src/akkudoktoreos/devices/genetic0/genetic0battery.py b/src/akkudoktoreos/devices/genetic0/genetic0battery.py index c55e5196..ec511c37 100644 --- a/src/akkudoktoreos/devices/genetic0/genetic0battery.py +++ b/src/akkudoktoreos/devices/genetic0/genetic0battery.py @@ -1,12 +1,103 @@ from typing import Any, Iterator, Optional import numpy as np +from pydantic import Field -from akkudoktoreos.devices.devices import BATTERY_DEFAULT_CHARGE_RATES -from akkudoktoreos.optimization.genetic0.genetic0devices import ( - Genetic0BaseBatteryParameters, - Genetic0SolarPanelBatteryParameters, -) +from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES +from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters + + +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: diff --git a/src/akkudoktoreos/devices/genetic0/genetic0homeappliance.py b/src/akkudoktoreos/devices/genetic0/genetic0homeappliance.py index a1fe3886..f0ddd797 100644 --- a/src/akkudoktoreos/devices/genetic0/genetic0homeappliance.py +++ b/src/akkudoktoreos/devices/genetic0/genetic0homeappliance.py @@ -1,12 +1,46 @@ +from typing import Optional + import numpy as np +from pydantic import Field from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence -from akkudoktoreos.optimization.genetic0.genetic0devices import ( - Genetic0HomeApplianceParameters, -) +from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters 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: def __init__( self, diff --git a/src/akkudoktoreos/devices/genetic0/genetic0inverter.py b/src/akkudoktoreos/devices/genetic0/genetic0inverter.py index 4d169b09..5a4ae4b0 100644 --- a/src/akkudoktoreos/devices/genetic0/genetic0inverter.py +++ b/src/akkudoktoreos/devices/genetic0/genetic0inverter.py @@ -1,16 +1,65 @@ from typing import Optional from loguru import logger +from pydantic import Field from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery -from akkudoktoreos.optimization.genetic0.genetic0devices import ( - Genetic0InverterParameters, -) +from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DeviceParameters from akkudoktoreos.optimization.genetic0.genetic0loadinterpolator import ( 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: def __init__( self, diff --git a/src/akkudoktoreos/devices/settings/__init__.py b/src/akkudoktoreos/devices/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/akkudoktoreos/devices/settings/batterysettings.py b/src/akkudoktoreos/devices/settings/batterysettings.py new file mode 100644 index 00000000..86934554 --- /dev/null +++ b/src/akkudoktoreos/devices/settings/batterysettings.py @@ -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, + ] diff --git a/src/akkudoktoreos/devices/settings/devicebasesettings.py b/src/akkudoktoreos/devices/settings/devicebasesettings.py new file mode 100644 index 00000000..71ad110c --- /dev/null +++ b/src/akkudoktoreos/devices/settings/devicebasesettings.py @@ -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"], + }, + ) diff --git a/src/akkudoktoreos/devices/settings/homeappliancesettings.py b/src/akkudoktoreos/devices/settings/homeappliancesettings.py new file mode 100644 index 00000000..81a6e448 --- /dev/null +++ b/src/akkudoktoreos/devices/settings/homeappliancesettings.py @@ -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 diff --git a/src/akkudoktoreos/devices/settings/invertersettings.py b/src/akkudoktoreos/devices/settings/invertersettings.py new file mode 100644 index 00000000..b142b641 --- /dev/null +++ b/src/akkudoktoreos/devices/settings/invertersettings.py @@ -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 [] diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index b0d15a1d..ee2d3b3f 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -1102,12 +1102,12 @@ class GeneticOptimization(OptimizationBase): self.ev_possible_charge_values = parameters.ev.charge_rates elif ( self.config.devices.electric_vehicles - and self.config.devices.electric_vehicles[0] - and self.config.devices.electric_vehicles[0].charge_rates is not None + and len(self.config.devices.electric_vehicles) > 0 + and list(self.config.devices.electric_vehicles.values())[0].charge_rates is not None ): - self.ev_possible_charge_values = self.config.devices.electric_vehicles[ - 0 - ].charge_rates + self.ev_possible_charge_values = list( + self.config.devices.electric_vehicles.values() + )[0].charge_rates else: warning_msg = "No charge rates provided for electric vehicle - using default." logger.warning(warning_msg) @@ -1136,11 +1136,11 @@ class GeneticOptimization(OptimizationBase): ] or [1.0] elif ( self.config.devices.batteries - and self.config.devices.batteries[0] - and self.config.devices.batteries[0].charge_rates + and len(self.config.devices.batteries) > 0 + and list(self.config.devices.batteries.values())[0].charge_rates ): 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] else: self.bat_possible_charge_values = [1.0] diff --git a/src/akkudoktoreos/optimization/genetic/geneticdevices.py b/src/akkudoktoreos/optimization/genetic/geneticdevices.py index d998c9a0..81a6cdab 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticdevices.py +++ b/src/akkudoktoreos/optimization/genetic/geneticdevices.py @@ -4,7 +4,6 @@ from typing import Optional from pydantic import Field -from akkudoktoreos.config.configabc import TimeWindowSequence from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel @@ -18,179 +17,3 @@ class DeviceParameters(GeneticParametersBaseModel): "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], - }, - ) diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index 6dff11e1..a6384634 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -28,13 +28,13 @@ from akkudoktoreos.core.coreabc import ( PredictionMixin, get_ems, ) -from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel -from akkudoktoreos.optimization.genetic.geneticdevices import ( +from akkudoktoreos.devices.genetic.battery import ( ElectricVehicleParameters, - HomeApplianceParameters, - InverterParameters, 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 # 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: logger.info("Genetic penalties unknown - defaulting to 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 start_solution = None @@ -579,36 +584,38 @@ class GeneticOptimizationParameters( else: if cls.config.devices.batteries is None: 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: - battery_config = cls.config.devices.batteries[0] - battery_params = SolarPanelBatteryParameters( - device_id=battery_config.device_id, - 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, - ) + # Take first battery + battery_config = list(cls.config.devices.batteries.values())[0] + battery_params = battery_config.to_genetic_pv_bat_param() except Exception as e: logger.info( "No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] + cls.config.devices.batteries = { + "battery1": { + "device_id": "battery1", + "capacity_wh": 8000, + }, + } # Retry continue # 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( "No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.", attempt, ) - battery_config.levelized_cost_of_storage_kwh = 0 - battery_lcos_kwh = battery_config.levelized_cost_of_storage_kwh + battery_config.levelized_cost_of_storage_amt_kwh = 0 + battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh # Initial SOC try: 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." ) cls.config.devices.max_electric_vehicles = 1 - cls.config.devices.electric_vehicles = [ - { - "device_id": "ev11", + cls.config.devices.electric_vehicles = { + "ev1": { + "device_id": "ev1", "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], "min_soc_percentage": 70, - } - ] + }, + } try: - electric_vehicle_config = cls.config.devices.electric_vehicles[0] - electric_vehicle_params = ElectricVehicleParameters( - device_id=electric_vehicle_config.device_id, - 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, - ) + # Take first electric_vehicle + electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0] + electric_vehicle_params = electric_vehicle_config.to_genetic_ev_bat_param() except Exception as e: logger.info( "No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}", @@ -672,14 +671,14 @@ class GeneticOptimizationParameters( e, ) cls.config.devices.max_electric_vehicles = 1 - cls.config.devices.electric_vehicles = [ - { - "device_id": "ev12", + cls.config.devices.electric_vehicles = { + "ev1": { + "device_id": "ev1", "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], "min_soc_percentage": 70, - } - ] + }, + } # Retry continue # Initial SOC @@ -715,36 +714,30 @@ class GeneticOptimizationParameters( else: if cls.config.devices.inverters is None: logger.info("No inverter device data available - defaulting to demo data.") - cls.config.devices.inverters = [ - { + cls.config.devices.inverters = { + "inverter1": { "device_id": "inverter1", "max_power_w": 10000, "battery_id": battery_config.device_id, - } - ] + }, + } try: - inverter_config = cls.config.devices.inverters[0] - inverter_params = InverterParameters( - device_id=inverter_config.device_id, - 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, - ) + # Take first inverter + inverter_config = list(cls.config.devices.inverters.values())[0] + inverter_params = inverter_config.to_genetic_param() except Exception as e: logger.info( "No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.inverters = [ - { + cls.config.devices.inverters = { + "inverter1": { "device_id": "inverter1", "max_power_w": 10000, "battery_id": battery_config.device_id, - } - ] + }, + } # Retry continue @@ -761,12 +754,12 @@ class GeneticOptimizationParameters( logger.info( "No home appliance device data available - defaulting to demo data." ) - cls.config.devices.home_appliances = [ - { + cls.config.devices.home_appliances = { + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3.0, - "time_windows": { + "cycle_time_windows": { "windows": [ { "start_time": "08:00", @@ -778,30 +771,26 @@ class GeneticOptimizationParameters( }, ], }, - } - ] + }, + } try: - home_appliance_config = cls.config.devices.home_appliances[0] - home_appliance_params = HomeApplianceParameters( - device_id=home_appliance_config.device_id, - consumption_wh=home_appliance_config.consumption_wh, - duration_h=home_appliance_config.duration_h, - time_windows=home_appliance_config.time_windows, - ) + # Take first appliance + home_appliance_config = list(cls.config.devices.home_appliances.values())[0] + home_appliance_params = home_appliance_config.to_genetic_param() except Exception as e: logger.info( "No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.home_appliances = [ - { + cls.config.devices.home_appliances = { + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3.0, - "time_windows": None, - } - ] + "cycle_time_windows": None, + }, + } # Retry continue diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index 896874af..0d79974d 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -350,21 +350,21 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): def _battery_device_id(self) -> str: """Get battery device id.""" try: - return self.config.devices.batteries[0].device_id + return list(self.config.devices.batteries.values())[0].device_id except Exception: return "battery1" def _ev_device_id(self) -> str: """Get electric vehicle device id.""" try: - return self.config.devices.electric_vehicles[0].device_id + return self.config.devices.electric_vehicles.values()[0].device_id except Exception: return "ev1" def _homeappliance_device_id(self) -> str: """Get home appliance device id.""" try: - return self.config.devices.home_appliances[0].device_id + return self.config.devices.home_appliances.values()[0].device_id except Exception: return "homeappliance1" @@ -453,11 +453,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): (the inverter curtails automatically, but this makes intent clear). - Discharge: blocked when SOC is at or below min SOC. """ - bat_list = self.config.devices.batteries - if not bat_list: + bat_dict = self.config.devices.batteries + if bat_dict is None or len(bat_dict) <= 0: return ac_charge, dc_charge, discharge_allowed - bat = bat_list[0] + bat = list(bat_dict.values())[0] min_soc = float(bat.min_soc_percentage) max_soc = float(bat.max_soc_percentage) capacity_wh = float(bat.capacity_wh) diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0.py b/src/akkudoktoreos/optimization/genetic0/genetic0.py index 1c17ccb5..97501391 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0.py @@ -1106,12 +1106,12 @@ class Genetic0Optimization(OptimizationBase): self.ev_possible_charge_values = parameters.ev.charge_rates elif ( self.config.devices.electric_vehicles - and self.config.devices.electric_vehicles[0] - and self.config.devices.electric_vehicles[0].charge_rates is not None + and len(self.config.devices.electric_vehicles) > 0 + and list(self.config.devices.electric_vehicles.values())[0].charge_rates is not None ): - self.ev_possible_charge_values = self.config.devices.electric_vehicles[ - 0 - ].charge_rates + self.ev_possible_charge_values = list( + self.config.devices.electric_vehicles.values() + )[0].charge_rates else: warning_msg = "No charge rates provided for electric vehicle - using default." logger.warning(warning_msg) @@ -1140,11 +1140,11 @@ class Genetic0Optimization(OptimizationBase): ] or [1.0] elif ( self.config.devices.batteries - and self.config.devices.batteries[0] - and self.config.devices.batteries[0].charge_rates + and len(self.config.devices.batteries) > 0 + and list(self.config.devices.batteries.values())[0].charge_rates ): 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] else: self.bat_possible_charge_values = [1.0] diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0devices.py b/src/akkudoktoreos/optimization/genetic0/genetic0devices.py index 24d8890b..ec259aa4 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0devices.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0devices.py @@ -4,7 +4,6 @@ from typing import Optional from pydantic import Field -from akkudoktoreos.config.configabc import TimeWindowSequence from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel @@ -18,179 +17,3 @@ class Genetic0DeviceParameters(Genetic0ParametersBaseModel): "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], - }, - ) diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0params.py b/src/akkudoktoreos/optimization/genetic0/genetic0params.py index eaf514ff..eed85f59 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0params.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0params.py @@ -27,13 +27,15 @@ from akkudoktoreos.core.coreabc import ( PredictionMixin, get_ems, ) -from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel -from akkudoktoreos.optimization.genetic0.genetic0devices import ( +from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0ElectricVehicleParameters, - Genetic0HomeApplianceParameters, - Genetic0InverterParameters, 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 # 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: logger.info("Genetic penalties unknown - defaulting to 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 start_solution = None @@ -548,36 +555,38 @@ class Genetic0OptimizationParameters( else: if cls.config.devices.batteries is None: 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: - battery_config = cls.config.devices.batteries[0] - battery_params = Genetic0SolarPanelBatteryParameters( - device_id=battery_config.device_id, - 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, - ) + # Take first battery + battery_config = list(cls.config.devices.batteries.values())[0] + battery_params = battery_config.to_genetic0_pv_bat_param() except Exception as e: logger.info( "No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.batteries = [{"device_id": "battery1", "capacity_wh": 8000}] + cls.config.devices.batteries = { + "battery1": { + "device_id": "battery1", + "capacity_wh": 8000, + }, + } # Retry continue # 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( "No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.", attempt, ) - battery_config.levelized_cost_of_storage_kwh = 0 - battery_lcos_kwh = battery_config.levelized_cost_of_storage_kwh + battery_config.levelized_cost_of_storage_amt_kwh = 0 + battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh # Initial SOC try: 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." ) cls.config.devices.max_electric_vehicles = 1 - cls.config.devices.electric_vehicles = [ - { - "device_id": "ev11", + cls.config.devices.electric_vehicles = { + "ev1": { + "device_id": "ev1", "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], "min_soc_percentage": 70, - } - ] + }, + } try: - electric_vehicle_config = cls.config.devices.electric_vehicles[0] - electric_vehicle_params = Genetic0ElectricVehicleParameters( - device_id=electric_vehicle_config.device_id, - 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, - ) + # Take first electric_vehicle + electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0] + electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param() except Exception as e: logger.info( "No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}", @@ -641,14 +642,14 @@ class Genetic0OptimizationParameters( e, ) cls.config.devices.max_electric_vehicles = 1 - cls.config.devices.electric_vehicles = [ - { - "device_id": "ev12", + cls.config.devices.electric_vehicles = { + "ev1": { + "device_id": "ev1", "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], "min_soc_percentage": 70, - } - ] + }, + } # Retry continue # Initial SOC @@ -684,36 +685,30 @@ class Genetic0OptimizationParameters( else: if cls.config.devices.inverters is None: logger.info("No inverter device data available - defaulting to demo data.") - cls.config.devices.inverters = [ - { + cls.config.devices.inverters = { + "inverter1": { "device_id": "inverter1", "max_power_w": 10000, "battery_id": battery_config.device_id, - } - ] + }, + } try: - inverter_config = cls.config.devices.inverters[0] - inverter_params = Genetic0InverterParameters( - device_id=inverter_config.device_id, - 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, - ) + # Take first inverter + inverter_config = list(cls.config.devices.inverters.values())[0] + inverter_params = inverter_config.to_genetic0_param() except Exception as e: logger.info( "No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.inverters = [ - { + cls.config.devices.inverters = { + "inverter1": { "device_id": "inverter1", "max_power_w": 10000, "battery_id": battery_config.device_id, - } - ] + }, + } # Retry continue @@ -730,12 +725,12 @@ class Genetic0OptimizationParameters( logger.info( "No home appliance device data available - defaulting to demo data." ) - cls.config.devices.home_appliances = [ - { + cls.config.devices.home_appliances = { + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3.0, - "time_windows": { + "cycle_time_windows": { "windows": [ { "start_time": "08:00", @@ -747,30 +742,26 @@ class Genetic0OptimizationParameters( }, ], }, - } - ] + }, + } try: - home_appliance_config = cls.config.devices.home_appliances[0] - home_appliance_params = Genetic0HomeApplianceParameters( - device_id=home_appliance_config.device_id, - consumption_wh=home_appliance_config.consumption_wh, - duration_h=home_appliance_config.duration_h, - time_windows=home_appliance_config.time_windows, - ) + # Take first appliance + home_appliance_config = list(cls.config.devices.home_appliances.values())[0] + home_appliance_params = home_appliance_config.to_genetic0_param() except Exception as e: logger.info( "No home appliance device data available - defaulting to demo data. Parameter preparation attempt {}: {}", attempt, e, ) - cls.config.devices.home_appliances = [ - { + cls.config.devices.home_appliances = { + "dishwasher1": { "device_id": "dishwasher1", "consumption_wh": 2000, "duration_h": 3.0, - "time_windows": None, - } - ] + "cycle_time_windows": None, + }, + } # Retry continue diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0solution.py b/src/akkudoktoreos/optimization/genetic0/genetic0solution.py index 6c1d2f09..e1dde0a7 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0solution.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0solution.py @@ -427,21 +427,21 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel): def _battery_device_id(self) -> str: """Get battery device id.""" try: - return self.config.devices.batteries[0].device_id + return list(self.config.devices.batteries.values())[0].device_id except Exception: return "battery1" def _ev_device_id(self) -> str: """Get electric vehicle device id.""" try: - return self.config.devices.electric_vehicles[0].device_id + return self.config.devices.electric_vehicles.values()[0].device_id except Exception: return "ev1" def _homeappliance_device_id(self) -> str: """Get home appliance device id.""" try: - return self.config.devices.home_appliances[0].device_id + return self.config.devices.home_appliances.values()[0].device_id except Exception: return "homeappliance1" @@ -530,11 +530,11 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel): (the inverter curtails automatically, but this makes intent clear). - Discharge: blocked when SOC is at or below min SOC. """ - bat_list = self.config.devices.batteries - if not bat_list: + bat_dict = self.config.devices.batteries + if bat_dict is None or len(bat_dict) <= 0: return ac_charge, dc_charge, discharge_allowed - bat = bat_list[0] + bat = list(bat_dict.values())[0] min_soc = float(bat.min_soc_percentage) max_soc = float(bat.max_soc_percentage) capacity_wh = float(bat.capacity_wh) diff --git a/src/akkudoktoreos/server/dash/itemscard.py b/src/akkudoktoreos/server/dash/itemscard.py index 9afe1ffe..c99ccc6f 100644 --- a/src/akkudoktoreos/server/dash/itemscard.py +++ b/src/akkudoktoreos/server/dash/itemscard.py @@ -445,9 +445,11 @@ def ConfigItemsCard( description = config["description"] 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" 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"] items_list = json.loads(value) or [] diff --git a/src/akkudoktoreos/server/dash/mapcard.py b/src/akkudoktoreos/server/dash/mapcard.py index 06081b18..fa92ce30 100644 --- a/src/akkudoktoreos/server/dash/mapcard.py +++ b/src/akkudoktoreos/server/dash/mapcard.py @@ -453,9 +453,11 @@ def ConfigMapCard( config_id = config_name.lower().replace(".", "-") 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" 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"] items_map = json.loads(value) or {} diff --git a/src/akkudoktoreos/server/dash/plan.py b/src/akkudoktoreos/server/dash/plan.py index da2a08e6..84f1a601 100644 --- a/src/akkudoktoreos/server/dash/plan.py +++ b/src/akkudoktoreos/server/dash/plan.py @@ -535,7 +535,8 @@ def InstructionCard( config.devices and config.devices.batteries 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 @@ -548,7 +549,7 @@ def InstructionCard( and config.devices.electric_vehicles and any( 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 @@ -558,7 +559,7 @@ def InstructionCard( and config.devices.home_appliances and any( 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 diff --git a/src/akkudoktoreos/server/dash/uihints.py b/src/akkudoktoreos/server/dash/uihints.py index aad8168b..0f897995 100644 --- a/src/akkudoktoreos/server/dash/uihints.py +++ b/src/akkudoktoreos/server/dash/uihints.py @@ -207,16 +207,18 @@ UI_HINTS: dict[str, UiHint] = { # ------------------------------------------------------------------ # Devices # ------------------------------------------------------------------ + # - batteries "devices.batteries": UiHint( - form="items", + form="map_items", item_path="devices.batteries", ), "devices.electric_vehicles": UiHint( - form="items", + form="map_items", item_path="devices.electric_vehicles", ), + # - home_appliances "devices.home_appliances": UiHint( - form="items", + form="map_items", item_path="devices.home_appliances", ), # Sub-field hint for the time_windows field inside each appliance entry @@ -224,6 +226,19 @@ UI_HINTS: dict[str, UiHint] = { form="time_windows", 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 # ------------------------------------------------------------------ @@ -362,26 +377,33 @@ def _ensure_item_models() -> None: UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting if UI_HINTS["devices.batteries"].item_model is None: - from akkudoktoreos.devices.devices import ( + from akkudoktoreos.devices.settings.batterysettings import ( BatteriesCommonSettings, ) UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings if UI_HINTS["devices.electric_vehicles"].item_model is None: - from akkudoktoreos.devices.devices import ( + from akkudoktoreos.devices.settings.batterysettings import ( BatteriesCommonSettings, ) UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings if UI_HINTS["devices.home_appliances"].item_model is None: - from akkudoktoreos.devices.devices import ( + from akkudoktoreos.devices.settings.homeappliancesettings import ( 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]: """Return the ``item_model`` for an ``"items"`` hint, resolving lazily. diff --git a/src/akkudoktoreos/server/server.py b/src/akkudoktoreos/server/server.py index 5abfc4eb..df0b4a91 100644 --- a/src/akkudoktoreos/server/server.py +++ b/src/akkudoktoreos/server/server.py @@ -81,8 +81,7 @@ def validate_ip_or_hostname(value: str) -> str: raise ValueError(f"Not a valid hostname: {value}") hostname_regex = re.compile( - r"^(?=.{1,253}$)(?!-)[A-Z\d-]{1,63}(? 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 diff --git a/tests/test_configabc.py b/tests/test_configabc.py index 6f3fec33..2ffeac50 100644 --- a/tests/test_configabc.py +++ b/tests/test_configabc.py @@ -28,6 +28,9 @@ import pendulum import pytest 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 as _TW_check 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[1] == pytest.approx(0.25) 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)", + ) diff --git a/tests/test_configmigrate.py b/tests/test_configmigrate.py index 154b66ad..e137c564 100644 --- a/tests/test_configmigrate.py +++ b/tests/test_configmigrate.py @@ -214,18 +214,20 @@ class TestConfigMigration: # Verify the migrated value matches the expected one 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 old_value = configmigrate._get_json_nested_value(old_data, old_path) keep_default = ( isinstance(mapping, tuple) and configmigrate._KEEP_DEFAULT in mapping ) + if keep_default and old_value is None: 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, ( "Some expected migration map entries were not migrated:\n" diff --git a/tests/test_consolidation_config.py b/tests/test_consolidation_config.py new file mode 100644 index 00000000..fa204565 --- /dev/null +++ b/tests/test_consolidation_config.py @@ -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 diff --git a/tests/test_genetic0inverterefficiency.py b/tests/test_genetic0inverterefficiency.py index bd2ac766..e74cd01c 100644 --- a/tests/test_genetic0inverterefficiency.py +++ b/tests/test_genetic0inverterefficiency.py @@ -17,17 +17,17 @@ from unittest.mock import Mock, patch import numpy as np import pytest -from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery -from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter -from akkudoktoreos.optimization.genetic0.genetic0 import ( - Genetic0Simulation, -) -from akkudoktoreos.optimization.genetic0.genetic0devices import ( - Genetic0InverterParameters, +from akkudoktoreos.devices.genetic0.genetic0battery import ( + Genetic0Battery, Genetic0SolarPanelBatteryParameters, ) -from akkudoktoreos.optimization.genetic0.genetic0params import ( +from akkudoktoreos.devices.genetic0.genetic0inverter import ( + Genetic0Inverter, + Genetic0InverterParameters, +) +from akkudoktoreos.optimization.genetic0.genetic0 import ( Genetic0EnergyManagementParameters, + Genetic0Simulation, ) # --------------------------------------------------------------------------- diff --git a/tests/test_genetic0optimize.py b/tests/test_genetic0optimize.py index 71710300..8b2a9c9e 100644 --- a/tests/test_genetic0optimize.py +++ b/tests/test_genetic0optimize.py @@ -83,11 +83,11 @@ async def test_optimize( }, "devices": { "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], } - ], + }, } } ) diff --git a/tests/test_genetic0simulation.py b/tests/test_genetic0simulation.py index f34f1605..c85ec35e 100644 --- a/tests/test_genetic0simulation.py +++ b/tests/test_genetic0simulation.py @@ -2,21 +2,23 @@ import numpy as np import pytest from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence -from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery -from akkudoktoreos.devices.genetic0.genetic0homeappliance import Genetic0HomeAppliance -from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0Inverter -from akkudoktoreos.optimization.genetic0.genetic0 import ( - Genetic0Simulation, - Genetic0SimulationResult, -) -from akkudoktoreos.optimization.genetic0.genetic0devices import ( +from akkudoktoreos.devices.genetic0.genetic0battery import ( + Genetic0Battery, Genetic0ElectricVehicleParameters, - Genetic0HomeApplianceParameters, - Genetic0InverterParameters, 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, + Genetic0Simulation, + Genetic0SimulationResult, ) from akkudoktoreos.utils.datetimeutil import to_duration, to_time diff --git a/tests/test_genetichomeappliance.py b/tests/test_genetichomeappliance.py new file mode 100644 index 00000000..bc93233c --- /dev/null +++ b/tests/test_genetichomeappliance.py @@ -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) diff --git a/tests/test_geneticoptimize.py b/tests/test_geneticoptimize.py index cf0b8b91..63e62bc8 100644 --- a/tests/test_geneticoptimize.py +++ b/tests/test_geneticoptimize.py @@ -83,11 +83,11 @@ async def test_optimize( }, "devices": { "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], } - ], + }, } } ) diff --git a/tests/test_geneticparams_feedin.py b/tests/test_geneticparams_feedin.py index 7a2afbc0..6dabe105 100644 --- a/tests/test_geneticparams_feedin.py +++ b/tests/test_geneticparams_feedin.py @@ -11,9 +11,8 @@ import numpy as np import pandas as pd 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.geneticdevices import InverterParameters from akkudoktoreos.optimization.genetic.geneticparams import ( GeneticOptimizationParameters, ) diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index 80e4ee13..e348790b 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -2,21 +2,24 @@ import numpy as np import pytest from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence -from akkudoktoreos.devices.genetic.battery import Battery -from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance -from akkudoktoreos.devices.genetic.inverter import Inverter -from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation -from akkudoktoreos.optimization.genetic.geneticdevices import ( +from akkudoktoreos.devices.genetic.battery import ( + Battery, ElectricVehicleParameters, - HomeApplianceParameters, - InverterParameters, 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, - GeneticOptimizationParameters, + GeneticSimulation, + GeneticSimulationResult, ) -from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult from akkudoktoreos.utils.datetimeutil import to_duration, to_time start_hour = 1 diff --git a/tests/test_geneticsimulation2.py b/tests/test_geneticsimulation2.py index bb537aac..de60b005 100644 --- a/tests/test_geneticsimulation2.py +++ b/tests/test_geneticsimulation2.py @@ -2,21 +2,24 @@ import numpy as np import pytest from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence -from akkudoktoreos.devices.genetic.battery import Battery -from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance -from akkudoktoreos.devices.genetic.inverter import Inverter -from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation -from akkudoktoreos.optimization.genetic.geneticdevices import ( +from akkudoktoreos.devices.genetic.battery import ( + Battery, ElectricVehicleParameters, - HomeApplianceParameters, - InverterParameters, 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, - GeneticOptimizationParameters, + GeneticSimulation, + GeneticSimulationResult, ) -from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult from akkudoktoreos.utils.datetimeutil import to_duration, to_time start_hour = 0 diff --git a/tests/test_inverter_efficiency.py b/tests/test_inverter_efficiency.py index c877ba62..f3f24f8b 100644 --- a/tests/test_inverter_efficiency.py +++ b/tests/test_inverter_efficiency.py @@ -17,12 +17,14 @@ from unittest.mock import Mock, patch import numpy as np import pytest -from akkudoktoreos.devices.genetic.battery import Battery -from akkudoktoreos.devices.genetic.inverter import Inverter -from akkudoktoreos.optimization.genetic.geneticdevices import ( - InverterParameters, +from akkudoktoreos.devices.genetic.battery import ( + Battery, SolarPanelBatteryParameters, ) +from akkudoktoreos.devices.genetic.inverter import ( + Inverter, + InverterParameters, +) # --------------------------------------------------------------------------- # Helpers / Fixtures diff --git a/tests/testdata/docs/_generated/configdevices.md b/tests/testdata/docs/_generated/configdevices.md index 2fe29f7e..5a9b4fb5 100644 --- a/tests/testdata/docs/_generated/configdevices.md +++ b/tests/testdata/docs/_generated/configdevices.md @@ -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, ]`` 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. :::{table} devices @@ -7,15 +12,15 @@ | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | -| batteries | `EOS_DEVICES__BATTERIES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of battery devices | -| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `list[akkudoktoreos.devices.devices.BatteriesCommonSettings] | None` | `rw` | `None` | List of electric vehicle devices | -| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings] | None` | `rw` | `None` | List of home appliances | -| inverters | `EOS_DEVICES__INVERTERS` | `list[akkudoktoreos.devices.devices.InverterCommonSettings] | None` | `rw` | `None` | List of inverters | -| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `int | None` | `rw` | `None` | Maximum number of batteries that can be set | -| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `int | None` | `rw` | `None` | Maximum number of electric vehicles that can be set | -| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `int | None` | `rw` | `None` | Maximum number of home_appliances that can be set | -| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters that can be set | -| measurement_keys | | `list[str] | None` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. | +| 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` | `dict[str, akkudoktoreos.devices.settings.batterysettings.BatteriesCommonSettings] | None` | `rw` | `None` | Electric vehicle battery packs, keyed by device_id. | +| 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` | `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 allowed. | +| 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 allowed. | +| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `int | None` | `rw` | `None` | Maximum number of inverters allowed. | +| measurement_keys | | `list[str]` | `ro` | `N/A` | All measurement keys across all configured devices. | ::: @@ -27,13 +32,13 @@ ```json { "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -52,15 +57,15 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -79,12 +84,22 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1 + "home_appliances": { + "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 { "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -122,28 +137,28 @@ ], "min_soc_percentage": 0, "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_key_soc_factor": "bat0-soc-factor", + "measurement_key_power_l1_w": "bat0-power-l1-w", + "measurement_key_power_l2_w": "bat0-power-l2-w", + "measurement_key_power_l3_w": "bat0-power-l3-w", + "measurement_key_power_3_phase_sym_w": "bat0-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" + "bat0-soc-factor", + "bat0-power-l1-w", + "bat0-power-l2-w", + "bat0-power-l3-w", + "bat0-power-3-phase-sym-w" ] } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -161,387 +176,48 @@ ], "min_soc_percentage": 0, "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_key_soc_factor": "ev0-soc-factor", + "measurement_key_power_l1_w": "ev0-power-l1-w", + "measurement_key_power_l2_w": "ev0-power-l2-w", + "measurement_key_power_l3_w": "ev0-power-l3-w", + "measurement_key_power_3_phase_sym_w": "ev0-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" + "ev0-soc-factor", + "ev0-power-l1-w", + "ev0-power-l2-w", + "ev0-power-l3-w", + "ev0-power-3-phase-sym-w" ] } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1, + "home_appliances": { + "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": [ - "battery1-soc-factor", - "battery1-power-l1-w", - "battery1-power-l2-w", - "battery1-power-l3-w", - "battery1-power-3-phase-sym-w", - "battery1-soc-factor", - "battery1-power-l1-w", - "battery1-power-l2-w", - "battery1-power-l3-w", - "battery1-power-3-phase-sym-w" - ] - } - } -``` - - -### Inverter devices base settings - - -:::{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. | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } -``` - - - -**Example Output** - - - -```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": [] - } - ] - } - } -``` - - -### 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. - - -:::{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(). | -::: - - - -**Example Input/Output** - - - -```json - { - "devices": { - "home_appliances": [ - { - "time_windows": { - "windows": [ - { - "start_time": "00:00:00.000000", - "duration": "2 hours", - "day_of_week": null, - "date": null, - "locale": null - } - ] - } - } - ] - } - } -``` - - -### 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. - - -:::{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. | -::: - - - -**Example Input/Output** - - - -```json - { - "devices": { - "home_appliances": [ - { - "time_windows": { - "windows": [] - } - } - ] - } - } -``` - - -### Home Appliance devices base settings - - -:::{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. | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } - ] - } - } -``` - - - -**Example Output** - - - -```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": [] - } - ] - } - } -``` - - -### Battery devices base settings - - -:::{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 | -::: - - - -**Example Input** - - - -```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 - } - ] - } - } -``` - - - -**Example Output** - - - -```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" - ] - } + "bat0-soc-factor", + "bat0-power-l1-w", + "bat0-power-l2-w", + "bat0-power-l3-w", + "bat0-power-3-phase-sym-w", + "ev0-soc-factor", + "ev0-power-l1-w", + "ev0-power-l2-w", + "ev0-power-l3-w", + "ev0-power-3-phase-sym-w" ] } } diff --git a/tests/testdata/docs/_generated/configexample.md b/tests/testdata/docs/_generated/configexample.md index c8827d32..02bf87bf 100644 --- a/tests/testdata/docs/_generated/configexample.md +++ b/tests/testdata/docs/_generated/configexample.md @@ -36,13 +36,13 @@ "batch_size": 100 }, "devices": { - "batteries": [ - { - "device_id": "battery1", + "batteries": { + "bat0": { + "device_id": "bat0", "capacity_wh": 8000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -61,15 +61,15 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { - "device_id": "battery1", - "capacity_wh": 8000, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", + "capacity_wh": 60000, "charging_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, "min_charge_power_w": 50, "charge_rates": [ @@ -88,12 +88,22 @@ "min_soc_percentage": 0, "max_soc_percentage": 100 } - ], + }, "max_electric_vehicles": 1, - "inverters": [], + "inverters": {}, "max_inverters": 1, - "home_appliances": [], - "max_home_appliances": 1 + "home_appliances": { + "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": { "provider": "ElecFeeFixed", diff --git a/tests/testdata/eos_config_andreas_now.json b/tests/testdata/eos_config_andreas_now.json index de0a2b2b..97303bfa 100644 --- a/tests/testdata/eos_config_andreas_now.json +++ b/tests/testdata/eos_config_andreas_now.json @@ -12,14 +12,15 @@ "console_level": "INFO" }, "devices": { - "batteries": [ - { + "batteries": { + "pv_akku": { "device_id": "pv_akku", "capacity_wh": 30000 } - ], - "electric_vehicles": [ - { + }, + "electric_vehicles": { + "ev0": { + "device_id": "ev0", "charge_rates": [ 0.0, 0.375, @@ -30,7 +31,7 @@ 1.0 ] } - ] + } }, "measurement": { "load_emr_keys": [ diff --git a/tests/testdata/eos_config_stripped.json b/tests/testdata/eos_config_stripped.json index e16d3381..8f9c94ee 100644 --- a/tests/testdata/eos_config_stripped.json +++ b/tests/testdata/eos_config_stripped.json @@ -10,54 +10,52 @@ "mode": "OPTIMIZATION" }, "devices": { - "batteries": [ - { + "batteries": { + "battery1": { "device_id": "battery1" } - ], + }, "max_batteries": 1, - "electric_vehicles": [ - { + "electric_vehicles": { + "ev11": { "device_id": "ev11", "capacity_wh": 50000, "min_soc_percentage": 70 } - ], + }, "max_electric_vehicles": 1, - "inverters": [ - { + "inverters": { + "inverter1": { "device_id": "inverter1", "max_power_w": 10000.0, "battery_id": "battery1" } - ], + }, "max_inverters": 1, - "home_appliances": [ - { + "home_appliances": { + "dishwasher1": { "device_id": "dishwasher1", - "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" - } - ] - } + "consumption_wh": 2000 } - ], + }, "max_home_appliances": 1 }, "elecprice": { "provider": "ElecPriceAkkudoktor" }, "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": { "provider": "LoadAkkudoktorAdjusted", @@ -122,4 +120,4 @@ "weather": { "provider": "BrightSky" } -} \ No newline at end of file +}