From e255f987bf4417f984801145edb7e9f77c96a89f Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Mon, 9 Nov 2026 19:11:15 +0000 Subject: [PATCH] feat(devices): add battery profiles A battery profile describes a battery model: the charge and discharge limit maps over temperature and SoC, the charge and discharge efficiency curves over C-rate, the technical SoC bounds, the hard temperature cutoffs and, for a model, its nominal capacity and power limits. EOS packages the generic profile generic-lfp; users add their own profiles. A battery or electric vehicle selects its profile by battery_profile and describes how the installation differs from it in profile_adjustment (capacity, power limits of the installation, periodic full charge). The effective values are resolved from both at the measured operating point (temperature, SoC, power) and shown as effective_* fields. The GENETIC simulation uses the limits and efficiencies of the profile over SoC, with precomputed tables as a fast path; GENETIC0 gets the values at the operating point. Optimization parameters are always valid: a battery whose profile is missing, unreadable or invalid uses the packaged generic-lfp profile, and the former defaults only if that is not usable either; each is logged as error, and an optimization run is never cancelled because of a battery. Additional changes - Add a generic device profile store: user and packaged profiles, atomic saves, compiled profiles cached per file and checked again at most once a second, plain file names as profile names ('schema' is reserved). On Windows, a save or read that collides with the other is retried briefly. - Add the REST API /v1/profile to list the profile kinds and to list, read (document and JSON schema), save and delete profiles. - Check the hard temperature cutoffs on the exact temperature. - Resolve inhibited charging or discharging to a power of 0, e.g. for a cold battery; give GENETIC0 its own discharge limit for that. - Limit generic profiles without own power limits to the former 5000 W. - Accept only the implemented units; current limits need the nominal voltage. - Migrate legacy battery settings into the profile adjustment, keep the former charge power limit as discharge limit (also for a Home Assistant entity on it) and report dropped efficiencies as errors. - Limit GENETIC battery discharge by the discharge power. - Allow battery profile lookups while the configuration is created. - Resolve a battery once per configuration dump. - Package the bundled profiles, document battery profiles and regenerate the configuration and OpenAPI documentation. - Ignore the report PDFs written by the visualization tests. Co-Authored-By: Claude Opus 5.5 Assisted-by: Claude Code (claude-opus-5-5) --- .gitignore | 2 + docs/_generated/configadapter.md | 6 +- docs/_generated/configdevices.md | 96 +- docs/_generated/configexample.md | 30 +- docs/_generated/openapi.md | 184 +- .../adapter/adapterhomeassistant.md | 6 +- docs/akkudoktoreos/optimauto.md | 83 +- docs/akkudoktoreos/resource.md | 133 ++ openapi.json | 1192 +++++++++-- pyproject.toml | 2 +- src/akkudoktoreos/adapter/homeassistant.py | 4 +- src/akkudoktoreos/config/config.py | 34 +- src/akkudoktoreos/config/configmigrate.py | 164 +- src/akkudoktoreos/core/coreabc.py | 25 +- .../data/profile/battery/generic-lfp.json | 88 + src/akkudoktoreos/devices/devices.py | 38 +- src/akkudoktoreos/devices/genetic/battery.py | 371 +++- src/akkudoktoreos/devices/genetic/inverter.py | 49 +- .../devices/genetic0/genetic0battery.py | 21 +- .../devices/profile/batteryprofile.py | 779 +++++++ .../devices/profile/batteryprofileruntime.py | 669 ++++++ .../devices/profile/profilestore.py | 607 ++++++ .../devices/settings/batterymeasurement.py | 106 + .../devices/settings/batteryresolution.py | 694 ++++++ .../devices/settings/batterysettings.py | 1045 ++++++++- src/akkudoktoreos/measurement/measurement.py | 9 +- .../optimization/genetic/configrequest.py | 30 +- .../optimization/genetic/genetic.py | 10 +- .../optimization/genetic/geneticsolution.py | 34 +- .../optimization/genetic/tailvalue.py | 4 +- .../optimization/genetic0/genetic0params.py | 105 +- .../optimization/genetic0/genetic0solution.py | 26 +- src/akkudoktoreos/server/eos.py | 2 + src/akkudoktoreos/server/rest/profile.py | 139 ++ tests/test_config.py | 106 +- tests/test_configmigrate.py | 243 +++ tests/test_devicesprofilebattery.py | 1467 +++++++++++++ tests/test_devicesprofilestore.py | 472 +++++ tests/test_devicessettingsbattery.py | 1861 +++++++++++++++++ tests/test_genetic0battery.py | 33 + tests/test_genetic0optimize.py | 59 + tests/test_genetic0params.py | 115 +- tests/test_geneticbattery.py | 243 ++- tests/test_geneticbatteryfullcharge.py | 152 +- tests/test_geneticcompletedevices.py | 22 +- tests/test_geneticcompletesolution.py | 2 +- tests/test_geneticconfigoptimizerequest.py | 40 +- tests/test_geneticinverter.py | 42 + tests/test_geneticsimulation.py | 115 + tests/test_homeassistant.py | 16 +- tests/test_loadakkudoktor.py | 2 +- tests/test_measurementbatterycapacity.py | 29 +- tests/test_optimizationcompatibility.py | 8 +- tests/test_serverrestprofile.py | 178 ++ tests/testdata/eos_config_andreas_now.json | 4 +- tests/testdata/eos_config_stripped.json | 4 +- 56 files changed, 11381 insertions(+), 619 deletions(-) create mode 100644 src/akkudoktoreos/data/profile/battery/generic-lfp.json create mode 100644 src/akkudoktoreos/devices/profile/batteryprofile.py create mode 100644 src/akkudoktoreos/devices/profile/batteryprofileruntime.py create mode 100644 src/akkudoktoreos/devices/profile/profilestore.py create mode 100644 src/akkudoktoreos/devices/settings/batterymeasurement.py create mode 100644 src/akkudoktoreos/devices/settings/batteryresolution.py create mode 100644 src/akkudoktoreos/server/rest/profile.py create mode 100644 tests/test_devicesprofilebattery.py create mode 100644 tests/test_devicesprofilestore.py create mode 100644 tests/test_devicessettingsbattery.py create mode 100644 tests/test_serverrestprofile.py diff --git a/.gitignore b/.gitignore index 5ebde0e1..006eaa58 100644 --- a/.gitignore +++ b/.gitignore @@ -264,6 +264,8 @@ tests/testdata/config-new.md tests/testdata/*.new # Written by test_visualize into the working directory example_report.pdf +# Written by test_geneticvisualize and test_genetic0visualize +tests/testdata/**/test_example_report_new.pdf # Written by test_config into the working directory tests/testdata/docs/ diff --git a/docs/_generated/configadapter.md b/docs/_generated/configadapter.md index db8e00e8..191e2862 100644 --- a/docs/_generated/configadapter.md +++ b/docs/_generated/configadapter.md @@ -136,7 +136,7 @@ There are two URLs that are used: | ---- | ---- | --------- | ------- | ----------- | | config_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS config keys to Home Assistant entity IDs. The config key has to be given by a ‘/’-separated path -e.g. devices/batteries/battery1/capacity_wh +e.g. devices/batteries/battery1/profile_adjustment/nominal_capacity_wh Devices are addressed by their device_id. The setting must exist. | | device_instruction_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for device (resource) instructions to be updated by EOS. The device ids (resource ids) have to be prepended by 'sensor.eos_' to build the entity_id. @@ -165,7 +165,7 @@ E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_batt "adapter": { "homeassistant": { "config_entity_ids": { - "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity" }, "load_emr_entity_ids": [ "sensor.load_energy_total_kwh" @@ -205,7 +205,7 @@ E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_batt "adapter": { "homeassistant": { "config_entity_ids": { - "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity" }, "load_emr_entity_ids": [ "sensor.load_energy_total_kwh" diff --git a/docs/_generated/configdevices.md b/docs/_generated/configdevices.md index b6301733..fa80bf5b 100644 --- a/docs/_generated/configdevices.md +++ b/docs/_generated/configdevices.md @@ -37,11 +37,15 @@ config path from ``self.device_id`` without needing an external index. "device_id": "bat0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 8000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 8000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -66,10 +70,7 @@ config path from ``self.device_id`` without needing an external index. ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false } }, @@ -79,11 +80,15 @@ config path from ``self.device_id`` without needing an external index. "device_id": "ev0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 60000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 60000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -108,10 +113,7 @@ config path from ``self.device_id`` without needing an external index. ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false } }, @@ -155,11 +157,15 @@ config path from ``self.device_id`` without needing an external index. "device_id": "bat0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 8000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 8000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -184,18 +190,30 @@ config path from ``self.device_id`` without needing an external index. ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false, + "effective_nominal_capacity_wh": 8000, + "effective_max_charge_power_w": 5000.0, + "effective_max_discharge_power_w": 4000.0, + "effective_charging_efficiency": 0.955, + "effective_discharging_efficiency": 0.96, + "effective_min_soc_percentage": 0, + "effective_max_soc_percentage": 100, + "effective_full_charge_interval_days": null, + "effective_full_charge_detect_percentage": 99, "measurement_key_soc_factor": "bat0-soc-factor", + "measurement_key_temperature_degc": "bat0-temperature-degc", + "measurement_key_charge_power_w": "bat0-charge-power-w", + "measurement_key_discharge_power_w": "bat0-discharge-power-w", "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": [ "bat0-soc-factor", + "bat0-temperature-degc", + "bat0-charge-power-w", + "bat0-discharge-power-w", "bat0-power-l1-w", "bat0-power-l2-w", "bat0-power-l3-w", @@ -209,11 +227,15 @@ config path from ``self.device_id`` without needing an external index. "device_id": "ev0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 60000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 60000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -238,18 +260,30 @@ config path from ``self.device_id`` without needing an external index. ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false, + "effective_nominal_capacity_wh": 60000, + "effective_max_charge_power_w": 5000.0, + "effective_max_discharge_power_w": 5000.0, + "effective_charging_efficiency": 0.975, + "effective_discharging_efficiency": 0.975, + "effective_min_soc_percentage": 0, + "effective_max_soc_percentage": 100, + "effective_full_charge_interval_days": null, + "effective_full_charge_detect_percentage": 99, "measurement_key_soc_factor": "ev0-soc-factor", + "measurement_key_temperature_degc": "ev0-temperature-degc", + "measurement_key_charge_power_w": "ev0-charge-power-w", + "measurement_key_discharge_power_w": "ev0-discharge-power-w", "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": [ "ev0-soc-factor", + "ev0-temperature-degc", + "ev0-charge-power-w", + "ev0-discharge-power-w", "ev0-power-l1-w", "ev0-power-l2-w", "ev0-power-l3-w", @@ -285,11 +319,17 @@ config path from ``self.device_id`` without needing an external index. "max_home_appliances": 3, "measurement_keys": [ "bat0-soc-factor", + "bat0-temperature-degc", + "bat0-charge-power-w", + "bat0-discharge-power-w", "bat0-power-l1-w", "bat0-power-l2-w", "bat0-power-l3-w", "bat0-power-3-phase-sym-w", "ev0-soc-factor", + "ev0-temperature-degc", + "ev0-charge-power-w", + "ev0-discharge-power-w", "ev0-power-l1-w", "ev0-power-l2-w", "ev0-power-l3-w", diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index d74c44aa..3ed59450 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -41,11 +41,15 @@ "device_id": "bat0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 8000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 8000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -70,10 +74,7 @@ ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false } }, @@ -83,11 +84,15 @@ "device_id": "ev0", "capacity_estimation": null, "capacity_estimate": null, - "capacity_wh": 60000, - "charging_efficiency": 0.88, - "discharging_efficiency": 0.88, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 60000, + "max_charge_power_w": null, + "max_discharge_power_w": null, + "full_charge_interval_days": null, + "full_charge_detect_percentage": null + }, "levelized_cost_of_storage_amt_kwh": 0.0, - "max_charge_power_w": 5000, "min_charge_power_w": 50, "charge_rates": [ 0.0, @@ -112,10 +117,7 @@ ], "min_soc_deadline_datetime": null, "min_soc_max_duration_h": null, - "cell_chemistry": null, - "full_charge_interval_days": null, "full_charge_soc_percentage": 100, - "full_charge_detect_percentage": 99, "full_charge_allow_grid": false } }, diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 5ddd2a5e..4f187bd4 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2611091577972738` +**Version**: `v0.3.0.dev2611111415621708` **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. @@ -2073,6 +2073,188 @@ Args: --- +## GET /v1/profile + + +**Links**: [local](http://localhost:8503/docs#/default/get_profile_kinds_v1_profile_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/get_profile_kinds_v1_profile_get) + + +Get Profile Kinds + + +```python +""" +Return the registered profile kinds. +""" +``` + + +**Responses**: + +- **200**: Successful Response + +--- + +## GET /v1/profile/{kind} + + +**Links**: [local](http://localhost:8503/docs#/default/get_profiles_v1_profile__kind__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/get_profiles_v1_profile__kind__get) + + +Get Profiles + + +```python +""" +Return the profiles of a kind, from the user and the packaged data directory. +""" +``` + + +**Parameters**: + +- `kind` (path, required): Profile kind, e.g. 'battery'. + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + +## GET /v1/profile/{kind}/schema + + +**Links**: [local](http://localhost:8503/docs#/default/get_profile_schema_v1_profile__kind__schema_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/get_profile_schema_v1_profile__kind__schema_get) + + +Get Profile Schema + + +```python +""" +Return the JSON schema of the profile documents of a kind. +""" +``` + + +**Parameters**: + +- `kind` (path, required): Profile kind, e.g. 'battery'. + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + +## DELETE /v1/profile/{kind}/{name} + + +**Links**: [local](http://localhost:8503/docs#/default/delete_profile_v1_profile__kind___name__delete), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/delete_profile_v1_profile__kind___name__delete) + + +Delete Profile + + +```python +""" +Delete a user profile. + +Returns the profile that applies now (the packaged one of the same name), +or null if there is none. +""" +``` + + +**Parameters**: + +- `kind` (path, required): Profile kind, e.g. 'battery'. + +- `name` (path, required): Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-'). + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + +## GET /v1/profile/{kind}/{name} + + +**Links**: [local](http://localhost:8503/docs#/default/get_profile_v1_profile__kind___name__get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/get_profile_v1_profile__kind___name__get) + + +Get Profile + + +```python +""" +Return the document of a profile (user profile first, then packaged). +""" +``` + + +**Parameters**: + +- `kind` (path, required): Profile kind, e.g. 'battery'. + +- `name` (path, required): Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-'). + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + +## PUT /v1/profile/{kind}/{name} + + +**Links**: [local](http://localhost:8503/docs#/default/put_profile_v1_profile__kind___name__put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/put_profile_v1_profile__kind___name__put) + + +Put Profile + + +```python +""" +Validate a profile document and save it to the user data directory. + +Returns the saved (validated, defaults filled in) document. +""" +``` + + +**Parameters**: + +- `kind` (path, required): Profile kind, e.g. 'battery'. + +- `name` (path, required): Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-'). + +**Request Body**: + +- `application/json`: { + "type": "object", + "additionalProperties": true, + "description": "The profile document.", + "title": "Document" +} + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + ## GET /v1/resource/status diff --git a/docs/akkudoktoreos/adapter/adapterhomeassistant.md b/docs/akkudoktoreos/adapter/adapterhomeassistant.md index a90513cf..6ca03d19 100644 --- a/docs/akkudoktoreos/adapter/adapterhomeassistant.md +++ b/docs/akkudoktoreos/adapter/adapterhomeassistant.md @@ -149,9 +149,9 @@ by Home Assistant integrations, such as: These values are typically consumed by EOS **device simulations** during optimisation. The configuration key is the `/`-separated path of the setting. Devices are addressed by their -`device_id`, e.g. `devices/batteries/battery1/capacity_wh`. Only existing settings are updated: a -path that names no setting, e.g. a device that is not configured, is reported as an error in the -EOS log. An entity that is unavailable or unknown leaves the configured value unchanged. +`device_id`, e.g. `devices/batteries/battery1/profile_adjustment/nominal_capacity_wh`. Only +existing settings are updated: a path that names no setting, e.g. a device that is not configured, +is reported as an error in the EOS log. An entity that is unavailable or unknown leaves the configured value unchanged. ### 3.2 Device measurement entity IDs diff --git a/docs/akkudoktoreos/optimauto.md b/docs/akkudoktoreos/optimauto.md index 4b61bdcb..4b6498a4 100644 --- a/docs/akkudoktoreos/optimauto.md +++ b/docs/akkudoktoreos/optimauto.md @@ -346,16 +346,19 @@ The inverter supports separate AC↔DC conversion efficiencies: { "devices": { "max_electric_vehicles": 1, - "electric_vehicles": [ - { + "electric_vehicles": { + "ev1": { "device_id": "ev1", - "capacity_wh": 50000, - "max_charge_power_w": 10000, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 50000, + "max_charge_power_w": 10000 + }, "charge_rates": [0.0, 0.25, 0.5, 0.75, 1.0], "min_soc_percentage": 10, "max_soc_percentage": 80 } - ] + } }, "measurement": { "electric_vehicle_soc_keys": ["ev1_soc"] @@ -371,20 +374,21 @@ The inverter supports separate AC↔DC conversion efficiencies: { "devices": { "max_batteries": 1, - "batteries": [ - { + "batteries": { + "battery1": { "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": 8000, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 8000, + "max_charge_power_w": 8000 + }, + "levelized_cost_of_storage_amt_kwh": 0.12, "min_charge_power_w": 50, "charge_rates": null, "min_soc_percentage": 5, "max_soc_percentage": 95 } - ] + } } } ``` @@ -397,22 +401,29 @@ manufacturers expect a full charge every few weeks, and some batteries start one at an arbitrary time. A periodic full charge lets EOS plan it instead, preferably from PV surplus or at a cheap price. -The feature is off by default. It is enabled per stationary battery with -`full_charge_interval_days` (GENETIC only): +The feature is off by default (GENETIC only). The interval and the detection threshold are +properties of the battery model and come from the battery profile; the `profile_adjustment` of a +stationary battery replaces them for this unit. The target and the use of the grid are battery +settings: -- `full_charge_interval_days`: maximum number of days between two full charges. `null` (default) - disables the feature. For LFP batteries 21 to 25 days are a reasonable start; check what the - manufacturer requires. +- `full_charge_interval_days` (battery profile or `profile_adjustment`): maximum number of days + between two full charges. `null` disables the feature; the bundled `generic-lfp` profile sets + none. For LFP batteries 21 to 25 days are a reasonable start; check what the manufacturer + requires. +- `full_charge_detect_percentage` (battery profile or `profile_adjustment`, profile default `99`): + the measured state of charge from which the battery counts as full. Many battery management + systems never report exactly 100 %. - `full_charge_soc_percentage` (default `100`): the target. It may be above `max_soc_percentage`, - which keeps applying on all other days. -- `full_charge_detect_percentage` (default `99`): the measured state of charge from which the - battery counts as full. Many battery management systems never report exactly 100 %. + which keeps applying on all other days, but is capped by the technical maximum SoC of the + battery profile. - `full_charge_allow_grid` (default `false`): with `false`, EOS only raises the maximum state of charge while the full charge is due, so that PV surplus can fill the battery; the target is not enforced. With `true`, the optimizer has to reach the target and charges from the grid if needed, at the cheapest time before the deadline. -- `cell_chemistry` (`LFP`, `NMC`, `NCA`, `LTO` or `null`): informational only, it does not - enable anything. + +The effective values are available as `effective_full_charge_interval_days` and +`effective_full_charge_detect_percentage`. The cell chemistry is the `chemistry` of the battery +profile. The last full charge is taken from the measured state of charge of the battery (`-soc-factor`), so a full charge from PV or one the battery started itself counts as @@ -429,10 +440,12 @@ a close or overdue full charge can still use the cheapest time. "batteries": { "battery1": { "device_id": "battery1", - "capacity_wh": 10000, + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 10000, + "full_charge_interval_days": 21 + }, "max_soc_percentage": 90, - "cell_chemistry": "LFP", - "full_charge_interval_days": 21, "full_charge_allow_grid": true } } @@ -517,18 +530,20 @@ Assuming the battery is named `battery1` and the EV is named `ev11`: ```json { "devices": { - "batteries": [ - { - "device_id": "battery1", "capacity_wh": 8000, ... + "batteries": { + "battery1": { + "device_id": "battery1", + "profile_adjustment": {"nominal_capacity_wh": 8000}, ... "measurement_key_soc_factor": "battery1-soc-factor", ... } - ], - "electric_vehicles": [ - { - "device_id": "ev11", "capacity_wh": 8000, ... + }, + "electric_vehicles": { + "ev11": { + "device_id": "ev11", + "profile_adjustment": {"nominal_capacity_wh": 8000}, ... "measurement_key_soc_factor": "ev11-soc-factor", ... } - ] + } } } ``` diff --git a/docs/akkudoktoreos/resource.md b/docs/akkudoktoreos/resource.md index 38dd8605..07fb36af 100644 --- a/docs/akkudoktoreos/resource.md +++ b/docs/akkudoktoreos/resource.md @@ -103,6 +103,139 @@ Intermediate values scale the fill level proportionally. ### Battery Configuration +The technical data of a battery or electric vehicle comes from a **battery profile**. The +configuration of the device (`devices/batteries/` or +`devices/electric_vehicles/`) selects the profile, adjusts it to the installed unit and +sets how the battery is operated: + +| **Field** | **Description** | +| -------------------------------------------------- | --------------------------------------------------------------- | +| `battery_profile` | Battery profile name (without `.json`), default `generic-lfp`. | +| `profile_adjustment/nominal_capacity_wh` | Capacity of the installed unit [Wh]. Replaces the profile's. | +| `profile_adjustment/max_charge_power_w` | Charge power limit of the installation [W]. Only lowers. | +| `profile_adjustment/max_discharge_power_w` | Discharge power limit of the installation [W]. Only lowers. | +| `profile_adjustment/full_charge_interval_days` | Days between two full charges [d]. Replaces the profile's. | +| `profile_adjustment/full_charge_detect_percentage` | SoC that counts as full [%]. Replaces the profile's. | +| `min_soc_percentage` | Minimum SoC [%], at least the technical minimum of the profile. | +| `max_soc_percentage` | Maximum SoC [%], at most the technical maximum of the profile. | + +```json +"house": { + "battery_profile": "generic-lfp", + "profile_adjustment": { + "nominal_capacity_wh": 10240, + "max_charge_power_w": 5000, + "max_discharge_power_w": 5000 + }, + "min_soc_percentage": 10, + "max_soc_percentage": 95 +} +``` + +EOS resolves every effective parameter from exactly two sources, the profile and the profile +adjustment, evaluated at the operating point (battery temperature and SoC). There are no built-in +fallback values: + +- **Capacity**: `profile_adjustment/nominal_capacity_wh`, else the nominal capacity of the + profile. A generic profile such as `generic-lfp` describes its limits as C-rates and has no + capacity of its own, so the adjustment has to provide it. +- **Power limits**: the profile limit maps give the maximum charge and discharge rate per + temperature and SoC. The power limits of the adjustment can only lower them. At temperatures + outside the hard cutoffs of the profile charging or discharging is inhibited. +- **Efficiencies**: the profile efficiency curves at the charge and discharge power. + +The GENETIC optimization evaluates the profile at the measured battery temperature, or at 25 °C if +no temperature is measured. Its battery simulation then limits the charge and discharge power +by the SoC at the start of each charge or discharge step, and takes the charge and discharge +efficiency at the power of the slot. + +The resolved values are shown as read-only fields of the device configuration +(`effective_nominal_capacity_wh`, `effective_max_charge_power_w`, `effective_max_discharge_power_w`, +`effective_charging_efficiency`, `effective_discharging_efficiency`, `effective_min_soc_percentage`, +`effective_max_soc_percentage`, `effective_full_charge_interval_days`, +`effective_full_charge_detect_percentage`). These fields are evaluated at the reference operating point of +25 °C and 50 % SoC. + +If a battery cannot be resolved, e.g. its profile does not exist or a generic profile lacks the +capacity in the adjustment, its read-only fields are empty (`null`) and EOS logs an error naming +the battery whenever the configuration is set up or changed. A power limit in the adjustment above +the profile limit has no effect and is logged as a warning. + +The parameter preparation of the GENETIC and GENETIC0 optimizations always succeeds for such a +configuration error. It resolves whatever the configuration allows and fills only the rest with +the defaults of earlier versions, and it logs an error that names the battery, the problem and the +default values used: + +- **Profile not usable** (missing, unreadable, invalid): the packaged generic profile + `generic-lfp` with the profile adjustment. +- **Generic profile not usable either**: the profile adjustment where set, else 8000 Wh, 5000 W + charge and discharge power and efficiencies of 0.88, without SoC dependent power limits. +- **Generic profile without capacity in the adjustment**: the profile with a capacity of 8000 Wh. +- **SoC range outside the technical SoC bounds of the profile**: the technical SoC range. + +A profile that inhibits charging at the measured battery temperature is not a configuration +error; the optimization reports it as error. + +:::{admonition} Note +:class: note +Configurations of earlier versions are migrated automatically. `capacity_wh` becomes +`profile_adjustment/nominal_capacity_wh` and `max_charge_power_w` becomes the installation limit +`profile_adjustment/max_charge_power_w`. A battery that relied on the former defaults gets them +written into its profile adjustment (8000 Wh, 5000 W), so its capacity and charge limit do not +change. The former fields `charging_efficiency` and `discharging_efficiency` are computed from the +profile now and are dropped. The periodic full charge settings `full_charge_interval_days` and +`full_charge_detect_percentage` move into the profile adjustment, `cell_chemistry` is dropped (the +profile has the `chemistry`). Home Assistant `config_entity_ids` that point at moved fields are +rewritten to the new paths. An entity on the former `max_charge_power_w` sets both +`profile_adjustment/max_charge_power_w` and `profile_adjustment/max_discharge_power_w`, unless the +discharge limit has its own entity. +::: + +#### Battery Profiles + +A battery profile is a JSON document with: + +- charge and discharge limit maps over temperature and SoC (as C-rate, W or A), with their axes + and units, +- charge and discharge efficiency curves over the C-rate, +- technical SoC bounds and hard temperature cutoffs for charging and discharging, +- the nominal capacity, except for a generic profile, +- optionally the nominal voltage and model power limits, +- optionally the cell chemistry and the periodic full charge the battery management system needs + to recalibrate its SoC: `full_charge_interval_days` (none by default) and + `full_charge_detect_percentage` (default 99), see the periodic full charge of the GENETIC + optimization. + +All of these are required, except the optional ones. Unknown keys are rejected, so a misspelled +key fails validation instead of being ignored. + +EOS ships the profile `generic-lfp` for a generic LFP home battery. Profiles are looked up by name +first in the user data directory `/profile/battery/` and then in the +packaged profiles. Saving a profile always writes to the user data directory; a user profile with +the name of a packaged profile replaces it until the user profile is deleted. Changed profiles are +used on their next access, without a restart of EOS. A profile name is the file name without +`.json`: letters, digits, `.`, `_` and `-`, starting with a letter or digit, at most 100 +characters. `schema` is reserved. + +Profiles can be managed with the REST API: + +| **Endpoint** | **Description** | +| -------------------------------------- | ---------------------------------------------------------------- | +| **GET** `/v1/profile` | List the profile kinds (e.g. `battery`). | +| **GET** `/v1/profile/{kind}` | List the user and packaged profiles of a kind. | +| **GET** `/v1/profile/{kind}/schema` | JSON schema of the profile documents of a kind. | +| **GET** `/v1/profile/{kind}/{name}` | Get a profile document. | +| **PUT** `/v1/profile/{kind}/{name}` | Validate a profile document and save it as user profile. | +| **DELETE** `/v1/profile/{kind}/{name}` | Delete a user profile. Returns the profile that applies instead. | + +The **Profiles** page of EOSdash edits battery profiles as JSON and plots the limit maps and +efficiency curves as EOS evaluates them. Edits can be previewed before they are saved. + +To share a profile with other users, open it on the **Profiles** page and press **Share on +GitHub**. It opens a new issue with the battery profile form in your browser, filled in with the +profile; check it and submit it. EOS sends nothing itself. Reviewed profiles are packaged with +EOS. + ### Battery Stati To keep the battery simulation in synchonization with the actual stati of the battery the following diff --git a/openapi.json b/openapi.json index bc1a67cd..93678f99 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.dev2611091577972738" + "version": "v0.3.0.dev2611111415621708" }, "paths": { "/v1/measurement/battery-capacity/{battery_id}": { @@ -330,6 +330,317 @@ } } }, + "/v1/profile": { + "get": { + "tags": [ + "profile" + ], + "summary": "Get Profile Kinds", + "description": "Return the registered profile kinds.", + "operationId": "get_profile_kinds_v1_profile_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Response Get Profile Kinds V1 Profile Get" + } + } + } + } + } + } + }, + "/v1/profile/{kind}": { + "get": { + "tags": [ + "profile" + ], + "summary": "Get Profiles", + "description": "Return the profiles of a kind, from the user and the packaged data directory.", + "operationId": "get_profiles_v1_profile__kind__get", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile kind, e.g. 'battery'.", + "title": "Kind" + }, + "description": "Profile kind, e.g. 'battery'." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileEntry" + }, + "title": "Response Get Profiles V1 Profile Kind Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/profile/{kind}/schema": { + "get": { + "tags": [ + "profile" + ], + "summary": "Get Profile Schema", + "description": "Return the JSON schema of the profile documents of a kind.", + "operationId": "get_profile_schema_v1_profile__kind__schema_get", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile kind, e.g. 'battery'.", + "title": "Kind" + }, + "description": "Profile kind, e.g. 'battery'." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Profile Schema V1 Profile Kind Schema Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/profile/{kind}/{name}": { + "get": { + "tags": [ + "profile" + ], + "summary": "Get Profile", + "description": "Return the document of a profile (user profile first, then packaged).", + "operationId": "get_profile_v1_profile__kind___name__get", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile kind, e.g. 'battery'.", + "title": "Kind" + }, + "description": "Profile kind, e.g. 'battery'." + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-').", + "title": "Name" + }, + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-')." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Profile V1 Profile Kind Name Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "profile" + ], + "summary": "Put Profile", + "description": "Validate a profile document and save it to the user data directory.\n\nReturns the saved (validated, defaults filled in) document.", + "operationId": "put_profile_v1_profile__kind___name__put", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile kind, e.g. 'battery'.", + "title": "Kind" + }, + "description": "Profile kind, e.g. 'battery'." + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-').", + "title": "Name" + }, + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-')." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "The profile document.", + "title": "Document" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Put Profile V1 Profile Kind Name Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "profile" + ], + "summary": "Delete Profile", + "description": "Delete a user profile.\n\nReturns the profile that applies now (the packaged one of the same name),\nor null if there is none.", + "operationId": "delete_profile_v1_profile__kind___name__delete", + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile kind, e.g. 'battery'.", + "title": "Kind" + }, + "description": "Profile kind, e.g. 'battery'." + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-').", + "title": "Name" + }, + "description": "Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-')." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileEntry" + }, + { + "type": "null" + } + ], + "title": "Response Delete Profile V1 Profile Kind Name Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/prediction/pv-adaptive/status": { "get": { "tags": [ @@ -3473,38 +3784,26 @@ "type": "null" } ], - "description": "Last explicitly stored capacity estimate; does not override capacity_wh." + "description": "Last explicitly stored capacity estimate; does not override the effective nominal capacity." }, - "capacity_wh": { - "type": "integer", - "exclusiveMinimum": 0.0, - "title": "Capacity Wh", - "description": "Capacity [Wh].", - "default": 8000, + "battery_profile": { + "type": "string", + "minLength": 1, + "title": "Battery Profile", + "description": "Name of the battery profile (without '.json') providing the nominal capacity, power limits, efficiency curves and technical SOC bounds. Differences of this unit go into 'profile_adjustment'.", + "default": "generic-lfp", "examples": [ - 8000 + "generic-lfp" ] }, - "charging_efficiency": { - "type": "number", - "maximum": 1.0, - "exclusiveMinimum": 0.0, - "title": "Charging Efficiency", - "description": "Charging efficiency [0.01 ... 1.00].", - "default": 0.88, + "profile_adjustment": { + "$ref": "#/components/schemas/BatteryProfileAdjustment", + "description": "How this battery unit differs from its battery profile: nominal capacity, installation power limits and periodic full charge.", "examples": [ - 0.88 - ] - }, - "discharging_efficiency": { - "type": "number", - "maximum": 1.0, - "exclusiveMinimum": 0.0, - "title": "Discharging Efficiency", - "description": "Discharge efficiency [0.01 ... 1.00].", - "default": 0.88, - "examples": [ - 0.88 + { + "max_charge_power_w": 5000, + "nominal_capacity_wh": 8000 + } ] }, "levelized_cost_of_storage_amt_kwh": { @@ -3516,23 +3815,6 @@ 0.12 ] }, - "max_charge_power_w": { - "anyOf": [ - { - "type": "number", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Max Charge Power W", - "description": "Maximum charging power [W].", - "default": 5000, - "examples": [ - 5000 - ] - }, "min_charge_power_w": { "anyOf": [ { @@ -3593,7 +3875,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. Raised to the technical minimum SOC of the battery profile if that is higher.", "default": 0, "examples": [ 10 @@ -3604,7 +3886,7 @@ "maximum": 100.0, "minimum": 0.0, "title": "Max Soc Percentage", - "description": "Maximum state of charge (SOC) as percentage of capacity [%].", + "description": "Maximum state of charge (SOC) as percentage of capacity [%]. Lowered to the technical maximum SOC of the battery profile if that is lower.", "default": 100, "examples": [ 100 @@ -3683,57 +3965,12 @@ "GENETIC" ] }, - "cell_chemistry": { - "anyOf": [ - { - "type": "string", - "enum": [ - "LFP", - "NMC", - "NCA", - "LTO" - ] - }, - { - "type": "null" - } - ], - "title": "Cell Chemistry", - "description": "Cell chemistry of the battery. Informational only, it does not enable anything. LFP batteries usually need a periodic full charge to recalibrate their state of charge, see 'full_charge_interval_days'.", - "examples": [ - null, - "LFP" - ], - "x-scope": [ - "GENETIC" - ] - }, - "full_charge_interval_days": { - "anyOf": [ - { - "type": "number", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Full Charge Interval Days", - "description": "Maximum number of days between two full charges of a stationary battery [d]. The last full charge is taken from the measured state of charge. When the next one falls due within the optimization horizon, the maximum state of charge is raised to 'full_charge_soc_percentage'. None disables the periodic full charge. Ignored for electric vehicles.", - "examples": [ - null, - 21 - ], - "x-scope": [ - "GENETIC" - ] - }, "full_charge_soc_percentage": { "type": "integer", "maximum": 100.0, "minimum": 1.0, "title": "Full Charge Soc Percentage", - "description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days.", + "description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days. Capped by the technical maximum SOC of the battery profile. The interval comes from the battery profile or 'profile_adjustment.full_charge_interval_days'.", "default": 100, "examples": [ 100 @@ -3742,20 +3979,6 @@ "GENETIC" ] }, - "full_charge_detect_percentage": { - "type": "integer", - "maximum": 100.0, - "minimum": 1.0, - "title": "Full Charge Detect Percentage", - "description": "Measured state of charge from which the battery counts as fully charged [%]. Many battery management systems never report exactly 100 %.", - "default": 99, - "examples": [ - 99 - ], - "x-scope": [ - "GENETIC" - ] - }, "full_charge_allow_grid": { "type": "boolean", "title": "Full Charge Allow Grid", @@ -3806,38 +4029,26 @@ "type": "null" } ], - "description": "Last explicitly stored capacity estimate; does not override capacity_wh." + "description": "Last explicitly stored capacity estimate; does not override the effective nominal capacity." }, - "capacity_wh": { - "type": "integer", - "exclusiveMinimum": 0.0, - "title": "Capacity Wh", - "description": "Capacity [Wh].", - "default": 8000, + "battery_profile": { + "type": "string", + "minLength": 1, + "title": "Battery Profile", + "description": "Name of the battery profile (without '.json') providing the nominal capacity, power limits, efficiency curves and technical SOC bounds. Differences of this unit go into 'profile_adjustment'.", + "default": "generic-lfp", "examples": [ - 8000 + "generic-lfp" ] }, - "charging_efficiency": { - "type": "number", - "maximum": 1.0, - "exclusiveMinimum": 0.0, - "title": "Charging Efficiency", - "description": "Charging efficiency [0.01 ... 1.00].", - "default": 0.88, + "profile_adjustment": { + "$ref": "#/components/schemas/BatteryProfileAdjustment", + "description": "How this battery unit differs from its battery profile: nominal capacity, installation power limits and periodic full charge.", "examples": [ - 0.88 - ] - }, - "discharging_efficiency": { - "type": "number", - "maximum": 1.0, - "exclusiveMinimum": 0.0, - "title": "Discharging Efficiency", - "description": "Discharge efficiency [0.01 ... 1.00].", - "default": 0.88, - "examples": [ - 0.88 + { + "max_charge_power_w": 5000, + "nominal_capacity_wh": 8000 + } ] }, "levelized_cost_of_storage_amt_kwh": { @@ -3849,23 +4060,6 @@ 0.12 ] }, - "max_charge_power_w": { - "anyOf": [ - { - "type": "number", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Max Charge Power W", - "description": "Maximum charging power [W].", - "default": 5000, - "examples": [ - 5000 - ] - }, "min_charge_power_w": { "anyOf": [ { @@ -3926,7 +4120,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. Raised to the technical minimum SOC of the battery profile if that is higher.", "default": 0, "examples": [ 10 @@ -3937,7 +4131,7 @@ "maximum": 100.0, "minimum": 0.0, "title": "Max Soc Percentage", - "description": "Maximum state of charge (SOC) as percentage of capacity [%].", + "description": "Maximum state of charge (SOC) as percentage of capacity [%]. Lowered to the technical maximum SOC of the battery profile if that is lower.", "default": 100, "examples": [ 100 @@ -4016,57 +4210,12 @@ "GENETIC" ] }, - "cell_chemistry": { - "anyOf": [ - { - "type": "string", - "enum": [ - "LFP", - "NMC", - "NCA", - "LTO" - ] - }, - { - "type": "null" - } - ], - "title": "Cell Chemistry", - "description": "Cell chemistry of the battery. Informational only, it does not enable anything. LFP batteries usually need a periodic full charge to recalibrate their state of charge, see 'full_charge_interval_days'.", - "examples": [ - null, - "LFP" - ], - "x-scope": [ - "GENETIC" - ] - }, - "full_charge_interval_days": { - "anyOf": [ - { - "type": "number", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Full Charge Interval Days", - "description": "Maximum number of days between two full charges of a stationary battery [d]. The last full charge is taken from the measured state of charge. When the next one falls due within the optimization horizon, the maximum state of charge is raised to 'full_charge_soc_percentage'. None disables the periodic full charge. Ignored for electric vehicles.", - "examples": [ - null, - 21 - ], - "x-scope": [ - "GENETIC" - ] - }, "full_charge_soc_percentage": { "type": "integer", "maximum": 100.0, "minimum": 1.0, "title": "Full Charge Soc Percentage", - "description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days.", + "description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days. Capped by the technical maximum SOC of the battery profile. The interval comes from the battery profile or 'profile_adjustment.full_charge_interval_days'.", "default": 100, "examples": [ 100 @@ -4075,20 +4224,6 @@ "GENETIC" ] }, - "full_charge_detect_percentage": { - "type": "integer", - "maximum": 100.0, - "minimum": 1.0, - "title": "Full Charge Detect Percentage", - "description": "Measured state of charge from which the battery counts as fully charged [%]. Many battery management systems never report exactly 100 %.", - "default": 99, - "examples": [ - 99 - ], - "x-scope": [ - "GENETIC" - ] - }, "full_charge_allow_grid": { "type": "boolean", "title": "Full Charge Allow Grid", @@ -4102,12 +4237,140 @@ "GENETIC" ] }, + "effective_nominal_capacity_wh": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Effective Nominal Capacity Wh", + "description": "Effective nominal capacity [Wh]: profile adjustment, else profile.\n\nNone if the battery cannot be resolved.", + "readOnly": true + }, + "effective_max_charge_power_w": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Effective Max Charge Power W", + "description": "Maximum charging power [W] at the reference operating point.", + "readOnly": true + }, + "effective_max_discharge_power_w": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Effective Max Discharge Power W", + "description": "Maximum discharging power [W] at the reference operating point.", + "readOnly": true + }, + "effective_charging_efficiency": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Effective Charging Efficiency", + "description": "Charging efficiency [0.01 ... 1.00] at the maximum charge power.", + "readOnly": true + }, + "effective_discharging_efficiency": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Effective Discharging Efficiency", + "description": "Discharging efficiency [0.01 ... 1.00] at the maximum discharge power.", + "readOnly": true + }, + "effective_min_soc_percentage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Effective Min Soc Percentage", + "description": "Effective minimum SoC [%], within the technical bounds of the profile.", + "readOnly": true + }, + "effective_max_soc_percentage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Effective Max Soc Percentage", + "description": "Effective maximum SoC [%], within the technical bounds of the profile.", + "readOnly": true + }, + "effective_full_charge_interval_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Effective Full Charge Interval Days", + "description": "Maximum days between two full charges [d]: profile adjustment, else profile.\n\nNone if the periodic full charge is disabled.", + "readOnly": true + }, + "effective_full_charge_detect_percentage": { + "type": "integer", + "title": "Effective Full Charge Detect Percentage", + "description": "Measured SoC from which the battery counts as fully charged [%].", + "readOnly": true + }, "measurement_key_soc_factor": { "type": "string", "title": "Measurement Key Soc Factor", "description": "Measurement key for SoC as factor of total capacity [0.0 ... 1.0].", "readOnly": true }, + "measurement_key_temperature_degc": { + "type": "string", + "title": "Measurement Key Temperature Degc", + "description": "Measurement key for the battery temperature [degC].", + "readOnly": true + }, + "measurement_key_charge_power_w": { + "type": "string", + "title": "Measurement Key Charge Power W", + "description": "Measurement key for the charge power [W], used to compute the charging efficiency.", + "readOnly": true + }, + "measurement_key_discharge_power_w": { + "type": "string", + "title": "Measurement Key Discharge Power W", + "description": "Measurement key for the discharge power [W], used for the discharging efficiency.", + "readOnly": true + }, "measurement_key_power_l1_w": { "type": "string", "title": "Measurement Key Power L1 W", @@ -4144,7 +4407,19 @@ }, "type": "object", "required": [ + "effective_nominal_capacity_wh", + "effective_max_charge_power_w", + "effective_max_discharge_power_w", + "effective_charging_efficiency", + "effective_discharging_efficiency", + "effective_min_soc_percentage", + "effective_max_soc_percentage", + "effective_full_charge_interval_days", + "effective_full_charge_detect_percentage", "measurement_key_soc_factor", + "measurement_key_temperature_degc", + "measurement_key_charge_power_w", + "measurement_key_discharge_power_w", "measurement_key_power_l1_w", "measurement_key_power_l2_w", "measurement_key_power_l3_w", @@ -4373,6 +4648,105 @@ "title": "BatteryCapacityRequest", "description": "Caller attests both anchors are independent of the capacity being fitted.\n\nEnd is the first confirmed full point when using the default end SoC.\nVoltage/current anchors must be independently established for the chemistry." }, + "BatteryProfileAdjustment": { + "properties": { + "nominal_capacity_wh": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Nominal Capacity Wh", + "description": "Nominal capacity of the installed battery [Wh]. Replaces the capacity of the battery profile. Required if the profile is generic and provides no capacity. The effective value is available as 'effective_nominal_capacity_wh'.", + "examples": [ + null, + 8000 + ] + }, + "max_charge_power_w": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Charge Power W", + "description": "Maximum charging power of the installation, e.g. the inverter limit [W]. Can only lower the limits of the battery profile. The effective value is available as 'effective_max_charge_power_w'.", + "examples": [ + null, + 5000 + ] + }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power of the installation, e.g. the inverter limit [W]. Can only lower the limits of the battery profile. The effective value is available as 'effective_max_discharge_power_w'.", + "examples": [ + null, + 5000 + ] + }, + "full_charge_interval_days": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Full Charge Interval Days", + "description": "Maximum number of days between two full charges of a stationary battery [d]. Replaces the interval of the battery profile. When the next full charge falls due within the optimization horizon, the maximum state of charge is raised to 'full_charge_soc_percentage'. The periodic full charge is disabled if neither this nor the profile sets an interval. Ignored for electric vehicles. The effective value is available as 'effective_full_charge_interval_days'.", + "examples": [ + null, + 21 + ], + "x-scope": [ + "GENETIC" + ] + }, + "full_charge_detect_percentage": { + "anyOf": [ + { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Full Charge Detect Percentage", + "description": "Measured state of charge from which the battery counts as fully charged [%]. Replaces the threshold of the battery profile. The effective value is available as 'effective_full_charge_detect_percentage'.", + "examples": [ + null, + 98 + ], + "x-scope": [ + "GENETIC" + ] + } + }, + "type": "object", + "title": "BatteryProfileAdjustment", + "description": "How a battery unit differs from its battery profile.\n\nEach field is named after the profile field it adjusts. None keeps the\nprofile value." + }, "CacheCommonSettings": { "properties": { "subpath": { @@ -4961,9 +5335,11 @@ "examples": [ { "bat0": { - "capacity_wh": 8000, "device_id": "bat0", - "ports": [] + "ports": [], + "profile_adjustment": { + "nominal_capacity_wh": 8000 + } } } ], @@ -5009,9 +5385,11 @@ "examples": [ { "ev0": { - "capacity_wh": 60000, "device_id": "ev0", - "ports": [] + "ports": [], + "profile_adjustment": { + "nominal_capacity_wh": 60000 + } } } ], @@ -5154,9 +5532,11 @@ "examples": [ { "bat0": { - "capacity_wh": 8000, "device_id": "bat0", - "ports": [] + "ports": [], + "profile_adjustment": { + "nominal_capacity_wh": 8000 + } } } ], @@ -5202,9 +5582,11 @@ "examples": [ { "ev0": { - "capacity_wh": 60000, "device_id": "ev0", - "ports": [] + "ports": [], + "profile_adjustment": { + "nominal_capacity_wh": 60000 + } } } ], @@ -6097,7 +6479,7 @@ "anyOf": [ { "type": "number", - "exclusiveMinimum": 0.0 + "minimum": 0.0 }, { "type": "null" @@ -6107,6 +6489,23 @@ "description": "Maximum charging power in watts.", "default": 5000 }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power in watts. 0 means discharging is inhibited. None uses max_charge_power_w.", + "examples": [ + null, + 4000 + ] + }, "initial_soc_percentage": { "type": "integer", "maximum": 100.0, @@ -6189,6 +6588,122 @@ null ] }, + "charge_power_limit_w_by_soc": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Charge Power Limit W By Soc", + "description": "Maximum charging power [W] per state of charge, e.g. from a battery profile. 101 values, index i is the limit at SoC i %. Further limits max_charge_power_w. None means no SoC dependent limit.", + "examples": [ + null + ] + }, + "discharge_power_limit_w_by_soc": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Discharge Power Limit W By Soc", + "description": "Maximum discharging power [W] per state of charge, e.g. from a battery profile. 101 values, index i is the limit at SoC i %. Further limits max_discharge_power_w. None means no SoC dependent limit.", + "examples": [ + null + ] + }, + "charging_efficiency_curve": { + "anyOf": [ + { + "items": { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Charging Efficiency Curve", + "description": "Power dependent charging efficiency as a list of (power_fraction, efficiency) points. The power fraction is the charging power relative to max_charge_power_w [0.0 ... 1.0]; points must be strictly increasing in power fraction, efficiencies in (0, 1]. Values are interpolated linearly and clamped to the first/last point. When set, the curve replaces charging_efficiency in the simulation. None means constant charging_efficiency.", + "examples": [ + null, + [ + [ + 0.0, + 0.975 + ], + [ + 0.5, + 0.96 + ], + [ + 1.0, + 0.94 + ] + ] + ] + }, + "discharging_efficiency_curve": { + "anyOf": [ + { + "items": { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Discharging Efficiency Curve", + "description": "Power dependent discharging efficiency as a list of (power_fraction, efficiency) points. The power fraction is the discharging power relative to max_discharge_power_w [0.0 ... 1.0]; same rules as charging_efficiency_curve. When set, the curve replaces discharging_efficiency in the simulation. None means constant discharging_efficiency.", + "examples": [ + null, + [ + [ + 0.0, + 0.975 + ], + [ + 1.0, + 0.96 + ] + ] + ] + }, "min_soc_deadline_datetime": { "anyOf": [ { @@ -6288,6 +6803,18 @@ "title": "Max Charge Power W", "description": "Maximum charging power in watts." }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power in watts." + }, "soc_wh": { "type": "number", "title": "Soc Wh", @@ -7726,7 +8253,7 @@ "anyOf": [ { "type": "number", - "exclusiveMinimum": 0.0 + "minimum": 0.0 }, { "type": "null" @@ -7736,6 +8263,23 @@ "description": "Maximum charging power in watts.", "default": 5000 }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power in watts. 0: the battery may not discharge. None uses max_charge_power_w.", + "examples": [ + null, + 4000 + ] + }, "initial_soc_percentage": { "type": "integer", "maximum": 100.0, @@ -8862,7 +9406,7 @@ "anyOf": [ { "type": "number", - "exclusiveMinimum": 0.0 + "minimum": 0.0 }, { "type": "null" @@ -8872,6 +9416,23 @@ "description": "Maximum charging power in watts.", "default": 5000 }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power in watts. 0: the battery may not discharge. None uses max_charge_power_w.", + "examples": [ + null, + 4000 + ] + }, "initial_soc_percentage": { "type": "integer", "maximum": 100.0, @@ -11398,10 +11959,10 @@ } ], "title": "Config Entity Ids", - "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/capacity_wh\nDevices are addressed by their device_id. The setting must exist.", + "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/profile_adjustment/nominal_capacity_wh\nDevices are addressed by their device_id. The setting must exist.", "examples": [ { - "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity" } ] }, @@ -11574,10 +12135,10 @@ } ], "title": "Config Entity Ids", - "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/capacity_wh\nDevices are addressed by their device_id. The setting must exist.", + "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/profile_adjustment/nominal_capacity_wh\nDevices are addressed by their device_id. The setting must exist.", "examples": [ { - "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity" } ] }, @@ -16448,6 +17009,46 @@ "title": "PredictionCommonSettings", "description": "General Prediction Configuration." }, + "ProfileEntry": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Profile name, without the '.json' suffix.", + "examples": [ + "generic-lfp" + ] + }, + "source": { + "type": "string", + "enum": [ + "user", + "package" + ], + "title": "Source", + "description": "Where the document in effect comes from: 'user' (user data directory, editable) or 'package' (shipped with EOS, read only).", + "examples": [ + "package" + ] + }, + "packaged": { + "type": "boolean", + "title": "Packaged", + "description": "Whether a packaged profile of this name exists. Deleting a user profile that overrides it makes the packaged profile apply again.", + "examples": [ + true + ] + } + }, + "type": "object", + "required": [ + "name", + "source", + "packaged" + ], + "title": "ProfileEntry", + "description": "A profile available in the store." + }, "PydanticDateTimeData": { "additionalProperties": { "anyOf": [ @@ -17083,7 +17684,7 @@ "anyOf": [ { "type": "number", - "exclusiveMinimum": 0.0 + "minimum": 0.0 }, { "type": "null" @@ -17093,6 +17694,23 @@ "description": "Maximum charging power in watts.", "default": 5000 }, + "max_discharge_power_w": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Discharge Power W", + "description": "Maximum discharging power in watts. 0 means discharging is inhibited. None uses max_charge_power_w.", + "examples": [ + null, + 4000 + ] + }, "initial_soc_percentage": { "type": "integer", "maximum": 100.0, @@ -17175,6 +17793,122 @@ null ] }, + "charge_power_limit_w_by_soc": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Charge Power Limit W By Soc", + "description": "Maximum charging power [W] per state of charge, e.g. from a battery profile. 101 values, index i is the limit at SoC i %. Further limits max_charge_power_w. None means no SoC dependent limit.", + "examples": [ + null + ] + }, + "discharge_power_limit_w_by_soc": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Discharge Power Limit W By Soc", + "description": "Maximum discharging power [W] per state of charge, e.g. from a battery profile. 101 values, index i is the limit at SoC i %. Further limits max_discharge_power_w. None means no SoC dependent limit.", + "examples": [ + null + ] + }, + "charging_efficiency_curve": { + "anyOf": [ + { + "items": { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Charging Efficiency Curve", + "description": "Power dependent charging efficiency as a list of (power_fraction, efficiency) points. The power fraction is the charging power relative to max_charge_power_w [0.0 ... 1.0]; points must be strictly increasing in power fraction, efficiencies in (0, 1]. Values are interpolated linearly and clamped to the first/last point. When set, the curve replaces charging_efficiency in the simulation. None means constant charging_efficiency.", + "examples": [ + null, + [ + [ + 0.0, + 0.975 + ], + [ + 0.5, + 0.96 + ], + [ + 1.0, + 0.94 + ] + ] + ] + }, + "discharging_efficiency_curve": { + "anyOf": [ + { + "items": { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Discharging Efficiency Curve", + "description": "Power dependent discharging efficiency as a list of (power_fraction, efficiency) points. The power fraction is the discharging power relative to max_discharge_power_w [0.0 ... 1.0]; same rules as charging_efficiency_curve. When set, the curve replaces discharging_efficiency in the simulation. None means constant discharging_efficiency.", + "examples": [ + null, + [ + [ + 0.0, + 0.975 + ], + [ + 1.0, + 0.96 + ] + ] + ] + }, "levelized_cost_of_storage_kwh": { "type": "number", "minimum": 0.0, diff --git a/pyproject.toml b/pyproject.toml index 3e79c556..9d73f4e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ where = ["src/"] include = ["akkudoktoreos"] [tool.setuptools.package-data] -akkudoktoreos = ["*.json", "data/*.npz", ] +akkudoktoreos = ["*.json", "data/*.npz", "data/profile/*/*.json"] [tool.pyright] # used in Pylance extension for language server diff --git a/src/akkudoktoreos/adapter/homeassistant.py b/src/akkudoktoreos/adapter/homeassistant.py index 4643143f..c8c36ec3 100644 --- a/src/akkudoktoreos/adapter/homeassistant.py +++ b/src/akkudoktoreos/adapter/homeassistant.py @@ -39,12 +39,12 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel): "description": ( "Mapping of EOS config keys to Home Assistant entity IDs.\n" "The config key has to be given by a ‘/’-separated path\n" - "e.g. devices/batteries/battery1/capacity_wh\n" + "e.g. devices/batteries/battery1/profile_adjustment/nominal_capacity_wh\n" "Devices are addressed by their device_id. The setting must exist." ), "examples": [ { - "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity", + "devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity", } ], }, diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 712e7359..c2e11f33 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -469,6 +469,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): # A ClassVar: without the annotation pydantic makes it a private attribute, # and the class attribute would be a (truthy) descriptor object. _force_documentation_mode: ClassVar[bool] = False + # Battery profile problems logged last, see _log_battery_profile_problems(). + _battery_profile_problems: ClassVar[tuple[tuple[str, ...], tuple[str, ...]]] = ((), ()) def __hash__(self) -> int: # ConfigEOS is a singleton @@ -806,7 +808,33 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): SettingsEOSDefaults.__init__(self, *args, **kwargs) self._initialized = True - logger.trace(f"Config setup:\n{self}") + # Lazy: rendering the configuration evaluates all computed fields, e.g. + # the battery profile lookups. Only do it if trace logging is enabled. + logger.opt(lazy=True).trace("Config setup:\n{config}", config=lambda: str(self)) + self._log_battery_profile_problems() + + def _log_battery_profile_problems(self) -> None: + """Log batteries that cannot be resolved from their battery profile. + + A battery profile is checked once the configuration is set up, because + the user profile directory depends on ``general.data_folder_path``. + Problems are logged when they change, not on every settings update. + """ + if self.documentation_mode(): + return + try: + errors, warnings = self.devices.check_battery_profiles() + except Exception: + logger.exception("Battery profile check failed.") + return + problems = (tuple(errors), tuple(warnings)) + if problems == ConfigEOS._battery_profile_problems: + return + ConfigEOS._battery_profile_problems = problems + for message in errors: + logger.error(message) + for message in warnings: + logger.warning(message) def merge_settings(self, settings: SettingsEOS) -> None: """Merges the provided settings into the global settings for EOS, with optional overwrite. @@ -893,6 +921,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): node[keys[-1]] = setting ConfigEOS._runtime_settings = deep_merge(ConfigEOS._runtime_settings, runtime_setting) + if keys and keys[0] in ("devices", "general"): + # Device settings and the data folder (user profiles) affect the batteries. + self._log_battery_profile_problems() + def reset_settings(self) -> None: """Reset all changed settings to environment/config file defaults. diff --git a/src/akkudoktoreos/config/configmigrate.py b/src/akkudoktoreos/config/configmigrate.py index 5249ee8b..9295648d 100644 --- a/src/akkudoktoreos/config/configmigrate.py +++ b/src/akkudoktoreos/config/configmigrate.py @@ -38,12 +38,17 @@ _KEEP_DEFAULT = object() def _list_to_device_dict( prefix: str, + migrate_item: Callable[[Dict[str, Any]], None] | None = None, ) -> 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"``). + + A dict that is already keyed by device id is accepted as well. If given, + *migrate_item* is called with a copy of every item (after the device id was + set) and migrates it in place, e.g. to rename or drop device specific fields. """ def _transform(value: Any) -> Any: @@ -67,12 +72,160 @@ def _list_to_device_dict( item.setdefault( "levelized_cost_of_storage_amt_kwh", item.pop("levelized_cost_of_storage_kwh") ) + if migrate_item is not None: + migrate_item(item) result[key] = item return result return _transform +# Fields of BatteriesCommonSettings that used to be configuration fields and are +# computed from the battery profile and the measurements now. Old values are +# dropped with an error log. +_BATTERY_FIELDS_NOW_COMPUTED = ( + "charging_efficiency", + "discharging_efficiency", +) + +# Former battery fields that describe how the unit differs from its battery +# profile and move into ``profile_adjustment``: (legacy name, adjustment field). +# The first two are fields of the settings before battery profiles, the others +# of the interim settings with battery profiles but without profile adjustment. +_BATTERY_FIELDS_TO_ADJUSTMENT = ( + ("capacity_wh", "nominal_capacity_wh"), + # max_charge_power_w was the configured charge power limit of the installation. + ("max_charge_power_w", "max_charge_power_w"), + ("nominal_capacity_wh", "nominal_capacity_wh"), + ("max_charge_power_limit_w", "max_charge_power_w"), + ("max_discharge_power_limit_w", "max_discharge_power_w"), + # Periodic full charge settings before battery profiles. + ("full_charge_interval_days", "full_charge_interval_days"), + ("full_charge_detect_percentage", "full_charge_detect_percentage"), +) + +# Former battery fields whose value the battery profile provides now. +# cell_chemistry was informational only; the profile has the chemistry. +_BATTERY_FIELDS_NOW_IN_PROFILE = ("cell_chemistry",) + +# Keys that only battery settings with battery profiles have. +_BATTERY_PROFILE_KEYS = ( + "battery_profile", + "profile_adjustment", + "nominal_capacity_wh", + "max_charge_power_limit_w", + "max_discharge_power_limit_w", +) + +# Defaults of the battery settings before battery profiles. Configuration files +# leave out default values, so a battery that relied on them gets them written +# into its profile adjustment and keeps its capacity and charge power limit. +_BATTERY_PRE_PROFILE_DEFAULTS = ( + ("nominal_capacity_wh", 8000), + ("max_charge_power_w", 5000.0), +) + + +def _migrate_battery_item(item: Dict[str, Any]) -> None: + """Migrate one battery or electric vehicle settings dict in place. + + - The capacity and the charge / discharge power limits of the installation + move into ``profile_adjustment``: ``capacity_wh`` and ``nominal_capacity_wh`` + become ``nominal_capacity_wh``, ``max_charge_power_w`` and + ``max_charge_power_limit_w`` become ``max_charge_power_w``, + ``max_discharge_power_limit_w`` becomes ``max_discharge_power_w``. + - The periodic full charge interval and detection threshold move into + ``profile_adjustment`` under their names. + - A value already present in ``profile_adjustment`` wins over a legacy one. + - A battery without any battery profile key is from before battery + profiles; it gets the former defaults (8000 Wh, 5000 W) for the values it + left out. Its charge power limit also limited discharging, so it becomes + the discharge power limit too. + - Fields that are computed from the battery profile now, or that the + profile provides (``cell_chemistry``), are dropped. A dropped efficiency + is logged as error, as the profile efficiency applies instead. + + Configuration files are written without computed fields, so + ``max_charge_power_w`` at the top level of a battery in a file is always the + legacy configuration value. + """ + before_profiles = not any(key in item for key in _BATTERY_PROFILE_KEYS) + raw_adjustment = item.get("profile_adjustment") + adjustment = dict(raw_adjustment) if isinstance(raw_adjustment, dict) else {} + for legacy_name, new_name in _BATTERY_FIELDS_TO_ADJUSTMENT: + if legacy_name in item: + value = item.pop(legacy_name) + if value is not None: + adjustment.setdefault(new_name, value) + if before_profiles: + for name, default in _BATTERY_PRE_PROFILE_DEFAULTS: + adjustment.setdefault(name, default) + # Before battery profiles the charge power limit also capped discharging. + adjustment.setdefault("max_discharge_power_w", adjustment["max_charge_power_w"]) + for name in _BATTERY_FIELDS_NOW_COMPUTED: + value = item.pop(name, None) + if value is not None: + logger.error( + "Battery '{}': '{}' {} is no longer a setting; the efficiency of the " + "battery profile applies. Adapt the battery profile if needed.", + item.get("device_id"), + name, + value, + ) + for name in _BATTERY_FIELDS_NOW_IN_PROFILE: + item.pop(name, None) + if adjustment: + item["profile_adjustment"] = adjustment + + +def _migrate_config_entity_ids(value: Any) -> Any: + """Rewrite Home Assistant config paths of battery fields moved into the profile adjustment. + + E.g. ``devices/batteries/bat0/capacity_wh`` becomes + ``devices/batteries/bat0/profile_adjustment/nominal_capacity_wh``. Paths of + fields that are computed now are dropped, because they cannot be set. + """ + if not isinstance(value, dict): + return value + moved = dict(_BATTERY_FIELDS_TO_ADJUSTMENT) + result: Dict[str, Any] = {} + for path, entity_id in value.items(): + parts = path.strip("/").split("/") if isinstance(path, str) else [] + if ( + len(parts) == 4 + and parts[0] == "devices" + and parts[1] in ("batteries", "electric_vehicles") + ): + if parts[3] in moved: + path = "/".join([*parts[:3], "profile_adjustment", moved[parts[3]]]) + elif parts[3] in _BATTERY_FIELDS_NOW_COMPUTED: + logger.warning( + f"Dropping Home Assistant config entity '{entity_id}' for '{path}': " + "the value is computed from the battery profile now." + ) + continue + elif parts[3] in _BATTERY_FIELDS_NOW_IN_PROFILE: + logger.warning( + f"Dropping Home Assistant config entity '{entity_id}' for '{path}': " + "the value comes from the battery profile now." + ) + continue + result[path] = entity_id + # Before battery profiles the charge power limit also capped discharging, + # like the migrated profile adjustment does (see _migrate_battery_item). + for path, entity_id in value.items(): + parts = path.strip("/").split("/") if isinstance(path, str) else [] + if ( + len(parts) == 4 + and parts[0] == "devices" + and parts[1] in ("batteries", "electric_vehicles") + and parts[3] == "max_charge_power_w" + ): + discharge_path = "/".join([*parts[:3], "profile_adjustment", "max_discharge_power_w"]) + result.setdefault(discharge_path, entity_id) + return result + + # ----------------------------- # Global migration map constant # ----------------------------- @@ -98,6 +251,10 @@ MIGRATION_MAP: Dict[ "adapter/homeassistant/solution_entity_ids", lambda v: v if isinstance(v, list) else None, ), + "adapter/homeassistant/config_entity_ids": ( + "adapter/homeassistant/config_entity_ids", + _migrate_config_entity_ids, + ), # - NodeRed # devices # ======= @@ -105,15 +262,18 @@ MIGRATION_MAP: Dict[ # 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 + # capacity and installation power limits -> profile_adjustment, + # fields that are computed now are dropped "devices/batteries": ( "devices/batteries", - _list_to_device_dict("bat"), + _list_to_device_dict("bat", _migrate_battery_item), ), "devices/batteries/0/initial_soc_percentage": None, # - electric_vehicles + # same item migration as for batteries "devices/electric_vehicles": ( "devices/electric_vehicles", - _list_to_device_dict("ev"), + _list_to_device_dict("ev", _migrate_battery_item), ), "devices/electric_vehicles/0/initial_soc_percentage": None, # - inverters diff --git a/src/akkudoktoreos/core/coreabc.py b/src/akkudoktoreos/core/coreabc.py index 34ced3ed..786bda6a 100644 --- a/src/akkudoktoreos/core/coreabc.py +++ b/src/akkudoktoreos/core/coreabc.py @@ -35,6 +35,9 @@ _measurement_eos: Optional[Measurement] = None _prediction_eos: Optional[Prediction] = None _resource_registry_eos: Optional[ResourceRegistry] = None +# True while get_config() creates the configuration, see get_config(). +_config_eos_setup_running: bool = False + def get_adapter(init: bool = False) -> Adapter: """Retrieve the singleton EOS Adapter instance. @@ -164,10 +167,14 @@ def get_config(init: Union[bool, dict[str, bool]] = False) -> ConfigEOS: Returns: ConfigEOS: The global EOS configuration singleton instance. + While the configuration is being created, its settings are loaded before + ``ConfigEOS()`` returns. Code that runs during that time, such as a computed + field that resolves a battery profile, gets the configuration being created. + Raises: RuntimeError: If the configuration has not been initialized and ``init`` is - ``False``. + ``False``, or its settings are not loaded yet. Usage: .. code-block:: python @@ -187,17 +194,29 @@ def get_config(init: Union[bool, dict[str, bool]] = False) -> ConfigEOS: # Access existing configuration host = get_config().server.host """ - global _config_eos + global _config_eos, _config_eos_setup_running if _config_eos is None: from akkudoktoreos.config.config import ConfigEOS + if _config_eos_setup_running: + # Called while ConfigEOS() below is running. The instance is + # registered already and usable once its settings are loaded. + config_in_setup = ConfigEOS._instances.get(ConfigEOS) + if config_in_setup is not None and getattr(config_in_setup, "_initialized", False): + return config_in_setup + raise RuntimeError("Config access before its settings are loaded.") + if not init and not ConfigEOS.documentation_mode(): raise RuntimeError("Config access before init.") if isinstance(init, dict): ConfigEOS._init_config_eos = init - _config_eos = ConfigEOS() + _config_eos_setup_running = True + try: + _config_eos = ConfigEOS() + finally: + _config_eos_setup_running = False return _config_eos diff --git a/src/akkudoktoreos/data/profile/battery/generic-lfp.json b/src/akkudoktoreos/data/profile/battery/generic-lfp.json new file mode 100644 index 00000000..8d95c807 --- /dev/null +++ b/src/akkudoktoreos/data/profile/battery/generic-lfp.json @@ -0,0 +1,88 @@ +{ + "schema_version": "1.0", + "battery_model": "Generic LFP home battery", + "chemistry": "LFP", + "nominal_voltage_v": null, + "technical_min_soc_percent": 0.0, + "technical_max_soc_percent": 100.0, + "units": { + "temperature": "degC", + "soc": "percent", + "limit": "C-rate" + }, + "interpolation": "bilinear", + "out_of_range_behavior": "clamp_to_nearest", + "axes": { + "temperature_degC": [ + -10.0, -7.5, -5.0, -2.5, + 0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, + 20.0, 25.0, 35.0, 48.0, 55.0, 60.0 + ], + "soc_percent": [0, 40, 80, 100] + }, + "charge_limit_crate": { + "description": "Max charge C-rate; rows = temperature, cols = SOC. Rows below 0 degC are zero because the datasheet curve ends at 0 degC with C-rate 0 and the charge hard cutoff is at 0 degC. Above 80 % SOC the limit tapers linearly to the value of the 100 % SOC column.", + "values": [ + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1], + [0.38, 0.38, 0.38, 0.38], + [0.38, 0.38, 0.38, 0.38], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [1.0, 1.0, 1.0, 0.75], + [1.0, 1.0, 1.0, 0.8], + [1.0, 1.0, 1.0, 0.8], + [0.8, 0.8, 0.8, 0.8], + [0.27, 0.27, 0.27, 0.27], + [0.0, 0.0, 0.0, 0.0] + ] + }, + "discharge_limit_crate": { + "description": "Generic continuous discharge C-rate for an LFP home battery. 0.5C in the normal range, derated near the low and high temperature ends. Peak discharge (typically around 1C) is deliberately not represented; only the continuous rating is stored, which is the safe value for controller-side power limiting.", + "values": [ + [0.0, 0.0, 0.0, 0.0], + [0.1, 0.1, 0.1, 0.1], + [0.2, 0.2, 0.2, 0.2], + [0.3, 0.3, 0.3, 0.3], + [0.3, 0.3, 0.3, 0.3], + [0.3, 0.3, 0.3, 0.3], + [0.3, 0.3, 0.3, 0.3], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + [0.3, 0.3, 0.3, 0.3], + [0.2, 0.2, 0.2, 0.2], + [0.0, 0.0, 0.0, 0.0] + ] + }, + "charge_efficiency": { + "description": "Generic one-way DC charge efficiency of an LFP battery pack (cells and BMS) versus absolute C-rate. Losses grow with current (I^2 R), so efficiency falls slowly with C-rate. AC/DC conversion losses are not included; they belong to the inverter settings (ac_to_dc_efficiency).", + "c_rate": [0.05, 0.1, 0.25, 0.5, 0.75, 1.0], + "efficiency": [0.975, 0.975, 0.97, 0.96, 0.95, 0.94], + "interpolation": "linear", + "out_of_range_behavior": "clamp_to_nearest" + }, + "discharge_efficiency": { + "description": "Generic one-way DC discharge efficiency of an LFP battery pack (cells and BMS) versus absolute C-rate. The curve ends at the 0.5C continuous discharge rating of this profile; higher C-rates are clamped to the last point. DC/AC conversion losses are not included; they belong to the inverter settings (dc_to_ac_efficiency).", + "c_rate": [0.05, 0.1, 0.25, 0.5], + "efficiency": [0.975, 0.975, 0.97, 0.96], + "interpolation": "linear", + "out_of_range_behavior": "clamp_to_nearest" + }, + "hard_cutoffs": { + "charge_allowed_below_degC": 0.0, + "charge_allowed_above_degC": 60.0, + "discharge_allowed_below_degC": -10.0, + "discharge_allowed_above_degC": 60.0, + "description": "Charge is inhibited strictly below 0 degC and strictly above 60 degC. At exactly 0 degC and exactly 60 degC the charge map returns 0.0, so charging is effectively disabled there too. Discharge is inhibited strictly below -10 degC and strictly above 60 degC; the discharge map already reaches 0.0 at both ends, so the hard cutoff only governs whether the accessor returns None or 0.0." + } +} diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 1c2682b6..854ccc7c 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -38,7 +38,15 @@ class DevicesCommonSettings(SettingsBaseModel): default=None, json_schema_extra={ "description": "Stationary battery storage devices, keyed by device_id.", - "examples": [{"bat0": {"device_id": "bat0", "capacity_wh": 8000, "ports": []}}], + "examples": [ + { + "bat0": { + "device_id": "bat0", + "profile_adjustment": {"nominal_capacity_wh": 8000}, + "ports": [], + } + } + ], "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) @@ -57,7 +65,15 @@ class DevicesCommonSettings(SettingsBaseModel): default=None, json_schema_extra={ "description": "Electric vehicle battery packs, keyed by device_id.", - "examples": [{"ev0": {"device_id": "ev0", "capacity_wh": 60000, "ports": []}}], + "examples": [ + { + "ev0": { + "device_id": "ev0", + "profile_adjustment": {"nominal_capacity_wh": 60000}, + "ports": [], + } + } + ], "x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], }, ) @@ -154,6 +170,24 @@ class DevicesCommonSettings(SettingsBaseModel): keys.extend(device.measurement_keys or []) return keys + def check_battery_profiles(self) -> tuple[list[str], list[str]]: + """Check that every battery and electric vehicle can be resolved from its profile. + + See ``BatteriesCommonSettings.check_profile``. + + Returns: + ``(errors, warnings)`` of all batteries and electric vehicles, each + message naming its device. + """ + errors: list[str] = [] + warnings: list[str] = [] + for device_dict in (self.batteries, self.electric_vehicles): + for device in (device_dict or {}).values(): + device_errors, device_warnings = device.check_profile() + errors.extend(device_errors) + warnings.extend(device_warnings) + return errors, warnings + # Type used for indexing: (resource_id, optional actuator_id) class ResourceKey(PydanticBaseModel): diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index 5e7cd5d4..e053c3ca 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -1,17 +1,35 @@ +import math from typing import Any, Iterator, Optional import numpy as np from pydantic import Field, field_validator, model_validator +from akkudoktoreos.devices.devicesabc import validate_efficiency_curve +from akkudoktoreos.devices.settings.batteryresolution import ( + BATTERY_SOC_TABLE_POINTS as SOC_TABLE_POINTS, +) from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime +# Resolution of the dense efficiency tables: one value per power percent. +_EFFICIENCY_TABLE_POINTS = 101 + + +def _dense_efficiency_table(curve: Optional[list[tuple[float, float]]]) -> Optional[list[float]]: + """Interpolate an efficiency curve at every power percent 0 % ... 100 %.""" + if curve is None: + return None + fractions, efficiencies = zip(*curve) + grid = np.linspace(0.0, 1.0, _EFFICIENCY_TABLE_POINTS) + return [float(value) for value in np.interp(grid, fractions, efficiencies)] + 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}) + # 0: the battery may not charge, e.g. the profile inhibits it at a low temperature. + return Field(default=5000, ge=0, json_schema_extra={"description": description}) def initial_soc_percentage_field(description: str) -> int: @@ -54,6 +72,17 @@ class BaseBatteryParameters(DeviceParameters): ) discharging_efficiency: float = discharging_efficiency_field(0.88) max_charge_power_w: Optional[float] = max_charging_power_field() + max_discharge_power_w: Optional[float] = Field( + default=None, + ge=0, + json_schema_extra={ + "description": ( + "Maximum discharging power in watts. 0 means discharging is inhibited. " + "None uses max_charge_power_w." + ), + "examples": [None, 4000], + }, + ) 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)." ) @@ -93,6 +122,79 @@ class BaseBatteryParameters(DeviceParameters): "examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None], }, ) + charge_power_limit_w_by_soc: Optional[list[float]] = Field( + default=None, + json_schema_extra={ + "description": ( + "Maximum charging power [W] per state of charge, e.g. from a battery " + f"profile. {SOC_TABLE_POINTS} values, index i is the limit at SoC i %. " + "Further limits max_charge_power_w. None means no SoC dependent limit." + ), + "examples": [None], + }, + ) + discharge_power_limit_w_by_soc: Optional[list[float]] = Field( + default=None, + json_schema_extra={ + "description": ( + "Maximum discharging power [W] per state of charge, e.g. from a battery " + f"profile. {SOC_TABLE_POINTS} values, index i is the limit at SoC i %. " + "Further limits max_discharge_power_w. None means no SoC dependent limit." + ), + "examples": [None], + }, + ) + charging_efficiency_curve: Optional[list[tuple[float, float]]] = Field( + default=None, + json_schema_extra={ + "description": ( + "Power dependent charging efficiency as a list of (power_fraction, " + "efficiency) points. The power fraction is the charging power relative " + "to max_charge_power_w [0.0 ... 1.0]; points must be strictly increasing " + "in power fraction, efficiencies in (0, 1]. Values are interpolated " + "linearly and clamped to the first/last point. When set, the curve " + "replaces charging_efficiency in the simulation. None means constant " + "charging_efficiency." + ), + "examples": [None, [[0.0, 0.975], [0.5, 0.96], [1.0, 0.94]]], + }, + ) + discharging_efficiency_curve: Optional[list[tuple[float, float]]] = Field( + default=None, + json_schema_extra={ + "description": ( + "Power dependent discharging efficiency as a list of (power_fraction, " + "efficiency) points. The power fraction is the discharging power " + "relative to max_discharge_power_w [0.0 ... 1.0]; same rules as " + "charging_efficiency_curve. When set, the curve replaces " + "discharging_efficiency in the simulation. None means constant " + "discharging_efficiency." + ), + "examples": [None, [[0.0, 0.975], [1.0, 0.96]]], + }, + ) + + @field_validator("charge_power_limit_w_by_soc", "discharge_power_limit_w_by_soc") + @classmethod + def _validate_power_limit_by_soc(cls, value: Optional[list[float]]) -> Optional[list[float]]: + if value is None: + return None + if len(value) != SOC_TABLE_POINTS: + raise ValueError( + f"A power limit table needs {SOC_TABLE_POINTS} values (SoC 0 % ... 100 %), " + f"got {len(value)}." + ) + for power_w in value: + if not math.isfinite(power_w) or power_w < 0: + raise ValueError(f"Power limits must be finite and >= 0 W, got {power_w}.") + return value + + @field_validator("charging_efficiency_curve", "discharging_efficiency_curve") + @classmethod + def _validate_efficiency_curve( + cls, value: Optional[list[tuple[float, float]]] + ) -> Optional[list[tuple[float, float]]]: + return validate_efficiency_curve(value) class SolarPanelBatteryParameters(BaseBatteryParameters): @@ -199,9 +301,106 @@ class ElectricVehicleParameters(BaseBatteryParameters): return to_datetime(value) +class _PowerCurves: + """Battery profile data of a simulated battery, prepared for fast access. + + All values are precomputed for the slot duration of the battery, so the + simulation only scales and indexes per call. Every table has 101 entries + (index 0 ... 100); the hot paths rely on that bound. + + Attributes: + charge_limit_wh_by_soc: Raw charge energy of one slot [Wh] per SoC + percent, capped at the rated charge power. None without limit table. + discharge_limit_wh_by_soc: Raw discharge energy of one slot [Wh] per + SoC percent, capped at the rated discharge power. None without table. + charging_efficiency_by_power: Charging efficiency per power percent of + the rated charge power. None without curve. + discharging_efficiency_by_power: Discharging efficiency per power + percent of the rated discharge power. None without curve. + soc_index_per_wh: Factor from stored energy [Wh] to SoC percent. + charge_index_per_wh: Factor from raw charge energy of one slot [Wh] to + power percent of the rated charge power. + discharge_index_per_wh: Factor from raw discharge energy of one slot + [Wh] to power percent of the rated discharge power. + discharged_dc_wh_per_slot: Energy delivered by the battery per slot [Wh]. + charged_stored_wh_per_slot: Energy stored into the battery per slot [Wh]. + """ + + __slots__ = ( + "charge_limit_wh_by_soc", + "discharge_limit_wh_by_soc", + "charging_efficiency_by_power", + "discharging_efficiency_by_power", + "soc_index_per_wh", + "charge_index_per_wh", + "discharge_index_per_wh", + "discharged_dc_wh_per_slot", + "charged_stored_wh_per_slot", + ) + + def __init__( + self, + parameters: "BaseBatteryParameters", + capacity_wh: float, + max_charge_power_w: float, + max_discharge_power_w: float, + slot_duration_h: float, + slots: int, + ) -> None: + rated_charge_wh = max_charge_power_w * slot_duration_h + rated_discharge_wh = max_discharge_power_w * slot_duration_h + self.charge_limit_wh_by_soc: Optional[list[float]] = ( + [min(limit_w * slot_duration_h, rated_charge_wh) for limit_w in limits] + if (limits := parameters.charge_power_limit_w_by_soc) is not None + else None + ) + self.discharge_limit_wh_by_soc: Optional[list[float]] = ( + [min(limit_w * slot_duration_h, rated_discharge_wh) for limit_w in limits] + if (limits := parameters.discharge_power_limit_w_by_soc) is not None + else None + ) + self.charging_efficiency_by_power = _dense_efficiency_table( + parameters.charging_efficiency_curve + ) + self.discharging_efficiency_by_power = _dense_efficiency_table( + parameters.discharging_efficiency_curve + ) + self.soc_index_per_wh = 100.0 / capacity_wh + # A rated power of 0 W maps every energy to the 0 % entry. + self.charge_index_per_wh = 100.0 / rated_charge_wh if rated_charge_wh > 0 else 0.0 + self.discharge_index_per_wh = 100.0 / rated_discharge_wh if rated_discharge_wh > 0 else 0.0 + self.discharged_dc_wh_per_slot = [0.0] * slots + self.charged_stored_wh_per_slot = [0.0] * slots + + def reset(self, slots: int) -> None: + """Clear the delivered and stored energy of all slots.""" + self.discharged_dc_wh_per_slot = [0.0] * slots + self.charged_stored_wh_per_slot = [0.0] * slots + + +def _table_entry(table: list[float], index: int) -> float: + """Return a table entry, with the index clamped to the table.""" + if index <= 0: + return table[0] + if index >= len(table): + return table[-1] + return table[index] + + class Battery: """Represents a battery device with methods to simulate energy charging and discharging.""" + # Battery profile data, set per instance only if the parameters provide it. + # A class default keeps plain batteries small: the simulation reads their + # attributes for every slot of every candidate plan, and CPython accesses + # attributes of instances with 30 or more of them noticeably slower. + _curves: Optional[_PowerCurves] = None + + @property + def has_power_curves(self) -> bool: + """Whether charging or discharging depends on SoC or power (battery profile).""" + return self._curves is not None + def __init__( self, parameters: BaseBatteryParameters, @@ -251,6 +450,29 @@ class Battery: self.max_charge_power_w = self.parameters.max_charge_power_w else: self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh + if self.parameters.max_discharge_power_w is not None: + self.max_discharge_power_w = self.parameters.max_discharge_power_w + else: + self.max_discharge_power_w = self.max_charge_power_w + + # Optional battery profile data: power limits per SoC percent and + # efficiencies per power percent. + parameters = self.parameters + if ( + parameters.charge_power_limit_w_by_soc is not None + or parameters.discharge_power_limit_w_by_soc is not None + or parameters.charging_efficiency_curve is not None + or parameters.discharging_efficiency_curve is not None + ): + self._curves = _PowerCurves( + parameters, + self.capacity_wh, + self.max_charge_power_w, + self.max_discharge_power_w, + self.slot_duration_h, + self.prediction_hours, + ) + self.discharge_array = np.full(self.prediction_hours, 0) self.charge_array = np.full(self.prediction_hours, 0) # Discharged energy per slot, a plain list: it is read and written one @@ -311,6 +533,7 @@ class Battery: "charging_efficiency": self.charging_efficiency, "discharging_efficiency": self.discharging_efficiency, "max_charge_power_w": self.max_charge_power_w, + "max_discharge_power_w": self.max_discharge_power_w, } def reset(self) -> None: @@ -321,8 +544,30 @@ class Battery: self.charge_array = np.zeros(self.prediction_hours, dtype=np.int_) self._discharged_raw_wh_per_slot = [0.0] * self.prediction_hours self._charged_raw_wh_per_slot = [0.0] * self.prediction_hours + if self._curves is not None: + self._curves.reset(self.prediction_hours) self._charge_limit_raw_wh_per_slot = [float("inf")] * self.prediction_hours + def _discharge_slot_cap_wh(self) -> float: + """Return the raw discharge energy of one slot [Wh] at the current SoC.""" + curves = self._curves + if curves is None or curves.discharge_limit_wh_by_soc is None: + return self.max_discharge_power_w * self.slot_duration_h + return _table_entry( + curves.discharge_limit_wh_by_soc, + int(self.soc_wh * curves.soc_index_per_wh + 0.5), + ) + + def _discharging_efficiency_at(self, slot_raw_wh: float) -> float: + """Return the discharging efficiency at the slot power of ``slot_raw_wh``.""" + curves = self._curves + if curves is None or curves.discharging_efficiency_by_power is None: + return self.discharging_efficiency + return _table_entry( + curves.discharging_efficiency_by_power, + int(slot_raw_wh * curves.discharge_index_per_wh + 0.5), + ) + def limit_slot_charge(self, hour: int, raw_wh: float) -> None: """Cap the raw energy the battery may take in one slot, from all sources. @@ -344,16 +589,18 @@ class Battery: exports at most half the battery's rated discharge power, independent of how much of the slot budget self-consumption already used. """ - return self.max_charge_power_w * self.slot_duration_h * self.discharging_efficiency + rated_slot_wh = self.max_discharge_power_w * self.slot_duration_h + return rated_slot_wh * self._discharging_efficiency_at(rated_slot_wh) def remaining_discharge_energy_wh(self, hour: int) -> float: """Return DC energy still deliverable within one optimization slot.""" - raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h - raw_power_remaining_wh = max( - raw_power_budget_wh - self._discharged_raw_wh_per_slot[hour], 0.0 - ) + discharged_raw_wh = self._discharged_raw_wh_per_slot[hour] + raw_power_remaining_wh = max(self._discharge_slot_cap_wh() - discharged_raw_wh, 0.0) raw_soc_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0) - return min(raw_power_remaining_wh, raw_soc_available_wh) * self.discharging_efficiency + raw_remaining_wh = min(raw_power_remaining_wh, raw_soc_available_wh) + return raw_remaining_wh * self._discharging_efficiency_at( + discharged_raw_wh + raw_remaining_wh + ) def discharge_released(self, hour: int) -> bool: """Whether the plan lets the battery discharge in this slot.""" @@ -361,8 +608,16 @@ class Battery: def discharged_energy_wh(self, hour: int) -> float: """Return DC energy delivered by the battery in one optimization slot.""" + if self._curves is not None: + return self._curves.discharged_dc_wh_per_slot[hour] return self._discharged_raw_wh_per_slot[hour] * self.discharging_efficiency + def charged_energy_wh(self, hour: int) -> float: + """Return the energy stored into the battery in one optimization slot.""" + if self._curves is not None: + return self._curves.charged_stored_wh_per_slot[hour] + return self._charged_raw_wh_per_slot[hour] * self.charging_efficiency + def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None: """Sets the discharge values for each hour.""" if len(discharge_array) != self.prediction_hours: @@ -387,10 +642,13 @@ class Battery: """Discharge energy from the battery. Discharge is limited by: + * Requested delivered energy * Remaining energy above minimum SoC - * Maximum discharge power - * Discharge efficiency + * Maximum discharge power, and the power limit at the current SoC if + ``discharge_power_limit_w_by_soc`` is given + * Discharge efficiency, at the slot power if + ``discharging_efficiency_curve`` is given Args: wh (float): Requested delivered energy in watt-hours. @@ -413,29 +671,56 @@ class Battery: return 0.0, 0.0 # Maximum raw discharge due to power limit, scaled to the slot duration. - # max_charge_power_w is a power [W]; energy movable in one slot is - # power x slot_duration_h. - max_raw_wh = max( - self.max_charge_power_w * self.slot_duration_h - self._discharged_raw_wh_per_slot[hour], - 0.0, - ) # TODO rename to max_discharge_power_w - - # Actual raw withdrawal (internal) - raw_withdrawal_wh = min(raw_available_wh, max_raw_wh) + # The power limit is a power [W]; energy movable in one slot is + # power x slot_duration_h. With a battery profile the limit and the + # efficiency are looked up inline (no calls): this runs for every slot + # of every candidate plan. + discharged_raw_wh = self._discharged_raw_wh_per_slot[hour] + discharging_efficiency = self.discharging_efficiency + curves = self._curves + if curves is None: + slot_cap_wh = self.max_discharge_power_w * self.slot_duration_h + raw_withdrawal_wh = min(raw_available_wh, max(slot_cap_wh - discharged_raw_wh, 0.0)) + else: + limits = curves.discharge_limit_wh_by_soc + if limits is None: + slot_cap_wh = self.max_discharge_power_w * self.slot_duration_h + else: + index = int(self.soc_wh * curves.soc_index_per_wh + 0.5) + slot_cap_wh = limits[index] if 0 <= index <= 100 else _table_entry(limits, index) + raw_withdrawal_wh = slot_cap_wh - discharged_raw_wh + if raw_withdrawal_wh < 0.0: + raw_withdrawal_wh = 0.0 + if raw_available_wh < raw_withdrawal_wh: + raw_withdrawal_wh = raw_available_wh + efficiencies = curves.discharging_efficiency_by_power + if efficiencies is not None: + # Efficiency at the slot power: the energy already discharged + # in this slot plus this request (estimated with the constant + # efficiency), within the possible withdrawal. + raw_estimate_wh = wh / discharging_efficiency + if raw_estimate_wh > raw_withdrawal_wh: + raw_estimate_wh = raw_withdrawal_wh + index = int( + (discharged_raw_wh + raw_estimate_wh) * curves.discharge_index_per_wh + 0.5 + ) + discharging_efficiency = efficiencies[index] if index <= 100 else efficiencies[-1] # Convert raw to delivered - max_deliverable_wh = raw_withdrawal_wh * self.discharging_efficiency + max_deliverable_wh = raw_withdrawal_wh * discharging_efficiency # Cap by requested delivered energy delivered_wh = min(wh, max_deliverable_wh) # Effective raw withdrawal based on what is delivered - raw_used_wh = delivered_wh / self.discharging_efficiency + raw_used_wh = delivered_wh / discharging_efficiency # Update SoC self.soc_wh -= raw_used_wh self.soc_wh = max(self.soc_wh, self.min_soc_wh) - self._discharged_raw_wh_per_slot[hour] += raw_used_wh + self._discharged_raw_wh_per_slot[hour] = discharged_raw_wh + raw_used_wh + if curves is not None: + curves.discharged_dc_wh_per_slot[hour] += delivered_wh # Losses losses_wh = raw_used_wh - delivered_wh @@ -474,8 +759,12 @@ class Battery: - Available SoC headroom (``max_soc_wh − soc_wh``) - ``max_charge_power_w`` + - The power limit at the current SoC, if ``charge_power_limit_w_by_soc`` + is given. It caps the accepted energy like a battery management system + does; it does not scale the charge factor. - A slot limit set by ``limit_slot_charge()``, if any - - ``charging_efficiency`` + - ``charging_efficiency``, or the efficiency at the slot power if + ``charging_efficiency_curve`` is given Args: wh (float | None): @@ -512,6 +801,7 @@ class Battery: # Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access soc_wh_fast = self.soc_wh + charged_raw_wh = self._charged_raw_wh_per_slot[hour] # Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours). # At slot_duration_h=1.0 (hourly) this equals the legacy power value. max_charge_per_slot_wh_fast = max( @@ -519,10 +809,39 @@ class Battery: self.max_charge_power_w * self.slot_duration_h, self._charge_limit_raw_wh_per_slot[hour], ) - - self._charged_raw_wh_per_slot[hour], + - charged_raw_wh, 0.0, ) charging_efficiency_fast = self.charging_efficiency + # Power limit at the current SoC (battery profile), as raw energy still + # acceptable in this slot. None without SoC dependent limit. Looked up + # inline (no calls): this runs for every slot of every candidate plan. + soc_limit_raw_wh: Optional[float] = None + curves = self._curves + if curves is not None: + limits = curves.charge_limit_wh_by_soc + if limits is not None: + index = int(soc_wh_fast * curves.soc_index_per_wh + 0.5) + soc_limit_raw_wh = ( + limits[index] if 0 <= index <= 100 else _table_entry(limits, index) + ) - charged_raw_wh + if soc_limit_raw_wh < 0.0: + soc_limit_raw_wh = 0.0 + efficiencies = curves.charging_efficiency_by_power + if efficiencies is not None: + # Efficiency at the slot power: the energy already charged in + # this slot plus the intended request within the limits. + intended_raw_wh = ( + wh if wh is not None else max_charge_per_slot_wh_fast * charge_factor + ) + if intended_raw_wh > max_charge_per_slot_wh_fast: + intended_raw_wh = max_charge_per_slot_wh_fast + if soc_limit_raw_wh is not None and soc_limit_raw_wh < intended_raw_wh: + intended_raw_wh = soc_limit_raw_wh + if intended_raw_wh < 0.0: + intended_raw_wh = 0.0 + index = int((charged_raw_wh + intended_raw_wh) * curves.charge_index_per_wh + 0.5) + charging_efficiency_fast = efficiencies[index] if index <= 100 else efficiencies[-1] # Decide mode & determine raw_request_wh and raw_charge_wh if wh is not None and charge_factor == 0.0: # mode 1 @@ -562,6 +881,8 @@ class Battery: # Remaining capacity max_raw_wh = min(raw_charge_wh, max_charge_per_slot_wh_fast) + if soc_limit_raw_wh is not None and soc_limit_raw_wh < max_raw_wh: + max_raw_wh = soc_limit_raw_wh # Actual raw intake raw_input_wh = raw_request_wh if raw_request_wh < max_raw_wh else max_raw_wh @@ -576,7 +897,9 @@ class Battery: ) self.soc_wh = new_soc - self._charged_raw_wh_per_slot[hour] += raw_input_wh + self._charged_raw_wh_per_slot[hour] = charged_raw_wh + raw_input_wh + if curves is not None: + curves.charged_stored_wh_per_slot[hour] += stored_wh losses_wh = raw_input_wh - stored_wh return stored_wh, losses_wh diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index dadf9eeb..f78316e1 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -284,10 +284,10 @@ class Inverter: return battery_discharge_ac, discharge_losses + inverter_discharge_losses # This runs for every slot of every candidate plan in which the battery - # could serve load or export. For the plain Battery the release check, - # the efficiency and Battery.discharge_energy() are computed in one go, - # without the nested calls. The arithmetic is that of - # Battery.discharge_energy(); keep both in step. + # could serve load or export. For the Battery the release check and the + # efficiency are computed in one go, without the nested calls. Without + # battery profile data, Battery.discharge_energy() is inlined as well; + # keep both in step. if battery.discharge_array[hour] == 0: # Not released in this slot: the battery delivers nothing. return 0.0, 0.0 @@ -299,24 +299,29 @@ class Inverter: dc_to_ac_efficiency = self.dc_to_ac_efficiency_at(requested_ac_wh) dc_request = requested_ac_wh / dc_to_ac_efficiency - soc_wh = battery.soc_wh - min_soc_wh = battery.min_soc_wh - discharged_raw_wh = battery._discharged_raw_wh_per_slot - discharging_efficiency = battery.discharging_efficiency - raw_available_wh = max(soc_wh - min_soc_wh, 0.0) - if raw_available_wh == 0.0: - # Match Battery.discharge_energy(): preserve a SoC below the reserve. - return 0.0, 0.0 - max_raw_wh = max( - battery.max_charge_power_w * battery.slot_duration_h - discharged_raw_wh[hour], 0.0 - ) - max_deliverable_wh = min(raw_available_wh, max_raw_wh) * discharging_efficiency - battery_discharge_dc = min(dc_request, max_deliverable_wh) - raw_used_wh = battery_discharge_dc / discharging_efficiency - soc_wh -= raw_used_wh - battery.soc_wh = max(soc_wh, min_soc_wh) - discharged_raw_wh[hour] += raw_used_wh - discharge_losses = raw_used_wh - battery_discharge_dc + if battery._curves is not None: + # SoC / power dependent limits and efficiencies: one call. + battery_discharge_dc, discharge_losses = battery.discharge_energy(dc_request, hour) + else: + soc_wh = battery.soc_wh + min_soc_wh = battery.min_soc_wh + discharged_raw_wh = battery._discharged_raw_wh_per_slot + discharging_efficiency = battery.discharging_efficiency + raw_available_wh = max(soc_wh - min_soc_wh, 0.0) + if raw_available_wh == 0.0: + # Match Battery.discharge_energy(): preserve a SoC below the reserve. + return 0.0, 0.0 + max_raw_wh = max( + battery.max_discharge_power_w * battery.slot_duration_h - discharged_raw_wh[hour], + 0.0, + ) + max_deliverable_wh = min(raw_available_wh, max_raw_wh) * discharging_efficiency + battery_discharge_dc = min(dc_request, max_deliverable_wh) + raw_used_wh = battery_discharge_dc / discharging_efficiency + soc_wh -= raw_used_wh + battery.soc_wh = max(soc_wh, min_soc_wh) + discharged_raw_wh[hour] += raw_used_wh + discharge_losses = raw_used_wh - battery_discharge_dc # Same rule as _delivered_ac_wh(), without the call: a limited discharge # is converted with the efficiency at the AC energy actually delivered. diff --git a/src/akkudoktoreos/devices/genetic0/genetic0battery.py b/src/akkudoktoreos/devices/genetic0/genetic0battery.py index 6faa75e1..bd973af9 100644 --- a/src/akkudoktoreos/devices/genetic0/genetic0battery.py +++ b/src/akkudoktoreos/devices/genetic0/genetic0battery.py @@ -10,7 +10,8 @@ from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DevicePa 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}) + # 0: the battery may not charge, e.g. the profile inhibits it at a low temperature. + return Field(default=5000, ge=0, json_schema_extra={"description": description}) def initial_soc_percentage_field(description: str) -> int: @@ -53,6 +54,17 @@ class Genetic0BaseBatteryParameters(Genetic0DeviceParameters): ) discharging_efficiency: float = discharging_efficiency_field(0.88) max_charge_power_w: Optional[float] = max_charging_power_field() + max_discharge_power_w: Optional[float] = Field( + default=None, + ge=0, + json_schema_extra={ + "description": ( + "Maximum discharging power in watts. 0: the battery may not discharge. " + "None uses max_charge_power_w." + ), + "examples": [None, 4000], + }, + ) 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)." ) @@ -136,6 +148,11 @@ class Genetic0Battery: self.max_charge_power_w = self.parameters.max_charge_power_w else: self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh + # The charge power may be 0 (e.g. too cold to charge) while discharging is allowed. + if self.parameters.max_discharge_power_w is not None: + self.max_discharge_power_w = self.parameters.max_discharge_power_w + else: + self.max_discharge_power_w = self.max_charge_power_w self.discharge_array = np.full(self.prediction_hours, 0) self.charge_array = np.full(self.prediction_hours, 0) self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh @@ -232,7 +249,7 @@ class Genetic0Battery: return 0.0, 0.0 # Maximum raw discharge due to power limit - max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w + max_raw_wh = self.max_discharge_power_w # Actual raw withdrawal (internal) raw_withdrawal_wh = min(raw_available_wh, max_raw_wh) diff --git a/src/akkudoktoreos/devices/profile/batteryprofile.py b/src/akkudoktoreos/devices/profile/batteryprofile.py new file mode 100644 index 00000000..638d511a --- /dev/null +++ b/src/akkudoktoreos/devices/profile/batteryprofile.py @@ -0,0 +1,779 @@ +"""Battery profile documents for AkkudoktorEOS device controllers and simulation. + +This module defines the schema of a battery profile document: temperature- and +SOC-dependent charge and discharge limit maps, optional C-rate-dependent +efficiency curves, model power limits, technical SOC bounds, an optional +nominal capacity and hard temperature cutoffs. C-rates refer to the effective +nominal capacity: the capacity of the battery device settings if configured, +else the nominal capacity of the profile. + +The document models only describe and validate the data. The other concerns +live in their own modules: + +- ``batteryprofileruntime``: the ready-to-use ``BatteryProfile`` and the + algorithms that compile a document into it (reference interpolation and + precomputation of dense lookup tables). +- ``profilestore``: where documents are stored, loading and saving them, + and a cache of compiled profiles. ``BatteryProfileSettings`` is the battery + profile type of the store (kind ``"battery"``). + +The classmethods and methods of ``BatteryProfileSettings`` that load, save or +compile documents are kept as thin delegates to these modules. The recommended +entry point is the store:: + + from akkudoktoreos.devices.profile.profilestore import get_profile_store + + profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp") + p_charge = profile.max_charge_crate(temperature_degC=12.0, soc_percent=30.0) + if p_charge is None: + ... # charging must be inhibited at this operating point +""" + +from pathlib import Path +from typing import Any, ClassVar, List, Literal, Optional, Union + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + BatteryProfile as BatteryProfile, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + EfficiencyInterpolation as EfficiencyInterpolation, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + Interpolation as Interpolation, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + OutOfRangeBehavior as OutOfRangeBehavior, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + PrecomputedChargeDischargeLimitMap as PrecomputedChargeDischargeLimitMap, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + PrecomputedEfficiencyCurve as PrecomputedEfficiencyCurve, +) +from akkudoktoreos.devices.profile.batteryprofileruntime import ( + compile_battery_profile, + precompute_limit_maps, + reference_efficiency_lookup, + reference_max_charge_crate, + reference_max_discharge_crate, +) +from akkudoktoreos.devices.profile.profilestore import ( + ProfileDocument, + get_profile_store, +) + + +class ProfileBaseModel(SettingsBaseModel): + """Base of all battery profile document models. + + Unknown keys are rejected, so a misspelled key (e.g. ``nominal_capacity_kwh`` + for ``nominal_capacity_wh``) fails validation instead of being ignored. + """ + + model_config = ConfigDict(extra="forbid") + + +class Units(ProfileBaseModel): + """Units of the axes and of the limit values in a limit table. + + The units describe how to interpret the numbers in Axes and ChargeDischargeLimitMap. + They do not affect the interpolation itself; they only tell the controller + how to convert the resulting limit into power or current before applying + it. + """ + + temperature: Literal["degC"] = Field( + default="degC", + json_schema_extra={ + "description": "Unit of the temperature axis and the hard cutoffs: 'degC'.", + "examples": ["degC"], + }, + ) + soc: Literal["percent"] = Field( + default="percent", + json_schema_extra={ + "description": "Unit of the SOC axis: 'percent' (0..100).", + "examples": ["percent"], + }, + ) + limit: Literal["C-rate", "W", "A"] = Field( + ..., + json_schema_extra={ + "description": ( + "Unit of the limit values. 'C-rate' (multiply by nominal capacity for power), " + "'W' (absolute power) or 'A' (absolute current, needs nominal_voltage_v)." + ), + "examples": ["C-rate"], + }, + ) + + +class EfficiencyCurve(ProfileBaseModel): + """Battery charge or discharge efficiency as a function of C-rate. + + C-rate is based on the effective nominal battery capacity: the capacity + configured on the device if set, else the nominal capacity of the profile. + Efficiency is expressed as a fraction in the range (0, 1]. + """ + + description: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": "Free-form note about the source or meaning of this curve.", + "examples": ["Charge efficiency of a typical LFP pack"], + }, + ) + c_rate: List[float] = Field( + ..., + min_length=2, + json_schema_extra={ + "description": "Strictly increasing absolute C-rate breakpoints.", + "examples": [[0.05, 0.1, 0.25, 0.5, 1.0]], + }, + ) + efficiency: List[float] = Field( + ..., + min_length=2, + json_schema_extra={ + "description": "Efficiency fractions corresponding to each C-rate.", + "examples": [[0.90, 0.94, 0.96, 0.95, 0.91]], + }, + ) + interpolation: EfficiencyInterpolation = Field( + default=EfficiencyInterpolation.LINEAR, + json_schema_extra={"description": "Interpolation between C-rate breakpoints."}, + ) + out_of_range_behavior: OutOfRangeBehavior = Field( + default=OutOfRangeBehavior.CLAMP_TO_NEAREST, + json_schema_extra={ + "description": "Clamp to the nearest endpoint or reject out-of-range C-rates." + }, + ) + + @field_validator("c_rate") + @classmethod + def valid_c_rates(cls, values: List[float]) -> List[float]: + """Validate non-negative, strictly increasing C-rate breakpoints.""" + if any(value < 0 for value in values): + raise ValueError("C-rate breakpoints must be non-negative") + if any(right <= left for left, right in zip(values, values[1:])): + raise ValueError("C-rate breakpoints must be strictly increasing") + return values + + @field_validator("efficiency") + @classmethod + def valid_efficiencies(cls, values: List[float]) -> List[float]: + """Validate efficiency fractions in the interval (0, 1].""" + if any(not 0.0 < value <= 1.0 for value in values): + raise ValueError("efficiency values must be in the range (0, 1]") + return values + + @model_validator(mode="after") + def matching_lengths(self) -> "EfficiencyCurve": + """Ensure each C-rate breakpoint has exactly one efficiency value.""" + if len(self.c_rate) != len(self.efficiency): + raise ValueError("c_rate and efficiency must contain the same number of values") + return self + + def lookup(self, c_rate: float) -> Optional[float]: + """Return the interpolated efficiency at an absolute C-rate. + + Reference implementation, see + ``batteryprofileruntime.reference_efficiency_lookup``. Runtime queries + use ``PrecomputedEfficiencyCurve.lookup``. + """ + return reference_efficiency_lookup(self, c_rate) + + +class Axes(ProfileBaseModel): + """Breakpoints of the temperature and SOC axes of a limit table. + + Both axes must be strictly increasing. The rows of a ChargeDischargeLimitMap align with + temperature_degC and its columns align with soc_percent. + """ + + temperature_degC: List[float] = Field( + ..., + min_length=2, + json_schema_extra={ + "description": ( + "Strictly increasing temperature breakpoints in degrees Celsius. " + "Rows of the limit maps align with these values." + ), + "examples": [[-10, -5, 0, 5, 10, 15, 25, 35, 45, 50]], + }, + ) + soc_percent: List[float] = Field( + ..., + min_length=2, + json_schema_extra={ + "description": ( + "Strictly increasing SOC breakpoints in percent (0..100). " + "Columns of the limit maps align with these values." + ), + "examples": [[0, 5, 10, 20, 40, 60, 80, 90, 95, 100]], + }, + ) + + @field_validator("temperature_degC", "soc_percent") + @classmethod + def strictly_increasing(cls, v: List[float]) -> List[float]: + """Validate that an axis is strictly increasing. + + Args: + v: Axis breakpoints, in order. + + Returns: + The unchanged breakpoints if the axis is strictly increasing. + + Raises: + ValueError: If any breakpoint is not greater than its predecessor. + """ + if any(b <= a for a, b in zip(v, v[1:])): + raise ValueError("axis values must be strictly increasing") + return v + + @field_validator("soc_percent") + @classmethod + def soc_in_range(cls, v: List[float]) -> List[float]: + """Validate that the SOC axis lies within [0, 100]. + + Args: + v: SOC breakpoints in percent. + + Returns: + The unchanged breakpoints if they lie within [0, 100]. + + Raises: + ValueError: If the first breakpoint is below 0 or the last is + above 100. + """ + if v[0] < 0 or v[-1] > 100: + raise ValueError("soc_percent must lie within [0, 100]") + return v + + +class ChargeDischargeLimitMap(ProfileBaseModel): + """Two-dimensional grid of non-negative charge or discharge limits. + + The grid is indexed by temperature along rows and by SOC along columns, + matching the breakpoints of Axes. Values are in the unit declared by + Units. + """ + + description: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": "Free-form note about what this map represents.", + "examples": ["Max charge C-rate; rows = temperature, cols = SOC"], + }, + ) + values: List[List[float]] = Field( + ..., + min_length=2, + json_schema_extra={ + "description": ( + "2D grid of non-negative limit values. Rows align with " + "axes.temperature_degC, columns with axes.soc_percent." + ), + "examples": [ + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.10, 0.10, 0.15, 0.20, 0.25, 0.25, 0.15, 0.08, 0.03, 0.0], + [0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.25, 0.10, 0.0], + ] + ], + }, + ) + + @field_validator("values") + @classmethod + def non_negative(cls, v: List[List[float]]) -> List[List[float]]: + """Validate that all limit values are non-negative. + + Args: + v: Two-dimensional grid of limit values. + + Returns: + The unchanged grid if every value is >= 0. + + Raises: + ValueError: If any value in the grid is negative. + """ + for row in v: + if any(x < 0 for x in row): + raise ValueError("limit values must be non-negative") + return v + + +class HardCutoffs(ProfileBaseModel): + """Absolute temperature thresholds that override the limit maps. + + Outside these bounds the controller must inhibit the operation entirely, + regardless of the interpolated limit map value. Charge and discharge + bounds are independent and may differ. + """ + + description: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": "Free-form note about the cutoffs, e.g. their source.", + "examples": ["Cell manufacturer data sheet"], + }, + ) + charge_allowed_below_degC: float = Field( + ..., + json_schema_extra={ + "description": "Charging is inhibited strictly below this temperature.", + "examples": [-10.0], + }, + ) + charge_allowed_above_degC: float = Field( + ..., + json_schema_extra={ + "description": "Charging is inhibited strictly above this temperature.", + "examples": [55.0], + }, + ) + discharge_allowed_below_degC: float = Field( + ..., + json_schema_extra={ + "description": "Discharging is inhibited strictly below this temperature.", + "examples": [-15.0], + }, + ) + discharge_allowed_above_degC: float = Field( + ..., + json_schema_extra={ + "description": "Discharging is inhibited strictly above this temperature.", + "examples": [60.0], + }, + ) + + @model_validator(mode="after") + def ordered(self) -> "HardCutoffs": + """Validate that each lower cutoff is below its corresponding upper one. + + Returns: + The validated instance. + + Raises: + ValueError: If a charge or discharge lower bound is not strictly + below its upper bound. + """ + if self.charge_allowed_below_degC >= self.charge_allowed_above_degC: + raise ValueError("charge cutoff lower bound must be < upper bound") + if self.discharge_allowed_below_degC >= self.discharge_allowed_above_degC: + raise ValueError("discharge cutoff lower bound must be < upper bound") + return self + + +class BatteryProfileSettings(ProfileBaseModel, ProfileDocument[BatteryProfile]): + """Temperature- and SOC-dependent charge/discharge limits for a battery. + + This profile holds model-specific operating characteristics: charge and + discharge power-limit maps, efficiency curves, technical SOC bounds, hard + temperature cutoffs, optional absolute power limits, an optional nominal + voltage and the nominal capacity. It is the configuration object read by an + AkkudoktorEOS device controller to constrain a battery or hybrid inverter at + the current operating point, and by the simulation to model the same limits. + + A profile contains every value the computation uses. The one exception is + the nominal capacity of a generic profile: its limits are C-rates that scale + with any capacity, so it may leave the capacity out. The battery device then + has to provide the capacity in its ``profile_adjustment``. Unknown keys are + rejected. + + The caller should not query this document directly. Use + ``get_profile_store().get(BatteryProfileSettings, name)`` (or the delegate + ``from_profile_name``) to obtain a precomputed BatteryProfile object instead. + + Attributes: + schema_version: Version of the JSON document format. + battery_model: Model name of the battery pack. + chemistry: Cell chemistry, if relevant for interpretation. + nominal_voltage_v: Nominal voltage, if known. + nominal_capacity_wh: Nominal capacity of the battery model, None for a + generic profile. + max_charge_power_w: Optional absolute model charge-power limit. + max_discharge_power_w: Optional absolute model discharge-power limit. + technical_min_soc_percent: Manufacturer-defined lower SOC limit. + technical_max_soc_percent: Manufacturer-defined upper SOC limit. + full_charge_interval_days: Maximum number of days between two full + charges (SoC recalibration), None if not needed or unknown. + full_charge_detect_percentage: Reported SOC from which the battery + counts as fully charged. + charge_efficiency: Charge-efficiency curve versus C-rate. + discharge_efficiency: Discharge-efficiency curve versus C-rate. + units: Units of the axes and of the limit values. + interpolation: Interpolation strategy between grid points. + out_of_range_behavior: Behavior for queries outside the axis range + but inside the hard cutoffs. + axes: Shared axis breakpoints. + charge_limit_crate: Grid of maximum charge limits. + discharge_limit_crate: Grid of maximum discharge limits. + hard_cutoffs: Absolute thresholds that override the maps. + """ + + profile_kind: ClassVar[str] = "battery" + + schema_version: str = Field( + default="1.1", + json_schema_extra={ + "description": "Version of this schema, for forward-compatibility.", + "examples": ["1.1"], + }, + ) + battery_model: str = Field( + ..., + min_length=1, + json_schema_extra={ + "description": "Model name of the battery pack.", + "examples": ["BYD Battery-Box Premium HVS"], + }, + ) + chemistry: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": "Cell chemistry, if relevant for interpretation.", + "examples": ["LFP"], + }, + ) + nominal_voltage_v: Optional[float] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": "Nominal voltage, used to convert power to current.", + "examples": [102.4], + }, + ) + nominal_capacity_wh: Optional[float] = Field( + default=None, + gt=0, + allow_inf_nan=False, + json_schema_extra={ + "description": ( + "Nominal capacity of this battery model [Wh]. Required for the profile " + "of a specific battery model. A generic profile, whose limits are " + "C-rates, leaves it out; the battery device then has to set " + "'profile_adjustment.nominal_capacity_wh'." + ), + "examples": [None, 10240.0], + }, + ) + max_charge_power_w: Optional[float] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": ( + "Optional absolute maximum charge power for this battery model. " + "The effective limit should also respect the installed inverter/device limit." + ), + "examples": [5000.0], + }, + ) + max_discharge_power_w: Optional[float] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": ( + "Optional absolute maximum discharge power for this battery model. " + "The effective limit should also respect the installed inverter/device limit." + ), + "examples": [5000.0], + }, + ) + technical_min_soc_percent: float = Field( + ..., + ge=0, + le=100, + json_schema_extra={ + "description": ( + "Manufacturer-defined technical minimum SOC [%], not the user's " + "operational reserve setting." + ), + "examples": [5.0], + }, + ) + technical_max_soc_percent: float = Field( + ..., + ge=0, + le=100, + json_schema_extra={ + "description": ( + "Manufacturer-defined technical maximum SOC [%], not the user's " + "operational target setting." + ), + "examples": [95.0], + }, + ) + full_charge_interval_days: Optional[float] = Field( + default=None, + gt=0, + allow_inf_nan=False, + json_schema_extra={ + "description": ( + "Maximum number of days between two full charges, e.g. the interval " + "the battery management system needs to recalibrate its state of " + "charge (typical for LFP) [d]. None if the battery needs no periodic " + "full charge or the interval is unknown." + ), + "examples": [None, 21.0], + }, + ) + full_charge_detect_percentage: int = Field( + default=99, + ge=1, + le=100, + json_schema_extra={ + "description": ( + "Reported state of charge from which the battery management system " + "counts as fully charged [%]. Many never report exactly 100 %." + ), + "examples": [99], + }, + ) + charge_efficiency: EfficiencyCurve = Field( + ..., + json_schema_extra={"description": "Charge efficiency curve versus absolute C-rate."}, + ) + discharge_efficiency: EfficiencyCurve = Field( + ..., + json_schema_extra={"description": "Discharge efficiency curve versus absolute C-rate."}, + ) + units: Units = Field( + ..., + json_schema_extra={ + "description": "Units of axes and limit values.", + }, + ) + interpolation: Interpolation = Field( + default=Interpolation.BILINEAR, + json_schema_extra={ + "description": ( + "How to interpolate between grid points: 'bilinear', 'nearest' " + "or 'none' (exact grid points only)." + ), + "examples": ["bilinear"], + }, + ) + out_of_range_behavior: OutOfRangeBehavior = Field( + default=OutOfRangeBehavior.CLAMP_TO_NEAREST, + json_schema_extra={ + "description": ( + "What to do when a query lies outside the axis range but inside " + "the hard cutoffs: 'clamp_to_nearest' or 'reject'." + ), + "examples": ["clamp_to_nearest"], + }, + ) + axes: Axes = Field( + ..., + json_schema_extra={"description": "Axis breakpoints for both dimensions."}, + ) + charge_limit_crate: ChargeDischargeLimitMap = Field( + ..., + json_schema_extra={"description": "Maximum charge limit map."}, + ) + discharge_limit_crate: ChargeDischargeLimitMap = Field( + ..., + json_schema_extra={"description": "Maximum discharge limit map."}, + ) + hard_cutoffs: HardCutoffs = Field( + ..., + json_schema_extra={"description": "Absolute protection thresholds."}, + ) + + @model_validator(mode="after") + def current_limits_need_voltage(self) -> "BatteryProfileSettings": + """Validate that current limits ('A') come with the nominal voltage. + + Without it the limits cannot be converted to power. + + Returns: + The validated instance. + + Raises: + ValueError: If units.limit is 'A' and nominal_voltage_v is not set. + """ + if self.units.limit == "A" and self.nominal_voltage_v is None: + raise ValueError("units.limit 'A' needs nominal_voltage_v to convert currents to power") + return self + + @model_validator(mode="after") + def grids_match_axes(self) -> "BatteryProfileSettings": + """Validate that both limit maps match the shape of the shared axes. + + The number of rows of each map must equal the number of temperature + breakpoints, and the number of columns must equal the number of SOC + breakpoints. + + Returns: + The validated instance. + + Raises: + ValueError: If either map's shape does not match the axes. + """ + n_temp = len(self.axes.temperature_degC) + n_soc = len(self.axes.soc_percent) + for name, grid in ( + ("charge_limit_crate", self.charge_limit_crate.values), + ("discharge_limit_crate", self.discharge_limit_crate.values), + ): + if len(grid) != n_temp: + raise ValueError(f"{name}: expected {n_temp} rows (temperature), got {len(grid)}") + for i, row in enumerate(grid): + if len(row) != n_soc: + raise ValueError( + f"{name}: row {i} expected {n_soc} columns (SOC), got {len(row)}" + ) + if self.technical_min_soc_percent >= self.technical_max_soc_percent: + raise ValueError( + "technical_min_soc_percent must be less than technical_max_soc_percent" + ) + return self + + # --- storage (delegates to profilestore) ------------------------------ + + def save_profile(self, name: str) -> Path: + """Save this document to the user data directory as JSON. + + Saved as ``.json``, see ``ProfileStore.save``. The profile name is + independent of ``battery_model``, which is free text. + + Args: + name: Profile name, see ``ProfileStore`` for the allowed characters. + + Returns: + The path the profile was written to. + + Raises: + ValueError: If the name is invalid or reserved. + OSError: If the file or its parent directories cannot be written. + """ + return get_profile_store().save(name, self) + + @classmethod + def load_profile(cls, name: str) -> "BatteryProfileSettings": + """Load the validated document of a profile by name, not precomputed. + + See ``ProfileStore.load``. + + Args: + name: Profile name, without the ``.json`` suffix. + + Returns: + The validated BatteryProfileSettings. + + Raises: + ValueError: If the name is invalid or the document fails validation. + FileNotFoundError: If no JSON document exists for the given name. + """ + return get_profile_store().load(cls, name) + + @classmethod + def from_profile_name( + cls, + name: str, + t_step: float = 1.0, + s_step: float = 1.0, + ) -> BatteryProfile: + """Return the compiled profile of a profile name. + + See ``ProfileStore.get``; the result is cached. + + Args: + name: Profile name, without the ``.json`` suffix. + t_step: Spacing of the precomputed temperature axis [degC]. + s_step: Spacing of the precomputed SOC axis [%]. + + Returns: + A ready-to-use BatteryProfile object. + + Raises: + FileNotFoundError: If no JSON document exists for the given name. + ValueError: If the document fails validation, or if t_step or + s_step is not positive. + """ + return get_profile_store().get(cls, name, t_step=t_step, s_step=s_step) + + @classmethod + def from_json_file( + cls, + path: Union[str, Path], + t_step: float = 1.0, + s_step: float = 1.0, + ) -> BatteryProfile: + """Load a JSON document from an explicit path and compile it (not cached). + + Intended for configuration overrides, tests and tooling. + + Args: + path: Path to the JSON document. + t_step: Spacing of the precomputed temperature axis [degC]. + s_step: Spacing of the precomputed SOC axis [%]. + + Returns: + A ready-to-use BatteryProfile object. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the document fails validation, or if t_step or + s_step is not positive. + """ + document = get_profile_store().load_file(cls, path) + return compile_battery_profile(document, t_step=t_step, s_step=s_step) + + # --- compilation (delegates to batteryprofileruntime) ----------------- + + def compile_profile( + self, t_step: float = 1.0, s_step: float = 1.0, **options: Any + ) -> BatteryProfile: + """Compile this document for the profile store, see ``to_limits``. + + Raises: + TypeError: If an unknown option is given. + """ + if options: + raise TypeError(f"Unknown battery profile options: {sorted(options)}") + return compile_battery_profile(self, t_step=t_step, s_step=s_step) + + def to_limits(self, t_step: float = 1.0, s_step: float = 1.0) -> BatteryProfile: + """Compile this document into a ready-to-use BatteryProfile. + + See ``batteryprofileruntime.compile_battery_profile``. + """ + return compile_battery_profile(self, t_step=t_step, s_step=s_step) + + def precompute_charge_discharge_limit_map( + self, + t_step: float = 1.0, + s_step: float = 1.0, + apply_cutoffs: bool = True, + ) -> tuple[PrecomputedChargeDischargeLimitMap, PrecomputedChargeDischargeLimitMap]: + """Resample both limit maps onto dense, uniformly spaced grids. + + See ``batteryprofileruntime.precompute_limit_maps``. + """ + return precompute_limit_maps( + self, t_step=t_step, s_step=s_step, apply_cutoffs=apply_cutoffs + ) + + def max_charge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]: + """Return the maximum charge limit by slow per-query interpolation. + + Reference implementation; controllers use ``BatteryProfile.max_charge_crate``. + See ``batteryprofileruntime.reference_max_charge_crate``. + """ + return reference_max_charge_crate(self, temperature_degC, soc_percent) + + def max_discharge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]: + """Return the maximum discharge limit by slow per-query interpolation. + + Reference implementation; controllers use ``BatteryProfile.max_discharge_crate``. + See ``batteryprofileruntime.reference_max_discharge_crate``. + """ + return reference_max_discharge_crate(self, temperature_degC, soc_percent) diff --git a/src/akkudoktoreos/devices/profile/batteryprofileruntime.py b/src/akkudoktoreos/devices/profile/batteryprofileruntime.py new file mode 100644 index 00000000..b6f8d164 --- /dev/null +++ b/src/akkudoktoreos/devices/profile/batteryprofileruntime.py @@ -0,0 +1,669 @@ +"""Runtime form of a battery profile and the algorithms that build it. + +A battery profile document (``BatteryProfileSettings``) describes limits as sparse, +non-uniform maps and curves. This module turns such a document into the +ready-to-use, immutable ``BatteryProfile`` object queried by controllers and the +simulation: + +- ``compile_battery_profile`` resamples both limit maps onto dense, uniformly + spaced grids and precomputes the efficiency curves, so each runtime query is a + single array index lookup. +- The ``reference_*`` functions are the slow per-query interpolation the + precomputation is built from. They define the semantics of a profile document. + +The module has no pydantic models and does not read files or configuration. The +interpolation enums live here because the algorithms interpret them; the +document module re-exports them. +""" + +from bisect import bisect_left, bisect_right +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, List, Optional + +import numpy as np + +if TYPE_CHECKING: + from akkudoktoreos.devices.profile.batteryprofile import ( + BatteryProfileSettings, + EfficiencyCurve, + ) + + +class Interpolation(str, Enum): + """Strategy used to interpolate between grid points of a limit map. + + Attributes: + BILINEAR: Interpolate linearly along both axes. + NEAREST: Snap to the nearest grid point. + NONE: Only exact grid points yield a value; anything else yields + None. + """ + + BILINEAR = "bilinear" + NEAREST = "nearest" + NONE = "none" + + +class OutOfRangeBehavior(str, Enum): + """Behavior when a query lies outside the axis range but inside cutoffs. + + Attributes: + CLAMP_TO_NEAREST: Return the value at the nearest edge of the map. + REJECT: Return None for the out-of-range query. + """ + + CLAMP_TO_NEAREST = "clamp_to_nearest" + REJECT = "reject" + + +class EfficiencyInterpolation(str, Enum): + """Interpolation strategy for one-dimensional efficiency curves.""" + + LINEAR = "linear" + NEAREST = "nearest" + NONE = "none" + + +# --------------------------------------------------------------------------- +# Precomputed runtime objects +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PrecomputedEfficiencyCurve: + """Immutable runtime representation of a C-rate efficiency curve. + + Segment slopes/intercepts and nearest-neighbor midpoints are calculated + once when the battery profile is created. Runtime lookups therefore avoid + repeatedly calculating interpolation coefficients or scanning every + breakpoint. The C-rate axis remains independent of installed capacity. + """ + + c_rate: tuple[float, ...] + efficiency: tuple[float, ...] + interpolation: EfficiencyInterpolation + out_of_range_behavior: OutOfRangeBehavior + slopes: tuple[float, ...] + intercepts: tuple[float, ...] + midpoints: tuple[float, ...] + + @classmethod + def from_curve(cls, curve: "EfficiencyCurve") -> "PrecomputedEfficiencyCurve": + """Build immutable lookup data from a validated configuration curve.""" + c_rates = tuple(curve.c_rate) + efficiencies = tuple(curve.efficiency) + slopes = tuple( + (efficiencies[i + 1] - efficiencies[i]) / (c_rates[i + 1] - c_rates[i]) + for i in range(len(c_rates) - 1) + ) + intercepts = tuple(efficiencies[i] - slopes[i] * c_rates[i] for i in range(len(slopes))) + midpoints = tuple((c_rates[i] + c_rates[i + 1]) / 2.0 for i in range(len(c_rates) - 1)) + return cls( + c_rate=c_rates, + efficiency=efficiencies, + interpolation=curve.interpolation, + out_of_range_behavior=curve.out_of_range_behavior, + slopes=slopes, + intercepts=intercepts, + midpoints=midpoints, + ) + + def lookup(self, c_rate: float) -> Optional[float]: + """Return efficiency at an absolute C-rate using precomputed data.""" + if c_rate < self.c_rate[0]: + if self.out_of_range_behavior == OutOfRangeBehavior.REJECT: + return None + return self.efficiency[0] + if c_rate > self.c_rate[-1]: + if self.out_of_range_behavior == OutOfRangeBehavior.REJECT: + return None + return self.efficiency[-1] + + if self.interpolation == EfficiencyInterpolation.NONE: + index = bisect_left(self.c_rate, c_rate) + if index < len(self.c_rate) and self.c_rate[index] == c_rate: + return self.efficiency[index] + return None + + if self.interpolation == EfficiencyInterpolation.NEAREST: + # bisect_left preserves the original min() tie-break: lower point. + index = bisect_left(self.midpoints, c_rate) + return self.efficiency[index] + + if c_rate == self.c_rate[-1]: + return self.efficiency[-1] + index = bisect_right(self.c_rate, c_rate) - 1 + return self.slopes[index] * c_rate + self.intercepts[index] + + +@dataclass(frozen=True) +class PrecomputedChargeDischargeLimitMap: + """Dense, uniformly spaced lookup table for one direction. + + Instances are produced by ``precompute_limit_maps``. They resample the + sparse, non-uniform limit map onto a regular grid once, so that each + runtime query becomes a single array index lookup instead of a full + bilinear interpolation. + + Attributes: + grid: Two-dimensional array indexed as grid[temp_index, soc_index]. + t_min: Lowest temperature covered by the grid, in degrees Celsius. + t_step: Temperature spacing between rows, in degrees Celsius. + s_min: Lowest SOC covered by the grid, in percent. + s_step: SOC spacing between columns, in percent. + n_temp: Number of temperature rows. + n_soc: Number of SOC columns. + fill: Value returned for queries outside the grid bounds. None means + such queries yield None; a float means they yield that value + (used for clamp-to-edge semantics). + allowed_min_degC: Lowest temperature the direction is allowed at + (hard cutoff, inclusive), None for no cutoff. Checked on the exact + query temperature, before rounding to a grid cell. + allowed_max_degC: Highest temperature the direction is allowed at + (hard cutoff, inclusive), None for no cutoff. + """ + + grid: np.ndarray + t_min: float + t_step: float + s_min: float + s_step: float + n_temp: int + n_soc: int + fill: Optional[float] = None + allowed_min_degC: Optional[float] = None + allowed_max_degC: Optional[float] = None + + def _allowed(self, temperature_degC: float) -> bool: + """Return whether the hard cutoffs allow the direction at the exact temperature.""" + if self.allowed_min_degC is not None and temperature_degC < self.allowed_min_degC: + return False + if self.allowed_max_degC is not None and temperature_degC > self.allowed_max_degC: + return False + return True + + def lookup(self, temperature_degC: float, soc_percent: float) -> Optional[float]: + """Return the precomputed limit at the given operating point. + + A temperature outside the hard cutoffs returns None; the cutoffs are + checked on the exact temperature. Otherwise the query is rounded to the + nearest grid cell. Queries whose coordinates lie outside the grid's + coordinate range return fill (or None if fill is None). The bounds check + is performed on the raw coordinates before rounding, so that a query + just below t_min is not accidentally rounded up into the first cell. + + Args: + temperature_degC: Cell temperature in degrees Celsius. + soc_percent: State of charge in percent. + + Returns: + The stored limit, None if the hard cutoffs inhibit the direction, + or fill if the query is outside the grid's coordinate range. + """ + if not self._allowed(temperature_degC): + return None + t_max = self.t_min + (self.n_temp - 1) * self.t_step + s_max = self.s_min + (self.n_soc - 1) * self.s_step + if ( + temperature_degC < self.t_min + or temperature_degC > t_max + or soc_percent < self.s_min + or soc_percent > s_max + ): + return self.fill + ti = int(round((temperature_degC - self.t_min) / self.t_step)) + si = int(round((soc_percent - self.s_min) / self.s_step)) + value = float(self.grid[ti, si]) + if np.isnan(value): + return self.fill + return value + + def lookup_many( + self, + temperatures: np.ndarray, + socs: np.ndarray, + ) -> np.ndarray: + """Vectorized lookup of many operating points at once. + + Args: + temperatures: Array of temperatures in degrees Celsius. + socs: Array of SOC values in percent, same shape as + temperatures. + + Returns: + Array of limits with the same shape as the inputs. Entries outside + the hard cutoffs are NaN; other out-of-bounds entries are set to + fill, or to NaN if fill is None. + """ + temperatures = np.asarray(temperatures, dtype=float) + socs = np.asarray(socs, dtype=float) + t_max = self.t_min + (self.n_temp - 1) * self.t_step + s_max = self.s_min + (self.n_soc - 1) * self.s_step + in_bounds = ( + (temperatures >= self.t_min) + & (temperatures <= t_max) + & (socs >= self.s_min) + & (socs <= s_max) + ) + ti = np.clip( + np.rint((temperatures - self.t_min) / self.t_step).astype(np.int64), + 0, + self.n_temp - 1, + ) + si = np.clip( + np.rint((socs - self.s_min) / self.s_step).astype(np.int64), + 0, + self.n_soc - 1, + ) + out = np.full(temperatures.shape, np.nan, dtype=float) + out[in_bounds] = self.grid[ti[in_bounds], si[in_bounds]] + if self.fill is not None: + out[~in_bounds] = self.fill + if self.allowed_min_degC is not None: + out[temperatures < self.allowed_min_degC] = np.nan + if self.allowed_max_degC is not None: + out[temperatures > self.allowed_max_degC] = np.nan + return out + + +@dataclass(frozen=True) +class BatteryProfile: + """Ready-to-use charge/discharge limits for a battery. + + Instances are produced by ``compile_battery_profile``, usually through the + profile store (``get_profile_store().get(BatteryProfileSettings, name)``). The metadata + fields identify the battery; precomputed limit maps and efficiency curves + answer queries at runtime. + + Attributes: + battery_model: Model name of the battery pack. + chemistry: Cell chemistry, if known. + nominal_voltage_v: Nominal voltage, if known. + max_charge_power_w: Optional absolute model charge-power limit. + max_discharge_power_w: Optional absolute model discharge-power limit. + technical_min_soc_percent: Manufacturer-defined lower SOC limit. + technical_max_soc_percent: Manufacturer-defined upper SOC limit. + charge_efficiency: Charge-efficiency curve. + discharge_efficiency: Discharge-efficiency curve. + limit_unit: Unit of the values returned by the limit accessors. + charge_map: Precomputed charge limit map. + discharge_map: Precomputed discharge limit map. + nominal_capacity_wh: Nominal capacity of the battery model [Wh], None for + a generic profile. + full_charge_interval_days: Maximum number of days between two full + charges (SoC recalibration), None if not needed or unknown. + full_charge_detect_percentage: Reported SOC from which the battery + counts as fully charged [%]. + """ + + battery_model: str + chemistry: Optional[str] + nominal_voltage_v: Optional[float] + max_charge_power_w: Optional[float] + max_discharge_power_w: Optional[float] + technical_min_soc_percent: float + technical_max_soc_percent: float + charge_efficiency: PrecomputedEfficiencyCurve + discharge_efficiency: PrecomputedEfficiencyCurve + limit_unit: str + charge_map: PrecomputedChargeDischargeLimitMap + discharge_map: PrecomputedChargeDischargeLimitMap + nominal_capacity_wh: Optional[float] = None + full_charge_interval_days: Optional[float] = None + full_charge_detect_percentage: int = 99 + + def max_charge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]: + """Return the maximum charge limit at the given operating point. + + Args: + temperature_degC: Cell temperature in degrees Celsius. + soc_percent: State of charge in percent. + + Returns: + The precomputed charge limit, or None if charging must be + inhibited at this operating point. + """ + return self.charge_map.lookup(temperature_degC, soc_percent) + + def max_discharge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]: + """Return the maximum discharge limit at the given operating point. + + Args: + temperature_degC: Cell temperature in degrees Celsius. + soc_percent: State of charge in percent. + + Returns: + The precomputed discharge limit, or None if discharging must be + inhibited at this operating point. + """ + return self.discharge_map.lookup(temperature_degC, soc_percent) + + def charge_efficiency_at(self, c_rate: float) -> Optional[float]: + """Return charge efficiency at the given absolute C-rate. + + None if the C-rate is outside the curve and the curve rejects it, or the + curve does not interpolate and the C-rate is no breakpoint. + """ + return self.charge_efficiency.lookup(abs(c_rate)) + + def discharge_efficiency_at(self, c_rate: float) -> Optional[float]: + """Return discharge efficiency at the given absolute C-rate. + + None in the same cases as ``charge_efficiency_at``. + """ + return self.discharge_efficiency.lookup(abs(c_rate)) + + +# --------------------------------------------------------------------------- +# Reference implementation (slow, per query) +# --------------------------------------------------------------------------- + + +def reference_efficiency_lookup(curve: "EfficiencyCurve", c_rate: float) -> Optional[float]: + """Return the interpolated efficiency of a curve document at an absolute C-rate. + + Reference semantics for ``PrecomputedEfficiencyCurve.lookup``. + + Args: + curve: Validated efficiency curve document. + c_rate: Absolute C-rate. + + Returns: + The efficiency, or None when the query is outside the curve and the + out-of-range behavior is REJECT, or when interpolation is NONE and the + query is not an exact breakpoint. + """ + if c_rate < curve.c_rate[0] or c_rate > curve.c_rate[-1]: + if curve.out_of_range_behavior == OutOfRangeBehavior.REJECT: + return None + return curve.efficiency[0] if c_rate < curve.c_rate[0] else curve.efficiency[-1] + + if curve.interpolation == EfficiencyInterpolation.NONE: + try: + return curve.efficiency[curve.c_rate.index(c_rate)] + except ValueError: + return None + + if curve.interpolation == EfficiencyInterpolation.NEAREST: + index = min( + range(len(curve.c_rate)), + key=lambda i: abs(curve.c_rate[i] - c_rate), + ) + return curve.efficiency[index] + + for index in range(len(curve.c_rate) - 1): + lower = curve.c_rate[index] + upper = curve.c_rate[index + 1] + if lower <= c_rate <= upper: + fraction = (c_rate - lower) / (upper - lower) + return ( + curve.efficiency[index] + + (curve.efficiency[index + 1] - curve.efficiency[index]) * fraction + ) + + # The endpoint is covered above; this is defensive for floating-point edge cases. + return curve.efficiency[-1] + + +def _bracket(axis: List[float], x: float) -> tuple[int, int, float]: + """Return (i_low, i_high, fraction) for x along a strictly increasing axis.""" + if x <= axis[0]: + return 0, 0, 0.0 + if x >= axis[-1]: + return len(axis) - 1, len(axis) - 1, 0.0 + for i in range(len(axis) - 1): + if axis[i] <= x <= axis[i + 1]: + frac = (x - axis[i]) / (axis[i + 1] - axis[i]) + return i, i + 1, frac + return len(axis) - 1, len(axis) - 1, 0.0 + + +def reference_limit_lookup( + document: "BatteryProfileSettings", + grid: List[List[float]], + temperature_degC: float, + soc_percent: float, +) -> Optional[float]: + """Interpolate a value from a limit grid of a profile document. + + Applies the document's out_of_range_behavior and honors the union of the + charge and discharge hard cutoffs. Callers that need a direction-specific + cutoff use ``reference_max_charge_crate`` or ``reference_max_discharge_crate``. + + Args: + document: Validated battery profile document. + grid: Two-dimensional grid of limit values, indexed by temperature + along rows and SOC along columns. + temperature_degC: Query temperature in degrees Celsius. + soc_percent: Query state of charge in percent. + + Returns: + The interpolated limit, or None if the query is outside the union of + the hard cutoffs, or outside the axis range when out_of_range_behavior + is REJECT, or not on an exact grid point when interpolation is NONE. + """ + temps = document.axes.temperature_degC + socs = document.axes.soc_percent + cutoffs = document.hard_cutoffs + + t_lo = min(cutoffs.charge_allowed_below_degC, cutoffs.discharge_allowed_below_degC) + t_hi = max(cutoffs.charge_allowed_above_degC, cutoffs.discharge_allowed_above_degC) + if temperature_degC < t_lo or temperature_degC > t_hi: + return None + + if document.out_of_range_behavior == OutOfRangeBehavior.REJECT: + if not (temps[0] <= temperature_degC <= temps[-1]): + return None + if not (socs[0] <= soc_percent <= socs[-1]): + return None + + if document.interpolation == Interpolation.NEAREST: + ti = min(range(len(temps)), key=lambda i: abs(temps[i] - temperature_degC)) + si = min(range(len(socs)), key=lambda i: abs(socs[i] - soc_percent)) + return grid[ti][si] + + if document.interpolation == Interpolation.NONE: + if temperature_degC in temps and soc_percent in socs: + return grid[temps.index(temperature_degC)][socs.index(soc_percent)] + return None + + t0, t1, tf = _bracket(temps, temperature_degC) + s0, s1, sf = _bracket(socs, soc_percent) + v00, v01 = grid[t0][s0], grid[t0][s1] + v10, v11 = grid[t1][s0], grid[t1][s1] + v0 = v00 + (v01 - v00) * sf + v1 = v10 + (v11 - v10) * sf + return v0 + (v1 - v0) * tf + + +def reference_max_charge_crate( + document: "BatteryProfileSettings", temperature_degC: float, soc_percent: float +) -> Optional[float]: + """Return the maximum charge limit of a profile document by per-query interpolation. + + Args: + document: Validated battery profile document. + temperature_degC: Cell temperature in degrees Celsius. + soc_percent: State of charge in percent. + + Returns: + The interpolated charge limit, or None if charging must be inhibited at + this operating point. + """ + c = document.hard_cutoffs + if not (c.charge_allowed_below_degC <= temperature_degC <= c.charge_allowed_above_degC): + return None + return reference_limit_lookup( + document, document.charge_limit_crate.values, temperature_degC, soc_percent + ) + + +def reference_max_discharge_crate( + document: "BatteryProfileSettings", temperature_degC: float, soc_percent: float +) -> Optional[float]: + """Return the maximum discharge limit of a profile document by per-query interpolation. + + Args: + document: Validated battery profile document. + temperature_degC: Cell temperature in degrees Celsius. + soc_percent: State of charge in percent. + + Returns: + The interpolated discharge limit, or None if discharging must be + inhibited at this operating point. + """ + c = document.hard_cutoffs + if not (c.discharge_allowed_below_degC <= temperature_degC <= c.discharge_allowed_above_degC): + return None + return reference_limit_lookup( + document, document.discharge_limit_crate.values, temperature_degC, soc_percent + ) + + +# --------------------------------------------------------------------------- +# Compilation +# --------------------------------------------------------------------------- + + +def precompute_limit_maps( + document: "BatteryProfileSettings", + t_step: float = 1.0, + s_step: float = 1.0, + apply_cutoffs: bool = True, +) -> tuple[PrecomputedChargeDischargeLimitMap, PrecomputedChargeDischargeLimitMap]: + """Resample both limit maps of a profile document onto dense, uniform grids. + + The expensive interpolation is performed once here, using the document's + interpolation and out_of_range_behavior. The returned objects answer each + runtime query with a single array index lookup. + + If apply_cutoffs is true, the precomputed grids extend beyond the axis + range far enough to cover the hard cutoffs, and the maps keep the exact + cutoff temperatures: a lookup outside the respective cutoff returns None, + decided on the exact temperature, not on the rounded grid cell. Cells + between the axis range and the cutoff are filled according to + out_of_range_behavior, i.e. with the clamped edge value under + CLAMP_TO_NEAREST or with NaN under REJECT. + + If apply_cutoffs is false, the precomputed grids cover exactly the axis + range and the caller is responsible for checking the cutoffs separately. + + Args: + document: Validated battery profile document. + t_step: Spacing of the precomputed temperature axis, in degrees + Celsius. Defaults to 1.0. + s_step: Spacing of the precomputed SOC axis, in percent. + Defaults to 1.0. + apply_cutoffs: Whether to bake the hard cutoffs into the precomputed + grids. Defaults to True. + + Returns: + A pair (charge_map, discharge_map) of precomputed lookup objects. + + Raises: + ValueError: If t_step or s_step is not positive, or if the axis span + is smaller than the step size. + """ + if t_step <= 0 or s_step <= 0: + raise ValueError("t_step and s_step must be positive") + + temps = np.asarray(document.axes.temperature_degC, dtype=float) + socs = np.asarray(document.axes.soc_percent, dtype=float) + cutoffs = document.hard_cutoffs + + if apply_cutoffs: + t_lo = min( + temps[0], + cutoffs.charge_allowed_below_degC, + cutoffs.discharge_allowed_below_degC, + ) + t_hi = max( + temps[-1], + cutoffs.charge_allowed_above_degC, + cutoffs.discharge_allowed_above_degC, + ) + else: + t_lo, t_hi = float(temps[0]), float(temps[-1]) + + t_grid = np.arange(t_lo, t_hi + t_step * 0.5, t_step) + s_grid = np.arange(socs[0], socs[-1] + s_step * 0.5, s_step) + if t_grid.size < 1 or s_grid.size < 1: + raise ValueError("axis span smaller than step size") + + def resample( + values: List[List[float]], + cutoff_lo: Optional[float], + cutoff_hi: Optional[float], + ) -> PrecomputedChargeDischargeLimitMap: + out = np.empty((t_grid.size, s_grid.size), dtype=float) + for i, t in enumerate(t_grid): + # The cutoffs are not baked into the cells: a cell stands for half a + # step around its temperature, so the map checks them exactly. + for j, s in enumerate(s_grid): + v = reference_limit_lookup(document, values, float(t), float(s)) + out[i, j] = np.nan if v is None else float(v) + # Compiled profiles are cached and shared, so the grid must not change. + out.setflags(write=False) + return PrecomputedChargeDischargeLimitMap( + grid=out, + t_min=float(t_grid[0]), + t_step=float(t_step), + s_min=float(s_grid[0]), + s_step=float(s_step), + n_temp=int(t_grid.size), + n_soc=int(s_grid.size), + fill=None, + allowed_min_degC=cutoff_lo, + allowed_max_degC=cutoff_hi, + ) + + charge_map = resample( + document.charge_limit_crate.values, + cutoffs.charge_allowed_below_degC if apply_cutoffs else None, + cutoffs.charge_allowed_above_degC if apply_cutoffs else None, + ) + discharge_map = resample( + document.discharge_limit_crate.values, + cutoffs.discharge_allowed_below_degC if apply_cutoffs else None, + cutoffs.discharge_allowed_above_degC if apply_cutoffs else None, + ) + return charge_map, discharge_map + + +def compile_battery_profile( + document: "BatteryProfileSettings", t_step: float = 1.0, s_step: float = 1.0 +) -> BatteryProfile: + """Precompute a profile document into a ready-to-use BatteryProfile. + + Args: + document: Validated battery profile document. + t_step: Spacing of the precomputed temperature axis, in degrees + Celsius. Defaults to 1.0. + s_step: Spacing of the precomputed SOC axis, in percent. + Defaults to 1.0. + + Returns: + A ready-to-use BatteryProfile object. + + Raises: + ValueError: If t_step or s_step is not positive. + """ + charge_map, discharge_map = precompute_limit_maps(document, t_step=t_step, s_step=s_step) + return BatteryProfile( + battery_model=document.battery_model, + chemistry=document.chemistry, + nominal_voltage_v=document.nominal_voltage_v, + max_charge_power_w=document.max_charge_power_w, + max_discharge_power_w=document.max_discharge_power_w, + technical_min_soc_percent=document.technical_min_soc_percent, + technical_max_soc_percent=document.technical_max_soc_percent, + charge_efficiency=PrecomputedEfficiencyCurve.from_curve(document.charge_efficiency), + discharge_efficiency=PrecomputedEfficiencyCurve.from_curve(document.discharge_efficiency), + limit_unit=document.units.limit, + charge_map=charge_map, + discharge_map=discharge_map, + nominal_capacity_wh=document.nominal_capacity_wh, + full_charge_interval_days=document.full_charge_interval_days, + full_charge_detect_percentage=document.full_charge_detect_percentage, + ) diff --git a/src/akkudoktoreos/devices/profile/profilestore.py b/src/akkudoktoreos/devices/profile/profilestore.py new file mode 100644 index 00000000..bf72a122 --- /dev/null +++ b/src/akkudoktoreos/devices/profile/profilestore.py @@ -0,0 +1,607 @@ +"""Storage of device profile documents. + +A device profile is a JSON document that describes a device model (e.g. a +battery or an inverter model). Each profile type is a ``ProfileDocument`` +subclass: it validates the document, names its kind (the profile directory) +and compiles the document into its runtime form. Defining a subclass with a +``profile_kind`` registers it, so the kind can be looked up by name (e.g. from +a REST path), see ``profile_type``. + +One store serves all profile types; callers pass the profile type:: + + from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings + from akkudoktoreos.devices.profile.profilestore import get_profile_store + + profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp") + +The store handles what is common to all profile types: + +- locating documents named ``.json``, searching in order + 1. the user data directory: ``/profile/`` + 2. the packaged data directory: ``/data/profile/`` +- listing, loading and validating documents with the profile type, +- saving and deleting documents in the user data directory (packaged profiles + are never modified; deleting a user profile makes a packaged profile of the + same name apply again), +- caching the compiled form per profile type, file and compile options. Saving + or deleting drops the cache entries of the file; a file changed by other + means is recompiled when its modification time or size changes. + +Concurrency: the store is a process-wide singleton and thread safe. Saves are +atomic (a reader sees the old or the new document, never a partial one) and +serialized per file. The methods block on file I/O; async code calls them +through ``asyncio.to_thread``. +""" + +import functools +import inspect +import os +import re +import tempfile +import threading +import time +from pathlib import Path +from typing import ( + Any, + Callable, + ClassVar, + Generic, + Hashable, + Literal, + NamedTuple, + Optional, + TypeVar, + Union, +) + +import cachebox +from loguru import logger +from pydantic import BaseModel, Field + +from akkudoktoreos.core.coreabc import SingletonMixin, get_config + +CompiledT = TypeVar("CompiledT") +ResultT = TypeVar("ResultT") + +PROFILE_SUFFIX: str = ".json" + +# Profile names: one file name stem, the same on every platform (no separators, +# drive letters or ".."). +PROFILE_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}") +# Names the REST API uses for other paths below /v1/profile/{kind}/. +RESERVED_PROFILE_NAMES: frozenset[str] = frozenset({"schema"}) + +# How long get() returns a compiled profile without checking its file again [s]. +# Saving or deleting through the store applies at once; an edit of the file +# outside EOS applies after at most this time. +PROFILE_RECHECK_INTERVAL_S: float = 1.0 +# Maximum number of recently checked profiles (type, name and options) kept. +PROFILE_RECENT_MAXSIZE: int = 256 + +# On Windows, replacing a profile file fails while a reader has it open, and a +# read fails while the file is being replaced (PermissionError). Such sharing +# violations last only as long as the other access, so the store retries them. +# Elsewhere a PermissionError is final. +_RETRY_SHARING_VIOLATIONS: bool = os.name == "nt" +# Number of attempts and the wait between them [s] for a sharing violation. +SHARING_VIOLATION_ATTEMPTS: int = 50 +SHARING_VIOLATION_RETRY_DELAY_S: float = 0.01 + +# Profile kinds are directory names, keep them simple and portable. +_KIND_PATTERN = re.compile(r"^[a-z][a-z0-9]*$") + +# Registered profile types by kind, see ProfileDocument.__pydantic_init_subclass__. +_PROFILE_TYPES: dict[str, type["ProfileDocument[Any]"]] = {} +_PROFILE_TYPES_LOCK = threading.Lock() + + +def _validated_kind(kind: Any, owner: str) -> str: + """Return ``kind`` if it is a valid profile kind. + + Raises: + ValueError: If the kind is not lowercase letters and digits. + """ + if not isinstance(kind, str) or not _KIND_PATTERN.match(kind): + raise ValueError( + f"Invalid profile kind {kind!r} of {owner}: use lowercase letters and digits." + ) + return kind + + +class ProfileDocument(BaseModel, Generic[CompiledT]): + """Base class of profile documents, parametrized by the compiled form. + + Subclasses set ``profile_kind`` and implement ``compile_profile``:: + + class InverterProfileSettings(SettingsBaseModel, ProfileDocument[InverterProfile]): + profile_kind: ClassVar[str] = "inverter" + + def compile_profile(self, **options: Any) -> InverterProfile: ... + + A subclass that sets ``profile_kind`` itself is registered for that kind. + + Attributes: + profile_kind: Profile kind, also the name of the profile subdirectory, + e.g. ``"battery"``. Lowercase letters and digits only. + """ + + profile_kind: ClassVar[str] + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + """Register a subclass that sets its own ``profile_kind``. + + Raises: + ValueError: If the kind is invalid, or another profile type already + has this kind. + """ + super().__pydantic_init_subclass__(**kwargs) + if "profile_kind" not in cls.__dict__: + return # e.g. the parametrized ProfileDocument[X] or a subclass of a profile type + kind = _validated_kind(cls.__dict__["profile_kind"], cls.__name__) + with _PROFILE_TYPES_LOCK: + registered = _PROFILE_TYPES.get(kind) + if registered is not None and ( + registered.__module__, + registered.__qualname__, + ) != (cls.__module__, cls.__qualname__): + raise ValueError( + f"Profile kind '{kind}' of {cls.__qualname__} is already used by " + f"{registered.__module__}.{registered.__qualname__}." + ) + # The same class defined again (e.g. a module reload) replaces the old one. + _PROFILE_TYPES[kind] = cls + + def compile_profile(self, **options: Any) -> CompiledT: + """Compile this document into its runtime form. + + Keyword options come from ``ProfileStore.get`` and are part of the + cache key, so they must be hashable. + """ + raise NotImplementedError(f"{type(self).__name__} does not implement compile_profile") + + +DocumentT = TypeVar("DocumentT", bound=ProfileDocument[Any]) + + +def profile_kind(document_type: type[ProfileDocument[Any]]) -> str: + """Return the validated profile kind of a profile type. + + Raises: + ValueError: If the profile type has no kind or an invalid one. + """ + return _validated_kind(getattr(document_type, "profile_kind", None), document_type.__name__) + + +def profile_type(kind: str) -> type[ProfileDocument[Any]]: + """Return the profile type registered for a kind. + + Raises: + KeyError: If no profile type is registered for the kind. + """ + with _PROFILE_TYPES_LOCK: + try: + return _PROFILE_TYPES[kind] + except KeyError: + raise KeyError(f"Unknown profile kind '{kind}'") from None + + +def profile_kinds() -> list[str]: + """Return the registered profile kinds, sorted.""" + with _PROFILE_TYPES_LOCK: + return sorted(_PROFILE_TYPES) + + +ProfileSource = Literal["user", "package"] + + +class ProfileEntry(BaseModel): + """A profile available in the store.""" + + name: str = Field( + json_schema_extra={ + "description": "Profile name, without the '.json' suffix.", + "examples": ["generic-lfp"], + } + ) + source: ProfileSource = Field( + json_schema_extra={ + "description": ( + "Where the document in effect comes from: 'user' (user data directory, " + "editable) or 'package' (shipped with EOS, read only)." + ), + "examples": ["package"], + } + ) + packaged: bool = Field( + json_schema_extra={ + "description": ( + "Whether a packaged profile of this name exists. Deleting a user profile " + "that overrides it makes the packaged profile apply again." + ), + "examples": [True], + } + ) + + +class _CacheKey(NamedTuple): + """Cache slot of a compiled profile: profile type, file and compile options.""" + + document_type: type[ProfileDocument[Any]] + path: Path + options: tuple[tuple[str, Hashable], ...] + + +class _FileState(NamedTuple): + """State of a profile file the compiled profile was built from.""" + + mtime_ns: int + size: int + + +@functools.lru_cache(maxsize=None) +def _compile_signature(document_type: type[ProfileDocument[Any]]) -> inspect.Signature: + """Return the signature of ``compile_profile`` of a profile type (cached).""" + return inspect.signature(document_type.compile_profile) + + +def _options_key( + document_type: type[ProfileDocument[Any]], options: dict[str, Any] +) -> tuple[tuple[str, Hashable], ...]: + """Return the cache key part of compile options, with defaults filled in. + + Calls that compile the same way share a cache entry, e.g. ``get(T, name)`` + and ``get(T, name, t_step=1.0)`` if ``1.0`` is the default of ``t_step``. + + Raises: + TypeError: If the options do not fit the signature of ``compile_profile``. + """ + signature = _compile_signature(document_type) + bound = signature.bind(None, **options) + bound.apply_defaults() + effective: dict[str, Any] = {} + for parameter_name, value in list(bound.arguments.items())[1:]: # skip self + if signature.parameters[parameter_name].kind is inspect.Parameter.VAR_KEYWORD: + effective.update(value) + else: + effective[parameter_name] = value + return tuple(sorted(effective.items())) + + +def _profile_names(directory: Path) -> set[str]: + """Return the names of the profile documents in a directory. + + Files whose stem is no valid profile name are skipped. + """ + if not directory.is_dir(): + return set() + return { + path.stem + for path in directory.glob(f"*{PROFILE_SUFFIX}") + if path.is_file() + and PROFILE_NAME_PATTERN.fullmatch(path.stem) + and path.stem not in RESERVED_PROFILE_NAMES + } + + +def _retry_sharing_violation(operation: Callable[[], ResultT]) -> ResultT: + """Run a file operation, retrying it while Windows reports a sharing violation. + + Args: + operation: The file operation, e.g. a read or an ``os.replace``. + + Returns: + The result of the operation. + + Raises: + PermissionError: If the operation still fails after all attempts, or at + once outside Windows. + """ + attempt = 1 + while True: + try: + return operation() + except PermissionError: + if not _RETRY_SHARING_VIOLATIONS or attempt >= SHARING_VIOLATION_ATTEMPTS: + raise + attempt += 1 + time.sleep(SHARING_VIOLATION_RETRY_DELAY_S) + + +class ProfileStore(SingletonMixin): + """Store for profile documents of all profile types (process-wide singleton). + + Every method takes the profile type (a ``ProfileDocument`` subclass) that + selects the profile directory, the document validation and the compile + function. Use ``get_profile_store()``. + """ + + def __init__(self) -> None: + if hasattr(self, "_initialized"): + return + self._compiled: dict[_CacheKey, tuple[_FileState, Any]] = {} + # Profiles checked recently: (type, name, options) -> compiled profile, + # expiring PROFILE_RECHECK_INTERVAL_S after they were checked. + self._recent: cachebox.TTLCache = cachebox.TTLCache( + PROFILE_RECENT_MAXSIZE, PROFILE_RECHECK_INTERVAL_S + ) + # Incremented whenever profiles are saved, deleted or the cache is cleared: + # a lookup that started before must not record its result as recent. + self._generation = 0 + # Guards _compiled, _write_locks, _generation and the generation check + # of _recent; never held for I/O. + self._cache_lock = threading.Lock() + self._write_locks: dict[Path, threading.Lock] = {} + self._initialized = True + + # --- locations --------------------------------------------------------- + + def user_dir(self, document_type: type[ProfileDocument[Any]]) -> Path: + """Return the user data directory of a profile type (the write location).""" + return get_config().general.data_folder_path / "profile" / profile_kind(document_type) + + def package_dir(self, document_type: type[ProfileDocument[Any]]) -> Path: + """Return the packaged data directory of a profile type (read only).""" + return get_config().package_root_path / "data" / "profile" / profile_kind(document_type) + + @staticmethod + def _filename(document_type: type[ProfileDocument[Any]], name: str) -> str: + """Return the file name of a profile, rejecting invalid names. + + A name is a single file name stem: letters, digits, ``.``, ``_`` and + ``-``, starting with a letter or digit, at most 100 characters. That + rules out separators, drive letters and ``..`` on every platform. + + Raises: + ValueError: If the name is invalid or reserved. + """ + kind = profile_kind(document_type) + if not PROFILE_NAME_PATTERN.fullmatch(name): + raise ValueError( + f"Invalid {kind} profile name '{name}': use letters, digits, '.', '_' and " + "'-', starting with a letter or digit (at most 100 characters)." + ) + if name in RESERVED_PROFILE_NAMES: + raise ValueError(f"The {kind} profile name '{name}' is reserved.") + return f"{name}{PROFILE_SUFFIX}" + + def path(self, document_type: type[ProfileDocument[Any]], name: str) -> tuple[Path, bool]: + """Locate the document of a profile. + + Searches the user data directory first and the packaged data directory + second. + + Args: + document_type: Profile type. + name: Profile name without the ``.json`` suffix. + + Returns: + A 2-tuple ``(path, exists)``. If the document exists, ``path`` is its + location; otherwise it is the candidate path in the user data + directory (the preferred write location). + + Raises: + ValueError: If the profile kind or the name is invalid. + """ + filename = self._filename(document_type, name) + user_dir = self.user_dir(document_type) + for directory in (user_dir, self.package_dir(document_type)): + candidate = directory / filename + if candidate.is_file(): + return candidate, True + return user_dir / filename, False + + def existing_path(self, document_type: type[ProfileDocument[Any]], name: str) -> Path: + """Return the path of an existing profile document. + + Raises: + ValueError: If the profile kind or the name is invalid. + FileNotFoundError: If no document exists for the name. The message + names the expected path and the searched directories. + """ + path, exists = self.path(document_type, name) + if not exists: + raise FileNotFoundError( + f"No {profile_kind(document_type)} profile for '{name}': expected {path}. " + f"Available profiles are the {PROFILE_SUFFIX} files in {path.parent} " + f"and {self.package_dir(document_type)}." + ) + return path + + def list_profiles(self, document_type: type[ProfileDocument[Any]]) -> list[ProfileEntry]: + """Return the profiles of a profile type, sorted by name. + + A user profile that has the name of a packaged profile overrides it and + is listed once, with ``source`` ``"user"``. + """ + user = _profile_names(self.user_dir(document_type)) + package = _profile_names(self.package_dir(document_type)) + return [ + ProfileEntry( + name=name, + source="user" if name in user else "package", + packaged=name in package, + ) + for name in sorted(user | package) + ] + + # --- documents ----------------------------------------------------------- + + def load_file(self, document_type: type[DocumentT], path: Union[str, Path]) -> DocumentT: + """Load and validate a profile document from an explicit path. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the document fails validation. + """ + content = _retry_sharing_violation(lambda: Path(path).read_text(encoding="utf-8")) + return document_type.model_validate_json(content) + + def load(self, document_type: type[DocumentT], name: str) -> DocumentT: + """Load and validate the document of a profile by name, not compiled. + + Raises: + ValueError: If the profile kind or the name is invalid, or the + document fails validation. + FileNotFoundError: If no document exists for the name. + """ + return self.load_file(document_type, self.existing_path(document_type, name)) + + def _write_lock(self, path: Path) -> threading.Lock: + """Return the lock that serializes writes of one profile file.""" + with self._cache_lock: + return self._write_locks.setdefault(path, threading.Lock()) + + def _invalidate(self, path: Path) -> None: + """Drop the compiled profiles built from a file.""" + with self._cache_lock: + for key in [key for key in self._compiled if key.path == path]: + del self._compiled[key] + # The name may now resolve to another file (e.g. a user profile that + # overrides a packaged one), so forget all recent checks. + self._recent.clear() + self._generation += 1 + + def save(self, name: str, document: ProfileDocument[Any]) -> Path: + """Save a profile document to the user data directory of its type. + + The file is replaced atomically: concurrent readers see the old or the + new document. On Windows, a replace or a read that collides with the + other is retried briefly. An existing file with the same name is overwritten and + missing parent directories are created. The packaged data directory is + never written. + + Args: + name: Profile name without the ``.json`` suffix. + document: The document to save; its type selects the directory. + + Returns: + The path the document was written to. + + Raises: + ValueError: If the profile kind or the name is invalid. + OSError: If the file or its parent directories cannot be written. + """ + document_type = type(document) + path = self.user_dir(document_type) / self._filename(document_type, name) + content = document.model_dump_json(indent=2) + with self._write_lock(path): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as tmp: + tmp.write(content) + tmp.flush() + os.fsync(tmp.fileno()) + _retry_sharing_violation(lambda: os.replace(tmp_name, path)) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + self._invalidate(path) + logger.debug("Saved {} profile '{}' to {}", profile_kind(document_type), name, path) + return path + + def delete(self, document_type: type[ProfileDocument[Any]], name: str) -> Path: + """Delete a profile document from the user data directory. + + A packaged profile of the same name applies again afterwards. + + Returns: + The path of the deleted file. + + Raises: + ValueError: If the profile kind or the name is invalid. + PermissionError: If only a packaged profile exists for the name. + FileNotFoundError: If no profile exists for the name. + """ + filename = self._filename(document_type, name) + path = self.user_dir(document_type) / filename + with self._write_lock(path): + if path.exists() and not path.is_file(): + raise ValueError( + f"The {profile_kind(document_type)} profile '{name}' is not a file: {path}." + ) + try: + path.unlink() + except FileNotFoundError: + if (self.package_dir(document_type) / filename).is_file(): + raise PermissionError( + f"The {profile_kind(document_type)} profile '{name}' is packaged " + "with EOS and cannot be deleted." + ) from None + raise FileNotFoundError( + f"No {profile_kind(document_type)} profile for '{name}' in {path.parent}." + ) from None + self._invalidate(path) + logger.debug("Deleted {} profile '{}' at {}", profile_kind(document_type), name, path) + return path + + # --- compiled profiles --------------------------------------------------- + + def get( + self, document_type: type[ProfileDocument[CompiledT]], name: str, **options: Any + ) -> CompiledT: + """Return the compiled form of a profile, cached per type, file and options. + + Args: + document_type: Profile type. + name: Profile name without the ``.json`` suffix. + **options: Keyword options passed to ``compile_profile``. They must + be hashable; each set of effective options (defaults filled in) + has its own cache entry. + + Returns: + The compiled profile. + + Raises: + ValueError: If the profile kind or the name is invalid, the document + fails validation or the compile step rejects it. + FileNotFoundError: If no document exists for the name. + """ + options_key = _options_key(document_type, options) + recent_key = (document_type, name, options_key) + recent: Optional[CompiledT] = self._recent.get(recent_key) + if recent is not None: + return recent + with self._cache_lock: + generation = self._generation + + path = self.existing_path(document_type, name) + stat = path.stat() + state = _FileState(stat.st_mtime_ns, stat.st_size) + key = _CacheKey(document_type, path, options_key) + + with self._cache_lock: + cached = self._compiled.get(key) + if cached is not None and cached[0] == state: + compiled: CompiledT = cached[1] + else: + compiled = self.load_file(document_type, path).compile_profile(**options) + # Cache only if nothing was saved or deleted meanwhile: this lookup may have + # read the file before a save, and the saved file may have the same size + # and, on file systems with coarse timestamps, the same modification time. + with self._cache_lock: + if self._generation == generation: + self._compiled[key] = (state, compiled) + self._recent[recent_key] = compiled + return compiled + + def clear_cache(self, document_type: Optional[type[ProfileDocument[Any]]] = None) -> None: + """Drop compiled profiles, e.g. after the data directories changed. + + Args: + document_type: Drop only the profiles of this type; all if None. + """ + with self._cache_lock: + self._recent.clear() + self._generation += 1 + if document_type is None: + self._compiled.clear() + return + for key in [key for key in self._compiled if key.document_type is document_type]: + del self._compiled[key] + + +def get_profile_store() -> ProfileStore: + """Return the store of all device profiles (process-wide singleton).""" + return ProfileStore() diff --git a/src/akkudoktoreos/devices/settings/batterymeasurement.py b/src/akkudoktoreos/devices/settings/batterymeasurement.py new file mode 100644 index 00000000..8d3a67bd --- /dev/null +++ b/src/akkudoktoreos/devices/settings/batterymeasurement.py @@ -0,0 +1,106 @@ +"""Reading the battery operating point from measurements. + +Measurement access is asynchronous (``DataSequence.key_to_value``), so it is +kept out of the battery settings model and out of the parameter resolution. +``get_measured_value`` is the only place that talks to the measurement +subsystem; ``measure_operating_point`` turns the measured values of one battery +into a ``BatteryOperatingPoint``. +""" + +import math +from typing import TYPE_CHECKING, Optional + +from loguru import logger + +from akkudoktoreos.devices.settings.batteryresolution import ( + BATTERY_DEFAULT_SOC_FACTOR, + BATTERY_DEFAULT_TEMPERATURE_DEGC, + BatteryOperatingPoint, +) +from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration + +if TYPE_CHECKING: + from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings + + +# Maximum age of a measurement that is still taken as "current". +BATTERY_MEASUREMENT_MAX_AGE: str = "1 hour" + + +async def get_measured_value(key: str, target_datetime: DateTime) -> Optional[float]: + """Return the measured value for a measurement key nearest to a datetime. + + Args: + key: Measurement key. + target_datetime: Datetime the value is looked up for. + + Returns: + The value nearest to target_datetime within BATTERY_MEASUREMENT_MAX_AGE, + or None if there is no such value or the measurement cannot be queried. + """ + try: + from akkudoktoreos.core.coreabc import get_measurement + + value = await get_measurement().key_to_value( + key=key, + target_datetime=target_datetime, + time_window=to_duration(BATTERY_MEASUREMENT_MAX_AGE), + ) + return None if value is None else float(value) + except Exception as exc: # a missing measurement falls back to the defaults + logger.warning(f"Battery measurement '{key}' not available: {exc}") + return None + + +def _soc_factor_or_default(value: Optional[float]) -> float: + """Return a measured SoC factor clamped to [0, 1], or the default.""" + if value is None or not math.isfinite(value): + return BATTERY_DEFAULT_SOC_FACTOR + return min(max(value, 0.0), 1.0) + + +def _temperature_or_default(value: Optional[float]) -> float: + """Return a measured battery temperature [degC], or the default.""" + if value is None or not math.isfinite(value): + return BATTERY_DEFAULT_TEMPERATURE_DEGC + return value + + +def _power_magnitude_or_none(value: Optional[float]) -> Optional[float]: + """Return a measured power as positive magnitude [W], or None. + + A missing, non-finite or zero measurement means the battery is not + operating in that direction, so there is no meaningful C-rate. + """ + if value is None or not math.isfinite(value) or value == 0.0: + return None + return abs(value) + + +async def measure_operating_point( + settings: "BatteriesCommonSettings", target_datetime: Optional[DateTime] = None +) -> BatteryOperatingPoint: + """Read the current operating point of a battery from its measurements. + + Missing, non-finite or unavailable measurements fall back to the reference + operating point (25 degC, SoC 0.5, no power); the SoC factor is clamped to + [0.0 ... 1.0]. This function never raises for missing measurements. + + Args: + settings: Battery device settings, providing the measurement keys. + target_datetime: Datetime the measurements are looked up for, now if None. + + Returns: + The measured operating point. + """ + target = to_datetime(target_datetime) if target_datetime is not None else to_datetime() + temperature = await get_measured_value(settings.measurement_key_temperature_degc, target) + soc_factor = await get_measured_value(settings.measurement_key_soc_factor, target) + charge_power = await get_measured_value(settings.measurement_key_charge_power_w, target) + discharge_power = await get_measured_value(settings.measurement_key_discharge_power_w, target) + return BatteryOperatingPoint( + temperature_degC=_temperature_or_default(temperature), + soc_factor=_soc_factor_or_default(soc_factor), + charge_power_w=_power_magnitude_or_none(charge_power), + discharge_power_w=_power_magnitude_or_none(discharge_power), + ) diff --git a/src/akkudoktoreos/devices/settings/batteryresolution.py b/src/akkudoktoreos/devices/settings/batteryresolution.py new file mode 100644 index 00000000..40bb9004 --- /dev/null +++ b/src/akkudoktoreos/devices/settings/batteryresolution.py @@ -0,0 +1,694 @@ +"""Resolution of the effective battery parameters. + +Every effective battery parameter (capacity, power limits, efficiencies, SOC +range) comes from exactly two sources: + +- the compiled battery profile (``BatteryProfile``): limit maps, efficiency + curves, model power limits, technical SOC bounds and, unless it is a generic + profile, the nominal capacity, +- the battery device settings (``BatteriesCommonSettings``): the + ``profile_adjustment`` of this unit (capacity, installation power limits) and + the operational SOC range. + +The profile is evaluated at an operating point (``BatteryOperatingPoint``): +temperature, SoC and the current charge / discharge power. There are no built-in +fallback values: if the parameters cannot be resolved, a +``BatteryResolutionError`` names the battery and the reason. + +The one exception is the parameter preparation of the optimizations, which +always succeeds for a configuration error: it fills the values that cannot be +resolved with the former defaults (``BATTERY_FALLBACK_*``) and reports them, +see ``BatteriesCommonSettings.resolve_operating_parameters_or_defaults``. + +The functions here are pure: they neither load profiles nor read measurements. +Loading the profile is done by the profile store, reading the operating point by +``batterymeasurement``. +""" + +import math +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Optional + +if TYPE_CHECKING: + from akkudoktoreos.devices.profile.batteryprofileruntime import ( + BatteryProfile, + PrecomputedEfficiencyCurve, + ) + from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings + + +# Operating point assumed when the battery measurement provides no value. +BATTERY_DEFAULT_SOC_FACTOR: float = 0.5 +BATTERY_DEFAULT_TEMPERATURE_DEGC: float = 25.0 + +# Defaults of the battery settings before battery profiles. Used only by the +# parameter preparation of the optimizations, for values the configuration +# does not allow to resolve. +BATTERY_FALLBACK_CAPACITY_WH: int = 8000 +BATTERY_FALLBACK_MAX_CHARGE_POWER_W: float = 5000.0 +BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W: float = 5000.0 +BATTERY_FALLBACK_CHARGING_EFFICIENCY: float = 0.88 +BATTERY_FALLBACK_DISCHARGING_EFFICIENCY: float = 0.88 + +# Power limits of the installation for a generic profile without own power +# limits, if the profile adjustment sets none: the former default, instead of +# the C-rate of the generic profile (e.g. 60 kW for a 60 kWh electric vehicle). +BATTERY_GENERIC_MAX_CHARGE_POWER_W: float = BATTERY_FALLBACK_MAX_CHARGE_POWER_W +BATTERY_GENERIC_MAX_DISCHARGE_POWER_W: float = BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W + +# Full charge detection threshold if neither the profile adjustment nor a usable +# profile provides one; the default of the profile document. +BATTERY_DEFAULT_FULL_CHARGE_DETECT_PERCENTAGE: int = 99 + + +class BatteryResolutionError(ValueError): + """The effective parameters of a battery cannot be resolved. + + The message names the battery device and the reason, e.g. a generic profile + without a capacity in the profile adjustment. The cause is the configuration + (settings or battery profile); the operating point never causes it. + """ + + +class BatterySocRangeError(BatteryResolutionError): + """The configured SoC range lies outside the technical SoC bounds of the profile.""" + + +@dataclass(frozen=True) +class BatteryOperatingPoint: + """Operating point the battery profile is evaluated at. + + The defaults form the reference operating point used when no measurement + is taken into account. + + Attributes: + temperature_degC: Battery temperature [degC]. + soc_factor: State of charge [0.0 ... 1.0]. + charge_power_w: Current charge power as positive magnitude [W], None if + the battery is not charging. Used for the charging efficiency. + discharge_power_w: Current discharge power as positive magnitude [W], + None if the battery is not discharging. Used for the discharging + efficiency. + """ + + temperature_degC: float = BATTERY_DEFAULT_TEMPERATURE_DEGC + soc_factor: float = BATTERY_DEFAULT_SOC_FACTOR + charge_power_w: Optional[float] = None + discharge_power_w: Optional[float] = None + + +@dataclass(frozen=True) +class BatteryOperatingParameters: + """Consistent snapshot of the effective battery parameters. + + All values belong to the same operating point and the same measurements, so + one snapshot can be used for a whole optimizer conversion even if + measurements change meanwhile. + + Attributes: + temperature_degC: Battery temperature the profile was evaluated at. + soc_factor: State of charge [0.0 ... 1.0] the profile was evaluated at. + capacity_wh: Effective nominal capacity [Wh]. + charging_efficiency: Charging efficiency [0.01 ... 1.00]. + discharging_efficiency: Discharging efficiency [0.01 ... 1.00]. + max_charge_power_w: Effective maximum charging power [W]. + max_discharge_power_w: Effective maximum discharging power [W], 0 if + discharging is inhibited at the operating point. + min_soc_percentage: Effective minimum SoC [%], within technical bounds. + max_soc_percentage: Effective maximum SoC [%], within technical bounds. + profile: Compiled battery profile the snapshot was resolved from, None + if it was resolved without profile. Conversions take the power + tables from it, so they need not load the profile again and use the + same profile version as the snapshot. Not part of comparisons. + """ + + temperature_degC: float + soc_factor: float + capacity_wh: int + charging_efficiency: float + discharging_efficiency: float + max_charge_power_w: float + max_discharge_power_w: float + min_soc_percentage: int + max_soc_percentage: int + profile: Optional["BatteryProfile"] = field(default=None, compare=False, repr=False) + + +@dataclass(frozen=True) +class BatteryFullCharge: + """Effective periodic full charge policy of a stationary battery. + + Attributes: + interval_days: Maximum number of days between two full charges [d], None + if the periodic full charge is disabled. + soc_percentage: State of charge a full charge has to reach [%], within + the technical maximum SOC of the profile. + detect_percentage: Measured state of charge from which the battery + counts as fully charged [%], at most ``soc_percentage``. + """ + + interval_days: Optional[float] + soc_percentage: int + detect_percentage: int + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resolution_error( + settings: "BatteriesCommonSettings", + reason: str, + error_type: type[BatteryResolutionError] = BatteryResolutionError, +) -> BatteryResolutionError: + """Return a resolution error naming the battery device.""" + return error_type(f"Battery '{settings.device_id}': {reason}") + + +def profile_limit_to_power_w(limit: float, profile: "BatteryProfile", capacity_wh: int) -> float: + """Convert a profile limit value into power [W] according to its unit. + + Args: + limit: Limit value in the unit of the profile (C-rate, W or A). + profile: Compiled battery profile. + capacity_wh: Effective nominal capacity [Wh], used for C-rate limits. + + Returns: + The limit as power [W]. + + Raises: + ValueError: If the unit is not supported, or the limit is in A and the + profile has no nominal voltage. + """ + unit = profile.limit_unit + if unit == "C-rate": + return limit * capacity_wh + if unit == "W": + return limit + if unit == "A": + if profile.nominal_voltage_v is None: + raise ValueError( + f"Battery profile '{profile.battery_model}' has limits in A " + "but no nominal_voltage_v to convert them to power." + ) + return limit * profile.nominal_voltage_v + raise ValueError(f"Unsupported limit unit '{unit}' in battery profile.") + + +def _installation_cap_w( + profile: "BatteryProfile", + adjusted_cap_w: Optional[float], + profile_cap_w: Optional[float], + generic_default_w: float, +) -> Optional[float]: + """Return the power limit of the installation [W], None for no limit. + + The profile adjustment sets it. Without one, a generic profile (no nominal + capacity of its own) without own power limit gets ``generic_default_w``; a + profile of a battery model needs no installation limit. + + Args: + profile: Compiled battery profile. + adjusted_cap_w: Power limit of the profile adjustment, None if not set. + profile_cap_w: Absolute power limit of the profile, None if not set. + generic_default_w: Limit for a generic profile without own limit. + + Returns: + The power limit of the installation, None if it does not limit. + """ + if adjusted_cap_w is not None: + return adjusted_cap_w + if profile.nominal_capacity_wh is None and profile_cap_w is None: + return generic_default_w + return None + + +def _limited_power_w( + limit: float, + profile: "BatteryProfile", + capacity_wh: int, + profile_cap_w: Optional[float], + installation_cap_w: Optional[float], +) -> float: + """Convert a profile limit to watts and apply the profile and installation caps.""" + power_w = profile_limit_to_power_w(limit, profile, capacity_wh) + if profile_cap_w is not None: + power_w = min(power_w, profile_cap_w) + if installation_cap_w is not None: + power_w = min(power_w, installation_cap_w) + return power_w + + +def _efficiency_at_power( + curve: "PrecomputedEfficiencyCurve", + measured_power_w: Optional[float], + max_power_w: float, + capacity_wh: int, +) -> float: + """Return the efficiency of a profile curve at the measured, else the maximum power. + + Without positive power (e.g. discharging is inhibited) the efficiency at the + lowest C-rate of the curve applies. If the curve gives no value at the + C-rate (it rejects out-of-range C-rates or does not interpolate), the + efficiency of the nearest curve breakpoint applies. + """ + power_w = measured_power_w if measured_power_w is not None else max_power_w + if power_w <= 0: + return curve.efficiency[0] + c_rate = power_w / capacity_wh + efficiency = curve.lookup(c_rate) + if efficiency is not None: + return efficiency + nearest = min(range(len(curve.c_rate)), key=lambda i: abs(curve.c_rate[i] - c_rate)) + return curve.efficiency[nearest] + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- + + +def resolve_nominal_capacity_wh( + settings: "BatteriesCommonSettings", profile: "BatteryProfile" +) -> int: + """Resolve the nominal capacity [Wh]: profile adjustment, else profile. + + Args: + settings: Battery device settings. + profile: Compiled battery profile. + + Returns: + The effective nominal capacity [Wh]. + + Raises: + BatteryResolutionError: If neither the profile adjustment nor the + profile provides a capacity (a generic profile without adjustment). + """ + adjusted_wh = settings.profile_adjustment.nominal_capacity_wh + if adjusted_wh is not None: + return adjusted_wh + if profile.nominal_capacity_wh is not None: + # The settings work in whole Wh; never round a tiny capacity down to 0. + return max(1, round(profile.nominal_capacity_wh)) + raise _resolution_error( + settings, + f"battery profile '{settings.battery_profile}' is generic and provides no " + "nominal capacity; set 'profile_adjustment.nominal_capacity_wh'.", + ) + + +def resolve_full_charge( + settings: "BatteriesCommonSettings", profile: Optional["BatteryProfile"] +) -> BatteryFullCharge: + """Resolve the periodic full charge policy: profile adjustment, else profile. + + The interval and the detection threshold are properties of the battery model + and come from the profile unless the profile adjustment sets them. The + target SOC and whether to charge from the grid are operating policy of the + battery settings. + + Args: + settings: Battery device settings. + profile: Compiled battery profile, None if it is not usable. Then only + the profile adjustment and the defaults apply. + + Returns: + The effective full charge policy. The target is capped by the technical + maximum SOC of the profile and the detection threshold by the target. + """ + adjustment = settings.profile_adjustment + interval_days = adjustment.full_charge_interval_days + detect_percentage = adjustment.full_charge_detect_percentage + soc_percentage = settings.full_charge_soc_percentage + if profile is not None: + if interval_days is None: + interval_days = profile.full_charge_interval_days + if detect_percentage is None: + detect_percentage = profile.full_charge_detect_percentage + soc_percentage = min(soc_percentage, math.floor(profile.technical_max_soc_percent)) + if detect_percentage is None: + detect_percentage = BATTERY_DEFAULT_FULL_CHARGE_DETECT_PERCENTAGE + return BatteryFullCharge( + interval_days=interval_days, + soc_percentage=soc_percentage, + detect_percentage=min(detect_percentage, soc_percentage), + ) + + +def resolve_battery_parameters( + settings: "BatteriesCommonSettings", + profile: "BatteryProfile", + capacity_wh: int, + operating_point: BatteryOperatingPoint, +) -> BatteryOperatingParameters: + """Resolve all parameters for a profile and an operating point. + + Args: + settings: Battery device settings. + profile: Compiled battery profile. + capacity_wh: Effective nominal capacity [Wh]. + operating_point: Operating point to evaluate the profile at. + + Returns: + The effective parameters. + + The operating point never makes the resolution fail: if the profile inhibits + charging or discharging there (e.g. the battery is too cold), the maximum + charge or discharge power is 0. + + Raises: + BatterySocRangeError: If the technical SOC bounds leave an empty SOC + range. + BatteryResolutionError: If the profile limits cannot be converted to + power. + """ + temperature_degC = operating_point.temperature_degC + soc_factor = operating_point.soc_factor + soc_percent = soc_factor * 100.0 + adjustment = settings.profile_adjustment + + # --- maximum charge power (0 if inhibited, e.g. too cold) ---------- + charge_limit = profile.max_charge_crate(temperature_degC, soc_percent) + if charge_limit is None: + max_charge_power_w = 0.0 + else: + try: + max_charge_power_w = max( + 0.0, + _limited_power_w( + charge_limit, + profile, + capacity_wh, + profile.max_charge_power_w, + _installation_cap_w( + profile, + adjustment.max_charge_power_w, + profile.max_charge_power_w, + BATTERY_GENERIC_MAX_CHARGE_POWER_W, + ), + ), + ) + except ValueError as exc: + raise _resolution_error(settings, str(exc)) from exc + + # --- maximum discharge power (0 if inhibited, nothing requires it) -- + discharge_limit = profile.max_discharge_crate(temperature_degC, soc_percent) + if discharge_limit is None: + max_discharge_power_w = 0.0 + else: + try: + max_discharge_power_w = _limited_power_w( + discharge_limit, + profile, + capacity_wh, + profile.max_discharge_power_w, + _installation_cap_w( + profile, + adjustment.max_discharge_power_w, + profile.max_discharge_power_w, + BATTERY_GENERIC_MAX_DISCHARGE_POWER_W, + ), + ) + except ValueError as exc: + raise _resolution_error(settings, str(exc)) from exc + + # --- efficiencies: measured power, else maximum power -------------- + charging_efficiency = _efficiency_at_power( + profile.charge_efficiency, + operating_point.charge_power_w, + max_charge_power_w, + capacity_wh, + ) + discharging_efficiency = _efficiency_at_power( + profile.discharge_efficiency, + operating_point.discharge_power_w, + max_discharge_power_w, + capacity_wh, + ) + + # --- SOC range: user's operational range within technical bounds --- + min_soc = max(settings.min_soc_percentage, math.ceil(profile.technical_min_soc_percent)) + max_soc = min(settings.max_soc_percentage, math.floor(profile.technical_max_soc_percent)) + if min_soc > max_soc: + raise _resolution_error( + settings, + f"SOC range [{min_soc}, {max_soc}] is empty after applying the technical " + f"SOC bounds of battery profile '{settings.battery_profile}'.", + BatterySocRangeError, + ) + + return BatteryOperatingParameters( + temperature_degC=temperature_degC, + soc_factor=soc_factor, + capacity_wh=capacity_wh, + charging_efficiency=charging_efficiency, + discharging_efficiency=discharging_efficiency, + max_charge_power_w=max_charge_power_w, + max_discharge_power_w=max_discharge_power_w, + min_soc_percentage=min_soc, + max_soc_percentage=max_soc, + profile=profile, + ) + + +def resolve_battery_parameters_without_profile( + settings: "BatteriesCommonSettings", operating_point: BatteryOperatingPoint +) -> tuple[BatteryOperatingParameters, list[str]]: + """Resolve the parameters from the profile adjustment and the former defaults. + + For the parameter preparation of the optimizations if the battery profile + cannot be used. The values of the profile adjustment apply where set; the + former defaults (``BATTERY_FALLBACK_*``) fill the others. + + Args: + settings: Battery device settings. + operating_point: Operating point the parameters belong to. + + Returns: + The parameters and a description of each default value used, e.g. + ``"nominal capacity 8000 Wh"``. + """ + adjustment = settings.profile_adjustment + defaults: list[str] = [] + capacity_wh = adjustment.nominal_capacity_wh + if capacity_wh is None: + capacity_wh = BATTERY_FALLBACK_CAPACITY_WH + defaults.append(f"nominal capacity {capacity_wh} Wh") + max_charge_power_w = adjustment.max_charge_power_w + if max_charge_power_w is None: + max_charge_power_w = BATTERY_FALLBACK_MAX_CHARGE_POWER_W + defaults.append(f"maximum charge power {max_charge_power_w} W") + max_discharge_power_w = adjustment.max_discharge_power_w + if max_discharge_power_w is None: + max_discharge_power_w = BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W + defaults.append(f"maximum discharge power {max_discharge_power_w} W") + defaults.append(f"charging efficiency {BATTERY_FALLBACK_CHARGING_EFFICIENCY}") + defaults.append(f"discharging efficiency {BATTERY_FALLBACK_DISCHARGING_EFFICIENCY}") + defaults.append("no SoC dependent power limits") + return ( + BatteryOperatingParameters( + temperature_degC=operating_point.temperature_degC, + soc_factor=operating_point.soc_factor, + capacity_wh=capacity_wh, + charging_efficiency=BATTERY_FALLBACK_CHARGING_EFFICIENCY, + discharging_efficiency=BATTERY_FALLBACK_DISCHARGING_EFFICIENCY, + max_charge_power_w=max_charge_power_w, + max_discharge_power_w=max_discharge_power_w, + min_soc_percentage=settings.min_soc_percentage, + max_soc_percentage=settings.max_soc_percentage, + ), + defaults, + ) + + +# --------------------------------------------------------------------------- +# Power curves for simulations +# --------------------------------------------------------------------------- + +# Number of points of a power limit table: one per SoC percent, 0 % ... 100 %. +BATTERY_SOC_TABLE_POINTS: int = 101 + +# Power fractions an efficiency curve is sampled at: 0.0, 0.05, ..., 1.0. +BATTERY_EFFICIENCY_CURVE_FRACTIONS: tuple[float, ...] = tuple(i / 20 for i in range(21)) + + +@dataclass(frozen=True) +class BatteryPowerCurves: + """Power limits and efficiencies of a battery profile at one temperature. + + Prepared for simulations that evaluate the battery many times, e.g. the + GENETIC optimizer: plain lists instead of profile lookups. + + Attributes: + temperature_degC: Battery temperature the profile was evaluated at. + charge_power_limit_w_by_soc: Maximum charging power [W] per SoC + percent; index i is SoC i %. 0 where charging is inhibited. + discharge_power_limit_w_by_soc: Maximum discharging power [W] per SoC + percent; 0 where discharging is inhibited. + max_charge_power_w: Highest charging power of the table [W]. + max_discharge_power_w: Highest discharging power of the table [W]. + charging_efficiency_curve: ``(power_fraction, efficiency)`` points; the + power fraction is relative to ``max_charge_power_w``. None if the + profile has no charge efficiency curve. + discharging_efficiency_curve: ``(power_fraction, efficiency)`` points; + the power fraction is relative to ``max_discharge_power_w``. None if + the profile has no discharge efficiency curve or discharging is + inhibited. + """ + + temperature_degC: float + charge_power_limit_w_by_soc: list[float] + discharge_power_limit_w_by_soc: list[float] + max_charge_power_w: float + max_discharge_power_w: float + charging_efficiency_curve: Optional[list[tuple[float, float]]] + discharging_efficiency_curve: Optional[list[tuple[float, float]]] + + +def _power_limit_table_w( + crate_at: Callable[[float, float], Optional[float]], + temperature_degC: float, + profile: "BatteryProfile", + capacity_wh: int, + profile_cap_w: Optional[float], + installation_cap_w: Optional[float], +) -> list[float]: + """Return the power limit [W] per SoC percent at one temperature.""" + table: list[float] = [] + for soc_percent in range(BATTERY_SOC_TABLE_POINTS): + limit = crate_at(temperature_degC, float(soc_percent)) + if limit is None: + table.append(0.0) + continue + power_w = _limited_power_w(limit, profile, capacity_wh, profile_cap_w, installation_cap_w) + table.append(max(power_w, 0.0)) + return table + + +def profile_peak_power_w( + profile: "BatteryProfile", capacity_wh: int, temperature_degC: float +) -> tuple[float, float]: + """Return the highest charge and discharge power of the profile over SoC [W]. + + Only the profile and its absolute model power limits apply, no installation + limit, so the result shows whether a ``profile_adjustment`` power limit + lowers the profile at all. + + Args: + profile: Compiled battery profile. + capacity_wh: Effective nominal capacity [Wh]. + temperature_degC: Battery temperature to evaluate the profile at. + + Returns: + ``(max_charge_power_w, max_discharge_power_w)``. + + Raises: + ValueError: If the profile limits cannot be converted to power. + """ + charge = _power_limit_table_w( + profile.max_charge_crate, + temperature_degC, + profile, + capacity_wh, + profile.max_charge_power_w, + None, + ) + discharge = _power_limit_table_w( + profile.max_discharge_crate, + temperature_degC, + profile, + capacity_wh, + profile.max_discharge_power_w, + None, + ) + return max(charge), max(discharge) + + +def _efficiency_curve( + lookup: Callable[[float], Optional[float]], + max_power_w: float, + capacity_wh: int, +) -> Optional[list[tuple[float, float]]]: + """Sample an efficiency curve over the power fraction of ``max_power_w``.""" + if max_power_w <= 0: + return None + curve: list[tuple[float, float]] = [] + for fraction in BATTERY_EFFICIENCY_CURVE_FRACTIONS: + efficiency = lookup(fraction * max_power_w / capacity_wh) + if efficiency is None: + return None + curve.append((fraction, efficiency)) + return curve + + +def resolve_battery_power_curves( + settings: "BatteriesCommonSettings", + profile: "BatteryProfile", + capacity_wh: int, + temperature_degC: float, +) -> Optional[BatteryPowerCurves]: + """Evaluate the profile over the whole SoC range at one temperature. + + The power limits are capped by the model power limits of the profile and + by the power limits of the profile adjustment, like the parameters resolved + by ``resolve_battery_parameters``. + + Args: + settings: Battery device settings. + profile: Compiled battery profile. + capacity_wh: Effective nominal capacity [Wh]. + temperature_degC: Battery temperature to evaluate the profile at. + + Returns: + The power curves, or None if the profile allows no charging at any SoC + at this temperature. + + Raises: + ValueError: If the profile limits cannot be converted to power (see + ``profile_limit_to_power_w``). + """ + adjustment = settings.profile_adjustment + charge_table = _power_limit_table_w( + profile.max_charge_crate, + temperature_degC, + profile, + capacity_wh, + profile.max_charge_power_w, + _installation_cap_w( + profile, + adjustment.max_charge_power_w, + profile.max_charge_power_w, + BATTERY_GENERIC_MAX_CHARGE_POWER_W, + ), + ) + max_charge_power_w = max(charge_table) + if max_charge_power_w <= 0: + return None + discharge_table = _power_limit_table_w( + profile.max_discharge_crate, + temperature_degC, + profile, + capacity_wh, + profile.max_discharge_power_w, + _installation_cap_w( + profile, + adjustment.max_discharge_power_w, + profile.max_discharge_power_w, + BATTERY_GENERIC_MAX_DISCHARGE_POWER_W, + ), + ) + max_discharge_power_w = max(discharge_table) + return BatteryPowerCurves( + temperature_degC=temperature_degC, + charge_power_limit_w_by_soc=charge_table, + discharge_power_limit_w_by_soc=discharge_table, + max_charge_power_w=max_charge_power_w, + max_discharge_power_w=max_discharge_power_w, + charging_efficiency_curve=_efficiency_curve( + profile.charge_efficiency_at, max_charge_power_w, capacity_wh + ), + discharging_efficiency_curve=_efficiency_curve( + profile.discharge_efficiency_at, max_discharge_power_w, capacity_wh + ), + ) diff --git a/src/akkudoktoreos/devices/settings/batterysettings.py b/src/akkudoktoreos/devices/settings/batterysettings.py index 3f399495..39611c3b 100644 --- a/src/akkudoktoreos/devices/settings/batterysettings.py +++ b/src/akkudoktoreos/devices/settings/batterysettings.py @@ -1,16 +1,117 @@ """Battery and electric vehicle device settings. Note: Used for the GENETIC and GENETIC0 algorithm. + +The technical battery data (capacity, power limits, efficiencies, technical +SOC bounds) comes from a battery profile (see BatteryProfileSettings). The user +selects the profile by name via ``battery_profile`` (default ``generic-lfp``); +there is always a profile. + +Responsibilities +---------------- + +``BatteriesCommonSettings`` holds the configuration fields, their validation and +the measurement keys. The other concerns live in their own modules, the +settings methods delegate to them: + +- ``profilestore``: loading (and caching) the compiled battery profile. +- ``batterymeasurement``: reading the operating point from the measurements. +- ``batteryresolution``: resolving the effective parameters from profile, + profile adjustment and operating point (pure functions). + +Configuration fields versus computed fields +------------------------------------------- + +Configuration fields are inputs, in three groups: + +- The battery profile: ``battery_profile`` names the profile, and + ``profile_adjustment`` holds how this unit differs from it: its nominal + capacity (replaces the profile capacity; required for a generic profile) and + the charge / discharge power limits of the installation (can only lower the + profile limits), and the periodic full charge interval and detection + threshold (replace the profile values). +- Operating policy: ``min_soc_percentage`` / ``max_soc_percentage`` (kept within + the technical SOC bounds of the profile), ``charge_rates``, + ``min_charge_power_w``, ``levelized_cost_of_storage_amt_kwh``, the minimum SoC + deadline, the grid export rates and the periodic full charge target + (``full_charge_soc_percentage``, ``full_charge_allow_grid``). +- Runtime state: ``capacity_estimation`` / ``capacity_estimate``. + +Computed fields are outputs, all named ``effective_*``. They are evaluated on +every access (pydantic does not cache them and does not evaluate them while +validating or constructing the model). They depend on the configuration and the +battery profile only, never on measurements, and are evaluated at the reference +operating point (``BatteryOperatingPoint()``: 25 degC, 50 % SoC, no measured +power): + +- ``effective_nominal_capacity_wh``: profile adjustment, else profile. +- ``effective_max_charge_power_w`` / ``effective_max_discharge_power_w``: profile + limits at the reference operating point, capped by the profile adjustment. +- ``effective_charging_efficiency`` / ``effective_discharging_efficiency``: profile + efficiency curve at the maximum charge / discharge power. +- ``effective_min_soc_percentage`` / ``effective_max_soc_percentage``. +- ``effective_full_charge_interval_days`` / ``effective_full_charge_detect_percentage``: + profile adjustment, else profile (``resolve_full_charge``). + +Because pydantic also evaluates computed fields on every serialization +(``model_dump``, ``model_dump_json``), they never raise: they are None if the +battery cannot be resolved, e.g. the profile does not exist. The strict methods +below raise a ``BatteryResolutionError`` naming the battery instead, and +``check_profile`` reports the problem. + +Measurements versus resolution +------------------------------ + +- ``measure_operating_point`` (async) reads temperature, SoC and charge / + discharge power (``-charge-power-w`` / ``-discharge-power-w``) + from the measurements and returns a ``BatteryOperatingPoint``. +- ``resolve_operating_parameters`` (sync, strict) evaluates the profile at a given + operating point, the reference operating point by default. +- ``resolve_current_operating_parameters`` (async, strict) combines both. +- The optimizer conversions (``to_*_param``) take a resolved snapshot, so async + callers resolve once at the measured operating point and convert. """ import re -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any, Optional import numpy as np +from loguru import logger from numpydantic import NDArray, Shape -from pydantic import Field, computed_field, field_validator, model_validator +from pydantic import ( + Field, + PrivateAttr, + computed_field, + field_validator, + model_validator, +) -from akkudoktoreos.config.configabc import ConfigScope +from akkudoktoreos.config.configabc import ConfigScope, SettingsBaseModel +from akkudoktoreos.devices.settings import batterymeasurement +from akkudoktoreos.devices.settings.batterymeasurement import ( + BATTERY_MEASUREMENT_MAX_AGE as BATTERY_MEASUREMENT_MAX_AGE, +) +from akkudoktoreos.devices.settings.batteryresolution import ( + BATTERY_DEFAULT_SOC_FACTOR as BATTERY_DEFAULT_SOC_FACTOR, +) +from akkudoktoreos.devices.settings.batteryresolution import ( + BATTERY_DEFAULT_TEMPERATURE_DEGC as BATTERY_DEFAULT_TEMPERATURE_DEGC, +) +from akkudoktoreos.devices.settings.batteryresolution import ( + BATTERY_FALLBACK_CAPACITY_WH, + BatteryFullCharge, + BatteryOperatingParameters, + BatteryOperatingPoint, + BatteryPowerCurves, + BatteryResolutionError, + BatterySocRangeError, + profile_peak_power_w, + resolve_battery_parameters, + resolve_battery_parameters_without_profile, + resolve_battery_power_curves, + resolve_full_charge, + resolve_nominal_capacity_wh, +) from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings from akkudoktoreos.measurement.batterycapacity import ( BatteryCapacityEstimate, @@ -27,6 +128,7 @@ if TYPE_CHECKING: ElectricVehicleParameters, SolarPanelBatteryParameters, ) + from akkudoktoreos.devices.profile.batteryprofileruntime import BatteryProfile # Default charge rates for battery @@ -35,6 +137,88 @@ BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, BATTERY_DEFAULT_GRID_EXPORT_RATES: list[float] = [0.25, 0.5, 0.75, 1.0] +# Name of the battery profile used when the user selects none. +BATTERY_DEFAULT_PROFILE: str = "generic-lfp" + + +class BatteryProfileAdjustment(SettingsBaseModel): + """How a battery unit differs from its battery profile. + + Each field is named after the profile field it adjusts. None keeps the + profile value. + """ + + nominal_capacity_wh: Optional[int] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": ( + "Nominal capacity of the installed battery [Wh]. Replaces the capacity " + "of the battery profile. Required if the profile is generic and " + "provides no capacity. The effective value is available as " + "'effective_nominal_capacity_wh'." + ), + "examples": [None, 8000], + }, + ) + max_charge_power_w: Optional[float] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": ( + "Maximum charging power of the installation, e.g. the inverter limit [W]. " + "Can only lower the limits of the battery profile. The effective value " + "is available as 'effective_max_charge_power_w'." + ), + "examples": [None, 5000], + }, + ) + max_discharge_power_w: Optional[float] = Field( + default=None, + gt=0, + json_schema_extra={ + "description": ( + "Maximum discharging power of the installation, e.g. the inverter limit " + "[W]. Can only lower the limits of the battery profile. The effective " + "value is available as 'effective_max_discharge_power_w'." + ), + "examples": [None, 5000], + }, + ) + full_charge_interval_days: Optional[float] = Field( + default=None, + gt=0, + allow_inf_nan=False, + json_schema_extra={ + "x-scope": [str(ConfigScope.GENETIC)], + "description": ( + "Maximum number of days between two full charges of a stationary " + "battery [d]. Replaces the interval of the battery profile. When the " + "next full charge falls due within the optimization horizon, the " + "maximum state of charge is raised to 'full_charge_soc_percentage'. " + "The periodic full charge is disabled if neither this nor the profile " + "sets an interval. Ignored for electric vehicles. The effective value " + "is available as 'effective_full_charge_interval_days'." + ), + "examples": [None, 21], + }, + ) + full_charge_detect_percentage: Optional[int] = Field( + default=None, + ge=1, + le=100, + json_schema_extra={ + "x-scope": [str(ConfigScope.GENETIC)], + "description": ( + "Measured state of charge from which the battery counts as fully " + "charged [%]. Replaces the threshold of the battery profile. The " + "effective value is available as " + "'effective_full_charge_detect_percentage'." + ), + "examples": [None, 98], + }, + ) + class BatteriesCommonSettings(DevicesBaseSettings): """Battery and electric vehicle device settings. @@ -44,33 +228,42 @@ class BatteriesCommonSettings(DevicesBaseSettings): Note: Used for the GENETIC and GENETIC0 algorithm. """ + # Reference resolution shared by the effective_* computed fields, see + # _reference_resolution(): (settings key, compiled profile or load error, + # parameters, full charge). + _reference_cache: Optional[ + tuple[tuple[Any, ...], Any, Optional[BatteryOperatingParameters], BatteryFullCharge] + ] = PrivateAttr(default=None) + capacity_estimation: Optional[BatteryCapacityEstimationSettings] = None capacity_estimate: Optional[BatteryCapacityEstimate] = Field( default=None, - description="Last explicitly stored capacity estimate; does not override capacity_wh.", + description=( + "Last explicitly stored capacity estimate; does not override the " + "effective nominal capacity." + ), ) - 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, + battery_profile: str = Field( + default=BATTERY_DEFAULT_PROFILE, + min_length=1, json_schema_extra={ - "description": "Charging efficiency [0.01 ... 1.00].", - "examples": [0.88], + "description": ( + "Name of the battery profile (without '.json') providing the nominal " + "capacity, power limits, efficiency curves and technical SOC bounds. " + "Differences of this unit go into 'profile_adjustment'." + ), + "examples": [BATTERY_DEFAULT_PROFILE], }, ) - discharging_efficiency: float = Field( - default=0.88, - gt=0, - le=1, + profile_adjustment: BatteryProfileAdjustment = Field( + default_factory=BatteryProfileAdjustment, json_schema_extra={ - "description": "Discharge efficiency [0.01 ... 1.00].", - "examples": [0.88], + "description": ( + "How this battery unit differs from its battery profile: nominal " + "capacity, installation power limits and periodic full charge." + ), + "examples": [{"nominal_capacity_wh": 8000, "max_charge_power_w": 5000}], }, ) levelized_cost_of_storage_amt_kwh: float = Field( @@ -83,14 +276,6 @@ class BatteriesCommonSettings(DevicesBaseSettings): "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, @@ -116,7 +301,8 @@ class BatteriesCommonSettings(DevicesBaseSettings): json_schema_extra={ "description": ( "Minimum state of charge (SOC) as percentage of capacity [%]. " - "This is the target SoC for charging." + "This is the target SoC for charging. Raised to the technical minimum " + "SOC of the battery profile if that is higher." ), "examples": [10], }, @@ -126,11 +312,556 @@ class BatteriesCommonSettings(DevicesBaseSettings): ge=0, le=100, json_schema_extra={ - "description": "Maximum state of charge (SOC) as percentage of capacity [%].", + "description": ( + "Maximum state of charge (SOC) as percentage of capacity [%]. " + "Lowered to the technical maximum SOC of the battery profile if that " + "is lower." + ), "examples": [100], }, ) + # ------------------------------------------------------------------ + # Battery profile loading (see profilestore) + # ------------------------------------------------------------------ + + def get_battery_profile(self) -> "BatteryProfile": + """Return the compiled battery profile for ``battery_profile``. + + The profile is loaded lazily (not at validation time, so configuration + loading does not depend on the data directories). The profile store + caches the compiled profile and recompiles it when the file changes. + + Returns: + The ready-to-use profile. + + Raises: + FileNotFoundError: If the configured battery profile does not exist. + ValueError: If the profile document fails validation. + """ + from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings + from akkudoktoreos.devices.profile.profilestore import get_profile_store + + return get_profile_store().get(BatteryProfileSettings, self.battery_profile) + + def _load_battery_profile(self) -> "BatteryProfile": + """Return the compiled battery profile, or raise an error naming the battery. + + Raises: + BatteryResolutionError: If the profile does not exist, cannot be + read or fails validation. + """ + try: + return self.get_battery_profile() + except BatteryResolutionError: + raise + except (OSError, ValueError) as exc: + raise BatteryResolutionError( + f"Battery '{self.device_id}': battery profile '{self.battery_profile}' " + f"is not usable: {exc}" + ) from exc + + def resolve_power_curves( + self, operating_parameters: BatteryOperatingParameters + ) -> Optional[BatteryPowerCurves]: + """Evaluate the battery profile over the whole SoC range. + + The profile is evaluated at the temperature of the operating parameters, + i.e. the measured battery temperature or the default temperature if + there is no measurement. + + Args: + operating_parameters: Resolved snapshot providing temperature and + capacity. + + Returns: + The power curves, or None if the profile allows no charging at any + SoC at this temperature. + + Raises: + BatteryResolutionError: If the profile cannot be loaded or its + limits cannot be converted to power. + """ + return self._power_curves(operating_parameters, self._load_battery_profile()) + + def _power_curves( + self, operating_parameters: BatteryOperatingParameters, profile: "BatteryProfile" + ) -> Optional[BatteryPowerCurves]: + """Evaluate a loaded profile like ``resolve_power_curves``.""" + try: + return resolve_battery_power_curves( + self, + profile, + operating_parameters.capacity_wh, + operating_parameters.temperature_degC, + ) + except BatteryResolutionError: + raise + except ValueError as exc: + raise BatteryResolutionError(f"Battery '{self.device_id}': {exc}") from exc + + def _genetic_snapshot( + self, operating_parameters: Optional[BatteryOperatingParameters], use_profile_tables: bool + ) -> tuple[BatteryOperatingParameters, Optional["BatteryProfile"]]: + """Return the snapshot to convert and the profile, loading the profile once. + + Without profile tables, the given snapshot is converted as is and no + profile is loaded. A given snapshot brings the profile it was resolved + from, so the conversion does not load it again: it cannot fail on a + profile that was changed or deleted meanwhile, and the power tables + belong to the same profile version as the snapshot. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``; only + if no snapshot is given. + """ + if not use_profile_tables: + if operating_parameters is None: + raise ValueError("Converting without profile tables requires a resolved snapshot.") + return operating_parameters, None + if operating_parameters is not None: + # None if the snapshot was resolved without profile: no tables then. + return operating_parameters, operating_parameters.profile + profile = self._load_battery_profile() + capacity_wh = resolve_nominal_capacity_wh(self, profile) + return ( + resolve_battery_parameters(self, profile, capacity_wh, BatteryOperatingPoint()), + profile, + ) + + def _genetic_power_parameters( + self, p: BatteryOperatingParameters, profile: Optional["BatteryProfile"] + ) -> dict[str, Any]: + """Return the power and efficiency fields of the GENETIC battery parameters. + + With power curves from the profile, the rated powers are the highest + powers over SoC; the per-SoC tables limit them in the simulation. + Without (no profile, or the profile allows no charging at this + temperature), the powers resolved at the operating point apply. + """ + curves = None + if profile is not None: + try: + curves = self._power_curves(p, profile) + except BatteryResolutionError as exc: + # Never abort the optimization: use the powers of the snapshot. + logger.error( + "{} Using the powers at the operating point without power tables.", exc + ) + if curves is None: + return { + "max_charge_power_w": p.max_charge_power_w, + "max_discharge_power_w": p.max_discharge_power_w, + } + return { + "max_charge_power_w": curves.max_charge_power_w, + "max_discharge_power_w": curves.max_discharge_power_w, + "charge_power_limit_w_by_soc": curves.charge_power_limit_w_by_soc, + "discharge_power_limit_w_by_soc": curves.discharge_power_limit_w_by_soc, + "charging_efficiency_curve": curves.charging_efficiency_curve, + "discharging_efficiency_curve": curves.discharging_efficiency_curve, + } + + # ------------------------------------------------------------------ + # Measurement access (see batterymeasurement) + # ------------------------------------------------------------------ + + async def measure_operating_point( + self, target_datetime: Optional[DateTime] = None + ) -> BatteryOperatingPoint: + """Read the current operating point from the battery measurements. + + Missing, non-finite or unavailable measurements fall back to the + reference operating point (25 degC, SoC 0.5, no power); the SoC factor + is clamped to [0.0 ... 1.0]. This method never raises for missing + measurements. + + Args: + target_datetime: Datetime the measurements are looked up for, now if None. + + Returns: + The measured operating point. + """ + return await batterymeasurement.measure_operating_point(self, target_datetime) + + # ------------------------------------------------------------------ + # Resolution (see batteryresolution) + # ------------------------------------------------------------------ + + def resolve_nominal_capacity_wh(self) -> int: + """Return the effective nominal capacity [Wh]. + + Order: ``profile_adjustment.nominal_capacity_wh``, then the capacity of + the battery profile. + + Raises: + BatteryResolutionError: If the profile cannot be loaded, or it is + generic and the profile adjustment provides no capacity. + """ + return resolve_nominal_capacity_wh(self, self._load_battery_profile()) + + def resolve_operating_parameters( + self, operating_point: Optional[BatteryOperatingPoint] = None + ) -> BatteryOperatingParameters: + """Resolve the effective battery parameters as one snapshot. + + Synchronous and deterministic: measurements are not read here. Use + ``resolve_current_operating_parameters`` for the measured operating point. + The efficiencies are taken at the charge / discharge power of the + operating point, or at the maximum power if there is none. + + Args: + operating_point: Operating point to evaluate the profile at, the + reference operating point ``BatteryOperatingPoint()`` if None. + + Returns: + The effective parameters. + + Raises: + BatteryResolutionError: If the profile cannot be loaded, provides no + capacity, or the technical SOC bounds leave an empty SOC range. + An operating point the profile inhibits charging or discharging + at gives a maximum power of 0, no error. + """ + if operating_point is None: + operating_point = BatteryOperatingPoint() + profile = self._load_battery_profile() + capacity_wh = resolve_nominal_capacity_wh(self, profile) + return resolve_battery_parameters(self, profile, capacity_wh, operating_point) + + async def resolve_current_operating_parameters( + self, target_datetime: Optional[DateTime] = None + ) -> BatteryOperatingParameters: + """Resolve the effective battery parameters at the measured operating point. + + Reads the operating point once (see ``measure_operating_point``) and + resolves all parameters from it, so the snapshot is consistent even if + measurements change meanwhile. + + Args: + target_datetime: Datetime the measurements are looked up for, now if None. + + Returns: + The effective parameters. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``. + """ + operating_point = await self.measure_operating_point(target_datetime) + return self.resolve_operating_parameters(operating_point) + + def _load_generic_battery_profile(self) -> "BatteryProfile": + """Return the packaged generic battery profile, the fallback for an unusable profile. + + Raises: + BatteryResolutionError: If the generic profile cannot be loaded. + """ + from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings + from akkudoktoreos.devices.profile.profilestore import get_profile_store + + try: + return get_profile_store().get(BatteryProfileSettings, BATTERY_DEFAULT_PROFILE) + except (OSError, ValueError) as exc: + raise BatteryResolutionError( + f"Battery '{self.device_id}': generic battery profile " + f"'{BATTERY_DEFAULT_PROFILE}' is not usable: {exc}" + ) from exc + + def _resolve_with_profile_or_defaults( + self, + profile: "BatteryProfile", + operating_point: BatteryOperatingPoint, + problems: list[str], + defaults: list[str], + ) -> BatteryOperatingParameters: + """Resolve with a loaded profile, with defaults for capacity and SoC range. + + Appends the problems and the defaults used to the given lists. + + Raises: + BatteryResolutionError: If the profile cannot be resolved, e.g. its + limits cannot be converted to power. + """ + try: + capacity_wh = resolve_nominal_capacity_wh(self, profile) + except BatteryResolutionError as exc: + problems.append(str(exc)) + capacity_wh = BATTERY_FALLBACK_CAPACITY_WH + defaults.append(f"nominal capacity {capacity_wh} Wh") + try: + return resolve_battery_parameters(self, profile, capacity_wh, operating_point) + except BatterySocRangeError as exc: + problems.append(str(exc)) + full_range = self.model_copy( + update={"min_soc_percentage": 0, "max_soc_percentage": 100} + ) + parameters = resolve_battery_parameters( + full_range, profile, capacity_wh, operating_point + ) + defaults.append( + f"SoC range {parameters.min_soc_percentage} ... " + f"{parameters.max_soc_percentage} % (technical bounds of the profile)" + ) + return parameters + + def resolve_operating_parameters_or_defaults( + self, operating_point: Optional[BatteryOperatingPoint] = None, report: bool = True + ) -> tuple[BatteryOperatingParameters, bool]: + """Resolve the parameters for the optimization, with defaults for configuration errors. + + The parameter preparation of the optimizations always succeeds for a + configuration error. Whatever the configuration allows is resolved from + the battery profile and the profile adjustment; only the values that + cannot be resolved are filled with the former defaults: + + - The profile cannot be used (missing, unreadable, invalid, limits not + convertible to power): the packaged generic profile + ``generic-lfp``. + - The generic profile cannot be used either: the profile adjustment + where set, else 8000 Wh, 5000 W charge and discharge power, + efficiencies 0.88, no SoC dependent power limits. + - A generic profile without capacity in the profile adjustment: the + capacity 8000 Wh. + - A configured SoC range outside the technical SoC bounds of the profile: + the technical SoC range of the profile. + + Args: + operating_point: Operating point to evaluate the profile at, the + reference operating point if None. + report: Whether to log an error that names the battery, the problem + and the default values used. + + Returns: + ``(parameters, use_profile_tables)``. ``use_profile_tables`` is + False if the parameters were resolved without profile; pass it to + the GENETIC conversions. The parameters are always valid; if the + profile inhibits charging at the operating point (e.g. too cold), + the maximum charge power is 0. + """ + if operating_point is None: + operating_point = BatteryOperatingPoint() + problems: list[str] = [] + defaults: list[str] = [] + parameters: Optional[BatteryOperatingParameters] = None + use_profile_tables = True + try: + parameters = self._resolve_with_profile_or_defaults( + self._load_battery_profile(), operating_point, problems, defaults + ) + except BatteryResolutionError as exc: + # Defaults of the failed attempt do not apply. + defaults.clear() + problems.append(str(exc)) + if parameters is None and self.battery_profile != BATTERY_DEFAULT_PROFILE: + generic_defaults = [f"battery profile '{BATTERY_DEFAULT_PROFILE}'"] + try: + parameters = self._resolve_with_profile_or_defaults( + self._load_generic_battery_profile(), + operating_point, + problems, + generic_defaults, + ) + defaults.extend(generic_defaults) + except BatteryResolutionError as exc: + problems.append(str(exc)) + if parameters is None: + parameters, defaults = resolve_battery_parameters_without_profile(self, operating_point) + use_profile_tables = False + if problems and report: + logger.error( + "{} Using defaults for the optimization: {}.", + " ".join(problems), + ", ".join(defaults), + ) + return parameters, use_profile_tables + + async def resolve_current_operating_parameters_or_defaults( + self, target_datetime: Optional[DateTime] = None, report: bool = True + ) -> tuple[BatteryOperatingParameters, bool]: + """Resolve like ``resolve_operating_parameters_or_defaults`` at the measured operating point. + + Args: + target_datetime: Datetime the measurements are looked up for, now if None. + report: Whether to log the configuration error and the defaults used. + + Returns: + ``(parameters, use_profile_tables)``. + """ + operating_point = await self.measure_operating_point(target_datetime) + return self.resolve_operating_parameters_or_defaults(operating_point, report) + + def check_profile(self) -> tuple[list[str], list[str]]: + """Check that the battery can be resolved from its profile and adjustment. + + Resolves the battery at the reference operating point. Also checks + that every power limit of the profile adjustment lowers the profile + limit; a higher one has no effect. + + Returns: + ``(errors, warnings)``: messages naming the battery. No errors means + the battery can be resolved. + """ + try: + profile = self._load_battery_profile() + capacity_wh = resolve_nominal_capacity_wh(self, profile) + reference = resolve_battery_parameters( + self, profile, capacity_wh, BatteryOperatingPoint() + ) + peak_charge_w, peak_discharge_w = profile_peak_power_w( + profile, capacity_wh, BatteryOperatingPoint().temperature_degC + ) + except ValueError as exc: + return [str(exc)], [] + if reference.max_charge_power_w <= 0: + # At the reference operating point the battery must be able to charge; + # otherwise the profile or the adjustment is wrong. + return [ + f"Battery '{self.device_id}': battery profile '{self.battery_profile}' " + f"allows no charging at the reference operating point " + f"({reference.temperature_degC} degC / {reference.soc_factor * 100.0} % SoC)." + ], [] + warnings: list[str] = [] + for name, limit_w, peak_w in ( + ("max_charge_power_w", self.profile_adjustment.max_charge_power_w, peak_charge_w), + ( + "max_discharge_power_w", + self.profile_adjustment.max_discharge_power_w, + peak_discharge_w, + ), + ): + if limit_w is not None and limit_w > peak_w: + warnings.append( + f"Battery '{self.device_id}': 'profile_adjustment.{name}' {limit_w} W is " + f"above the {peak_w} W of battery profile '{self.battery_profile}' and " + "has no effect; the profile value applies." + ) + return [], warnings + + def _reference_key(self) -> tuple[Any, ...]: + """Return the settings the reference resolution depends on. + + Exactly the fields the resolution functions of ``batteryresolution`` + read, and all fields of the profile adjustment. Cheaper than a dump of + all settings, which each effective_* field would otherwise pay. + """ + adjustment = self.profile_adjustment + return ( + self.device_id, + self.battery_profile, + self.min_soc_percentage, + self.max_soc_percentage, + self.full_charge_soc_percentage, + tuple(getattr(adjustment, name) for name in type(adjustment).model_fields), + ) + + def _reference_resolution( + self, + ) -> tuple[Optional[BatteryOperatingParameters], BatteryFullCharge]: + """Resolve the parameters and the full charge policy at the reference operating point. + + The effective_* computed fields share the result: a configuration dump + evaluates all of them. It is reused while the battery settings and the + compiled profile are unchanged; the profile store hands out the same + compiled profile until its file changes, so a changed profile applies + at once. + + Returns: + ``(parameters, full_charge)``; parameters is None if the battery + cannot be resolved. + """ + key = self._reference_key() + profile: Any + try: + profile = self._load_battery_profile() + except BatteryResolutionError as exc: + profile = str(exc) + cached = self._reference_cache + if cached is not None and cached[0] == key: + cached_profile = cached[1] + unchanged = ( + cached_profile is profile + if not isinstance(profile, str) + else cached_profile == profile + ) + if unchanged: + return cached[2], cached[3] + parameters: Optional[BatteryOperatingParameters] = None + if isinstance(profile, str): + logger.debug(f"Battery '{self.device_id}' not resolvable: {profile}") + full_charge = resolve_full_charge(self, None) + else: + # Resolve with the profile loaded above, not loading it again. + try: + capacity_wh = resolve_nominal_capacity_wh(self, profile) + parameters = resolve_battery_parameters( + self, profile, capacity_wh, BatteryOperatingPoint() + ) + except ValueError as exc: + logger.debug(f"Battery '{self.device_id}' not resolvable: {exc}") + full_charge = resolve_full_charge(self, profile) + self._reference_cache = (key, profile, parameters, full_charge) + return parameters, full_charge + + def _effective_parameters(self) -> Optional[BatteryOperatingParameters]: + """Resolve at the reference operating point, None if that is not possible.""" + return self._reference_resolution()[0] + + # ------------------------------------------------------------------ + # Computed fields, evaluated on every access at the reference operating point + # ------------------------------------------------------------------ + + @computed_field # type: ignore[prop-decorator] + @property + def effective_nominal_capacity_wh(self) -> Optional[int]: + """Effective nominal capacity [Wh]: profile adjustment, else profile. + + None if the battery cannot be resolved. + """ + p = self._effective_parameters() + return p.capacity_wh if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_max_charge_power_w(self) -> Optional[float]: + """Maximum charging power [W] at the reference operating point.""" + p = self._effective_parameters() + return p.max_charge_power_w if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_max_discharge_power_w(self) -> Optional[float]: + """Maximum discharging power [W] at the reference operating point.""" + p = self._effective_parameters() + return p.max_discharge_power_w if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_charging_efficiency(self) -> Optional[float]: + """Charging efficiency [0.01 ... 1.00] at the maximum charge power.""" + p = self._effective_parameters() + return p.charging_efficiency if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_discharging_efficiency(self) -> Optional[float]: + """Discharging efficiency [0.01 ... 1.00] at the maximum discharge power.""" + p = self._effective_parameters() + return p.discharging_efficiency if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_min_soc_percentage(self) -> Optional[int]: + """Effective minimum SoC [%], within the technical bounds of the profile.""" + p = self._effective_parameters() + return p.min_soc_percentage if p is not None else None + + @computed_field # type: ignore[prop-decorator] + @property + def effective_max_soc_percentage(self) -> Optional[int]: + """Effective maximum SoC [%], within the technical bounds of the profile.""" + p = self._effective_parameters() + return p.max_soc_percentage if p is not None else None + # ------------------------------------------------------------------ # GENETIC domain conversion # ------------------------------------------------------------------ @@ -215,37 +946,10 @@ class BatteriesCommonSettings(DevicesBaseSettings): # ------------------------------------------------------------------ # Periodic full charge (stationary batteries, GENETIC only) # ------------------------------------------------------------------ + # + # The interval and the detection threshold are battery model properties: + # they come from the battery profile, see ``resolve_full_charge``. - cell_chemistry: Optional[Literal["LFP", "NMC", "NCA", "LTO"]] = Field( - default=None, - json_schema_extra={ - "x-scope": [str(ConfigScope.GENETIC)], - "description": ( - "Cell chemistry of the battery. Informational only, it does not " - "enable anything. LFP batteries usually need a periodic full charge " - "to recalibrate their state of charge, see " - "'full_charge_interval_days'." - ), - "examples": [None, "LFP"], - }, - ) - full_charge_interval_days: Optional[float] = Field( - default=None, - gt=0, - allow_inf_nan=False, - json_schema_extra={ - "x-scope": [str(ConfigScope.GENETIC)], - "description": ( - "Maximum number of days between two full charges of a stationary " - "battery [d]. The last full charge is taken from the measured state " - "of charge. When the next one falls due within the optimization " - "horizon, the maximum state of charge is raised to " - "'full_charge_soc_percentage'. None disables the periodic full " - "charge. Ignored for electric vehicles." - ), - "examples": [None, 21], - }, - ) full_charge_soc_percentage: int = Field( default=100, ge=1, @@ -254,25 +958,14 @@ class BatteriesCommonSettings(DevicesBaseSettings): "x-scope": [str(ConfigScope.GENETIC)], "description": ( "State of charge a periodic full charge has to reach [%]. May be " - "above 'max_soc_percentage', which then applies on the other days." + "above 'max_soc_percentage', which then applies on the other days. " + "Capped by the technical maximum SOC of the battery profile. The " + "interval comes from the battery profile or " + "'profile_adjustment.full_charge_interval_days'." ), "examples": [100], }, ) - full_charge_detect_percentage: int = Field( - default=99, - ge=1, - le=100, - json_schema_extra={ - "x-scope": [str(ConfigScope.GENETIC)], - "description": ( - "Measured state of charge from which the battery counts as fully " - "charged [%]. Many battery management systems never report exactly " - "100 %." - ), - "examples": [99], - }, - ) full_charge_allow_grid: bool = Field( default=False, json_schema_extra={ @@ -286,75 +979,175 @@ class BatteriesCommonSettings(DevicesBaseSettings): }, ) - def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters": - """Return SolarPanelBatteryParameters for the GENETIC optimizer.""" + def resolve_full_charge(self) -> BatteryFullCharge: + """Return the effective periodic full charge policy of this battery. + + Never raises: if the battery profile is not usable, only the profile + adjustment and the defaults apply. + """ + try: + profile: Optional["BatteryProfile"] = self._load_battery_profile() + except BatteryResolutionError as exc: + logger.debug(f"Battery '{self.device_id}': full charge without profile: {exc}") + profile = None + return resolve_full_charge(self, profile) + + @computed_field # type: ignore[prop-decorator] + @property + def effective_full_charge_interval_days(self) -> Optional[float]: + """Maximum days between two full charges [d]: profile adjustment, else profile. + + None if the periodic full charge is disabled. + """ + return self._reference_resolution()[1].interval_days + + @computed_field # type: ignore[prop-decorator] + @property + def effective_full_charge_detect_percentage(self) -> int: + """Measured SoC from which the battery counts as fully charged [%].""" + return self._reference_resolution()[1].detect_percentage + + def to_genetic_pv_bat_param( + self, + operating_parameters: Optional[BatteryOperatingParameters] = None, + use_profile_tables: bool = True, + ) -> "SolarPanelBatteryParameters": + """Return SolarPanelBatteryParameters for the GENETIC optimizer. + + Args: + operating_parameters: Resolved snapshot to convert, e.g. from + ``resolve_current_operating_parameters``. None resolves at the + reference operating point. + use_profile_tables: Whether to add the SoC dependent power limits + and efficiency curves of the profile. False for a snapshot + resolved without profile (see + ``resolve_operating_parameters_or_defaults``); requires + ``operating_parameters``. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``. + """ from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters + p, profile = self._genetic_snapshot(operating_parameters, use_profile_tables) 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, + capacity_wh=p.capacity_wh, + charging_efficiency=p.charging_efficiency, + discharging_efficiency=p.discharging_efficiency, + min_soc_percentage=p.min_soc_percentage, + max_soc_percentage=p.max_soc_percentage, charge_rates=self.charge_rates, grid_export_rates=self.grid_export_rates, + **self._genetic_power_parameters(p, profile), levelized_cost_of_storage_kwh=self.levelized_cost_of_storage_amt_kwh, ) - def to_genetic_ev_bat_param(self) -> "ElectricVehicleParameters": - """Return ElectricVehicleParameters for the GENETIC optimizer.""" + def to_genetic_ev_bat_param( + self, + operating_parameters: Optional[BatteryOperatingParameters] = None, + use_profile_tables: bool = True, + ) -> "ElectricVehicleParameters": + """Return ElectricVehicleParameters for the GENETIC optimizer. + + Args: + operating_parameters: Resolved snapshot to convert, e.g. from + ``resolve_current_operating_parameters``. None resolves at the + reference operating point. + use_profile_tables: Whether to add the SoC dependent power limits + and efficiency curves of the profile. False for a snapshot + resolved without profile (see + ``resolve_operating_parameters_or_defaults``); requires + ``operating_parameters``. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``. + """ from akkudoktoreos.devices.genetic.battery import ElectricVehicleParameters + p, profile = self._genetic_snapshot(operating_parameters, use_profile_tables) return ElectricVehicleParameters( device_id=self.device_id, - capacity_wh=self.capacity_wh, - charging_efficiency=self.charging_efficiency, - discharging_efficiency=self.discharging_efficiency, + capacity_wh=p.capacity_wh, + charging_efficiency=p.charging_efficiency, + discharging_efficiency=p.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, + min_soc_percentage=p.min_soc_percentage, + max_soc_percentage=p.max_soc_percentage, min_soc_deadline_datetime=self.min_soc_deadline_datetime, min_soc_max_duration_h=self.min_soc_max_duration_h, + **self._genetic_power_parameters(p, profile), ) # ------------------------------------------------------------------ # GENETIC0 domain conversion # ------------------------------------------------------------------ - def to_genetic0_pv_bat_param(self) -> "Genetic0SolarPanelBatteryParameters": - """Return Genetic0SolarPanelBatteryParameters for the GENETIC0 optimizer.""" + def to_genetic0_pv_bat_param( + self, operating_parameters: Optional[BatteryOperatingParameters] = None + ) -> "Genetic0SolarPanelBatteryParameters": + """Return Genetic0SolarPanelBatteryParameters for the GENETIC0 optimizer. + + Args: + operating_parameters: Resolved snapshot to convert, e.g. from + ``resolve_current_operating_parameters``. None resolves at the + reference operating point. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``. + """ from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0SolarPanelBatteryParameters, ) + p = ( + operating_parameters + if operating_parameters is not None + else self.resolve_operating_parameters() + ) 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, + capacity_wh=p.capacity_wh, + charging_efficiency=p.charging_efficiency, + discharging_efficiency=p.discharging_efficiency, + max_charge_power_w=p.max_charge_power_w, + max_discharge_power_w=p.max_discharge_power_w, + min_soc_percentage=p.min_soc_percentage, + max_soc_percentage=p.max_soc_percentage, ) - def to_genetic0_ev_bat_param(self) -> "Genetic0ElectricVehicleParameters": - """Return Genetic0ElectricVehicleParameters for the GENETI0C optimizer.""" + def to_genetic0_ev_bat_param( + self, operating_parameters: Optional[BatteryOperatingParameters] = None + ) -> "Genetic0ElectricVehicleParameters": + """Return Genetic0ElectricVehicleParameters for the GENETIC0 optimizer. + + Args: + operating_parameters: Resolved snapshot to convert, e.g. from + ``resolve_current_operating_parameters``. None resolves at the + reference operating point. + + Raises: + BatteryResolutionError: See ``resolve_operating_parameters``. + """ from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0ElectricVehicleParameters, ) + p = ( + operating_parameters + if operating_parameters is not None + else self.resolve_operating_parameters() + ) return Genetic0ElectricVehicleParameters( device_id=self.device_id, - capacity_wh=self.capacity_wh, - charging_efficiency=self.charging_efficiency, - discharging_efficiency=self.discharging_efficiency, + capacity_wh=p.capacity_wh, + charging_efficiency=p.charging_efficiency, + discharging_efficiency=p.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, + max_charge_power_w=p.max_charge_power_w, + max_discharge_power_w=p.max_discharge_power_w, + min_soc_percentage=p.min_soc_percentage, + max_soc_percentage=p.max_soc_percentage, ) @field_validator("charge_rates", mode="before") @@ -391,14 +1184,19 @@ class BatteriesCommonSettings(DevicesBaseSettings): 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.full_charge_detect_percentage > self.full_charge_soc_percentage: - raise ValueError("full_charge_detect_percentage must be <= full_charge_soc_percentage") + detect_percentage = self.profile_adjustment.full_charge_detect_percentage + if detect_percentage is not None and detect_percentage > self.full_charge_soc_percentage: + raise ValueError( + "profile_adjustment.full_charge_detect_percentage must be <= " + "full_charge_soc_percentage" + ) + max_charge_power_w = self.profile_adjustment.max_charge_power_w 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 + and max_charge_power_w is not None + and self.min_charge_power_w > max_charge_power_w ): - raise ValueError("min_charge_power_w must be <= max_charge_power_w") + raise ValueError("min_charge_power_w must be <= profile_adjustment.max_charge_power_w") return self @computed_field # type: ignore[prop-decorator] @@ -407,6 +1205,24 @@ class BatteriesCommonSettings(DevicesBaseSettings): """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_temperature_degc(self) -> str: + """Measurement key for the battery temperature [degC].""" + return f"{self.device_id}-temperature-degc" + + @computed_field # type: ignore[prop-decorator] + @property + def measurement_key_charge_power_w(self) -> str: + """Measurement key for the charge power [W], used to compute the charging efficiency.""" + return f"{self.device_id}-charge-power-w" + + @computed_field # type: ignore[prop-decorator] + @property + def measurement_key_discharge_power_w(self) -> str: + """Measurement key for the discharge power [W], used for the discharging efficiency.""" + return f"{self.device_id}-discharge-power-w" + @computed_field # type: ignore[prop-decorator] @property def measurement_key_power_l1_w(self) -> str: @@ -437,6 +1253,9 @@ class BatteriesCommonSettings(DevicesBaseSettings): """All measurement keys for this battery.""" return [ self.measurement_key_soc_factor, + self.measurement_key_temperature_degc, + self.measurement_key_charge_power_w, + self.measurement_key_discharge_power_w, self.measurement_key_power_l1_w, self.measurement_key_power_l2_w, self.measurement_key_power_l3_w, diff --git a/src/akkudoktoreos/measurement/measurement.py b/src/akkudoktoreos/measurement/measurement.py index a38ed8a4..4ca0235c 100644 --- a/src/akkudoktoreos/measurement/measurement.py +++ b/src/akkudoktoreos/measurement/measurement.py @@ -422,15 +422,18 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence[MeasurementDataR ) if len(samples) > 250000: raise ValueError("More than 250000 power samples; request a shorter period.") + # Raises a BatteryResolutionError (a ValueError) naming the battery if + # its profile cannot be resolved. + parameters = battery.resolve_operating_parameters() return estimate_capacity( request, settings, channel, samples, battery_id=battery.device_id, - capacity_wh=battery.capacity_wh, - charging_efficiency=battery.charging_efficiency, - discharging_efficiency=battery.discharging_efficiency, + capacity_wh=parameters.capacity_wh, + charging_efficiency=parameters.charging_efficiency, + discharging_efficiency=parameters.discharging_efficiency, ) async def _energy_from_meter_readings( diff --git a/src/akkudoktoreos/optimization/genetic/configrequest.py b/src/akkudoktoreos/optimization/genetic/configrequest.py index d19b5516..4813d49e 100644 --- a/src/akkudoktoreos/optimization/genetic/configrequest.py +++ b/src/akkudoktoreos/optimization/genetic/configrequest.py @@ -13,6 +13,7 @@ from akkudoktoreos.core.coreabc import ( PredictionMixin, get_ems, ) +from akkudoktoreos.devices.settings.batteryresolution import BatteryFullCharge from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array from akkudoktoreos.optimization.genetic.fullcharge import ( @@ -137,9 +138,11 @@ class ConfigOptimizationRequest( ) return 0 - async def full_charge(device: BatteriesCommonSettings) -> Optional[DateTime]: + async def full_charge( + device: BatteriesCommonSettings, policy: BatteryFullCharge + ) -> Optional[DateTime]: """Deadline of the next periodic full charge, None if disabled or unknown.""" - interval_days = device.full_charge_interval_days + interval_days = policy.interval_days if interval_days is None: return None try: @@ -155,7 +158,7 @@ class ConfigOptimizationRequest( zip(dates, values), now=observation_time, interval_days=interval_days, - detect_percentage=device.full_charge_detect_percentage, + detect_percentage=policy.detect_percentage, ) if deadline is None: logger.warning( @@ -164,18 +167,25 @@ class ConfigOptimizationRequest( ) return deadline + # A battery configuration error does not cancel the run: the battery gets + # default values for what cannot be resolved, and an error is logged. battery = None if groups["batteries"]: device = groups["batteries"][0] - battery = device.to_genetic_pv_bat_param() + ( + operating_parameters, + use_profile_tables, + ) = await device.resolve_current_operating_parameters_or_defaults(observation_time) + battery = device.to_genetic_pv_bat_param(operating_parameters, use_profile_tables) battery.initial_soc_percentage = await state(device) - deadline = await full_charge(device) + policy = device.resolve_full_charge() + deadline = await full_charge(device, policy) horizon_end = start.add(seconds=settings.horizon * settings.interval_sec) if deadline is not None and deadline.timestamp() <= horizon_end.timestamp(): # Due within the horizon: lift the upper limit so the battery may # reach the target, and enforce the target only if grid charging - # is allowed for it. - target = device.full_charge_soc_percentage + # is allowed for it. The target is within the technical SOC bounds. + target = policy.soc_percentage battery.max_soc_percentage = max(battery.max_soc_percentage, target) if device.full_charge_allow_grid: enforced = enforced_full_charge_deadline(deadline, observation_time) @@ -198,7 +208,11 @@ class ConfigOptimizationRequest( ev = None if groups["electric_vehicles"]: device = groups["electric_vehicles"][0] - ev = device.to_genetic_ev_bat_param() + ( + operating_parameters, + use_profile_tables, + ) = await device.resolve_current_operating_parameters_or_defaults(observation_time) + ev = device.to_genetic_ev_bat_param(operating_parameters, use_profile_tables) ev.initial_soc_percentage = await state(device) inverter = None if groups["inverters"]: diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 872c62bc..1c552db3 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -745,8 +745,14 @@ class GeneticSimulation(PydanticBaseModel): battery_lcos_direct = False if battery_fast is not None and type(battery_fast) is Battery: battery_lcos_direct = True - battery_discharged_raw_fast = battery_fast._discharged_raw_wh_per_slot - battery_discharging_efficiency_fast = battery_fast.discharging_efficiency + if battery_fast._curves is None: + battery_discharged_raw_fast = battery_fast._discharged_raw_wh_per_slot + battery_discharging_efficiency_fast = battery_fast.discharging_efficiency + else: + # With power dependent efficiencies the delivered energy is kept + # per slot (see Battery.discharged_energy_wh()). + battery_discharged_raw_fast = battery_fast._curves.discharged_dc_wh_per_slot + battery_discharging_efficiency_fast = 1.0 for hour in range(start_hour, end_hour): hour_idx = hour - start_hour diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index 450431f4..c7f1853d 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -74,6 +74,10 @@ class ElectricVehicleResult(DeviceOptimizeResult): max_charge_power_w: int = Field( json_schema_extra={"description": "Maximum charging power in watts."} ) + max_discharge_power_w: Optional[float] = Field( + default=None, + json_schema_extra={"description": "Maximum discharging power in watts."}, + ) soc_wh: float = Field( json_schema_extra={ "description": "State of charge of the battery in watt-hours at the start of the simulation." @@ -553,9 +557,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): max(float(battery_grid_export_factor), 0.0), 1.0 ) - # (0,0,1) -> Discharge for local load only + # (0,0,1) -> Discharge for local load only: serve local demand, no grid + # charging and no export. if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed: - return BatteryOperationMode.PEAK_SHAVING, 1.0 + return BatteryOperationMode.SELF_CONSUMPTION, 1.0 # (ac>0,0,0) → AC charge only if ac_charge > 0.0 and dc_charge <= 0.0 and not discharge_allowed: @@ -614,13 +619,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): - Battery grid export: blocked when SOC is at or below min SOC. """ parameters = getattr(self, "parameters", None) - bat_list = ( - [parameters.pv_battery] - if parameters is not None and parameters.pv_battery is not None - else list((self.config.devices.batteries or {}).values()) - if parameters is None - else [] - ) + # Without parameters, convert the battery settings: they hold no + # capacity / efficiency / power fields, these come from the battery profile. + # A configuration error gives the defaults of the parameter preparation; + # the configuration check already reported it. + bat_list = [] + if parameters is not None: + if parameters.pv_battery is not None: + bat_list = [parameters.pv_battery] + else: + for settings in list((self.config.devices.batteries or {}).values())[:1]: + snapshot, use_tables = settings.resolve_operating_parameters_or_defaults( + report=False + ) + bat_list.append(settings.to_genetic_pv_bat_param(snapshot, use_tables)) if not bat_list: return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed @@ -679,8 +691,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): - battery_grid_export_allowed: discharge to grid The following battery modes are supported: - - SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1 - - PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1 + - SELF_CONSUMPTION: discharge_allowed == 1 and ac_charge == 0 + (with or without dc_charge) - GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1 - GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1 """ diff --git a/src/akkudoktoreos/optimization/genetic/tailvalue.py b/src/akkudoktoreos/optimization/genetic/tailvalue.py index f5b68c21..2b8100db 100644 --- a/src/akkudoktoreos/optimization/genetic/tailvalue.py +++ b/src/akkudoktoreos/optimization/genetic/tailvalue.py @@ -50,6 +50,8 @@ def _simulate_action( bat.soc_wh = float(energy_wh) bat._charged_raw_wh_per_slot[:] = [0.0] * len(bat._charged_raw_wh_per_slot) bat._discharged_raw_wh_per_slot[:] = [0.0] * len(bat._discharged_raw_wh_per_slot) + if bat._curves is not None: + bat._curves.reset(len(bat._charged_raw_wh_per_slot)) bat._charge_limit_raw_wh_per_slot[:] = [float("inf")] * len(bat._charge_limit_raw_wh_per_slot) ac_enabled = inv.ac_to_dc_efficiency > 0 and ( inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0 @@ -84,7 +86,7 @@ def _simulate_action( "reward_euro": reward, "grid_export_wh": sold, "grid_import_wh": bought, - "battery_charge_wh": (bat._charged_raw_wh_per_slot[0] * bat.charging_efficiency), + "battery_charge_wh": bat.charged_energy_wh(0), "battery_discharge_wh": discharged_wh, "losses_wh": losses, "ac_grid_charge_wh": ac_grid_charge_wh, diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0params.py b/src/akkudoktoreos/optimization/genetic0/genetic0params.py index f03c6269..b91be0fe 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0params.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0params.py @@ -37,6 +37,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import ( Genetic0HomeApplianceParameters, ) from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters +from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array from akkudoktoreos.utils.datetimeutil import DateTime, to_duration @@ -46,6 +47,20 @@ from akkudoktoreos.utils.datetimeutil import DateTime, to_duration # StartMixin - Creates circular dependency with ems.py +# Demo devices: used if no device is configured, and for this run only if the +# configured device cannot be prepared. +DEMO_BATTERY_SETTINGS: dict = { + "device_id": "battery1", + "profile_adjustment": {"nominal_capacity_wh": 8000}, +} +DEMO_ELECTRIC_VEHICLE_SETTINGS: dict = { + "device_id": "ev1", + "profile_adjustment": {"nominal_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, +} + + class Genetic0EnergyManagementParameters(Genetic0ParametersBaseModel): """Encapsulates energy-related forecasts and costs used in GENETIC optimization.""" @@ -577,30 +592,30 @@ 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 = { - "battery1": { - "device_id": "battery1", - "capacity_wh": 8000, - }, - } + cls.config.devices.batteries = {"battery1": dict(DEMO_BATTERY_SETTINGS)} + # Take first battery + battery_config = list(cls.config.devices.batteries.values())[0] try: - # Take first battery - battery_config = list(cls.config.devices.batteries.values())[0] - battery_params = battery_config.to_genetic0_pv_bat_param() + # A configuration error gives default values, reported as error. + ( + battery_parameters, + _, + ) = await battery_config.resolve_current_operating_parameters_or_defaults() + battery_params = battery_config.to_genetic0_pv_bat_param(battery_parameters) except Exception as e: - logger.info( - "No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}", - attempt, + # Never cancel the run and never change the user's configuration: + # optimize with the demo battery for this run only. + logger.error( + "Battery '{}' cannot be prepared for the optimization: {} " + "Using the demo battery for this run.", + battery_config.device_id, e, ) - cls.config.devices.batteries = { - "battery1": { - "device_id": "battery1", - "capacity_wh": 8000, - }, - } - # Retry - continue + battery_config = BatteriesCommonSettings(**DEMO_BATTERY_SETTINGS) + battery_parameters, _ = ( + battery_config.resolve_operating_parameters_or_defaults() + ) + battery_params = battery_config.to_genetic0_pv_bat_param(battery_parameters) # Levelized cost of ownership if battery_config.levelized_cost_of_storage_amt_kwh is None: logger.info( @@ -644,34 +659,38 @@ class Genetic0OptimizationParameters( ) cls.config.devices.max_electric_vehicles = 1 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, - }, + "ev1": dict(DEMO_ELECTRIC_VEHICLE_SETTINGS) } + # Take first electric_vehicle + electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0] try: - # 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() + # A configuration error gives default values, reported as error. + ( + ev_parameters, + _, + ) = await electric_vehicle_config.resolve_current_operating_parameters_or_defaults() + electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param( + ev_parameters + ) except Exception as e: - logger.info( - "No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}", - attempt, + # Never cancel the run and never change the user's configuration: + # optimize with the demo electric vehicle for this run only. + logger.error( + "Electric vehicle '{}' cannot be prepared for the optimization: {} " + "Using the demo electric vehicle for this run.", + electric_vehicle_config.device_id, e, ) - cls.config.devices.max_electric_vehicles = 1 - 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 + electric_vehicle_config = BatteriesCommonSettings( + **DEMO_ELECTRIC_VEHICLE_SETTINGS + ) + ( + ev_parameters, + _, + ) = electric_vehicle_config.resolve_operating_parameters_or_defaults() + electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param( + ev_parameters + ) # Initial SOC try: initial_soc_factor = await cls._nearest_soc_factor( diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0solution.py b/src/akkudoktoreos/optimization/genetic0/genetic0solution.py index ee97cdd5..cf67569a 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0solution.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0solution.py @@ -529,16 +529,34 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel): - DC charge factor (PV): zeroed when battery is at or above max SOC (the inverter curtails automatically, but this makes intent clear). - Discharge: blocked when SOC is at or below min SOC. + + The battery values are the optimizer parameters the solution was computed + with (capacity, efficiency, power limit and SOC range as resolved from the + battery profile), not the raw battery settings. """ - bat_dict = self.config.devices.batteries - if bat_dict is None or len(bat_dict) <= 0: + parameters = getattr(self, "parameters", None) + if parameters is not None: + bat = parameters.pv_battery + else: + bat_dict = self.config.devices.batteries or {} + bat = None + if bat_dict: + # A configuration error gives the defaults of the parameter + # preparation; the configuration check already reported it. + settings = list(bat_dict.values())[0] + snapshot, _ = settings.resolve_operating_parameters_or_defaults(report=False) + bat = settings.to_genetic0_pv_bat_param(snapshot) + if bat is None: return ac_charge, dc_charge, discharge_allowed - 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) ch_eff = float(bat.charging_efficiency) + # Same fallback as the GENETIC0 battery simulation. + bat_max_charge_power_w = ( + float(bat.max_charge_power_w) if bat.max_charge_power_w is not None else capacity_wh + ) headroom_wh = max(0.0, (max_soc - soc_pct) / 100.0 * capacity_wh) # --- AC charge: scale to available headroom --- @@ -553,7 +571,7 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel): max_ac_cp_w = ( float(inv.max_ac_charge_power_w) if inv and inv.max_ac_charge_power_w is not None - else float(bat.max_charge_power_w) + else bat_max_charge_power_w ) max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff if max_dc_per_h_wh > headroom_wh: diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 83fe72ef..33caacd7 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -81,6 +81,7 @@ from akkudoktoreos.server.rest.error import ( register_problem_handlers, ) from akkudoktoreos.server.rest.measurement import router as measurement_router +from akkudoktoreos.server.rest.profile import router as profile_router from akkudoktoreos.server.rest.pvakkuadaptrest import router as pvadaptive_router from akkudoktoreos.server.rest.starteosdash import supervise_eosdash from akkudoktoreos.server.retentionmanager import RetentionManager @@ -271,6 +272,7 @@ The genetic optimization API fields were renamed from German to English. For bac # ---------------------- app.include_router(measurement_router) +app.include_router(profile_router) app.include_router(pvadaptive_router) register_problem_handlers(app) diff --git a/src/akkudoktoreos/server/rest/profile.py b/src/akkudoktoreos/server/rest/profile.py new file mode 100644 index 00000000..b655e4a2 --- /dev/null +++ b/src/akkudoktoreos/server/rest/profile.py @@ -0,0 +1,139 @@ +"""REST API of the device profile store. + +Lists, reads, writes and deletes device profile documents (e.g. battery +profiles) for clients such as EOSdash. The profile kind in the path selects the +profile type (see ``profilestore.profile_type``):: + + GET /v1/profile registered profile kinds + GET /v1/profile/{kind} profiles of a kind + GET /v1/profile/{kind}/schema JSON schema of the documents of a kind + GET /v1/profile/{kind}/{name} document of a profile + PUT /v1/profile/{kind}/{name} validate and save a document (user data directory) + DELETE /v1/profile/{kind}/{name} delete a user profile + +A profile name is a single file name stem: letters, digits, ``.``, ``_`` and +``-``, starting with a letter or digit (``profilestore.PROFILE_NAME_PATTERN``); +``schema`` is reserved, as that path serves the JSON schema. Packaged profiles +are read only: a PUT stores a user profile that overrides the packaged one of +the same name, a DELETE of that user profile makes the packaged one apply again. + +Saved profiles take effect at once: the store drops the compiled profile, and +users of the profile (battery settings, device controllers) get the new one on +their next access. +""" + +import asyncio +from typing import Annotated, Any + +from fastapi import APIRouter, Body +from fastapi import Path as FastapiPath + +# Import the profile types so that they are registered with the store. +from akkudoktoreos.devices.profile import batteryprofile # noqa: F401 +from akkudoktoreos.devices.profile.profilestore import ( + ProfileDocument, + ProfileEntry, + get_profile_store, + profile_kinds, + profile_type, +) +from akkudoktoreos.server.rest.error import EOSProblem + +router = APIRouter(prefix="/v1/profile", tags=["profile"]) + +KindPath = Annotated[str, FastapiPath(description="Profile kind, e.g. 'battery'.")] +NamePath = Annotated[ + str, + FastapiPath( + description="Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-')." + ), +] + + +def _document_type(kind: str) -> type[ProfileDocument[Any]]: + """Return the profile type of a kind, or raise 404.""" + try: + return profile_type(kind) + except KeyError as exc: + raise EOSProblem( + status=404, title="Unknown profile kind", detail=f"Unknown profile kind '{kind}'." + ) from exc + + +@router.get("", response_model=list[str]) +async def get_profile_kinds() -> list[str]: + """Return the registered profile kinds.""" + return profile_kinds() + + +@router.get("/{kind}", response_model=list[ProfileEntry]) +async def get_profiles(kind: KindPath) -> list[ProfileEntry]: + """Return the profiles of a kind, from the user and the packaged data directory.""" + document_type = _document_type(kind) + return await asyncio.to_thread(get_profile_store().list_profiles, document_type) + + +@router.get("/{kind}/schema") +async def get_profile_schema(kind: KindPath) -> dict[str, Any]: + """Return the JSON schema of the profile documents of a kind.""" + return _document_type(kind).model_json_schema() + + +@router.get("/{kind}/{name}") +async def get_profile(kind: KindPath, name: NamePath) -> dict[str, Any]: + """Return the document of a profile (user profile first, then packaged).""" + document_type = _document_type(kind) + try: + document = await asyncio.to_thread(get_profile_store().load, document_type, name) + except FileNotFoundError as exc: + raise EOSProblem(status=404, title="Profile not found", detail=str(exc)) from exc + except ValueError as exc: # invalid name or invalid document on disk + raise EOSProblem(status=422, title="Profile retrieval failed", detail=str(exc)) from exc + return document.model_dump(mode="json") + + +@router.put("/{kind}/{name}") +async def put_profile( + kind: KindPath, + name: NamePath, + document: Annotated[dict[str, Any], Body(description="The profile document.")], +) -> dict[str, Any]: + """Validate a profile document and save it to the user data directory. + + Returns the saved (validated, defaults filled in) document. + """ + document_type = _document_type(kind) + try: + validated = document_type.model_validate(document) + await asyncio.to_thread(get_profile_store().save, name, validated) + except ValueError as exc: # pydantic ValidationError is a ValueError + raise EOSProblem(status=422, title="Profile save failed", detail=str(exc)) from exc + except OSError as exc: + raise EOSProblem( + status=500, + title="Profile save failed", + detail=f"Cannot save {kind} profile '{name}': {exc}", + cause=exc, + ) from exc + return validated.model_dump(mode="json") + + +@router.delete("/{kind}/{name}", response_model=ProfileEntry | None) +async def delete_profile(kind: KindPath, name: NamePath) -> ProfileEntry | None: + """Delete a user profile. + + Returns the profile that applies now (the packaged one of the same name), + or null if there is none. + """ + document_type = _document_type(kind) + store = get_profile_store() + try: + await asyncio.to_thread(store.delete, document_type, name) + except PermissionError as exc: + raise EOSProblem(status=409, title="Profile delete refused", detail=str(exc)) from exc + except FileNotFoundError as exc: + raise EOSProblem(status=404, title="Profile not found", detail=str(exc)) from exc + except ValueError as exc: + raise EOSProblem(status=422, title="Profile delete failed", detail=str(exc)) from exc + entries = await asyncio.to_thread(store.list_profiles, document_type) + return next((entry for entry in entries if entry.name == name), None) diff --git a/tests/test_config.py b/tests/test_config.py index 1c91a97b..9e7f8ef0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -844,7 +844,7 @@ class TestConfigEOSToConfigFile: class TestDeviceSettings: def test_device_map_supplies_stable_identity(self): - raw = {"batteries": {"house": {"capacity_wh": 12000}}} + raw = {"batteries": {"house": {"profile_adjustment": {"nominal_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 @@ -855,6 +855,43 @@ class TestDeviceSettings: with pytest.raises(ValidationError, match="device_id"): DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}}) + def test_check_battery_profiles_names_unresolvable_devices(self, config_eos): + # generic-lfp provides no capacity: the house battery lacks it, the car has it. + devices = DevicesCommonSettings.model_validate( + { + "batteries": {"house": {}}, + "electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}}, + } + ) + errors, warnings = devices.check_battery_profiles() + assert len(errors) == 1 + assert "Battery 'house'" in errors[0] + assert warnings == [] + + def test_config_logs_battery_profile_problems_once(self, config_eos, monkeypatch): + from loguru import logger + + monkeypatch.setattr(ConfigEOS, "_battery_profile_problems", ((), ())) + messages: list[str] = [] + sink_id = logger.add( + lambda message: messages.append(message.record["message"]), level="WARNING" + ) + try: + config_eos.merge_settings_from_dict({"devices": {"batteries": {"house": {}}}}) + config_eos.merge_settings_from_dict({"devices": {"max_batteries": 1}}) + unresolvable = [m for m in messages if "Battery 'house'" in m] + # Logged on the change only, not again on the unrelated update. + assert len(unresolvable) == 1 + + messages.clear() + config_eos.set_nested_value( + "devices/batteries/house/profile_adjustment/nominal_capacity_wh", 10000 + ) + assert not [m for m in messages if "Battery 'house'" in m] + assert ConfigEOS._battery_profile_problems == ((), ()) + finally: + logger.remove(sink_id) + class TestConfigProviderValidation: """Provider ids are validated while the configuration is created.""" @@ -924,3 +961,70 @@ class TestDocumentationMode: assert settings.keys == ["a", "b"] monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True) assert settings.keys == [] + + +class TestConfigSetup: + """The configuration can be created when its computed fields need it. + + A battery's computed fields resolve the battery profile, and the profile + store reads its directories from the configuration. Rendering the + configuration while ``ConfigEOS()`` is still being created must not fail. + """ + + @pytest.fixture + def uncreated_config_with_battery(self, uncreated_config: Path) -> None: + """Write a config file with a battery; get_config(init=True) creates the configuration.""" + uncreated_config.write_text( + json.dumps( + { + "devices": { + "batteries": { + "battery1": { + "device_id": "battery1", + "profile_adjustment": {"nominal_capacity_wh": 10240}, + } + } + } + } + ) + ) + + def test_config_with_battery_is_created(self, uncreated_config_with_battery): + from akkudoktoreos.core.coreabc import get_config + + config = get_config(init=True) + + assert config.devices.batteries is not None + assert config.devices.batteries["battery1"].effective_nominal_capacity_wh == 10240 + + def test_config_with_battery_is_created_with_trace_logging( + self, uncreated_config_with_battery + ): + """With trace logging the configuration is rendered during its creation.""" + from loguru import logger + + from akkudoktoreos.core.coreabc import get_config + + messages: list[str] = [] + sink_id = logger.add( + lambda message: messages.append(message.record["message"]), level="TRACE" + ) + try: + config = get_config(init=True) + finally: + logger.remove(sink_id) + + assert config.devices.batteries is not None + assert "battery1" in config.devices.batteries + setup_messages = [m for m in messages if m.startswith("Config setup:")] + assert len(setup_messages) == 1 + assert "effective_nominal_capacity_wh=10240" in setup_messages[0] + + def test_access_before_settings_are_loaded_raises(self, uncreated_config, monkeypatch): + """During creation the configuration is usable only once its settings are loaded.""" + from akkudoktoreos.core import coreabc + + monkeypatch.setattr(coreabc, "_config_eos_setup_running", True) + + with pytest.raises(RuntimeError, match="before its settings are loaded"): + coreabc.get_config(init=True) diff --git a/tests/test_configmigrate.py b/tests/test_configmigrate.py index 4cc7eac1..c82f26bf 100644 --- a/tests/test_configmigrate.py +++ b/tests/test_configmigrate.py @@ -265,6 +265,249 @@ class TestDeviceMigration: assert migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123 assert raw == original + @pytest.mark.parametrize("devices_key", ["batteries", "electric_vehicles"]) + @pytest.mark.parametrize("as_list", [False, True]) + def test_migration_of_legacy_battery_fields(self, devices_key, as_list): + """Legacy battery fields migrate to the profile based settings.""" + battery = { + "device_id": "house", + "capacity_wh": 12000, + "charging_efficiency": 0.9, + "discharging_efficiency": 0.9, + "max_charge_power_w": 3000, + } + raw = {"devices": {devices_key: [battery] if as_list else {"house": battery}}} + migrated = migrate_config_data(raw) + devices = getattr(migrated.devices, devices_key) + assert devices is not None + settings = devices["house"] + assert settings.profile_adjustment.nominal_capacity_wh == 12000 + # The configured charge power limit of the installation still applies. + assert settings.profile_adjustment.max_charge_power_w == 3000 + assert settings.effective_max_charge_power_w <= 3000 + dumped = settings.model_dump(exclude_computed_fields=True) + for legacy in ("capacity_wh", "charging_efficiency", "discharging_efficiency", + "max_charge_power_w"): + assert legacy not in dumped + + def test_migration_writes_former_defaults_of_a_battery_before_profiles(self): + """Former defaults were left out of files; the battery keeps them.""" + battery = {"device_id": "house", "min_soc_percentage": 10} + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + adjustment = migrated.devices.batteries["house"].profile_adjustment + assert adjustment.nominal_capacity_wh == 8000 + assert adjustment.max_charge_power_w == 5000 + # The former charge power limit also limited discharging. + assert adjustment.max_discharge_power_w == 5000 + assert migrated.devices.batteries["house"].min_soc_percentage == 10 + + def test_migration_keeps_the_charge_limit_as_discharge_limit(self): + """Before battery profiles, max_charge_power_w also capped discharging.""" + battery = {"device_id": "house", "capacity_wh": 8000, "max_charge_power_w": 3000} + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + settings = migrated.devices.batteries["house"] + assert settings.profile_adjustment.max_discharge_power_w == 3000 + assert settings.effective_max_discharge_power_w is not None + assert settings.effective_max_discharge_power_w <= 3000 + + def test_migration_logs_dropped_efficiencies(self): + """Efficiencies are no setting anymore; dropping them is logged as error.""" + from loguru import logger + + messages: list[str] = [] + sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR") + try: + battery = {"device_id": "house", "charging_efficiency": 0.9, "discharging_efficiency": 0.85} + migrate_config_data({"devices": {"batteries": {"house": battery}}}) + finally: + logger.remove(sink_id) + assert len(messages) == 2 + assert all("Battery 'house'" in message for message in messages) + assert any("'charging_efficiency' 0.9" in message for message in messages) + assert any("'discharging_efficiency' 0.85" in message for message in messages) + + @pytest.mark.parametrize( + "legacy_name, adjustment_name", + [ + ("nominal_capacity_wh", "nominal_capacity_wh"), + ("max_charge_power_limit_w", "max_charge_power_w"), + ("max_discharge_power_limit_w", "max_discharge_power_w"), + ], + ) + def test_migration_of_interim_profile_fields(self, legacy_name, adjustment_name): + """Interim fields of the settings with profiles move into the adjustment. + + Such a battery is not from before battery profiles, so it gets no former + defaults. + """ + battery = {"device_id": "house", legacy_name: 4000} + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + adjustment = migrated.devices.batteries["house"].profile_adjustment + assert getattr(adjustment, adjustment_name) == 4000 + for name, value in adjustment.model_dump().items(): + if name != adjustment_name: + assert value is None + + def test_migration_of_full_charge_settings(self): + """Full charge interval and threshold move into the adjustment, chemistry is dropped.""" + battery = { + "device_id": "house", + "cell_chemistry": "LFP", + "full_charge_interval_days": 21, + "full_charge_detect_percentage": 98, + "full_charge_soc_percentage": 100, + "full_charge_allow_grid": True, + } + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + settings = migrated.devices.batteries["house"] + assert settings.profile_adjustment.full_charge_interval_days == 21 + assert settings.profile_adjustment.full_charge_detect_percentage == 98 + assert settings.full_charge_soc_percentage == 100 + assert settings.full_charge_allow_grid is True + dumped = settings.model_dump(exclude_computed_fields=True) + for legacy in ("cell_chemistry", "full_charge_interval_days", + "full_charge_detect_percentage"): + assert legacy not in dumped + + def test_migration_drops_home_assistant_config_path_of_cell_chemistry(self): + raw = { + "adapter": { + "homeassistant": { + "config_entity_ids": { + "devices/batteries/house/cell_chemistry": "sensor.chemistry", + "devices/batteries/house/full_charge_interval_days": "sensor.interval", + } + } + } + } + migrated = migrate_config_data(raw) + assert migrated.adapter.homeassistant.config_entity_ids == { + "devices/batteries/house/profile_adjustment/full_charge_interval_days": ( + "sensor.interval" + ), + } + + def test_migration_maps_a_charge_limit_entity_to_both_limits(self): + """Before battery profiles, one max_charge_power_w entity also capped discharging.""" + raw = { + "adapter": { + "homeassistant": { + "config_entity_ids": { + "devices/batteries/house/max_charge_power_w": "sensor.limit", + "devices/batteries/garage/max_charge_power_w": "sensor.charge", + "devices/batteries/garage/profile_adjustment/max_discharge_power_w": ( + "sensor.discharge" + ), + } + } + } + } + migrated = migrate_config_data(raw) + assert migrated.adapter.homeassistant.config_entity_ids == { + "devices/batteries/house/profile_adjustment/max_charge_power_w": "sensor.limit", + "devices/batteries/house/profile_adjustment/max_discharge_power_w": "sensor.limit", + "devices/batteries/garage/profile_adjustment/max_charge_power_w": "sensor.charge", + "devices/batteries/garage/profile_adjustment/max_discharge_power_w": ( + "sensor.discharge" + ), + } + + def test_migration_keeps_a_migrated_battery(self): + """A battery in the current layout is not changed, e.g. on a version bump.""" + battery = { + "device_id": "house", + "battery_profile": "my-battery", + "profile_adjustment": {"max_discharge_power_w": 3000}, + } + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + settings = migrated.devices.batteries["house"] + assert settings.battery_profile == "my-battery" + assert settings.profile_adjustment.max_discharge_power_w == 3000 + assert settings.profile_adjustment.nominal_capacity_wh is None + assert settings.profile_adjustment.max_charge_power_w is None + + @pytest.mark.parametrize( + "legacy_name, adjustment_name", + [ + ("capacity_wh", "nominal_capacity_wh"), + ("max_charge_power_w", "max_charge_power_w"), + ("nominal_capacity_wh", "nominal_capacity_wh"), + ("max_charge_power_limit_w", "max_charge_power_w"), + ], + ) + def test_migration_keeps_present_adjustment(self, legacy_name, adjustment_name): + """A value already in the profile adjustment wins over its legacy field.""" + battery = { + "device_id": "house", + legacy_name: 12000, + "profile_adjustment": {adjustment_name: 10000}, + } + migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}}) + assert migrated.devices.batteries is not None + adjustment = migrated.devices.batteries["house"].profile_adjustment + assert getattr(adjustment, adjustment_name) == 10000 + + def test_migration_of_home_assistant_config_paths(self): + """Config paths of moved battery fields follow them into the adjustment.""" + raw = { + "adapter": { + "homeassistant": { + "config_entity_ids": { + "devices/batteries/house/capacity_wh": "sensor.capacity", + "devices/electric_vehicles/car/max_charge_power_w": "sensor.ev_limit", + "devices/batteries/house/charging_efficiency": "sensor.efficiency", + "devices/batteries/house/min_soc_percentage": "sensor.min_soc", + "general/latitude": "sensor.latitude", + } + } + } + } + migrated = migrate_config_data(raw) + assert migrated.adapter.homeassistant.config_entity_ids == { + "devices/batteries/house/profile_adjustment/nominal_capacity_wh": "sensor.capacity", + "devices/electric_vehicles/car/profile_adjustment/max_charge_power_w": ( + "sensor.ev_limit" + ), + # The former charge limit also capped discharging. + "devices/electric_vehicles/car/profile_adjustment/max_discharge_power_w": ( + "sensor.ev_limit" + ), + # charging_efficiency is computed now and cannot be set. + "devices/batteries/house/min_soc_percentage": "sensor.min_soc", + "general/latitude": "sensor.latitude", + } + + def test_migrate_config_file_keeps_charge_power_limit(self, tmp_path: Path): + """The legacy charge power limit survives the file migration.""" + config_file = tmp_path / "EOS.config.json" + battery = {"device_id": "house", "capacity_wh": 12000, "max_charge_power_w": 3000} + config_file.write_text( + json.dumps( + { + "general": {"version": "0.0.0-old"}, + "devices": {"batteries": {"house": battery}}, + } + ), + encoding="utf-8", + ) + + assert configmigrate.migrate_config_file(config_file, tmp_path / "EOS.config.bak") + + migrated = json.loads(config_file.read_text(encoding="utf-8")) + house = migrated["devices"]["batteries"]["house"] + assert house["profile_adjustment"] == { + "nominal_capacity_wh": 12000, + "max_charge_power_w": 3000, + "max_discharge_power_w": 3000, + } + assert "max_charge_power_w" not in house + assert "capacity_wh" not in house + class TestSlashKeyedDictMigration: """Dict entries whose keys contain '/' must survive migration unchanged.""" diff --git a/tests/test_devicesprofilebattery.py b/tests/test_devicesprofilebattery.py new file mode 100644 index 00000000..9d7ac22e --- /dev/null +++ b/tests/test_devicesprofilebattery.py @@ -0,0 +1,1467 @@ +"""Tests for the batteryprofile module. + +The module exercises the Pydantic validation rules, the interpolation +strategies, the out-of-range behaviors, the hard-cutoff handling, the +precomputed lookup path and the JSON file entry point of the +BatteryProfileSettings configuration model. +""" +import json +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from pydantic import ValidationError + +from akkudoktoreos.devices.profile.batteryprofile import ( + Axes, + BatteryProfile, + BatteryProfileSettings, + ChargeDischargeLimitMap, + EfficiencyCurve, + EfficiencyInterpolation, + HardCutoffs, + Interpolation, + OutOfRangeBehavior, + PrecomputedChargeDischargeLimitMap, + PrecomputedEfficiencyCurve, + Units, +) +from akkudoktoreos.devices.profile.profilestore import get_profile_store + +# --- helpers ------------------------------------------------------------- + +# Values of the required fields that the tests do not vary. +TEST_AXES = { + "temperature_degC": [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50], + "soc_percent": [0, 5, 10, 20, 40, 60, 80, 90, 95, 100], +} +TEST_CUTOFFS = { + "charge_allowed_below_degC": -10.0, + "charge_allowed_above_degC": 55.0, + "discharge_allowed_below_degC": -15.0, + "discharge_allowed_above_degC": 60.0, +} +TEST_UNITS = {"temperature": "degC", "soc": "percent", "limit": "C-rate"} +TEST_CURVE = {"c_rate": [0.1, 0.5, 1.0], "efficiency": [0.90, 0.96, 0.92]} + + +def make_grid(rows: int = 10, cols: int = 10, value: float = 0.5) -> list[list[float]]: + """Return a constant rows x cols grid filled with value.""" + return [[value for _ in range(cols)] for _ in range(rows)] + + +def make_limits( + charge_values: list[list[float]] | None = None, + discharge_values: list[list[float]] | None = None, + **overrides, +) -> BatteryProfileSettings: + """Build a BatteryProfileSettings with sensible defaults. + + Args: + charge_values: Optional charge grid; defaults to a constant 0.5 grid. + discharge_values: Optional discharge grid; defaults to a constant 0.5 + grid. + **overrides: Further keyword arguments forwarded to the model. The + required fields get test values unless the caller provides them. + + Returns: + A validated BatteryProfileSettings instance. + """ + payload: dict[str, Any] = dict( + battery_model="Test Battery", + technical_min_soc_percent=0.0, + technical_max_soc_percent=100.0, + charge_efficiency=EfficiencyCurve.model_validate(TEST_CURVE), + discharge_efficiency=EfficiencyCurve.model_validate(TEST_CURVE), + units=Units.model_validate(TEST_UNITS), + axes=Axes.model_validate(TEST_AXES), + hard_cutoffs=HardCutoffs.model_validate(TEST_CUTOFFS), + charge_limit_crate=ChargeDischargeLimitMap(values=charge_values or make_grid()), + discharge_limit_crate=ChargeDischargeLimitMap(values=discharge_values or make_grid()), + ) + payload.update(overrides) + return BatteryProfileSettings(**payload) + + +def make_efficiency_curve( + c_rate: list[float] | None = None, + efficiency: list[float] | None = None, + **overrides, +) -> EfficiencyCurve: + """Build a validated efficiency curve with useful defaults.""" + payload: dict[str, Any] = { + "c_rate": c_rate or [0.1, 0.5, 1.0], + "efficiency": efficiency or [0.90, 0.96, 0.92], + } + payload.update(overrides) + return EfficiencyCurve(**payload) + + +def make_document(charge_values=None, discharge_values=None, **overrides) -> dict: + """Return a JSON-serializable document for a full limits configuration. + + Args: + charge_values: Optional charge grid; defaults to a constant 0.5 grid. + discharge_values: Optional discharge grid; defaults to a constant 0.5 + grid. + **overrides: Further top-level keys forwarded to the document. + + Returns: + A dict that can be written with ``json.dump`` and loaded back. + """ + doc = { + "schema_version": "1.1", + "battery_model": "Test Battery", + "chemistry": "LFP", + "nominal_voltage_v": 102.4, + "technical_min_soc_percent": 0.0, + "technical_max_soc_percent": 100.0, + "charge_efficiency": dict(TEST_CURVE), + "discharge_efficiency": dict(TEST_CURVE), + "units": dict(TEST_UNITS), + "interpolation": "bilinear", + "out_of_range_behavior": "clamp_to_nearest", + "axes": { + "temperature_degC": [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50], + "soc_percent": [0, 5, 10, 20, 40, 60, 80, 90, 95, 100], + }, + "charge_limit_crate": { + "description": "Max charge C-rate; rows = temperature, cols = SOC", + "values": charge_values or make_grid(), + }, + "discharge_limit_crate": { + "description": "Max discharge C-rate; rows = temperature, cols = SOC", + "values": discharge_values or make_grid(), + }, + "hard_cutoffs": { + "charge_allowed_below_degC": -10.0, + "charge_allowed_above_degC": 55.0, + "discharge_allowed_below_degC": -15.0, + "discharge_allowed_above_degC": 60.0, + }, + } + doc.update(overrides) + return doc + + +# --- Units --------------------------------------------------------------- + + +class TestUnits: + """Tests for the Units defaults and schema metadata.""" + + def test_axis_units_default_but_limit_unit_is_required(self): + u = Units(limit="C-rate") + assert u.temperature == "degC" + assert u.soc == "percent" + with pytest.raises(ValidationError, match="limit"): + Units.model_validate({}) + + def test_json_schema_contains_descriptions(self): + schema = Units.model_json_schema() + assert schema["properties"]["temperature"]["description"] + assert schema["properties"]["temperature"]["examples"] == ["degC"] + + +# --- EfficiencyCurve: configuration validation and lookup ------------------ + + +class TestEfficiencyCurveValidation: + """Tests for C-rate efficiency curve validation.""" + + def test_accepts_well_formed_curve(self): + curve = make_efficiency_curve() + assert curve.c_rate == [0.1, 0.5, 1.0] + assert curve.efficiency == [0.90, 0.96, 0.92] + assert curve.interpolation is EfficiencyInterpolation.LINEAR + assert curve.out_of_range_behavior is OutOfRangeBehavior.CLAMP_TO_NEAREST + + def test_rejects_negative_c_rate(self): + with pytest.raises(ValidationError, match="non-negative"): + make_efficiency_curve(c_rate=[-0.1, 0.5], efficiency=[0.9, 0.95]) + + def test_rejects_non_increasing_c_rate(self): + with pytest.raises(ValidationError, match="strictly increasing"): + make_efficiency_curve(c_rate=[0.1, 0.1], efficiency=[0.9, 0.95]) + + def test_rejects_efficiency_outside_valid_range(self): + for values in ([0.0, 0.9], [-0.1, 0.9], [0.9, 1.01]): + with pytest.raises(ValidationError, match=r"\(0, 1\]"): + make_efficiency_curve(c_rate=[0.1, 0.5], efficiency=list(values)) + + def test_rejects_mismatched_lengths(self): + with pytest.raises(ValidationError, match="same number"): + make_efficiency_curve(c_rate=[0.1, 0.5, 1.0], efficiency=[0.9, 0.95]) + + def test_json_schema_contains_curve_descriptions(self): + schema = EfficiencyCurve.model_json_schema() + assert schema["properties"]["c_rate"]["description"] + assert schema["properties"]["efficiency"]["description"] + + +class TestEfficiencyCurveLookup: + """Tests for the configuration model's reference lookup implementation.""" + + def test_linear_interpolation(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 1.0] + ) + assert curve.lookup(0.1) == pytest.approx(0.8) + assert curve.lookup(0.3) == pytest.approx(0.9) + assert curve.lookup(0.5) == pytest.approx(1.0) + + def test_nearest_interpolation(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5, 1.0], + efficiency=[0.8, 0.9, 0.95], + interpolation=EfficiencyInterpolation.NEAREST, + ) + assert curve.lookup(0.2) == pytest.approx(0.8) + assert curve.lookup(0.4) == pytest.approx(0.9) + + def test_nearest_tie_selects_lower_breakpoint(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9], + interpolation=EfficiencyInterpolation.NEAREST, + ) + assert curve.lookup(0.3) == pytest.approx(0.8) + + def test_none_interpolation_requires_exact_breakpoint(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9], + interpolation=EfficiencyInterpolation.NONE, + ) + assert curve.lookup(0.5) == pytest.approx(0.9) + assert curve.lookup(0.3) is None + + def test_out_of_range_clamps_to_endpoints(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9] + ) + assert curve.lookup(0.0) == pytest.approx(0.8) + assert curve.lookup(1.0) == pytest.approx(0.9) + + def test_out_of_range_rejects_query(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9], + out_of_range_behavior=OutOfRangeBehavior.REJECT, + ) + assert curve.lookup(0.0) is None + assert curve.lookup(1.0) is None + assert curve.lookup(0.3) == pytest.approx(0.85) + + +# --- PrecomputedEfficiencyCurve: standalone runtime lookup ------------------ + + +class TestPrecomputedEfficiencyCurve: + """Tests for the immutable precomputed efficiency-curve representation.""" + + @pytest.mark.parametrize( + "interpolation", + [ + EfficiencyInterpolation.LINEAR, + EfficiencyInterpolation.NEAREST, + EfficiencyInterpolation.NONE, + ], + ) + @pytest.mark.parametrize( + "out_of_range_behavior", + [ + OutOfRangeBehavior.CLAMP_TO_NEAREST, + OutOfRangeBehavior.REJECT, + ], + ) + def test_precomputed_lookup_matches_reference( + self, interpolation, out_of_range_behavior + ): + curve = make_efficiency_curve( + c_rate=[0.05, 0.2, 0.5, 1.0], + efficiency=[0.88, 0.94, 0.97, 0.91], + interpolation=interpolation, + out_of_range_behavior=out_of_range_behavior, + ) + precomputed = PrecomputedEfficiencyCurve.from_curve(curve) + queries = [-0.1, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1.0, 1.2] + for c_rate in queries: + expected = curve.lookup(c_rate) + actual = precomputed.lookup(c_rate) + if expected is None: + assert actual is None + else: + assert actual == pytest.approx(expected, abs=1e-12) + + def test_precomputes_segment_coefficients_and_midpoints(self): + curve = make_efficiency_curve( + c_rate=[0.1, 0.5, 1.0], + efficiency=[0.8, 1.0, 0.9], + ) + precomputed = PrecomputedEfficiencyCurve.from_curve(curve) + assert len(precomputed.slopes) == 2 + assert len(precomputed.intercepts) == 2 + assert precomputed.midpoints == pytest.approx((0.3, 0.75)) + + def test_runtime_data_is_immutable(self): + precomputed = PrecomputedEfficiencyCurve.from_curve(make_efficiency_curve()) + with pytest.raises((AttributeError, TypeError)): + precomputed.c_rate = (0.0, 1.0) # type: ignore[misc] + + +class TestBatteryProfileEfficiency: + """Tests for efficiency-curve conversion and access on BatteryProfile.""" + + def test_to_limits_precomputes_both_efficiency_curves(self): + charge_curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.9, 0.96] + ) + discharge_curve = make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.91, 0.95] + ) + profile = make_limits( + charge_efficiency=charge_curve, + discharge_efficiency=discharge_curve, + ).to_limits() + + assert isinstance(profile.charge_efficiency, PrecomputedEfficiencyCurve) + assert isinstance(profile.discharge_efficiency, PrecomputedEfficiencyCurve) + + def test_charge_and_discharge_efficiencies_are_independent(self): + profile = make_limits( + charge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9] + ), + discharge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.7, 0.95] + ), + ).to_limits() + + assert profile.charge_efficiency_at(0.3) == pytest.approx(0.85) + assert profile.discharge_efficiency_at(0.3) == pytest.approx(0.825) + + def test_efficiency_accessors_use_absolute_c_rate(self): + profile = make_limits( + charge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.8, 0.9] + ), + discharge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.7, 0.9] + ), + ).to_limits() + + assert profile.charge_efficiency_at(-0.3) == pytest.approx(0.85) + assert profile.discharge_efficiency_at(-0.3) == pytest.approx(0.8) + + @pytest.mark.parametrize("field", ["charge_efficiency", "discharge_efficiency"]) + def test_efficiency_curves_are_required(self, field): + with pytest.raises(ValidationError, match=field): + make_limits(**{field: None}) + + def test_profile_carries_new_model_characteristics(self): + profile = make_limits( + max_charge_power_w=5000.0, + max_discharge_power_w=4500.0, + technical_min_soc_percent=5.0, + technical_max_soc_percent=95.0, + ).to_limits() + + assert profile.max_charge_power_w == pytest.approx(5000.0) + assert profile.max_discharge_power_w == pytest.approx(4500.0) + assert profile.technical_min_soc_percent == pytest.approx(5.0) + assert profile.technical_max_soc_percent == pytest.approx(95.0) + + def test_profile_carries_full_charge_characteristics(self): + profile = make_limits( + full_charge_interval_days=21.0, full_charge_detect_percentage=98 + ).to_limits() + + assert profile.full_charge_interval_days == pytest.approx(21.0) + assert profile.full_charge_detect_percentage == 98 + + def test_full_charge_characteristics_default_to_no_periodic_full_charge(self): + profile = make_limits().to_limits() + + assert profile.full_charge_interval_days is None + assert profile.full_charge_detect_percentage == 99 + + @pytest.mark.parametrize( + "overrides", + [ + {"full_charge_interval_days": 0.0}, + {"full_charge_interval_days": float("inf")}, + {"full_charge_detect_percentage": 0}, + {"full_charge_detect_percentage": 101}, + ], + ) + def test_rejects_invalid_full_charge_characteristics(self, overrides): + with pytest.raises(ValidationError): + make_limits(**overrides) + + +# --- Axes ---------------------------------------------------------------- + + +class TestAxes: + """Tests for axis validation.""" + + def test_axes_are_required(self): + with pytest.raises(ValidationError, match="temperature_degC"): + Axes.model_validate({"soc_percent": [0, 100]}) + with pytest.raises(ValidationError, match="soc_percent"): + Axes.model_validate({"temperature_degC": [0, 10]}) + + def test_temperature_must_be_strictly_increasing(self): + with pytest.raises(ValidationError, match="strictly increasing"): + Axes(temperature_degC=[0, 5, 5, 10], soc_percent=[0, 50, 100]) + + def test_soc_must_be_strictly_increasing(self): + with pytest.raises(ValidationError, match="strictly increasing"): + Axes(temperature_degC=[0, 10], soc_percent=[0, 50, 40]) + + def test_soc_bounds_enforced(self): + with pytest.raises(ValidationError, match=r"\[0, 100\]"): + Axes(temperature_degC=[0, 10], soc_percent=[-1, 50]) + with pytest.raises(ValidationError, match=r"\[0, 100\]"): + Axes(temperature_degC=[0, 10], soc_percent=[50, 101]) + + def test_min_length_enforced(self): + with pytest.raises(ValidationError): + Axes(temperature_degC=[0], soc_percent=[0, 100]) + + +# --- ChargeDischargeLimitMap ------------------------------------------------------------ + + +class TestChargeDischargeLimitMap: + """Tests for ChargeDischargeLimitMap value validation.""" + + def test_accepts_well_formed_grid(self): + m = ChargeDischargeLimitMap(values=make_grid(2, 3, 0.0)) + assert len(m.values) == 2 + assert len(m.values[0]) == 3 + + def test_rejects_negative_values(self): + with pytest.raises(ValidationError, match="non-negative"): + ChargeDischargeLimitMap(values=[[0.1, -0.1], [0.2, 0.3]]) + + def test_rejects_single_row(self): + with pytest.raises(ValidationError): + ChargeDischargeLimitMap(values=[[0.1, 0.2]]) + + def test_description_is_optional(self): + assert ChargeDischargeLimitMap(values=make_grid()).description is None + + +# --- HardCutoffs --------------------------------------------------------- + + +class TestHardCutoffs: + """Tests for cutoff ordering validation.""" + + def test_all_cutoffs_are_required(self): + for field in TEST_CUTOFFS: + values = {k: v for k, v in TEST_CUTOFFS.items() if k != field} + with pytest.raises(ValidationError, match=field): + HardCutoffs.model_validate(values) + + def test_charge_bounds_must_be_ordered(self): + with pytest.raises(ValidationError, match="charge cutoff"): + HardCutoffs.model_validate( + { + **TEST_CUTOFFS, + "charge_allowed_below_degC": 60.0, + "charge_allowed_above_degC": 50.0, + } + ) + + def test_discharge_bounds_must_be_ordered(self): + with pytest.raises(ValidationError, match="discharge cutoff"): + HardCutoffs.model_validate( + { + **TEST_CUTOFFS, + "discharge_allowed_below_degC": 60.0, + "discharge_allowed_above_degC": 50.0, + } + ) + + +# --- BatteryProfileSettings: validation ----------------------------------- + + +class TestBatteryProfileSettingsValidation: + """Tests for cross-field validation of BatteryProfileSettings.""" + + def test_minimal_config_is_valid(self): + limits = make_limits() + assert limits.interpolation is Interpolation.BILINEAR + assert limits.out_of_range_behavior is OutOfRangeBehavior.CLAMP_TO_NEAREST + + def test_charge_grid_must_match_axes_rows(self): + grid = make_grid(rows=3, cols=10) + with pytest.raises(ValidationError, match="charge_limit_crate.*rows"): + make_limits(charge_values=grid) + + def test_charge_grid_must_match_axes_cols(self): + grid = make_grid(rows=10, cols=4) + with pytest.raises(ValidationError, match="charge_limit_crate.*columns"): + make_limits(charge_values=grid) + + def test_discharge_grid_must_match_axes(self): + grid = make_grid(rows=3, cols=10) + with pytest.raises(ValidationError, match="discharge_limit_crate.*rows"): + make_limits(discharge_values=grid) + + def test_custom_axes_respected(self): + axes = Axes(temperature_degC=[0, 10, 20], soc_percent=[0, 50, 100]) + limits = make_limits( + charge_values=make_grid(3, 3), + discharge_values=make_grid(3, 3), + axes=axes, + ) + assert len(limits.charge_limit_crate.values) == 3 + + def test_custom_axes_shape_mismatch_rejected(self): + axes = Axes(temperature_degC=[0, 10, 20], soc_percent=[0, 50, 100]) + with pytest.raises(ValidationError, match="charge_limit_crate"): + make_limits( + charge_values=make_grid(10, 10), + discharge_values=make_grid(3, 3), + axes=axes, + ) + + +# --- BatteryProfileSettings: interpolation -------------------------------- + + +class TestInterpolationBilinear: + """Tests for bilinear interpolation.""" + + def test_exact_grid_point_returns_stored_value(self): + values = make_grid(2, 2, 0.0) + values[0][0] = 1.0 + values[0][1] = 2.0 + values[1][0] = 3.0 + values[1][1] = 4.0 + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + ) + assert limits.max_charge_crate(0.0, 0.0) == pytest.approx(1.0) + assert limits.max_charge_crate(10.0, 100.0) == pytest.approx(4.0) + + def test_midpoint_is_average_of_corners(self): + values = [[1.0, 2.0], [3.0, 4.0]] + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + ) + # Center of the cell: 0.25*(1+2+3+4) = 2.5 + assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(2.5) + + def test_one_direction_linear(self): + values = [[1.0, 2.0], [3.0, 4.0]] + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + ) + # Halfway along SOC only, at the first temperature row: (1+2)/2 = 1.5 + assert limits.max_charge_crate(0.0, 50.0) == pytest.approx(1.5) + # Halfway along temperature only, at the first SOC column: (1+3)/2 = 2.0 + assert limits.max_charge_crate(5.0, 0.0) == pytest.approx(2.0) + + +class TestInterpolationNearest: + """Tests for nearest-neighbor interpolation.""" + + def test_snaps_to_nearest_point(self): + values = [[1.0, 2.0], [3.0, 4.0]] + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + interpolation=Interpolation.NEAREST, + ) + assert limits.max_charge_crate(1.0, 10.0) == pytest.approx(1.0) + assert limits.max_charge_crate(9.0, 90.0) == pytest.approx(4.0) + + +class TestInterpolationNone: + """Tests for strict grid-point-only interpolation.""" + + def test_off_grid_returns_none(self): + values = [[1.0, 2.0], [3.0, 4.0]] + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + interpolation=Interpolation.NONE, + ) + assert limits.max_charge_crate(5.0, 50.0) is None + + def test_on_grid_returns_value(self): + values = [[1.0, 2.0], [3.0, 4.0]] + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + interpolation=Interpolation.NONE, + ) + assert limits.max_charge_crate(10.0, 100.0) == pytest.approx(4.0) + + +# --- BatteryProfileSettings: out-of-range behavior ------------------------ + + +class TestOutOfRange: + """Tests for clamping versus rejection outside the axis range.""" + + def _limits(self, behavior: OutOfRangeBehavior) -> BatteryProfileSettings: + # Axes deliberately narrower than the hard cutoffs so that queries can + # land outside the axis but inside the cutoffs. + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + cutoffs = HardCutoffs( + charge_allowed_below_degC=-20.0, + charge_allowed_above_degC=40.0, + discharge_allowed_below_degC=-20.0, + discharge_allowed_above_degC=40.0, + ) + return make_limits( + charge_values=make_grid(2, 2, 0.7), + discharge_values=make_grid(2, 2, 0.7), + axes=axes, + hard_cutoffs=cutoffs, + out_of_range_behavior=behavior, + ) + + def test_clamp_returns_edge_value(self): + limits = self._limits(OutOfRangeBehavior.CLAMP_TO_NEAREST) + assert limits.max_charge_crate(15.0, 50.0) == pytest.approx(0.7) + assert limits.max_charge_crate(5.0, 200.0) == pytest.approx(0.7) + + def test_reject_returns_none(self): + limits = self._limits(OutOfRangeBehavior.REJECT) + assert limits.max_charge_crate(15.0, 50.0) is None + assert limits.max_charge_crate(5.0, 200.0) is None + assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.7) + + +# --- BatteryProfileSettings: hard cutoffs --------------------------------- + + +class TestHardCutoffsRuntime: + """Tests that hard cutoffs override the maps.""" + + def test_charge_below_lower_cutoff_returns_none(self): + limits = make_limits() + c = limits.hard_cutoffs + assert limits.max_charge_crate(c.charge_allowed_below_degC - 0.1, 50.0) is None + + def test_charge_above_upper_cutoff_returns_none(self): + limits = make_limits() + c = limits.hard_cutoffs + assert limits.max_charge_crate(c.charge_allowed_above_degC + 0.1, 50.0) is None + + def test_discharge_below_lower_cutoff_returns_none(self): + limits = make_limits() + c = limits.hard_cutoffs + assert limits.max_discharge_crate(c.discharge_allowed_below_degC - 0.1, 50.0) is None + + def test_charge_and_discharge_cutoffs_are_independent(self): + limits = make_limits() + assert limits.max_discharge_crate(-12.0, 50.0) is not None + assert limits.max_charge_crate(-12.0, 50.0) is None + + def test_inside_cutoffs_returns_map_value(self): + limits = make_limits(charge_values=make_grid(value=0.42)) + assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.42) + + +# --- BatteryProfileSettings: accessors ------------------------------------ + + +class TestAccessors: + """Tests that the two accessors read their respective maps.""" + + def test_charge_and_discharge_use_different_maps(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + charge = make_grid(2, 2, 0.3) + discharge = make_grid(2, 2, 0.9) + limits = make_limits( + charge_values=charge, + discharge_values=discharge, + axes=axes, + ) + assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.3) + assert limits.max_discharge_crate(5.0, 50.0) == pytest.approx(0.9) + + +# --- PrecomputedChargeDischargeLimitMap: standalone ------------------------------------ + + +class TestPrecomputedChargeDischargeLimitMap: + """Tests for the PrecomputedChargeDischargeLimitMap dataclass on its own.""" + + def _map(self, fill=None) -> PrecomputedChargeDischargeLimitMap: + grid = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=float) + return PrecomputedChargeDischargeLimitMap( + grid=grid, + t_min=0.0, + t_step=10.0, + s_min=0.0, + s_step=100.0, + n_temp=2, + n_soc=2, + fill=fill, + ) + + def test_lookup_returns_stored_value_at_grid_points(self): + m = self._map() + assert m.lookup(0.0, 0.0) == pytest.approx(1.0) + assert m.lookup(10.0, 100.0) == pytest.approx(4.0) + + def test_lookup_rounds_to_nearest_cell(self): + m = self._map() + assert m.lookup(1.0, 10.0) == pytest.approx(1.0) + assert m.lookup(9.0, 90.0) == pytest.approx(4.0) + + def test_lookup_out_of_bounds_returns_fill(self): + m = self._map(fill=0.0) + # Below the lower bound: previously rounded to index 0 by mistake + assert m.lookup(-5.0, 0.0) == pytest.approx(0.0) + assert m.lookup(20.0, 0.0) == pytest.approx(0.0) + assert m.lookup(0.0, 200.0) == pytest.approx(0.0) + + def test_lookup_out_of_bounds_returns_none_when_fill_is_none(self): + m = self._map(fill=None) + assert m.lookup(-5.0, 0.0) is None + assert m.lookup(20.0, 0.0) is None + assert m.lookup(0.0, 200.0) is None + + def test_lookup_many_matches_lookup(self): + m = self._map(fill=None) + temps = np.array([0.0, 10.0, 5.0, -5.0]) + socs = np.array([0.0, 100.0, 50.0, 50.0]) + result = m.lookup_many(temps, socs) + expected = [m.lookup(t, s) for t, s in zip(temps, socs)] + for got, exp in zip(result, expected): + if exp is None: + assert np.isnan(got) + else: + assert got == pytest.approx(exp) + + def test_lookup_many_uses_fill(self): + m = self._map(fill=0.0) + temps = np.array([-5.0, 0.0, 20.0]) + socs = np.array([0.0, 0.0, 0.0]) + result = m.lookup_many(temps, socs) + assert result[0] == pytest.approx(0.0) + assert result[1] == pytest.approx(1.0) + assert result[2] == pytest.approx(0.0) + + def test_lookup_many_preserves_shape(self): + m = self._map() + temps = np.array([[0.0, 10.0], [10.0, 0.0]]) + socs = np.array([[0.0, 100.0], [0.0, 100.0]]) + result = m.lookup_many(temps, socs) + assert result.shape == temps.shape + + +# --- BatteryProfileSettings: precompute_charge_discharge_limit_map ----------------------------------- + + +class TestPrecompute: + """Tests for the precompute_charge_discharge_limit_map path on BatteryProfileSettings.""" + + def test_returns_two_precomputed_maps(self): + charge, discharge = make_limits().precompute_charge_discharge_limit_map() + assert isinstance(charge, PrecomputedChargeDischargeLimitMap) + assert isinstance(discharge, PrecomputedChargeDischargeLimitMap) + + def test_charge_and_discharge_use_different_maps(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + limits = make_limits( + charge_values=make_grid(2, 2, 0.3), + discharge_values=make_grid(2, 2, 0.9), + axes=axes, + ) + charge, discharge = limits.precompute_charge_discharge_limit_map(t_step=1.0, s_step=1.0) + assert charge.lookup(5.0, 50.0) == pytest.approx(0.3) + assert discharge.lookup(5.0, 50.0) == pytest.approx(0.9) + + def test_precomputed_matches_reference_at_axis_points(self): + # Non-trivial, non-uniform grid so any interpolation bug shows up. + axes = Axes( + temperature_degC=[-10, 0, 10, 25, 40], + soc_percent=[0, 20, 50, 80, 100], + ) + charge_values = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.1, 0.2, 0.3, 0.2, 0.05], + [0.4, 0.5, 0.6, 0.4, 0.1], + [0.5, 0.6, 0.7, 0.5, 0.15], + [0.3, 0.4, 0.5, 0.3, 0.1], + ] + limits = make_limits( + charge_values=charge_values, + discharge_values=charge_values, + axes=axes, + ) + charge_map, discharge_map = limits.precompute_charge_discharge_limit_map(t_step=1.0, s_step=1.0) + for t in axes.temperature_degC: + for s in axes.soc_percent: + ref_charge = limits.max_charge_crate(t, s) + ref_discharge = limits.max_discharge_crate(t, s) + if ref_charge is None: + assert charge_map.lookup(t, s) is None + else: + assert charge_map.lookup(t, s) == pytest.approx(ref_charge, abs=1e-9) + if ref_discharge is None: + assert discharge_map.lookup(t, s) is None + else: + assert discharge_map.lookup(t, s) == pytest.approx(ref_discharge, abs=1e-9) + + def test_precomputed_without_cutoffs_covers_axis_only(self): + limits = make_limits() + charge_map, _ = limits.precompute_charge_discharge_limit_map(apply_cutoffs=False) + assert charge_map.t_min == pytest.approx(limits.axes.temperature_degC[0]) + + def test_precomputed_grid_point_matches_reference(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + values = [[1.0, 2.0], [3.0, 4.0]] + limits = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + ) + charge_map, _ = limits.precompute_charge_discharge_limit_map(t_step=10.0, s_step=100.0, apply_cutoffs=False) + assert charge_map.lookup(0.0, 0.0) == pytest.approx(1.0) + assert charge_map.lookup(10.0, 100.0) == pytest.approx(4.0) + + def test_cutoffs_are_baked_in_by_default(self): + # Discharge down to -15, charge only to -10. + limits = make_limits() + charge_map, discharge_map = limits.precompute_charge_discharge_limit_map() + # -12 degC: discharge still fine, charge inhibited + assert charge_map.lookup(-12.0, 50.0) is None + assert discharge_map.lookup(-12.0, 50.0) is not None + + def test_cutoffs_can_be_disabled(self): + limits = make_limits() + charge_map, _ = limits.precompute_charge_discharge_limit_map(apply_cutoffs=False) + # Inside the axis and above the charge cutoff: a real value. + value = charge_map.lookup(-9.0, 50.0) + assert value is not None + + def test_rejects_non_positive_step(self): + limits = make_limits() + with pytest.raises(ValueError, match="positive"): + limits.precompute_charge_discharge_limit_map(t_step=0.0) + with pytest.raises(ValueError, match="positive"): + limits.precompute_charge_discharge_limit_map(s_step=-1.0) + + def test_out_of_range_semantics_preserved_reject(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + cutoffs = HardCutoffs( + charge_allowed_below_degC=-20.0, + charge_allowed_above_degC=40.0, + discharge_allowed_below_degC=-20.0, + discharge_allowed_above_degC=40.0, + ) + limits = make_limits( + charge_values=make_grid(2, 2, 0.7), + discharge_values=make_grid(2, 2, 0.7), + axes=axes, + hard_cutoffs=cutoffs, + out_of_range_behavior=OutOfRangeBehavior.REJECT, + ) + charge_map, _ = limits.precompute_charge_discharge_limit_map() + # Outside the axis, inside the cutoff -> None (REJECT) + assert charge_map.lookup(15.0, 50.0) is None + + def test_out_of_range_semantics_preserved_clamp(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + cutoffs = HardCutoffs( + charge_allowed_below_degC=-20.0, + charge_allowed_above_degC=40.0, + discharge_allowed_below_degC=-20.0, + discharge_allowed_above_degC=40.0, + ) + limits = make_limits( + charge_values=make_grid(2, 2, 0.7), + discharge_values=make_grid(2, 2, 0.7), + axes=axes, + hard_cutoffs=cutoffs, + out_of_range_behavior=OutOfRangeBehavior.CLAMP_TO_NEAREST, + ) + charge_map, _ = limits.precompute_charge_discharge_limit_map() + # Outside the axis, inside the cutoff -> clamped edge value 0.7 + assert charge_map.lookup(15.0, 50.0) == pytest.approx(0.7) + + @staticmethod + def cutoff_limits(charge_lo: float, charge_hi: float) -> BatteryProfileSettings: + """Constant 0.5 C limits on a 0..50 degC axis with the given charge cutoffs.""" + return make_limits( + charge_values=make_grid(2, 2, 0.5), + discharge_values=make_grid(2, 2, 0.5), + axes=Axes(temperature_degC=[0, 50], soc_percent=[0, 100]), + hard_cutoffs=HardCutoffs( + charge_allowed_below_degC=charge_lo, + charge_allowed_above_degC=charge_hi, + discharge_allowed_below_degC=-20.0, + discharge_allowed_above_degC=60.0, + ), + ) + + @pytest.mark.parametrize( + ("temperature", "allowed"), + [(45.0, True), (45.4, False), (45.6, False), (44.6, True)], + ) + def test_upper_cutoff_is_exact(self, temperature, allowed): + """Charging is inhibited strictly above the cutoff, not at the rounded grid cell.""" + charge_map, _ = self.cutoff_limits(-10.0, 45.0).precompute_charge_discharge_limit_map() + assert (charge_map.lookup(temperature, 50.0) is not None) is allowed + many = charge_map.lookup_many(np.array([temperature]), np.array([50.0])) + assert (not np.isnan(many[0])) is allowed + + @pytest.mark.parametrize( + ("temperature", "allowed"), + [(0.3, True), (0.4, True), (0.2, False), (-0.4, False)], + ) + def test_lower_cutoff_between_grid_points_is_exact(self, temperature, allowed): + """A cutoff that is no grid point neither allows below nor inhibits above it.""" + limits = self.cutoff_limits(0.3, 45.0) + # The axis starts at -5 degC, so the 1 degC grid points are integers. + limits = limits.model_copy( + update={"axes": Axes(temperature_degC=[-5, 50], soc_percent=[0, 100])} + ) + charge_map, _ = limits.precompute_charge_discharge_limit_map() + assert (charge_map.lookup(temperature, 50.0) is not None) is allowed + + def test_lookup_many_matches_single_lookup(self): + limits = make_limits() + charge_map, _ = limits.precompute_charge_discharge_limit_map() + temps = np.array([25.0, 30.0, -12.0, 45.0]) + socs = np.array([50.0, 60.0, 50.0, 90.0]) + many = charge_map.lookup_many(temps, socs) + for t, s, got in zip(temps, socs, many): + single = charge_map.lookup(float(t), float(s)) + if single is None: + assert np.isnan(got) + else: + assert got == pytest.approx(single) + + +# --- BatteryProfile: the user-facing object ------------------------------ + + +class TestBatteryProfile: + """Tests for the BatteryProfile wrapper returned to the controller.""" + + def test_to_limits_carries_metadata(self): + config = make_limits( + chemistry="LFP", + nominal_voltage_v=102.4, + max_charge_power_w=5000.0, + max_discharge_power_w=4500.0, + technical_min_soc_percent=5.0, + technical_max_soc_percent=95.0, + ) + limits = config.to_limits() + assert isinstance(limits, BatteryProfile) + assert limits.battery_model == "Test Battery" + assert limits.chemistry == "LFP" + assert limits.nominal_voltage_v == pytest.approx(102.4) + assert limits.max_charge_power_w == pytest.approx(5000.0) + assert limits.max_discharge_power_w == pytest.approx(4500.0) + assert limits.technical_min_soc_percent == pytest.approx(5.0) + assert limits.technical_max_soc_percent == pytest.approx(95.0) + assert limits.limit_unit == "C-rate" + + def test_accessors_delegate_to_precomputed_maps(self): + axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100]) + config = make_limits( + charge_values=make_grid(2, 2, 0.3), + discharge_values=make_grid(2, 2, 0.9), + axes=axes, + ) + limits = config.to_limits() + assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.3) + assert limits.max_discharge_crate(5.0, 50.0) == pytest.approx(0.9) + + def test_accessors_return_none_outside_cutoffs(self): + limits = make_limits().to_limits() + assert limits.max_charge_crate(-40.0, 50.0) is None + assert limits.max_discharge_crate(80.0, 50.0) is None + + def test_accessors_match_reference_implementation(self): + axes = Axes( + temperature_degC=[-10, 0, 10, 25, 40], + soc_percent=[0, 20, 50, 80, 100], + ) + values = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.1, 0.2, 0.3, 0.2, 0.05], + [0.4, 0.5, 0.6, 0.4, 0.1], + [0.5, 0.6, 0.7, 0.5, 0.15], + [0.3, 0.4, 0.5, 0.3, 0.1], + ] + config = make_limits( + charge_values=values, + discharge_values=values, + axes=axes, + ) + limits = config.to_limits() + for t in axes.temperature_degC: + for s in axes.soc_percent: + ref_charge = config.max_charge_crate(t, s) + ref_discharge = config.max_discharge_crate(t, s) + if ref_charge is None: + assert limits.max_charge_crate(t, s) is None + else: + assert limits.max_charge_crate(t, s) == pytest.approx( + ref_charge, abs=1e-9 + ) + if ref_discharge is None: + assert limits.max_discharge_crate(t, s) is None + else: + assert limits.max_discharge_crate(t, s) == pytest.approx( + ref_discharge, abs=1e-9 + ) + + +# --- JSON file entry point ---------------------------------------------- + + +class TestFromJsonFile: + """Tests for the BatteryProfileSettings.from_json_file entry point.""" + + def test_loads_valid_document(self, tmp_path: Path): + path = tmp_path / "limits.json" + path.write_text(json.dumps(make_document()), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(path) + assert isinstance(limits, BatteryProfile) + assert limits.battery_model == "Test Battery" + assert limits.chemistry == "LFP" + + def test_accepts_string_path(self, tmp_path: Path): + path = tmp_path / "limits.json" + path.write_text(json.dumps(make_document()), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(str(path)) + assert isinstance(limits, BatteryProfile) + + def test_missing_file_raises(self, tmp_path: Path): + with pytest.raises(FileNotFoundError): + BatteryProfileSettings.from_json_file(tmp_path / "missing.json") + + def test_invalid_json_raises(self, tmp_path: Path): + path = tmp_path / "limits.json" + path.write_text("not json", encoding="utf-8") + with pytest.raises(ValidationError): + BatteryProfileSettings.from_json_file(path) + + def test_invalid_document_raises(self, tmp_path: Path): + doc = make_document() + # Shape mismatch: 3 rows for a 10-entry temperature axis + doc["charge_limit_crate"]["values"] = make_grid(3, 10) + path = tmp_path / "limits.json" + path.write_text(json.dumps(doc), encoding="utf-8") + with pytest.raises(ValidationError, match="charge_limit_crate"): + BatteryProfileSettings.from_json_file(path) + + def test_custom_steps_are_respected(self, tmp_path: Path): + path = tmp_path / "limits.json" + path.write_text(json.dumps(make_document()), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(path, t_step=5.0, s_step=10.0) + assert limits.charge_map.t_step == pytest.approx(5.0) + assert limits.charge_map.s_step == pytest.approx(10.0) + + def test_loaded_limits_answer_queries(self, tmp_path: Path): + doc = make_document(charge_values=make_grid(value=0.42)) + path = tmp_path / "limits.json" + path.write_text(json.dumps(doc), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(path) + assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.42) + + def test_loaded_efficiency_curves_are_precomputed(self, tmp_path: Path): + doc = make_document( + charge_efficiency={ + "c_rate": [0.1, 0.5], + "efficiency": [0.9, 0.96], + }, + discharge_efficiency={ + "c_rate": [0.1, 0.5], + "efficiency": [0.91, 0.95], + }, + ) + path = tmp_path / "battery-profile.json" + path.write_text(json.dumps(doc), encoding="utf-8") + + profile = BatteryProfileSettings.from_json_file(path) + + assert isinstance(profile.charge_efficiency, PrecomputedEfficiencyCurve) + assert isinstance(profile.discharge_efficiency, PrecomputedEfficiencyCurve) + assert profile.charge_efficiency_at(0.3) == pytest.approx(0.93) + assert profile.discharge_efficiency_at(0.3) == pytest.approx(0.93) + + def test_loaded_cutoffs_are_honored(self, tmp_path: Path): + path = tmp_path / "limits.json" + path.write_text(json.dumps(make_document()), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(path) + # Below the charge cutoff but inside the discharge cutoff + assert limits.max_charge_crate(-12.0, 50.0) is None + assert limits.max_discharge_crate(-12.0, 50.0) is not None + + def test_round_trip_from_model(self, tmp_path: Path): + original = make_limits(charge_values=make_grid(value=0.25)) + path = tmp_path / "limits.json" + path.write_text(original.model_dump_json(), encoding="utf-8") + limits = BatteryProfileSettings.from_json_file(path) + assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.25) + + +# --- Integration test -------------------------------------------------- + +class TestFromBatteryModel: + """Tests for the BatteryProfileSettings.from_profile_name entry point. + + These tests exercise the real package data directory and the real + generic-lfp.json file that ships with it. They are integration tests of + name resolution plus the JSON-loading and precompute_charge_discharge_limit_map path. + """ + + def test_loads_generic_lfp(self): + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + assert isinstance(limits, BatteryProfile) + # The shipped file must at least declare a model name and a unit. + assert limits.battery_model + assert limits.limit_unit + + def test_generic_lfp_answers_queries(self): + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + # Inside the precomputed grid, both accessors must return a number. + assert limits.max_charge_crate(20.0, 50.0) is not None + assert limits.max_discharge_crate(20.0, 50.0) is not None + + def test_generic_lfp_charge_prohibited_below_zero(self): + # The generic-lfp file sets the charge hard cutoff at 0 degC. + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + assert limits.max_charge_crate(-1.0, 50.0) is None + + def test_generic_lfp_discharge_allowed_between_cutoffs(self): + # Discharge is allowed from -10 degC to 60 degC. + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + assert limits.max_discharge_crate(-11.0, 50.0) is None + assert limits.max_discharge_crate(20.0, 50.0) is not None + assert limits.max_discharge_crate(61.0, 50.0) is None + + def test_generic_lfp_has_efficiency_curves(self): + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + assert limits.charge_efficiency_at(0.25) == pytest.approx(0.97) + assert limits.discharge_efficiency_at(0.25) == pytest.approx(0.97) + + def test_generic_lfp_efficiency_falls_with_c_rate(self): + # DC pack efficiency: resistive losses grow with current. + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + charge = [limits.charge_efficiency_at(c) or 0.0 for c in (0.05, 0.25, 0.5, 1.0)] + discharge = [limits.discharge_efficiency_at(c) or 0.0 for c in (0.05, 0.25, 0.5)] + assert charge == sorted(charge, reverse=True) + assert discharge == sorted(discharge, reverse=True) + assert all(0.9 < e <= 1.0 for e in charge + discharge) + + def test_generic_lfp_efficiency_is_clamped_beyond_curve(self): + limits = BatteryProfileSettings.from_profile_name("generic-lfp") + assert limits.charge_efficiency_at(2.0) == pytest.approx(0.94) + assert limits.discharge_efficiency_at(1.0) == pytest.approx(0.96) + + def test_missing_model_raises_filenotfound(self): + with pytest.raises(FileNotFoundError, match="does-not-exist"): + BatteryProfileSettings.from_profile_name("does-not-exist") + + def test_error_message_names_expected_path(self): + with pytest.raises(FileNotFoundError, match=".json"): + BatteryProfileSettings.from_profile_name("does-not-exist") + + def test_custom_steps_are_respected(self): + limits = BatteryProfileSettings.from_profile_name( + "generic-lfp", t_step=5.0, s_step=10.0 + ) + assert limits.charge_map.t_step == pytest.approx(5.0) + assert limits.charge_map.s_step == pytest.approx(10.0) + + def test_returns_same_metadata_as_from_json_file(self): + # Both entry points must produce an equivalent BatteryProfile for the + # same file; this guards against a divergence in name resolution. + from_model = BatteryProfileSettings.from_profile_name("generic-lfp") + + profile_path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp") + assert exists + from_path = BatteryProfileSettings.from_json_file(profile_path) + + assert from_model.battery_model == from_path.battery_model + assert from_model.chemistry == from_path.chemistry + assert from_model.limit_unit == from_path.limit_unit + +# --- nominal capacity ----------------------------------------------------- + + +class TestNominalCapacity: + """Tests for the optional nominal_capacity_wh of a battery profile.""" + + def test_defaults_to_none(self): + assert make_limits().nominal_capacity_wh is None + assert make_limits().to_limits().nominal_capacity_wh is None + + def test_is_carried_into_the_compiled_profile(self): + profile = make_limits(nominal_capacity_wh=10240.0).to_limits() + assert profile.nominal_capacity_wh == pytest.approx(10240.0) + + @pytest.mark.parametrize("value", [0.0, -1.0, float("nan"), float("inf")]) + def test_invalid_values_are_rejected(self, value): + with pytest.raises(ValidationError): + make_limits(nominal_capacity_wh=value) + + def test_survives_json_round_trip(self): + original = make_limits(nominal_capacity_wh=5120.0) + restored = BatteryProfileSettings.model_validate_json(original.model_dump_json()) + assert restored.nominal_capacity_wh == pytest.approx(5120.0) + + def test_documents_without_capacity_stay_valid(self): + document = json.loads(make_limits().model_dump_json()) + del document["nominal_capacity_wh"] + assert BatteryProfileSettings.model_validate(document).nominal_capacity_wh is None + + def test_schema_describes_the_field(self): + props = BatteryProfileSettings.model_json_schema()["properties"] + assert "description" in props["nominal_capacity_wh"] + + +# --- profile store -------------------------------------------------------- + + +class TestProfileStore: + """Tests for battery profiles in the profile store: lookup, saving, caching.""" + + @pytest.fixture(autouse=True) + def _empty_cache(self, config_eos): + get_profile_store().clear_cache() + yield + get_profile_store().clear_cache() + + def test_packaged_profile_is_found(self): + path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp") + assert exists + assert path == get_profile_store().package_dir(BatteryProfileSettings) / "generic-lfp.json" + assert path.parent.name == "battery" + + def test_missing_profile_points_to_user_dir(self): + path, exists = get_profile_store().path(BatteryProfileSettings, "does-not-exist") + assert not exists + assert path == get_profile_store().user_dir(BatteryProfileSettings) / "does-not-exist.json" + + def test_empty_name_raises(self): + with pytest.raises(ValueError, match="Invalid battery profile name"): + get_profile_store().path(BatteryProfileSettings, "") + + def test_user_profile_overrides_packaged_profile(self): + user_path = make_limits(battery_model="generic-lfp").save_profile("generic-lfp") + path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp") + assert exists + assert path == user_path + assert get_profile_store().load(BatteryProfileSettings, "generic-lfp").battery_model == ( + "generic-lfp" + ) + + def test_save_and_load_round_trip(self): + original = make_limits(battery_model="my-battery", charge_values=make_grid(value=0.3)) + path = original.save_profile("my-battery") + assert path == get_profile_store().user_dir(BatteryProfileSettings) / "my-battery.json" + assert BatteryProfileSettings.load_profile("my-battery").model_dump() == original.model_dump() + + def test_model_name_is_free_text(self): + """The profile name, not the model name, is the file name.""" + document = make_limits(battery_model="BYD Battery-Box Premium HVS 7.7") + path = document.save_profile("byd-hvs-7.7") + assert path.name == "byd-hvs-7.7.json" + loaded = BatteryProfileSettings.load_profile("byd-hvs-7.7") + assert loaded.battery_model == "BYD Battery-Box Premium HVS 7.7" + + def test_load_missing_profile_names_expected_path(self): + with pytest.raises(FileNotFoundError, match=r"No battery profile.*does-not-exist\.json"): + BatteryProfileSettings.load_profile("does-not-exist") + with pytest.raises(FileNotFoundError, match="does-not-exist.json"): + get_profile_store().get(BatteryProfileSettings, "does-not-exist") + + def test_compiled_profile_is_cached(self): + first = get_profile_store().get(BatteryProfileSettings, "generic-lfp") + second = BatteryProfileSettings.from_profile_name("generic-lfp") + assert first is second + + # Other precomputation steps are a different compiled profile. + coarse = get_profile_store().get(BatteryProfileSettings, "generic-lfp", t_step=5.0) + assert coarse is not first + assert coarse.charge_map.t_step == pytest.approx(5.0) + assert BatteryProfileSettings.from_profile_name("generic-lfp", t_step=5.0) is coarse + + def test_unknown_compile_option_raises(self): + with pytest.raises(TypeError, match="Unknown battery profile options"): + get_profile_store().get(BatteryProfileSettings, "generic-lfp", u_step=1.0) + + def test_document_is_a_battery_profile_type(self): + assert BatteryProfileSettings.profile_kind == "battery" + assert isinstance(make_limits().compile_profile(), BatteryProfile) + + def test_changed_file_is_recompiled(self): + make_limits(battery_model="edited", charge_values=make_grid(value=0.2)).save_profile("edited") + before = get_profile_store().get(BatteryProfileSettings, "edited") + assert before.max_charge_crate(25.0, 50.0) == pytest.approx(0.2) + + # A different size changes the cache key even if mtime has coarse resolution. + make_limits( + battery_model="edited", + charge_values=make_grid(value=0.25), + chemistry="LFP", + ).save_profile("edited") + after = get_profile_store().get(BatteryProfileSettings, "edited") + assert after is not before + assert after.max_charge_crate(25.0, 50.0) == pytest.approx(0.25) + + def test_compiled_grids_are_read_only(self): + profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp") + with pytest.raises(ValueError): + profile.charge_map.grid[0, 0] = 1.0 + + +# --- JSON schema --------------------------------------------------------- + + +class TestJsonSchema: + """Tests that the generated JSON schema carries descriptions and examples.""" + + def test_control_battery_limits_schema(self): + schema = BatteryProfileSettings.model_json_schema() + props = schema["properties"] + assert "description" in props["interpolation"] + assert props["interpolation"]["examples"] == ["bilinear"] + assert "description" in props["hard_cutoffs"] + assert "description" in props["battery_model"] + + def test_axes_schema_examples(self): + schema = Axes.model_json_schema() + assert schema["properties"]["temperature_degC"]["examples"] + assert schema["properties"]["soc_percent"]["examples"] + + +# --- Round-trip ---------------------------------------------------------- + + +class TestRoundTrip: + """Tests that a config survives a JSON round-trip unchanged.""" + + def test_round_trip_preserves_efficiency_curves(self): + original = make_limits( + charge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.9, 0.96] + ), + discharge_efficiency=make_efficiency_curve( + c_rate=[0.1, 0.5], efficiency=[0.91, 0.95], + interpolation=EfficiencyInterpolation.NEAREST, + out_of_range_behavior=OutOfRangeBehavior.REJECT, + ), + ) + restored = BatteryProfileSettings.model_validate_json( + original.model_dump_json() + ) + + assert restored.model_dump() == original.model_dump() + assert restored.charge_efficiency is not None + assert restored.discharge_efficiency is not None + assert restored.charge_efficiency.lookup(0.3) == pytest.approx(0.93) + assert restored.discharge_efficiency.lookup(0.3) == pytest.approx(0.91) + + def test_round_trip_preserves_values(self): + original = make_limits(charge_values=make_grid(value=0.25)) + dumped = original.model_dump_json() + restored = BatteryProfileSettings.model_validate_json(dumped) + assert restored.model_dump() == original.model_dump() + assert restored.max_charge_crate(25.0, 50.0) == pytest.approx(0.25) + + +# --- Strict profile documents -------------------------------------------- + + +class TestStrictProfile: + """A profile contains every value the computation uses, and nothing unknown.""" + + @pytest.mark.parametrize( + "field", + [ + "technical_min_soc_percent", + "technical_max_soc_percent", + "charge_efficiency", + "discharge_efficiency", + "units", + "axes", + "hard_cutoffs", + "charge_limit_crate", + "discharge_limit_crate", + ], + ) + def test_required_fields(self, field): + document = make_document() + del document[field] + with pytest.raises(ValidationError, match=field): + BatteryProfileSettings.model_validate(document) + + @pytest.mark.parametrize( + "field", + ["nominal_capacity_wh", "max_charge_power_w", "max_discharge_power_w", "chemistry"], + ) + def test_optional_fields(self, field): + document = make_document() + document.pop(field, None) + assert getattr(BatteryProfileSettings.model_validate(document), field) is None + + def test_unknown_top_level_key_is_rejected(self): + # A typo of nominal_capacity_wh must not be ignored silently. + document = make_document(nominal_capacity_kwh=10.0) + with pytest.raises(ValidationError, match="nominal_capacity_kwh"): + BatteryProfileSettings.model_validate(document) + + @pytest.mark.parametrize("section", ["units", "axes", "hard_cutoffs", "charge_efficiency"]) + def test_unknown_nested_key_is_rejected(self, section): + document = make_document() + document[section] = {**document[section], "unknown_key": 1} + with pytest.raises(ValidationError, match="unknown_key"): + BatteryProfileSettings.model_validate(document) + + @pytest.mark.parametrize("section", ["charge_efficiency", "hard_cutoffs"]) + def test_sections_take_a_description(self, section): + document = make_document() + document[section] = {**document[section], "description": "Data sheet values"} + profile = BatteryProfileSettings.model_validate(document) + assert getattr(profile, section).description == "Data sheet values" + + def test_technical_soc_bounds_must_be_ordered(self): + document = make_document(technical_min_soc_percent=90.0, technical_max_soc_percent=10.0) + with pytest.raises(ValidationError, match="technical_min_soc_percent"): + BatteryProfileSettings.model_validate(document) + + def test_bundled_generic_profile_is_valid_and_generic(self, config_eos): + path = ( + config_eos.package_root_path / "data" / "profile" / "battery" / "generic-lfp.json" + ) + profile = BatteryProfileSettings.model_validate(json.loads(path.read_text())) + # Generic: C-rate limits, no capacity of its own. + assert profile.nominal_capacity_wh is None + assert profile.units.limit == "C-rate" + assert profile.technical_min_soc_percent == 0.0 + assert profile.technical_max_soc_percent == 100.0 diff --git a/tests/test_devicesprofilestore.py b/tests/test_devicesprofilestore.py new file mode 100644 index 00000000..b0d34bc7 --- /dev/null +++ b/tests/test_devicesprofilestore.py @@ -0,0 +1,472 @@ +"""Tests for the profilestore module. + +Uses minimal, non-battery profile types to show that one store serves any +profile type given by parameter. +""" + +import os +import threading +import time +from pathlib import Path +from typing import Any, ClassVar + +import cachebox +import pytest +from pydantic import ValidationError + +from akkudoktoreos.devices.profile import profilestore as profilestore_module +from akkudoktoreos.devices.profile.profilestore import ( + ProfileDocument, + ProfileEntry, + ProfileStore, + get_profile_store, + profile_kind, + profile_kinds, + profile_type, +) + + +class DummyProfile(ProfileDocument[dict[str, Any]]): + """Minimal profile document of a test profile kind.""" + + profile_kind: ClassVar[str] = "dummy" + + model: str + max_power_w: float + + def compile_profile(self, scale: float = 1.0, **options: Any) -> dict[str, Any]: + """Compile into a plain dict.""" + return {"model": self.model, "power_w": self.max_power_w * scale} + + +class OtherProfile(ProfileDocument[str]): + """Second test profile kind, to check that kinds are kept apart.""" + + profile_kind: ClassVar[str] = "other" + + model: str + + def compile_profile(self, **options: Any) -> str: + """Compile into the model name.""" + return self.model + + +@pytest.fixture +def store(config_eos, tmp_path, monkeypatch): + """Return a fresh singleton store with the packaged directories in tmp_path. + + The packaged directories are moved so the package is not modified. The + singleton is reset afterwards, so other tests get an unpatched store. + """ + ProfileStore.reset_instance() + store = get_profile_store() + monkeypatch.setattr( + store, "package_dir", lambda document_type: tmp_path / "package" / document_type.profile_kind + ) + yield store + ProfileStore.reset_instance() + + +def write_packaged(store: ProfileStore, name: str, document: ProfileDocument[Any]) -> Path: + """Write a document to the packaged directory of its type.""" + path = store.package_dir(type(document)) / f"{name}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(document.model_dump_json(), encoding="utf-8") + return path + + +class TestSingleton: + def test_store_is_a_singleton(self, store): + assert get_profile_store() is store + assert ProfileStore() is store + + def test_reset_gives_a_fresh_store_with_an_empty_cache(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=1.0)) + before = store.get(DummyProfile, "model-a") + ProfileStore.reset_instance() + fresh = get_profile_store() + assert fresh is not store + assert fresh._compiled == {} + assert fresh.get(DummyProfile, "model-a") == before + + +class TestRegistry: + def test_profile_types_are_registered_by_kind(self): + assert profile_type("dummy") is DummyProfile + assert profile_type("other") is OtherProfile + assert {"dummy", "other"} <= set(profile_kinds()) + + def test_battery_profile_is_registered(self): + from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings + + assert profile_type("battery") is BatteryProfileSettings + + def test_unknown_kind_raises(self): + with pytest.raises(KeyError, match="Unknown profile kind 'nope'"): + profile_type("nope") + + def test_kind_of_another_type_cannot_be_taken(self): + with pytest.raises(ValueError, match="already used by"): + + class Imposter(ProfileDocument[str]): + profile_kind: ClassVar[str] = "dummy" + + assert profile_type("dummy") is DummyProfile + + def test_subclass_without_own_kind_is_not_registered(self): + class Derived(DummyProfile): + pass + + assert profile_type("dummy") is DummyProfile + assert profile_kind(Derived) == "dummy" + + +class TestProfileKind: + def test_directories_follow_kind(self, config_eos): + store = get_profile_store() + assert store.user_dir(DummyProfile) == ( + config_eos.general.data_folder_path / "profile" / "dummy" + ) + assert store.package_dir(DummyProfile) == ( + config_eos.package_root_path / "data" / "profile" / "dummy" + ) + + @pytest.mark.parametrize("kind", ["", "Battery", "inverter-x", "../x", "1battery", 3]) + def test_invalid_kind_is_rejected_when_the_type_is_defined(self, kind): + with pytest.raises(ValueError, match="Invalid profile kind"): + + class BadProfile(DummyProfile): + profile_kind: ClassVar[Any] = kind + + def test_missing_kind_is_rejected(self, store): + class NoKindProfile(ProfileDocument[str]): + model: str + + with pytest.raises(ValueError, match="Invalid profile kind"): + profile_kind(NoKindProfile) + with pytest.raises(ValueError, match="Invalid profile kind"): + store.save("model-a", NoKindProfile(model="a")) + + def test_kinds_are_kept_apart(self, store): + store.save("model-a", DummyProfile(model="dummy", max_power_w=1.0)) + assert store.path(OtherProfile, "model-a")[1] is False + store.save("model-a", OtherProfile(model="other")) + assert store.get(OtherProfile, "model-a") == "other" + assert store.get(DummyProfile, "model-a")["model"] == "dummy" + + def test_missing_compile_raises(self, store): + class NoCompileProfile(ProfileDocument[str]): + profile_kind: ClassVar[str] = "nocompile" + model: str + + store.save("model-a", NoCompileProfile(model="a")) + with pytest.raises(NotImplementedError, match="compile_profile"): + store.get(NoCompileProfile, "model-a") + + +class TestProfileNames: + @pytest.mark.parametrize( + "name", + [ + "../escape", + "a/../../b", + "/etc/passwd", + "..\\escape", + "vendor/model-a", + # Drive relative on Windows: not absolute for PurePosixPath. + "C:evil", + ".hidden", + "-dash", + "name\n", + "x" * 101, + ], + ) + def test_invalid_names_are_rejected(self, store, name): + with pytest.raises(ValueError, match="Invalid dummy profile name"): + store.path(DummyProfile, name) + with pytest.raises(ValueError, match="Invalid dummy profile name"): + store.save(name, DummyProfile(model="x", max_power_w=1.0)) + + def test_empty_name_is_rejected(self, store): + with pytest.raises(ValueError, match="Invalid dummy profile name"): + store.path(DummyProfile, "") + + def test_reserved_name_is_rejected(self, store): + """'schema' is the path of the JSON schema in the REST API.""" + with pytest.raises(ValueError, match="reserved"): + store.save("schema", DummyProfile(model="x", max_power_w=1.0)) + + @pytest.mark.parametrize("name", ["model-a", "Vendor_Model.v2", "9kwh"]) + def test_valid_names_are_files_in_the_profile_directory(self, store, name): + path = store.save(name, DummyProfile(model="a", max_power_w=1.0)) + assert path == store.user_dir(DummyProfile) / f"{name}.json" + assert store.path(DummyProfile, name) == (path, True) + + +class TestProfileDocuments: + def test_missing_profile_names_kind_and_path(self, store): + path, exists = store.path(DummyProfile, "missing") + assert not exists + assert path == store.user_dir(DummyProfile) / "missing.json" + with pytest.raises(FileNotFoundError, match=r"No dummy profile for 'missing'.*missing\.json"): + store.load(DummyProfile, "missing") + + def test_packaged_profile_is_found(self, store): + packaged = write_packaged(store, "model-a", DummyProfile(model="a", max_power_w=1.0)) + assert store.path(DummyProfile, "model-a") == (packaged, True) + assert store.load(DummyProfile, "model-a").model == "a" + + def test_user_profile_overrides_packaged_profile(self, store): + write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0)) + user = store.save("model-a", DummyProfile(model="user", max_power_w=2.0)) + assert store.path(DummyProfile, "model-a") == (user, True) + assert store.load(DummyProfile, "model-a").model == "user" + + def test_invalid_document_raises(self, store): + path = store.user_dir(DummyProfile) / "broken.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"model": "x"}', encoding="utf-8") + with pytest.raises(ValidationError): + store.load(DummyProfile, "broken") + + +class TestCompiledCache: + def test_compiled_profile_is_cached(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + first = store.get(DummyProfile, "model-a") + assert first == {"model": "a", "power_w": 100.0} + assert store.get(DummyProfile, "model-a") is first + + def test_options_are_passed_and_part_of_cache_key(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + default = store.get(DummyProfile, "model-a") + scaled = store.get(DummyProfile, "model-a", scale=0.5) + assert scaled["power_w"] == pytest.approx(50.0) + assert store.get(DummyProfile, "model-a", scale=0.5) is scaled + assert store.get(DummyProfile, "model-a") is default + # Passing the default value explicitly is the same compilation. + assert store.get(DummyProfile, "model-a", scale=1.0) is default + + def test_changed_file_is_recompiled(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + before = store.get(DummyProfile, "model-a") + store.save("model-a", DummyProfile(model="a", max_power_w=2500.0)) + after = store.get(DummyProfile, "model-a") + assert after is not before + assert after["power_w"] == pytest.approx(2500.0) + + def test_file_edited_outside_eos_applies_after_the_recheck_interval(self, store, monkeypatch): + recheck_s = 0.2 + # cachebox expires with its own clock: use a short interval and wait. + monkeypatch.setattr(store, "_recent", cachebox.TTLCache(16, recheck_s)) + path = store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + before = store.get(DummyProfile, "model-a") + path.write_text( + DummyProfile(model="a", max_power_w=2500.0).model_dump_json(), encoding="utf-8" + ) + os.utime(path, ns=(path.stat().st_mtime_ns + 10**9,) * 2) + # Checked recently: the file is not looked at again. + assert store.get(DummyProfile, "model-a") is before + time.sleep(recheck_s * 1.5) + assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(2500.0) + + def test_saved_override_of_a_packaged_profile_applies_at_once(self, store): + write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0)) + assert store.get(DummyProfile, "model-a")["model"] == "packaged" + store.save("model-a", DummyProfile(model="user", max_power_w=2.0)) + assert store.get(DummyProfile, "model-a")["model"] == "user" + + def test_clear_cache_forces_recompile(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + before = store.get(DummyProfile, "model-a") + store.clear_cache() + assert store.get(DummyProfile, "model-a") is not before + + def test_clear_cache_of_one_type_keeps_others(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + store.save("model-a", OtherProfile(model="a")) + dummy = store.get(DummyProfile, "model-a") + store.get(OtherProfile, "model-a") + store.clear_cache(OtherProfile) + assert store.get(DummyProfile, "model-a") is dummy + assert [key.document_type for key in store._compiled] == [DummyProfile] + + +class TestListAndDelete: + def test_list_shows_user_and_packaged_profiles(self, store): + write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0)) + write_packaged(store, "model-b", DummyProfile(model="packaged", max_power_w=1.0)) + store.save("model-a", DummyProfile(model="user", max_power_w=2.0)) + store.save("model-c", DummyProfile(model="user", max_power_w=3.0)) + # Files that are no valid profile, e.g. in a subdirectory, are not listed. + (store.user_dir(DummyProfile) / "vendor").mkdir() + (store.user_dir(DummyProfile) / "vendor" / "model-d.json").write_text("{}", encoding="utf-8") + (store.user_dir(DummyProfile) / "schema.json").write_text("{}", encoding="utf-8") + assert store.list_profiles(DummyProfile) == [ + ProfileEntry(name="model-a", source="user", packaged=True), + ProfileEntry(name="model-b", source="package", packaged=True), + ProfileEntry(name="model-c", source="user", packaged=False), + ] + + def test_list_of_a_kind_without_profiles_is_empty(self, store): + assert store.list_profiles(OtherProfile) == [] + + def test_delete_user_profile_makes_the_packaged_one_apply(self, store): + write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0)) + store.save("model-a", DummyProfile(model="user", max_power_w=2.0)) + assert store.get(DummyProfile, "model-a")["model"] == "user" + store.delete(DummyProfile, "model-a") + assert store.get(DummyProfile, "model-a")["model"] == "packaged" + assert store.list_profiles(DummyProfile) == [ + ProfileEntry(name="model-a", source="package", packaged=True) + ] + + def test_packaged_profile_cannot_be_deleted(self, store): + packaged = write_packaged(store, "model-a", DummyProfile(model="a", max_power_w=1.0)) + with pytest.raises(PermissionError, match="packaged"): + store.delete(DummyProfile, "model-a") + assert packaged.is_file() + + def test_delete_of_a_missing_profile_raises(self, store): + with pytest.raises(FileNotFoundError, match="No dummy profile for 'missing'"): + store.delete(DummyProfile, "missing") + + def test_delete_rejects_unsafe_names(self, store): + with pytest.raises(ValueError, match="Invalid dummy profile name"): + store.delete(DummyProfile, "../escape") + + def test_delete_refuses_a_directory(self, store): + """A directory named like a profile file is no profile: ValueError, not OSError.""" + (store.user_dir(DummyProfile) / "model-a.json").mkdir(parents=True) + with pytest.raises(ValueError, match="is not a file"): + store.delete(DummyProfile, "model-a") + + +class TestConcurrency: + def test_save_replaces_atomically_and_leaves_no_temp_files(self, store): + path = store.save("model-a", DummyProfile(model="a", max_power_w=1.0)) + store.save("model-a", DummyProfile(model="b", max_power_w=2.0)) + assert [p.name for p in path.parent.iterdir()] == ["model-a.json"] + + def test_failed_save_keeps_the_old_document(self, store, monkeypatch): + import os + + path = store.save("model-a", DummyProfile(model="old", max_power_w=1.0)) + + def broken_replace(src: Any, dst: Any) -> None: + raise OSError("disk full") + + monkeypatch.setattr(os, "replace", broken_replace) + with pytest.raises(OSError, match="disk full"): + store.save("model-a", DummyProfile(model="new", max_power_w=2.0)) + assert store.load(DummyProfile, "model-a").model == "old" + assert [p.name for p in path.parent.iterdir()] == ["model-a.json"] + + def test_save_drops_the_cache_of_the_file(self, store, monkeypatch): + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + store.get(DummyProfile, "model-a") + assert store._compiled + store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + assert store._compiled == {} + + def test_save_during_a_lookup_is_not_hidden_by_the_cache(self, store, monkeypatch): + """A lookup that read the old file must not cache it after a concurrent save. + + The saved file has the same size and, as on file systems with coarse + timestamps, the same modification time as the old one. + """ + path = store.save("model-a", DummyProfile(model="a", max_power_w=100.0)) + old_mtime_ns = path.stat().st_mtime_ns + original_load = store.load_file + saved: list[bool] = [] + + def load_then_save(document_type, file_path): + document = original_load(document_type, file_path) + if not saved: + saved.append(True) + store.save("model-a", DummyProfile(model="a", max_power_w=200.0)) + os.utime(path, ns=(old_mtime_ns, old_mtime_ns)) + return document + + monkeypatch.setattr(store, "load_file", load_then_save) + assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(100.0) + assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(200.0) + + def test_readers_never_see_a_partial_document(self, store): + store.save("model-a", DummyProfile(model="a", max_power_w=0.0)) + errors: list[BaseException] = [] + stop = threading.Event() + + def write() -> None: + try: + for i in range(200): + store.save("model-a", DummyProfile(model="a" * (i % 50 + 1), max_power_w=i)) + except BaseException as exc: # noqa: BLE001 - collect for the assertion + errors.append(exc) + finally: + stop.set() + + def read() -> None: + while not stop.is_set(): + try: + store.get(DummyProfile, "model-a") + except BaseException as exc: # noqa: BLE001 - collect for the assertion + errors.append(exc) + return + + threads = [threading.Thread(target=write)] + [ + threading.Thread(target=read) for _ in range(4) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + assert errors == [] + assert store.get(DummyProfile, "model-a")["power_w"] == 199.0 + + +class TestSharingViolation: + """On Windows, replacing or reading a file fails while another handle has it open.""" + + @pytest.fixture + def windows(self, monkeypatch): + monkeypatch.setattr(profilestore_module, "_RETRY_SHARING_VIOLATIONS", True) + monkeypatch.setattr(profilestore_module, "SHARING_VIOLATION_RETRY_DELAY_S", 0.0) + + @staticmethod + def failing_first(original, failures: int): + calls: list[int] = [] + + def operation(*args: Any, **kwargs: Any) -> Any: + calls.append(1) + if len(calls) <= failures: + raise PermissionError("[WinError 5] Access denied") + return original(*args, **kwargs) + + return operation + + def test_save_retries_a_replace_blocked_by_a_reader(self, store, windows, monkeypatch): + store.save("model-a", DummyProfile(model="old", max_power_w=1.0)) + monkeypatch.setattr(os, "replace", self.failing_first(os.replace, 3)) + path = store.save("model-a", DummyProfile(model="new", max_power_w=2.0)) + assert store.load(DummyProfile, "model-a").model == "new" + assert [p.name for p in path.parent.iterdir()] == ["model-a.json"] + + def test_load_retries_a_read_blocked_by_a_replace(self, store, windows, monkeypatch): + store.save("model-a", DummyProfile(model="a", max_power_w=1.0)) + monkeypatch.setattr(Path, "read_text", self.failing_first(Path.read_text, 3)) + assert store.load(DummyProfile, "model-a").model == "a" + + def test_lasting_permission_error_is_raised(self, store, windows, monkeypatch): + store.save("model-a", DummyProfile(model="a", max_power_w=1.0)) + monkeypatch.setattr(Path, "read_text", self.failing_first(Path.read_text, 10**6)) + with pytest.raises(PermissionError): + store.load(DummyProfile, "model-a") + + def test_permission_error_is_not_retried_elsewhere(self, store, monkeypatch): + monkeypatch.setattr(profilestore_module, "_RETRY_SHARING_VIOLATIONS", False) + store.save("model-a", DummyProfile(model="a", max_power_w=1.0)) + read_text = self.failing_first(Path.read_text, 1) + monkeypatch.setattr(Path, "read_text", read_text) + with pytest.raises(PermissionError): + store.load(DummyProfile, "model-a") diff --git a/tests/test_devicessettingsbattery.py b/tests/test_devicessettingsbattery.py new file mode 100644 index 00000000..4e6bfd59 --- /dev/null +++ b/tests/test_devicessettingsbattery.py @@ -0,0 +1,1861 @@ +"""Tests for the batterysettings module. + +The module exercises the configuration fields of BatteriesCommonSettings, the +computed fields that are evaluated on access at the reference operating point, +the battery profile that is always present (default ``generic-lfp``), the +operating point and the charge / discharge power read asynchronously from +measurements, and the conversion to the GENETIC and GENETIC0 optimization +parameters. + +The battery profile loader and the measurement access are replaced by autouse +fixtures, so most tests do not depend on the configuration singleton, the +measurement store or the packaged data directory. Unless a test installs another +profile, settings use a plain profile (8000 Wh, charge 0.5 C, discharge 0.4 C, +flat efficiency curves of 0.88, technical SoC bounds 0 % and 100 %) and no +measurement is available. TestMeasurementAccess uses the real measurement store. +""" + +import dataclasses +from typing import Any, Optional + +import pytest +from pydantic import ValidationError + +from akkudoktoreos.devices.profile.batteryprofile import ( + Axes, + BatteryProfile, + BatteryProfileSettings, + ChargeDischargeLimitMap, + EfficiencyCurve, + HardCutoffs, + OutOfRangeBehavior, + Units, +) +from akkudoktoreos.devices.profile.profilestore import get_profile_store +from akkudoktoreos.devices.settings import batterymeasurement +from akkudoktoreos.devices.settings.batterysettings import ( + BATTERY_DEFAULT_PROFILE, + BATTERY_DEFAULT_SOC_FACTOR, + BATTERY_DEFAULT_TEMPERATURE_DEGC, + BatteriesCommonSettings, + BatteryOperatingParameters, + BatteryOperatingPoint, + BatteryProfileAdjustment, + BatteryResolutionError, +) + +# The real measurement accessor, captured before any fixture replaces it. +REAL_GET_MEASURED_VALUE = batterymeasurement.get_measured_value + +# Capacity and flat efficiency of the plain test profile. +PROFILE_CAPACITY_WH = 8000 +PROFILE_EFFICIENCY = 0.88 + +# Axes of the test profiles: temperatures [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50], +# SoC [0, 5, 10, 20, 40, 60, 80, 90, 95, 100]. +TEST_AXES = { + "temperature_degC": [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50], + "soc_percent": [0, 5, 10, 20, 40, 60, 80, 90, 95, 100], +} +# Hard cutoffs of the test profiles: charging -10 ... 55 degC, discharging -15 ... 60 degC. +TEST_CUTOFFS = { + "charge_allowed_below_degC": -10.0, + "charge_allowed_above_degC": 55.0, + "discharge_allowed_below_degC": -15.0, + "discharge_allowed_above_degC": 60.0, +} +ROW_0_DEGC = 2 +ROW_25_DEGC = 6 +COLUMN_40_PERCENT = 4 +COLUMN_60_PERCENT = 5 + +PARAMETER_METHODS = [ + "to_genetic_pv_bat_param", + "to_genetic_ev_bat_param", + "to_genetic0_pv_bat_param", + "to_genetic0_ev_bat_param", +] + +COMPUTED_FIELDS = [ + "effective_nominal_capacity_wh", + "effective_max_charge_power_w", + "effective_max_discharge_power_w", + "effective_charging_efficiency", + "effective_discharging_efficiency", + "effective_min_soc_percentage", + "effective_max_soc_percentage", +] + +# Fields that moved into the profile adjustment: (former field, adjustment field). +MOVED_TO_ADJUSTMENT = [ + ("nominal_capacity_wh", "nominal_capacity_wh"), + ("max_charge_power_limit_w", "max_charge_power_w"), + ("max_discharge_power_limit_w", "max_discharge_power_w"), +] + +# Computed fields that used to read measurements and are gone now. +REMOVED_COMPUTED_FIELDS = ["current_soc_factor", "current_temperature_degc"] + +# Fields that used to be configuration fields and are computed now. +FORMER_CONFIGURATION_FIELDS = [ + "capacity_wh", + "charging_efficiency", + "discharging_efficiency", + "max_charge_power_w", + "max_discharge_power_w", +] + + +# --- helpers ------------------------------------------------------------- + + +def make_grid(value: float, rows: int = 10, cols: int = 10) -> list[list[float]]: + """Return a constant rows x cols grid filled with value.""" + return [[value for _ in range(cols)] for _ in range(rows)] + + +def make_profile_config( + charge: float | list[list[float]] = 0.5, + discharge: float | list[list[float]] = 0.4, + **overrides: Any, +) -> BatteryProfileSettings: + """Build a validated BatteryProfileSettings. + + Without overrides the profile is the plain test profile: 8000 Wh, flat + efficiency curves of 0.88, technical SoC bounds 0 % and 100 %, C-rate limits. + + Args: + charge: Constant charge limit or a full 10 x 10 grid. + discharge: Constant discharge limit or a full 10 x 10 grid. + **overrides: Further keyword arguments forwarded to the model, e.g. + ``nominal_capacity_wh=None`` for a generic profile. + + Returns: + A validated BatteryProfileSettings instance. + """ + charge_values = make_grid(charge) if isinstance(charge, (int, float)) else charge + discharge_values = make_grid(discharge) if isinstance(discharge, (int, float)) else discharge + payload: dict[str, Any] = dict( + battery_model="Test Battery", + nominal_capacity_wh=float(PROFILE_CAPACITY_WH), + technical_min_soc_percent=0.0, + technical_max_soc_percent=100.0, + charge_efficiency=make_curve([0.0, 2.0], [PROFILE_EFFICIENCY, PROFILE_EFFICIENCY]), + discharge_efficiency=make_curve([0.0, 2.0], [PROFILE_EFFICIENCY, PROFILE_EFFICIENCY]), + units=Units(limit="C-rate"), + axes=Axes.model_validate(TEST_AXES), + hard_cutoffs=HardCutoffs.model_validate(TEST_CUTOFFS), + charge_limit_crate=ChargeDischargeLimitMap(values=charge_values), + discharge_limit_crate=ChargeDischargeLimitMap(values=discharge_values), + ) + payload.update(overrides) + return BatteryProfileSettings(**payload) + + +def make_profile( + charge: float | list[list[float]] = 0.5, + discharge: float | list[list[float]] = 0.4, + **overrides: Any, +) -> BatteryProfile: + """Build a ready-to-use BatteryProfile, see make_profile_config.""" + return make_profile_config(charge, discharge, **overrides).to_limits() + + +def make_curve(c_rate: list[float], efficiency: list[float]) -> EfficiencyCurve: + """Build a validated efficiency curve with linear interpolation.""" + return EfficiencyCurve(c_rate=c_rate, efficiency=efficiency) + + +def charge_inhibited_below_30() -> HardCutoffs: + """Cutoffs that inhibit charging at the default temperature of 25 degC.""" + return HardCutoffs.model_validate( + {**TEST_CUTOFFS, "charge_allowed_below_degC": 30.0, "charge_allowed_above_degC": 55.0} + ) + + +def discharge_inhibited_below_30() -> HardCutoffs: + """Cutoffs that inhibit discharging at the default temperature of 25 degC.""" + return HardCutoffs.model_validate( + { + **TEST_CUTOFFS, + "discharge_allowed_below_degC": 30.0, + "discharge_allowed_above_degC": 60.0, + } + ) + + +def make_settings(**overrides: Any) -> BatteriesCommonSettings: + """Build a BatteriesCommonSettings with device_id battery1. + + The keyword arguments ``capacity_wh``, ``charge_limit_w`` and + ``discharge_limit_w`` go into the profile adjustment as + ``nominal_capacity_wh``, ``max_charge_power_w`` and ``max_discharge_power_w``. + """ + adjustment: dict[str, Any] = {} + for short_name, name in ( + ("capacity_wh", "nominal_capacity_wh"), + ("charge_limit_w", "max_charge_power_w"), + ("discharge_limit_w", "max_discharge_power_w"), + ): + if short_name in overrides: + adjustment[name] = overrides.pop(short_name) + payload: dict[str, Any] = {"device_id": "battery1"} + if adjustment: + payload["profile_adjustment"] = adjustment + payload.update(overrides) + return BatteriesCommonSettings(**payload) + + +async def current(settings: BatteriesCommonSettings) -> BatteryOperatingParameters: + """Resolve the parameters at the measured operating point.""" + return await settings.resolve_current_operating_parameters() + + +# --- fixtures ------------------------------------------------------------ + + +@pytest.fixture(autouse=True) +def measurements(monkeypatch: pytest.MonkeyPatch) -> dict[str, Optional[float]]: + """Replace the measurement access by a dict keyed by measurement key. + + Keys missing from the dict yield None, i.e. no measurement available. + The dict is returned, so tests set values like + ``measurements["battery1-soc-factor"] = 0.4`` at any time. + """ + values: dict[str, Optional[float]] = {} + + async def fake_get_measured_value(key: str, target_datetime: Any) -> Optional[float]: + return values.get(key) + + monkeypatch.setattr(batterymeasurement, "get_measured_value", fake_get_measured_value) + return values + + +@pytest.fixture +def measurement_calls(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Record every measurement query, answering None for all of them.""" + calls: list[str] = [] + + async def fake_get_measured_value(key: str, target_datetime: Any) -> Optional[float]: + calls.append(key) + return None + + monkeypatch.setattr(batterymeasurement, "get_measured_value", fake_get_measured_value) + return calls + + +@pytest.fixture +def install_profile(monkeypatch: pytest.MonkeyPatch): + """Return a function that makes the profile store return a given profile. + + The returned function yields the list of profile names that were requested, + so tests can assert how often the loader ran. Calling it again replaces the + previously installed profile. + """ + + def _install(profile: Any) -> list[str]: + calls: list[str] = [] + + def fake_get(document_type: Any, name: str, **options: Any) -> Any: + calls.append(name) + return profile + + monkeypatch.setattr(get_profile_store(), "get", fake_get) + return calls + + return _install + + +@pytest.fixture +def broken_profile_loader(monkeypatch: pytest.MonkeyPatch) -> None: + """Make every profile load fail with FileNotFoundError.""" + + def missing(document_type, name, **options): + raise FileNotFoundError(f"No battery profile for '{name}'") + + monkeypatch.setattr(get_profile_store(), "get", missing) + + +@pytest.fixture(autouse=True) +def _plain_default_profile(install_profile) -> None: + """Install the plain profile that settings use unless a test installs another.""" + install_profile(make_profile()) + + +@pytest.fixture +def make_profiled_settings(install_profile): + """Return a factory for settings that use the given in-memory profile.""" + + def _make(profile: Any, **overrides: Any) -> BatteriesCommonSettings: + install_profile(profile) + return make_settings(**overrides) + + return _make + + +@pytest.fixture +def full_profile() -> BatteryProfile: + """Profile with efficiency curves and technical SoC bounds. + + At the default operating point: charge 0.5 C, discharge 0.4 C. + """ + return make_profile( + charge_efficiency=make_curve([0.1, 0.5, 1.0], [0.90, 0.96, 0.92]), + discharge_efficiency=make_curve([0.1, 0.5], [0.90, 0.95]), + technical_min_soc_percent=5.0, + technical_max_soc_percent=95.0, + ) + + +# --- Defaults and validation ------------------------------------------------ + + +class TestDefaultsAndValidation: + """Tests for field defaults and validation.""" + + def test_default_battery_profile(self): + assert BATTERY_DEFAULT_PROFILE == "generic-lfp" + assert make_settings().battery_profile == "generic-lfp" + + def test_battery_profile_is_not_optional(self): + with pytest.raises(ValidationError): + make_settings(battery_profile=None) + + def test_battery_profile_must_not_be_empty(self): + with pytest.raises(ValidationError): + make_settings(battery_profile="") + + def test_profile_adjustment_defaults_to_no_adjustment(self): + settings = make_settings() + assert settings.profile_adjustment.model_dump() == BatteryProfileAdjustment().model_dump() + assert settings.profile_adjustment.nominal_capacity_wh is None + assert settings.profile_adjustment.max_charge_power_w is None + assert settings.profile_adjustment.max_discharge_power_w is None + + def test_old_name_battery_model_is_gone(self): + assert "battery_model" not in BatteriesCommonSettings.model_fields + assert "battery_profile" in BatteriesCommonSettings.model_fields + + @pytest.mark.parametrize("field", FORMER_CONFIGURATION_FIELDS) + def test_former_configuration_fields_are_not_fields_any_more(self, field): + assert field not in BatteriesCommonSettings.model_fields + + @pytest.mark.parametrize("former, adjustment_field", MOVED_TO_ADJUSTMENT) + def test_fields_moved_into_the_profile_adjustment(self, former, adjustment_field): + assert former not in BatteriesCommonSettings.model_fields + assert adjustment_field in BatteryProfileAdjustment.model_fields + + def test_adjustment_fields_are_named_after_profile_fields(self): + for name in BatteryProfileAdjustment.model_fields: + assert name in BatteryProfileSettings.model_fields + + def test_operating_point_defaults(self): + assert BATTERY_DEFAULT_SOC_FACTOR == pytest.approx(0.5) + assert BATTERY_DEFAULT_TEMPERATURE_DEGC == pytest.approx(25.0) + + def test_unusable_profile_resolves_to_none(self, broken_profile_loader): + settings = make_settings() + for field in COMPUTED_FIELDS: + assert getattr(settings, field) is None + + def test_unusable_profile_raises_naming_the_battery(self, broken_profile_loader): + with pytest.raises(BatteryResolutionError, match="Battery 'battery1'"): + make_settings().resolve_operating_parameters() + + @pytest.mark.parametrize("value", [0, -1]) + def test_rejects_non_positive_nominal_capacity(self, value): + with pytest.raises(ValidationError): + make_settings(capacity_wh=value) + + @pytest.mark.parametrize("field", ["charge_limit_w", "discharge_limit_w"]) + @pytest.mark.parametrize("value", [0, -1.0]) + def test_rejects_non_positive_power_limits(self, field, value): + with pytest.raises(ValidationError): + make_settings(**{field: value}) + + def test_rejects_min_soc_above_max_soc(self): + with pytest.raises(ValidationError, match="min_soc_percentage"): + make_settings(min_soc_percentage=80, max_soc_percentage=20) + + def test_rejects_min_charge_power_above_installation_limit(self): + with pytest.raises(ValidationError, match="min_charge_power_w"): + make_settings(min_charge_power_w=6000.0, charge_limit_w=5000.0) + + def test_min_charge_power_without_installation_limit_is_accepted(self): + # The profile decides the limit, which is not known at validation time. + settings = make_settings(min_charge_power_w=6000.0) + assert settings.profile_adjustment.max_charge_power_w is None + + +class TestJsonSchema: + """Tests for the schema of configuration fields and computed fields.""" + + @pytest.mark.parametrize("field", ["battery_profile", "profile_adjustment"]) + def test_configuration_fields_are_documented(self, field): + schema = BatteriesCommonSettings.model_json_schema() + assert schema["properties"][field]["description"] + assert schema["properties"][field]["examples"] + + @pytest.mark.parametrize( + "field", ["nominal_capacity_wh", "max_charge_power_w", "max_discharge_power_w"] + ) + def test_adjustment_fields_are_documented(self, field): + schema = BatteryProfileAdjustment.model_json_schema() + assert schema["properties"][field]["description"] + assert schema["properties"][field]["examples"] + + def test_profile_and_adjustment_are_not_required(self): + schema = BatteriesCommonSettings.model_json_schema() + assert schema["properties"]["battery_profile"]["default"] == "generic-lfp" + assert "battery_profile" not in schema.get("required", []) + assert "profile_adjustment" not in schema.get("required", []) + + @pytest.mark.parametrize("field", FORMER_CONFIGURATION_FIELDS) + def test_former_configuration_fields_are_not_in_validation_schema(self, field): + schema = BatteriesCommonSettings.model_json_schema(mode="validation") + assert field not in schema["properties"] + + @pytest.mark.parametrize("field", COMPUTED_FIELDS) + def test_computed_fields_are_part_of_serialization_schema(self, field): + schema = BatteriesCommonSettings.model_json_schema(mode="serialization") + assert field in schema["properties"] + + def test_capacity_wh_is_neither_field_nor_computed_field(self): + """'capacity_wh' must not appear in settings, dumps or schemas. + + An old config file containing 'capacity_wh' is then unambiguously legacy. + """ + assert "capacity_wh" not in BatteriesCommonSettings.model_fields + assert "capacity_wh" not in BatteriesCommonSettings.model_computed_fields + for mode in ("validation", "serialization"): + schema = BatteriesCommonSettings.model_json_schema(mode=mode) + assert "capacity_wh" not in schema["properties"] + assert "capacity_wh" not in make_settings().model_dump() + + +# --- Computed fields ---------------------------------------------------------------- + + +class TestComputedFieldEvaluation: + """Tests that computed fields are evaluated on access and never raise.""" + + def test_not_evaluated_during_construction(self, measurement_calls, install_profile): + calls = install_profile(make_profile()) + make_settings(capacity_wh=10000) + assert measurement_calls == [] + assert calls == [] + + def test_not_evaluated_by_reading_configuration_fields(self, measurement_calls): + settings = make_settings(capacity_wh=10000) + _ = settings.profile_adjustment.nominal_capacity_wh + _ = settings.battery_profile + assert measurement_calls == [] + + def test_never_read_measurements(self, measurement_calls): + settings = make_settings() + for field in COMPUTED_FIELDS: + _ = getattr(settings, field) + settings.model_dump() + settings.model_dump_json() + assert measurement_calls == [] + + @pytest.mark.parametrize("field", REMOVED_COMPUTED_FIELDS) + def test_measurement_based_computed_fields_are_gone(self, field): + assert field not in BatteriesCommonSettings.model_computed_fields + assert field not in make_settings().model_dump() + + def test_follows_changed_configuration(self): + settings = make_settings(capacity_wh=10000) + assert settings.effective_nominal_capacity_wh == 10000 + assert settings.effective_max_charge_power_w == pytest.approx(5000.0) + settings.profile_adjustment.nominal_capacity_wh = 6000 + assert settings.effective_nominal_capacity_wh == 6000 + assert settings.effective_max_charge_power_w == pytest.approx(3000.0) + settings.profile_adjustment.nominal_capacity_wh = None + assert settings.effective_nominal_capacity_wh == PROFILE_CAPACITY_WH + + def test_power_ignores_measured_temperature(self, make_profiled_settings, measurements): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + measurements["battery1-temperature-degc"] = 0.0 + assert settings.effective_max_charge_power_w == pytest.approx(5000.0) + + def test_efficiency_ignores_measured_power( + self, make_profiled_settings, full_profile, measurements + ): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + measurements["battery1-charge-power-w"] = 2500.0 + assert settings.effective_charging_efficiency == pytest.approx(0.96) + + def test_model_dump_contains_computed_fields(self): + dumped = make_settings(capacity_wh=10000).model_dump() + for field in COMPUTED_FIELDS: + assert field in dumped + assert dumped["effective_nominal_capacity_wh"] == 10000 + assert dumped["effective_max_charge_power_w"] == pytest.approx(5000.0) + assert dumped["effective_max_discharge_power_w"] == pytest.approx(4000.0) + + def test_model_dump_json_contains_computed_fields(self): + dumped = make_settings(capacity_wh=10000).model_dump_json() + assert '"effective_nominal_capacity_wh":10000' in dumped.replace(" ", "") + + def test_round_trip_keeps_configuration_and_ignores_computed_values(self): + original = make_settings( + battery_profile="my-profile", capacity_wh=10000, charge_limit_w=3000.0 + ) + restored = BatteriesCommonSettings.model_validate_json(original.model_dump_json()) + assert restored.battery_profile == "my-profile" + assert restored.profile_adjustment.nominal_capacity_wh == 10000 + assert restored.profile_adjustment.max_charge_power_w == pytest.approx(3000.0) + assert restored.effective_nominal_capacity_wh == 10000 + assert restored.effective_max_charge_power_w == pytest.approx(3000.0) + + def test_serialization_survives_missing_profile(self, broken_profile_loader): + dumped = make_settings(battery_profile="does-not-exist").model_dump() + for field in COMPUTED_FIELDS: + assert dumped[field] is None + + def test_serialization_survives_inhibited_charging(self, make_profiled_settings): + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + dumped = settings.model_dump() + # The battery resolves; it may only not charge at the reference point. + assert dumped["effective_max_charge_power_w"] == 0.0 + assert dumped["effective_nominal_capacity_wh"] == 10000 + + def test_generic_profile_without_capacity_is_not_resolvable(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(nominal_capacity_wh=None)) + assert settings.effective_nominal_capacity_wh is None + with pytest.raises(BatteryResolutionError, match="profile_adjustment.nominal_capacity_wh"): + settings.resolve_operating_parameters() + + def test_generic_profile_takes_capacity_from_adjustment(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(nominal_capacity_wh=None), capacity_wh=6000) + assert settings.effective_nominal_capacity_wh == 6000 + assert settings.effective_max_charge_power_w == pytest.approx(3000.0) + + def test_computed_fields_match_strict_resolution(self, make_profiled_settings, full_profile): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + resolved = settings.resolve_operating_parameters() + assert settings.effective_nominal_capacity_wh == resolved.capacity_wh + assert settings.effective_charging_efficiency == pytest.approx(resolved.charging_efficiency) + assert settings.effective_discharging_efficiency == pytest.approx(resolved.discharging_efficiency) + assert settings.effective_max_charge_power_w == pytest.approx(resolved.max_charge_power_w) + assert settings.effective_max_discharge_power_w == pytest.approx(resolved.max_discharge_power_w) + assert settings.effective_min_soc_percentage == resolved.min_soc_percentage + assert settings.effective_max_soc_percentage == resolved.max_soc_percentage + + +# --- Operating point and measurement keys ------------------------------------------ + + +class TestOperatingPoint: + """Tests for SoC and temperature taken from measurements.""" + + def test_measurement_keys(self): + settings = make_settings() + assert settings.measurement_key_soc_factor == "battery1-soc-factor" + assert settings.measurement_key_temperature_degc == "battery1-temperature-degc" + assert settings.measurement_key_charge_power_w == "battery1-charge-power-w" + assert settings.measurement_key_discharge_power_w == "battery1-discharge-power-w" + + def test_new_keys_are_listed_in_measurement_keys(self): + settings = make_settings() + for key in ( + settings.measurement_key_temperature_degc, + settings.measurement_key_charge_power_w, + settings.measurement_key_discharge_power_w, + ): + assert key in settings.measurement_keys + + @pytest.mark.asyncio + async def test_defaults_without_measurement(self): + point = await make_settings().measure_operating_point() + assert point == BatteryOperatingPoint() + assert point.soc_factor == pytest.approx(0.5) + assert point.temperature_degC == pytest.approx(25.0) + assert point.charge_power_w is None + assert point.discharge_power_w is None + + @pytest.mark.asyncio + async def test_measured_values_are_used(self, measurements): + measurements["battery1-soc-factor"] = 0.35 + measurements["battery1-temperature-degc"] = 12.5 + point = await make_settings().measure_operating_point() + assert point.soc_factor == pytest.approx(0.35) + assert point.temperature_degC == pytest.approx(12.5) + + @pytest.mark.asyncio + async def test_only_one_measurement_available(self, measurements): + measurements["battery1-temperature-degc"] = 10.0 + point = await make_settings().measure_operating_point() + assert point.soc_factor == pytest.approx(0.5) + assert point.temperature_degC == pytest.approx(10.0) + + @pytest.mark.asyncio + async def test_follows_changed_measurement(self, measurements): + settings = make_settings() + measurements["battery1-soc-factor"] = 0.8 + assert (await settings.measure_operating_point()).soc_factor == pytest.approx(0.8) + measurements["battery1-soc-factor"] = 0.2 + assert (await settings.measure_operating_point()).soc_factor == pytest.approx(0.2) + + @pytest.mark.asyncio + async def test_reads_every_operating_point_key(self, measurement_calls): + await make_settings().measure_operating_point() + assert sorted(measurement_calls) == sorted( + [ + "battery1-soc-factor", + "battery1-temperature-degc", + "battery1-charge-power-w", + "battery1-discharge-power-w", + ] + ) + + @pytest.mark.parametrize("measured, expected", [(-0.2, 0.0), (1.3, 1.0)]) + @pytest.mark.asyncio + async def test_soc_factor_is_clamped(self, measurements, measured, expected): + measurements["battery1-soc-factor"] = measured + point = await make_settings().measure_operating_point() + assert point.soc_factor == pytest.approx(expected) + + @pytest.mark.parametrize("measured", [float("nan"), float("inf")]) + @pytest.mark.asyncio + async def test_non_finite_measurements_fall_back_to_defaults(self, measurements, measured): + measurements["battery1-soc-factor"] = measured + measurements["battery1-temperature-degc"] = measured + point = await make_settings().measure_operating_point() + assert point.soc_factor == pytest.approx(0.5) + assert point.temperature_degC == pytest.approx(25.0) + + @pytest.mark.asyncio + async def test_powers_are_positive_magnitudes(self, measurements): + measurements["battery1-charge-power-w"] = 2500.0 + measurements["battery1-discharge-power-w"] = -2000.0 + point = await make_settings().measure_operating_point() + assert point.charge_power_w == pytest.approx(2500.0) + assert point.discharge_power_w == pytest.approx(2000.0) + + @pytest.mark.asyncio + async def test_keys_follow_device_id(self, measurements): + measurements["house-battery-soc-factor"] = 0.9 + point = await make_settings(device_id="house-battery").measure_operating_point() + assert point.soc_factor == pytest.approx(0.9) + + @pytest.mark.asyncio + async def test_failing_measurement_access_falls_back_to_defaults(self, monkeypatch): + # Use the real accessor, but let the measurement subsystem fail. + import akkudoktoreos.core.coreabc as coreabc + + def broken(): + raise RuntimeError("measurement store not initialised") + + monkeypatch.setattr(batterymeasurement, "get_measured_value", REAL_GET_MEASURED_VALUE) + monkeypatch.setattr(coreabc, "get_measurement", broken) + point = await make_settings().measure_operating_point() + assert point == BatteryOperatingPoint() + + @pytest.mark.asyncio + async def test_profile_is_evaluated_at_measured_temperature( + self, make_profiled_settings, measurements + ): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + assert (await current(settings)).max_charge_power_w == pytest.approx(5000.0) + measurements["battery1-temperature-degc"] = 0.0 + assert (await current(settings)).max_charge_power_w == pytest.approx(1000.0) + + @pytest.mark.asyncio + async def test_profile_is_evaluated_at_measured_soc( + self, make_profiled_settings, measurements + ): + grid = make_grid(0.5) + for row in grid: + row[COLUMN_40_PERCENT] = 0.2 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + measurements["battery1-soc-factor"] = 0.4 + assert (await current(settings)).max_charge_power_w == pytest.approx(2000.0) + + def test_sync_resolution_ignores_measurements(self, make_profiled_settings, measurements): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + measurements["battery1-temperature-degc"] = 0.0 + resolved = settings.resolve_operating_parameters() + assert resolved.temperature_degC == pytest.approx(25.0) + assert resolved.max_charge_power_w == pytest.approx(5000.0) + + def test_default_operating_point_is_25_degc_and_half_soc(self, make_profiled_settings): + # A grid that only holds 0.5 around 25 degC / 50 % (halfway between the + # 40 % and 60 % columns); every other cell holds 0.1. + grid = make_grid(0.1) + grid[ROW_25_DEGC][COLUMN_40_PERCENT] = 0.5 + grid[ROW_25_DEGC][COLUMN_60_PERCENT] = 0.5 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + resolved = settings.resolve_operating_parameters() + assert resolved.temperature_degC == pytest.approx(25.0) + assert resolved.soc_factor == pytest.approx(0.5) + assert resolved.max_charge_power_w == pytest.approx(5000.0) + + def test_explicit_operating_point_is_used(self, make_profiled_settings, measurements): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + measurements["battery1-temperature-degc"] = 25.0 + cold = settings.resolve_operating_parameters( + BatteryOperatingPoint(temperature_degC=0.0, soc_factor=0.5) + ) + assert cold.max_charge_power_w == pytest.approx(1000.0) + assert cold.temperature_degC == pytest.approx(0.0) + + +# --- Capacity ------------------------------------------------------------------ + + +class TestCapacityResolution: + """Tests for the capacity: profile adjustment > profile, no built-in default.""" + + def test_explicit_capacity_is_used(self): + assert make_settings(capacity_wh=10000).effective_nominal_capacity_wh == 10000 + + def test_plain_test_profile_capacity(self): + assert make_settings().effective_nominal_capacity_wh == PROFILE_CAPACITY_WH + + def test_profile_capacity_is_used_when_not_configured(self, make_profiled_settings): + profile = make_profile(nominal_capacity_wh=12000.0) + assert make_profiled_settings(profile).effective_nominal_capacity_wh == 12000 + + def test_adjustment_capacity_replaces_profile_capacity(self, make_profiled_settings): + profile = make_profile(nominal_capacity_wh=12000.0) + assert make_profiled_settings(profile, capacity_wh=7000).effective_nominal_capacity_wh == 7000 + + def test_profile_capacity_is_rounded_to_whole_wh(self, make_profiled_settings): + profile = make_profile(nominal_capacity_wh=9999.6) + assert make_profiled_settings(profile).effective_nominal_capacity_wh == 10000 + + def test_profile_without_capacity_requires_the_adjustment(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(nominal_capacity_wh=None)) + with pytest.raises(BatteryResolutionError, match="Battery 'battery1'.*generic"): + settings.resolve_nominal_capacity_wh() + + def test_resolve_nominal_capacity_wh_matches_computed_field(self, make_profiled_settings): + profile = make_profile(nominal_capacity_wh=12000.0) + settings = make_profiled_settings(profile) + assert settings.resolve_nominal_capacity_wh() == settings.effective_nominal_capacity_wh == 12000 + + def test_power_scales_with_profile_capacity(self, make_profiled_settings): + profile = make_profile(charge=0.5, nominal_capacity_wh=8000.0) + resolved = make_profiled_settings(profile).resolve_operating_parameters() + assert resolved.capacity_wh == 8000 + assert resolved.max_charge_power_w == pytest.approx(4000.0) + + def test_power_scales_with_adjustment_capacity(self, make_profiled_settings): + profile = make_profile(charge=0.5, nominal_capacity_wh=8000.0) + resolved = make_profiled_settings( + profile, capacity_wh=10000 + ).resolve_operating_parameters() + assert resolved.capacity_wh == 10000 + assert resolved.max_charge_power_w == pytest.approx(5000.0) + + +# --- Loading the battery profile -------------------------------------------------- + + +class TestGetBatteryProfile: + """Tests for lazy loading and caching of the battery profile.""" + + def test_loads_default_profile_by_name(self, install_profile): + profile = make_profile() + calls = install_profile(profile) + settings = make_settings() + assert settings.get_battery_profile() is profile + assert calls == ["generic-lfp"] + + def test_loads_configured_profile_by_name(self, install_profile): + calls = install_profile(make_profile()) + make_settings(battery_profile="byd-hvs").get_battery_profile() + assert calls == ["byd-hvs"] + + def test_loading_is_lazy(self, install_profile): + calls = install_profile(make_profile()) + make_settings(battery_profile="generic-lfp") + assert calls == [] + + def test_profile_is_always_taken_from_the_store(self, install_profile): + # Caching is the job of the profile store (see test_devicesprofilebattery), + # so a changed profile file is picked up by existing settings instances. + calls = install_profile(make_profile()) + settings = make_settings(capacity_wh=10000) + settings.get_battery_profile() + settings.resolve_operating_parameters() + _ = settings.effective_nominal_capacity_wh + _ = settings.effective_max_charge_power_w + settings.to_genetic_pv_bat_param() + assert calls == ["generic-lfp"] * 5 + + def test_dump_resolves_the_battery_once(self, install_profile, monkeypatch): + """All effective_* fields of one dump share one resolution.""" + from akkudoktoreos.devices.settings import batterysettings + + install_profile(make_profile()) + settings = make_settings(capacity_wh=10000) + resolutions: list[int] = [] + original = batterysettings.resolve_battery_parameters + + def counting(*args, **kwargs): + resolutions.append(1) + return original(*args, **kwargs) + + monkeypatch.setattr(batterysettings, "resolve_battery_parameters", counting) + dumped = settings.model_dump() + assert dumped["effective_max_charge_power_w"] is not None + assert len(resolutions) == 1 + + def test_changed_settings_are_resolved_again(self, install_profile): + install_profile(make_profile()) + settings = make_settings(capacity_wh=10000) + before = settings.effective_nominal_capacity_wh + settings.profile_adjustment.nominal_capacity_wh = 5000 + assert before == 10000 + assert settings.effective_nominal_capacity_wh == 5000 + + @pytest.mark.parametrize( + ("field", "value", "effective", "expected"), + [ + ("min_soc_percentage", 20, "effective_min_soc_percentage", 20), + ("max_soc_percentage", 80, "effective_max_soc_percentage", 80), + ], + ) + def test_changed_soc_settings_are_resolved_again( + self, install_profile, field, value, effective, expected + ): + install_profile(make_profile()) + settings = make_settings(capacity_wh=10000) + _ = getattr(settings, effective) + setattr(settings, field, value) + assert getattr(settings, effective) == expected + + def test_changed_store_profile_is_used_by_existing_instance(self, install_profile): + install_profile(make_profile()) + settings = make_settings(capacity_wh=10000) + before = settings.effective_max_charge_power_w + assert before is not None + install_profile(make_profile(charge=0.25)) + assert settings.effective_max_charge_power_w == pytest.approx(before / 2) + + def test_profile_is_reloaded_when_profile_name_changes(self, install_profile): + calls = install_profile(make_profile()) + settings = make_settings(battery_profile="profile-a") + settings.get_battery_profile() + settings.battery_profile = "profile-b" + settings.get_battery_profile() + assert calls == ["profile-a", "profile-b"] + + def test_missing_profile_error_is_propagated_by_strict_methods(self, broken_profile_loader): + settings = make_settings(battery_profile="does-not-exist") + with pytest.raises(FileNotFoundError, match="does-not-exist"): + settings.get_battery_profile() + # The resolution names the battery and keeps the cause. + with pytest.raises(BatteryResolutionError, match="Battery 'battery1'.*does-not-exist"): + settings.resolve_operating_parameters() + with pytest.raises(BatteryResolutionError, match="does-not-exist") as raised: + settings.resolve_nominal_capacity_wh() + assert isinstance(raised.value.__cause__, FileNotFoundError) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_missing_profile_error_is_propagated_by_conversions( + self, broken_profile_loader, method + ): + settings = make_settings(battery_profile="does-not-exist") + with pytest.raises(BatteryResolutionError, match="does-not-exist"): + getattr(settings, method)() + + +# --- Maximum charge power ------------------------------------------------------- + + +class TestMaxChargePower: + """Tests for the maximum charge power.""" + + def test_c_rate_limit_is_scaled_by_capacity(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(charge=0.5), capacity_wh=10000) + assert settings.effective_max_charge_power_w == pytest.approx(5000.0) + + def test_profile_power_limit_caps_c_rate_power(self, make_profiled_settings): + profile = make_profile(charge=0.5, max_charge_power_w=3000.0) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_max_charge_power_w == pytest.approx(3000.0) + + def test_installation_limit_below_profile_limit_wins(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(charge=0.5), capacity_wh=10000, charge_limit_w=2500.0 + ) + assert settings.effective_max_charge_power_w == pytest.approx(2500.0) + + def test_installation_limit_cannot_exceed_profile_limit(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(charge=0.5), capacity_wh=10000, charge_limit_w=9000.0 + ) + assert settings.effective_max_charge_power_w == pytest.approx(5000.0) + + def test_power_unit_is_used_as_is(self, make_profiled_settings): + profile = make_profile(charge=3000.0, units=Units(limit="W")) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_max_charge_power_w == pytest.approx(3000.0) + + def test_current_unit_is_converted_with_nominal_voltage(self, make_profiled_settings): + profile = make_profile(charge=20.0, units=Units(limit="A"), nominal_voltage_v=100.0) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_max_charge_power_w == pytest.approx(2000.0) + + def test_current_unit_without_nominal_voltage_is_rejected(self): + """The profile document is rejected, not only its resolution.""" + with pytest.raises(ValueError, match="nominal_voltage_v"): + make_profile_config(charge=20.0, units=Units(limit="A")) + + @pytest.mark.parametrize( + "units", [{"limit": "kW"}, {"limit": "C-rate", "temperature": "degF"}, + {"limit": "C-rate", "soc": "fraction"}], + ) + def test_unsupported_unit_is_rejected(self, units): + with pytest.raises(ValueError): + Units(**units) + + def test_unsupported_unit_of_a_compiled_profile_raises(self, make_profiled_settings): + """Defensive: a compiled profile that bypassed validation fails with a resolution error.""" + profile = dataclasses.replace(make_profile(charge=1.0), limit_unit="kW") + settings = make_profiled_settings(profile, capacity_wh=10000) + with pytest.raises(BatteryResolutionError, match="Unsupported limit unit"): + settings.resolve_operating_parameters() + + def test_inhibited_charging_yields_zero_without_raising(self, make_profiled_settings): + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.resolve_operating_parameters().max_charge_power_w == 0.0 + + @pytest.mark.asyncio + async def test_charging_is_inhibited_at_measured_cold_temperature( + self, make_profiled_settings, measurements + ): + """A cold battery may not charge, but the parameters stay valid for the optimization.""" + settings = make_profiled_settings(make_profile(), capacity_wh=10000) + measurements["battery1-temperature-degc"] = -12.0 + parameters = await current(settings) + assert parameters.max_charge_power_w == 0.0 + assert settings.to_genetic_pv_bat_param(parameters).max_charge_power_w == 0.0 + assert settings.to_genetic0_pv_bat_param(parameters).max_charge_power_w == 0.0 + + def test_zero_charge_power_yields_zero(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(charge=0.0), capacity_wh=10000) + assert settings.resolve_operating_parameters().max_charge_power_w == 0.0 + + +# --- Maximum discharge power ---------------------------------------------------- + + +class TestMaxDischargePower: + """Tests for the maximum discharge power.""" + + def test_c_rate_limit_is_scaled_by_capacity(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(discharge=0.4), capacity_wh=10000) + assert settings.effective_max_discharge_power_w == pytest.approx(4000.0) + + def test_profile_power_limit_caps_c_rate_power(self, make_profiled_settings): + profile = make_profile(discharge=0.4, max_discharge_power_w=2000.0) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_max_discharge_power_w == pytest.approx(2000.0) + + def test_installation_limit_below_profile_limit_wins(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(discharge=0.4), + capacity_wh=10000, + discharge_limit_w=1500.0, + ) + assert settings.effective_max_discharge_power_w == pytest.approx(1500.0) + + def test_installation_limit_cannot_exceed_profile_limit(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(discharge=0.4), + capacity_wh=10000, + discharge_limit_w=9000.0, + ) + assert settings.effective_max_discharge_power_w == pytest.approx(4000.0) + + def test_charge_and_discharge_limits_are_independent(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(charge=0.5, discharge=0.2), + capacity_wh=10000, + charge_limit_w=1000.0, + ) + assert settings.effective_max_charge_power_w == pytest.approx(1000.0) + assert settings.effective_max_discharge_power_w == pytest.approx(2000.0) + + @pytest.mark.asyncio + async def test_discharge_follows_measured_temperature( + self, make_profiled_settings, measurements + ): + grid = make_grid(0.4) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(discharge=grid), capacity_wh=10000) + measurements["battery1-temperature-degc"] = 0.0 + assert (await current(settings)).max_discharge_power_w == pytest.approx(1000.0) + # The computed field stays at the reference operating point. + assert settings.effective_max_discharge_power_w == pytest.approx(4000.0) + + def test_inhibited_discharging_yields_zero_without_raising(self, make_profiled_settings): + profile = make_profile(hard_cutoffs=discharge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.resolve_operating_parameters().max_discharge_power_w == 0.0 + assert settings.effective_max_discharge_power_w == 0.0 + + def test_inhibited_discharging_does_not_block_conversions(self, make_profiled_settings): + profile = make_profile(hard_cutoffs=discharge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.to_genetic_pv_bat_param().max_charge_power_w == pytest.approx(5000.0) + + +# --- Efficiencies ---------------------------------------------------------------- + + +class TestEfficiencyAtMaximumPower: + """Tests for efficiencies without a usable power measurement.""" + + def test_charge_efficiency_at_max_charge_power(self, make_profiled_settings, full_profile): + # 5000 W on 10000 Wh -> 0.5 C -> 0.96 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert settings.effective_charging_efficiency == pytest.approx(0.96) + + def test_discharge_efficiency_at_max_discharge_power(self, make_profiled_settings, full_profile): + # 4000 W on 10000 Wh -> 0.4 C -> 0.90 + 0.05 * 0.3 / 0.4 = 0.9375 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert settings.effective_discharging_efficiency == pytest.approx(0.9375) + + def test_installation_limit_changes_c_rate(self, make_profiled_settings): + # Charge capped to 2500 W -> 0.25 C -> 0.90 + 0.06 * 0.15 / 0.4 = 0.9225 + profile = make_profile(charge_efficiency=make_curve([0.1, 0.5, 1.0], [0.90, 0.96, 0.92])) + settings = make_profiled_settings( + profile, capacity_wh=10000, charge_limit_w=2500.0 + ) + assert settings.effective_charging_efficiency == pytest.approx(0.9225) + + def test_profile_power_limit_changes_c_rate(self, make_profiled_settings): + # Discharge capped to 2000 W -> 0.2 C -> 0.90 + 0.05 * 0.1 / 0.4 = 0.9125 + profile = make_profile( + discharge_efficiency=make_curve([0.1, 0.5], [0.90, 0.95]), + max_discharge_power_w=2000.0, + ) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_discharging_efficiency == pytest.approx(0.9125) + + def test_efficiency_depends_on_c_rate_not_on_capacity( + self, make_profiled_settings, full_profile + ): + # Same 0.5 C limit on a smaller capacity: lower power, same C-rate. + settings = make_profiled_settings(full_profile, capacity_wh=5000) + assert settings.effective_max_charge_power_w == pytest.approx(2500.0) + assert settings.effective_charging_efficiency == pytest.approx(0.96) + + def test_flat_curves_give_their_efficiency(self): + settings = make_settings(capacity_wh=10000) + assert settings.effective_charging_efficiency == pytest.approx(PROFILE_EFFICIENCY) + assert settings.effective_discharging_efficiency == pytest.approx(PROFILE_EFFICIENCY) + + def test_inhibited_discharging_uses_efficiency_at_lowest_c_rate(self, make_profiled_settings): + profile = make_profile( + discharge_efficiency=make_curve([0.1, 0.5], [0.90, 0.95]), + hard_cutoffs=discharge_inhibited_below_30(), + ) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_max_discharge_power_w == pytest.approx(0.0) + assert settings.effective_discharging_efficiency == pytest.approx(0.90) + + def test_rejected_c_rate_uses_nearest_curve_breakpoint(self, make_profiled_settings): + curve = EfficiencyCurve( + c_rate=[0.1, 1.0], + efficiency=[0.90, 0.92], + out_of_range_behavior=OutOfRangeBehavior.REJECT, + ) + # 1.5 C is beyond the curve, which rejects it. + profile = make_profile(charge=1.5, charge_efficiency=curve) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert settings.effective_charging_efficiency == pytest.approx(0.92) + + +class TestEfficiencyAtMeasuredPower: + """Tests for efficiencies taken at the measured charge / discharge power.""" + + @pytest.mark.asyncio + async def test_charge_efficiency_at_measured_charge_power( + self, make_profiled_settings, full_profile, measurements + ): + # 2500 W -> 0.25 C -> 0.9225 (maximum power would give 0.96) + measurements["battery1-charge-power-w"] = 2500.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.9225) + + @pytest.mark.asyncio + async def test_discharge_efficiency_at_measured_discharge_power( + self, make_profiled_settings, full_profile, measurements + ): + # 2000 W -> 0.2 C -> 0.9125 (maximum power would give 0.9375) + measurements["battery1-discharge-power-w"] = 2000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).discharging_efficiency == pytest.approx(0.9125) + + @pytest.mark.asyncio + async def test_directions_are_independent(self, make_profiled_settings, full_profile, measurements): + measurements["battery1-charge-power-w"] = 2500.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.9225) + assert (await current(settings)).discharging_efficiency == pytest.approx(0.9375) + + @pytest.mark.asyncio + async def test_measured_power_scales_with_capacity( + self, make_profiled_settings, full_profile, measurements + ): + # 2500 W on 5000 Wh is 0.5 C -> 0.96 + measurements["battery1-charge-power-w"] = 2500.0 + settings = make_profiled_settings(full_profile, capacity_wh=5000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.96) + + @pytest.mark.asyncio + async def test_negative_measurement_is_used_as_magnitude( + self, make_profiled_settings, full_profile, measurements + ): + measurements["battery1-discharge-power-w"] = -2000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).discharging_efficiency == pytest.approx(0.9125) + + @pytest.mark.asyncio + async def test_zero_measurement_falls_back_to_maximum_power( + self, make_profiled_settings, full_profile, measurements + ): + measurements["battery1-charge-power-w"] = 0.0 + measurements["battery1-discharge-power-w"] = 0.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.96) + assert (await current(settings)).discharging_efficiency == pytest.approx(0.9375) + + @pytest.mark.parametrize("measured", [float("nan"), float("inf")]) + @pytest.mark.asyncio + async def test_non_finite_measurement_falls_back_to_maximum_power( + self, make_profiled_settings, full_profile, measurements, measured + ): + measurements["battery1-charge-power-w"] = measured + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.96) + + @pytest.mark.asyncio + async def test_measurement_beyond_curve_is_clamped_by_profile( + self, make_profiled_settings, full_profile, measurements + ): + # 20000 W -> 2 C is beyond the curve, clamped to the last point 0.92. + measurements["battery1-charge-power-w"] = 20000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(0.92) + + @pytest.mark.asyncio + async def test_measured_power_is_not_limited_by_maximum_power( + self, make_profiled_settings, full_profile, measurements + ): + # Installation limit 1000 W, measured 5000 W (0.5 C): measurement wins. + measurements["battery1-charge-power-w"] = 5000.0 + settings = make_profiled_settings( + full_profile, capacity_wh=10000, charge_limit_w=1000.0 + ) + assert (await current(settings)).charging_efficiency == pytest.approx(0.96) + + @pytest.mark.asyncio + async def test_measured_discharge_power_used_while_discharging_is_inhibited( + self, make_profiled_settings, measurements + ): + profile = make_profile( + discharge_efficiency=make_curve([0.1, 0.5], [0.90, 0.95]), + hard_cutoffs=discharge_inhibited_below_30(), + ) + measurements["battery1-discharge-power-w"] = 2000.0 + settings = make_profiled_settings(profile, capacity_wh=10000) + assert (await current(settings)).discharging_efficiency == pytest.approx(0.9125) + + @pytest.mark.asyncio + async def test_measured_power_on_flat_curve_gives_its_efficiency( + self, make_profiled_settings, measurements + ): + measurements["battery1-charge-power-w"] = 2500.0 + settings = make_profiled_settings(make_profile(), capacity_wh=10000) + assert (await current(settings)).charging_efficiency == pytest.approx(PROFILE_EFFICIENCY) + + def test_explicit_powers_are_used( + self, make_profiled_settings, full_profile, measurements + ): + measurements["battery1-charge-power-w"] = 5000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + resolved = settings.resolve_operating_parameters( + BatteryOperatingPoint(charge_power_w=2500.0, discharge_power_w=2000.0) + ) + assert resolved.charging_efficiency == pytest.approx(0.9225) + assert resolved.discharging_efficiency == pytest.approx(0.9125) + + @pytest.mark.asyncio + async def test_measured_power_does_not_change_maximum_power( + self, make_profiled_settings, full_profile, measurements + ): + measurements["battery1-charge-power-w"] = 2500.0 + measurements["battery1-discharge-power-w"] = 2000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + assert (await current(settings)).max_charge_power_w == pytest.approx(5000.0) + assert (await current(settings)).max_discharge_power_w == pytest.approx(4000.0) + + +# --- SoC range ----------------------------------------------------------------- + + +class TestSocRangeResolution: + """Tests that the user's SoC range is clamped into the technical bounds.""" + + def test_technical_bounds_apply_to_default_range(self, make_profiled_settings): + profile = make_profile(technical_min_soc_percent=5.0, technical_max_soc_percent=95.0) + resolved = make_profiled_settings(profile).resolve_operating_parameters() + assert resolved.min_soc_percentage == 5 + assert resolved.max_soc_percentage == 95 + + def test_technical_bounds_are_rounded_inwards(self, make_profiled_settings): + profile = make_profile(technical_min_soc_percent=4.2, technical_max_soc_percent=95.8) + resolved = make_profiled_settings(profile).resolve_operating_parameters() + assert resolved.min_soc_percentage == 5 + assert resolved.max_soc_percentage == 95 + + def test_stricter_user_range_is_kept(self, make_profiled_settings): + profile = make_profile(technical_min_soc_percent=5.0, technical_max_soc_percent=95.0) + settings = make_profiled_settings(profile, min_soc_percentage=10, max_soc_percentage=90) + resolved = settings.resolve_operating_parameters() + assert resolved.min_soc_percentage == 10 + assert resolved.max_soc_percentage == 90 + + def test_profile_without_bounds_keeps_user_range(self, make_profiled_settings): + settings = make_profiled_settings( + make_profile(), min_soc_percentage=10, max_soc_percentage=90 + ) + resolved = settings.resolve_operating_parameters() + assert resolved.min_soc_percentage == 10 + assert resolved.max_soc_percentage == 90 + + def test_empty_range_after_clamping_raises(self, make_profiled_settings): + profile = make_profile(technical_min_soc_percent=60.0) + settings = make_profiled_settings(profile, max_soc_percentage=50) + with pytest.raises(ValueError, match="empty"): + settings.resolve_operating_parameters() + + def test_effective_soc_fields_use_technical_bounds(self, make_profiled_settings): + profile = make_profile(technical_min_soc_percent=5.0, technical_max_soc_percent=95.0) + settings = make_profiled_settings(profile) + assert settings.effective_min_soc_percentage == 5 + assert settings.effective_max_soc_percentage == 95 + + +# --- Strict resolution ----------------------------------------------------------- + + +class TestResolveOperatingParameters: + """Tests for the consistent strict snapshot.""" + + def test_snapshot_contains_all_values(self, make_profiled_settings, full_profile): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + resolved = settings.resolve_operating_parameters() + assert isinstance(resolved, BatteryOperatingParameters) + values = dataclasses.asdict(resolved) + # The snapshot brings the profile it was resolved from (see the conversion tests). + assert values.pop("profile") is not None + assert values == pytest.approx( + { + "temperature_degC": 25.0, + "soc_factor": 0.5, + "capacity_wh": 10000, + "charging_efficiency": 0.96, + "discharging_efficiency": 0.9375, + "max_charge_power_w": 5000.0, + "max_discharge_power_w": 4000.0, + "min_soc_percentage": 5, + "max_soc_percentage": 95, + } + ) + + def test_snapshot_is_immutable(self): + resolved = make_settings().resolve_operating_parameters() + with pytest.raises((AttributeError, TypeError)): + resolved.capacity_wh = 1 # type: ignore[misc] + + def test_snapshot_is_not_affected_by_later_measurement_changes( + self, make_profiled_settings, full_profile, measurements + ): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + resolved = settings.resolve_operating_parameters() + measurements["battery1-charge-power-w"] = 2500.0 + assert resolved.charging_efficiency == pytest.approx(0.96) + + +# --- Optimization parameters --------------------------------------------------- + + +class TestOptimizationParameters: + """Tests that the conversions use the battery profile at the operating point.""" + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_values_come_from_profile(self, make_profiled_settings, full_profile, method): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + params = getattr(settings, method)() + assert params.device_id == "battery1" + assert params.capacity_wh == 10000 + assert params.max_charge_power_w == pytest.approx(5000.0) + assert params.charging_efficiency == pytest.approx(0.96) + assert params.discharging_efficiency == pytest.approx(0.9375) + assert params.min_soc_percentage == 5 + assert params.max_soc_percentage == 95 + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_parameters_match_computed_fields(self, make_profiled_settings, full_profile, method): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + params = getattr(settings, method)() + assert params.capacity_wh == settings.effective_nominal_capacity_wh + assert params.charging_efficiency == pytest.approx(settings.effective_charging_efficiency) + assert params.discharging_efficiency == pytest.approx(settings.effective_discharging_efficiency) + assert params.max_charge_power_w == pytest.approx(settings.effective_max_charge_power_w) + assert params.min_soc_percentage == settings.effective_min_soc_percentage + assert params.max_soc_percentage == settings.effective_max_soc_percentage + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + @pytest.mark.asyncio + async def test_parameters_use_measured_operating_point( + self, make_profiled_settings, measurements, method + ): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + measurements["battery1-temperature-degc"] = 0.0 + params = getattr(settings, method)(await current(settings)) + assert params.max_charge_power_w == pytest.approx(1000.0) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + @pytest.mark.asyncio + async def test_parameters_use_measured_power_for_efficiency( + self, make_profiled_settings, full_profile, measurements, method + ): + measurements["battery1-charge-power-w"] = 2500.0 + measurements["battery1-discharge-power-w"] = 2000.0 + settings = make_profiled_settings(full_profile, capacity_wh=10000) + params = getattr(settings, method)(await current(settings)) + assert params.charging_efficiency == pytest.approx(0.9225) + assert params.discharging_efficiency == pytest.approx(0.9125) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_without_snapshot_reference_point_is_used( + self, make_profiled_settings, measurement_calls, method + ): + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + assert getattr(settings, method)().max_charge_power_w == pytest.approx(5000.0) + assert measurement_calls == [] + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_snapshot_is_converted_as_given(self, make_profiled_settings, full_profile, method): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + snapshot = settings.resolve_operating_parameters( + BatteryOperatingPoint(charge_power_w=2500.0) + ) + params = getattr(settings, method)(snapshot) + assert params.capacity_wh == snapshot.capacity_wh + assert params.charging_efficiency == pytest.approx(snapshot.charging_efficiency) + assert params.max_charge_power_w == pytest.approx(snapshot.max_charge_power_w) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_capacity_comes_from_profile_when_not_configured( + self, make_profiled_settings, method + ): + profile = make_profile(charge=0.5, nominal_capacity_wh=8000.0) + params = getattr(make_profiled_settings(profile), method)() + assert params.capacity_wh == 8000 + assert params.max_charge_power_w == pytest.approx(4000.0) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_installation_limit_is_applied(self, make_profiled_settings, full_profile, method): + settings = make_profiled_settings( + full_profile, capacity_wh=10000, charge_limit_w=2000.0 + ) + assert getattr(settings, method)().max_charge_power_w == pytest.approx(2000.0) + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_user_soc_range_is_clamped_into_technical_bounds( + self, make_profiled_settings, full_profile, method + ): + settings = make_profiled_settings( + full_profile, capacity_wh=10000, min_soc_percentage=10, max_soc_percentage=90 + ) + params = getattr(settings, method)() + assert params.min_soc_percentage == 10 + assert params.max_soc_percentage == 90 + + @pytest.mark.parametrize("method", PARAMETER_METHODS) + def test_inhibited_charging_gives_valid_parameters(self, make_profiled_settings, method): + """The optimization gets valid parameters that do not allow charging.""" + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + assert getattr(settings, method)().max_charge_power_w == 0.0 + + def test_genetic_pv_receives_storage_cost(self): + params = make_settings(levelized_cost_of_storage_amt_kwh=0.12).to_genetic_pv_bat_param() + assert params.levelized_cost_of_storage_kwh == pytest.approx(0.12) + + def test_genetic_ev_receives_soc_deadline_settings(self): + params = make_settings(min_soc_max_duration_h=6.0).to_genetic_ev_bat_param() + assert params.min_soc_max_duration_h == pytest.approx(6.0) + assert params.min_soc_deadline_datetime is None + + +# --- Real measurement store ------------------------------------------------------ + + +class TestMeasurementAccess: + """Tests that the operating point is read from the real measurement store. + + These tests use the real (async) accessor, so a missing ``await`` on + ``DataSequence.key_to_value`` surfaces here and not only in production. + """ + + @pytest.fixture(autouse=True) + def _real_measurement_access(self, monkeypatch: pytest.MonkeyPatch, config_eos): + from akkudoktoreos.core.coreabc import get_measurement + + monkeypatch.setattr(batterymeasurement, "get_measured_value", REAL_GET_MEASURED_VALUE) + config_eos.merge_settings_from_dict( + {"devices": {"batteries": {"battery1": {"device_id": "battery1"}}}} + ) + get_measurement()._db_reset_state() + yield + get_measurement()._db_reset_state() + + @pytest.mark.asyncio + async def test_measured_values_are_read(self): + from akkudoktoreos.core.coreabc import get_measurement + from akkudoktoreos.utils.datetimeutil import to_datetime + + now = to_datetime() + await get_measurement().update_value( + now, + { + "battery1-soc-factor": 0.35, + "battery1-temperature-degc": 12.5, + "battery1-charge-power-w": 2500.0, + }, + ) + point = await make_settings().measure_operating_point(now) + assert point.soc_factor == pytest.approx(0.35) + assert point.temperature_degC == pytest.approx(12.5) + assert point.charge_power_w == pytest.approx(2500.0) + assert point.discharge_power_w is None + + @pytest.mark.asyncio + async def test_empty_store_yields_reference_point(self): + point = await make_settings().measure_operating_point() + assert point == BatteryOperatingPoint() + + @pytest.mark.asyncio + async def test_measurement_outside_age_window_is_ignored(self): + from akkudoktoreos.core.coreabc import get_measurement + from akkudoktoreos.utils.datetimeutil import to_datetime + + now = to_datetime() + await get_measurement().update_value( + now.subtract(hours=3), {"battery1-soc-factor": 0.9} + ) + point = await make_settings().measure_operating_point(now) + assert point.soc_factor == pytest.approx(BATTERY_DEFAULT_SOC_FACTOR) + + @pytest.mark.asyncio + async def test_current_resolution_uses_real_measurements(self, make_profiled_settings): + from akkudoktoreos.core.coreabc import get_measurement + from akkudoktoreos.utils.datetimeutil import to_datetime + + grid = make_grid(0.5) + grid[ROW_0_DEGC] = [0.1] * 10 + settings = make_profiled_settings(make_profile(charge=grid), capacity_wh=10000) + now = to_datetime() + await get_measurement().update_value(now, {"battery1-temperature-degc": 0.0}) + resolved = await settings.resolve_current_operating_parameters(now) + assert resolved.max_charge_power_w == pytest.approx(1000.0) + + +class TestGeneticPowerCurves: + """The GENETIC parameters carry the profile as SoC tables and efficiency curves.""" + + # SoC axis [0, 5, 10, 20, 40, 60, 80, 90, 95, 100]: full 1.0 C up to 80 %, + # then tapering to 0.5 C at 100 %. + TAPER = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.6, 0.5] + # Temperature axis [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50]: 0.2 C at 10 degC, + # 0.5 C at 25 degC. + BY_TEMPERATURE = [0.1, 0.1, 0.1, 0.15, 0.2, 0.3, 0.5, 0.5, 0.5, 0.5] + + @staticmethod + def snapshot(temperature_degC: float = 25.0) -> BatteryOperatingParameters: + """Return a resolved snapshot that does not depend on a profile.""" + return BatteryOperatingParameters( + temperature_degC=temperature_degC, + soc_factor=0.5, + capacity_wh=10000, + charging_efficiency=0.9, + discharging_efficiency=0.9, + max_charge_power_w=3000.0, + max_discharge_power_w=2000.0, + min_soc_percentage=0, + max_soc_percentage=100, + ) + + @pytest.mark.parametrize("converter", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"]) + def test_tables_follow_soc(self, make_profiled_settings, converter): + profile = make_profile(charge=[list(self.TAPER) for _ in range(10)], discharge=0.4) + settings = make_profiled_settings(profile, capacity_wh=10000) + parameters = getattr(settings, converter)() + + charge = parameters.charge_power_limit_w_by_soc + discharge = parameters.discharge_power_limit_w_by_soc + assert charge is not None and len(charge) == 101 + assert discharge is not None and len(discharge) == 101 + assert charge[50] == pytest.approx(10000.0) + assert charge[80] == pytest.approx(10000.0) + assert charge[90] == pytest.approx(8000.0) + assert charge[100] == pytest.approx(5000.0) + assert all(a >= b for a, b in zip(charge[80:], charge[81:])) + assert discharge == pytest.approx([4000.0] * 101) + assert parameters.max_charge_power_w == pytest.approx(10000.0) + assert parameters.max_discharge_power_w == pytest.approx(4000.0) + + @pytest.mark.asyncio + async def test_rated_power_is_the_peak_over_soc(self, make_profiled_settings, measurements): + """At a high measured SoC the snapshot is tapered, the rated power is not.""" + profile = make_profile(charge=[list(self.TAPER) for _ in range(10)], discharge=0.4) + settings = make_profiled_settings(profile, capacity_wh=10000) + measurements["battery1-soc-factor"] = 1.0 + snapshot = await current(settings) + assert snapshot.max_charge_power_w == pytest.approx(5000.0) + + parameters = settings.to_genetic_pv_bat_param(snapshot) + assert parameters.max_charge_power_w == pytest.approx(10000.0) + assert parameters.charge_power_limit_w_by_soc[100] == pytest.approx(5000.0) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "temperature, expected_w", + [(10.0, 2000.0), (None, 5000.0)], # None: no measurement, 25 degC assumed + ) + async def test_tables_at_measured_temperature( + self, make_profiled_settings, measurements, temperature, expected_w + ): + grid = [[value] * 10 for value in self.BY_TEMPERATURE] + settings = make_profiled_settings( + make_profile(charge=grid, discharge=grid), capacity_wh=10000 + ) + if temperature is not None: + measurements["battery1-temperature-degc"] = temperature + + parameters = settings.to_genetic_pv_bat_param(await current(settings)) + assert parameters.charge_power_limit_w_by_soc == pytest.approx([expected_w] * 101) + assert parameters.discharge_power_limit_w_by_soc == pytest.approx([expected_w] * 101) + + def test_installation_limits_cap_tables(self, make_profiled_settings): + profile = make_profile(charge=[list(self.TAPER) for _ in range(10)], discharge=0.4) + settings = make_profiled_settings( + profile, + capacity_wh=10000, + charge_limit_w=6000, + discharge_limit_w=1000, + ) + parameters = settings.to_genetic_pv_bat_param() + assert max(parameters.charge_power_limit_w_by_soc) == pytest.approx(6000.0) + assert parameters.charge_power_limit_w_by_soc[100] == pytest.approx(5000.0) + assert parameters.discharge_power_limit_w_by_soc == pytest.approx([1000.0] * 101) + assert parameters.max_charge_power_w == pytest.approx(6000.0) + assert parameters.max_discharge_power_w == pytest.approx(1000.0) + + def test_efficiency_curves_over_power_fraction(self, make_profiled_settings, full_profile): + settings = make_profiled_settings(full_profile, capacity_wh=10000) + parameters = settings.to_genetic_pv_bat_param() + + charging = parameters.charging_efficiency_curve + discharging = parameters.discharging_efficiency_curve + assert charging is not None and discharging is not None + assert [point[0] for point in charging] == pytest.approx([i / 20 for i in range(21)]) + # Rated charge power 0.5 C: fraction 1.0 is 0.5 C, fraction 0.2 is 0.1 C. + assert charging[-1][1] == pytest.approx(0.96) + assert charging[4][1] == pytest.approx(0.90) + # Rated discharge power 0.4 C: between 0.90 at 0.1 C and 0.95 at 0.5 C. + assert discharging[-1][1] == pytest.approx(0.9375) + + def test_efficiency_curves_come_from_the_profile(self): + parameters = make_settings(capacity_wh=10000).to_genetic_pv_bat_param() + assert parameters.charge_power_limit_w_by_soc is not None + assert parameters.charging_efficiency_curve is not None + assert parameters.discharging_efficiency_curve is not None + assert {e for _, e in parameters.charging_efficiency_curve} == {PROFILE_EFFICIENCY} + + def test_no_tables_if_charging_is_inhibited_at_every_soc(self, make_profiled_settings): + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + snapshot = self.snapshot(temperature_degC=20.0) + assert settings.resolve_power_curves(snapshot) is None + + parameters = settings.to_genetic_pv_bat_param(snapshot) + assert parameters.charge_power_limit_w_by_soc is None + assert parameters.max_charge_power_w == pytest.approx(snapshot.max_charge_power_w) + + def test_snapshot_without_profile_converts_without_tables(self, broken_profile_loader): + """A snapshot resolved without profile is converted as is: no load, no abort.""" + snapshot = self.snapshot() + settings = make_settings(battery_profile="does-not-exist") + converted = settings.to_genetic_pv_bat_param(snapshot) + assert converted.max_charge_power_w == snapshot.max_charge_power_w + assert converted.max_discharge_power_w == snapshot.max_discharge_power_w + assert converted.charge_power_limit_w_by_soc is None + + +# --- Profile check --------------------------------------------------------------- + + +class TestProfileCheck: + """Tests for check_profile, which reports problems instead of raising.""" + + def test_resolvable_battery_has_no_problems(self): + assert make_settings(capacity_wh=10000).check_profile() == ([], []) + + def test_missing_profile_is_an_error(self, broken_profile_loader): + errors, warnings = make_settings(battery_profile="does-not-exist").check_profile() + assert len(errors) == 1 + assert "Battery 'battery1'" in errors[0] + assert "does-not-exist" in errors[0] + assert warnings == [] + + def test_generic_profile_without_capacity_is_an_error(self, make_profiled_settings): + settings = make_profiled_settings(make_profile(nominal_capacity_wh=None)) + errors, _ = settings.check_profile() + assert len(errors) == 1 + assert "profile_adjustment.nominal_capacity_wh" in errors[0] + + def test_power_limit_above_profile_is_a_warning(self): + # The plain profile allows 0.5 C of 10 kWh = 5 kW charging, 0.4 C = 4 kW discharging. + settings = make_settings(capacity_wh=10000, charge_limit_w=6000.0, discharge_limit_w=3000.0) + errors, warnings = settings.check_profile() + assert errors == [] + assert len(warnings) == 1 + assert "profile_adjustment.max_charge_power_w" in warnings[0] + # The profile value applies. + assert settings.effective_max_charge_power_w == pytest.approx(5000.0) + assert settings.effective_max_discharge_power_w == pytest.approx(3000.0) + + def test_power_limit_below_profile_is_no_warning(self): + settings = make_settings(capacity_wh=10000, charge_limit_w=2000.0) + assert settings.check_profile() == ([], []) + + def test_no_charging_at_the_reference_point_is_an_error(self, make_profiled_settings): + """A profile that cannot charge at the reference temperature is a configuration error.""" + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + errors, _ = make_profiled_settings(profile, capacity_wh=10000).check_profile() + assert len(errors) == 1 + assert "allows no charging at the reference operating point" in errors[0] + + +# --- Defaults for the optimization ------------------------------------------------ + + +@pytest.fixture +def logged_errors(): + """Collect the messages logged at ERROR level.""" + from loguru import logger + + messages: list[str] = [] + sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR") + yield messages + logger.remove(sink_id) + + +class TestOptimizationDefaults: + """resolve_operating_parameters_or_defaults never fails for a configuration error.""" + + def test_resolvable_battery_matches_strict_resolution(self, logged_errors): + settings = make_settings(capacity_wh=10000) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert parameters == settings.resolve_operating_parameters() + assert use_tables is True + assert logged_errors == [] + + def test_missing_profile_uses_adjustment_and_former_defaults( + self, broken_profile_loader, logged_errors + ): + settings = make_settings(battery_profile="does-not-exist", charge_limit_w=3000.0) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is False + assert parameters.capacity_wh == 8000 + # The adjustment applies where set. + assert parameters.max_charge_power_w == pytest.approx(3000.0) + assert parameters.max_discharge_power_w == pytest.approx(5000.0) + assert parameters.charging_efficiency == pytest.approx(0.88) + assert parameters.discharging_efficiency == pytest.approx(0.88) + assert len(logged_errors) == 1 + message = logged_errors[0] + assert "Battery 'battery1'" in message and "does-not-exist" in message + assert "nominal capacity 8000 Wh" in message + assert "maximum discharge power 5000.0 W" in message + # Not a default: the configured charge limit. + assert "maximum charge power" not in message + + def test_unusable_profile_uses_the_generic_profile(self, monkeypatch, logged_errors): + generic = make_profile(nominal_capacity_wh=None) + requested: list[str] = [] + + def get(document_type, name, **options): + requested.append(name) + if name == BATTERY_DEFAULT_PROFILE: + return generic + raise FileNotFoundError(f"No battery profile for '{name}'") + + monkeypatch.setattr(get_profile_store(), "get", get) + settings = make_settings(battery_profile="does-not-exist", capacity_wh=10000) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert requested == ["does-not-exist", BATTERY_DEFAULT_PROFILE] + # The generic profile applies: its tables and 0.5 C of the configured capacity. + assert use_tables is True + assert parameters.profile is generic + assert parameters.capacity_wh == 10000 + assert parameters.max_charge_power_w == pytest.approx(5000.0) + assert len(logged_errors) == 1 + assert "does-not-exist" in logged_errors[0] + assert logged_errors[0].endswith( + f"Using defaults for the optimization: battery profile '{BATTERY_DEFAULT_PROFILE}'." + ) + + def test_unusable_generic_profile_uses_the_former_defaults( + self, broken_profile_loader, logged_errors + ): + settings = make_settings(battery_profile=BATTERY_DEFAULT_PROFILE) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is False + assert parameters.charging_efficiency == pytest.approx(0.88) + assert len(logged_errors) == 1 + assert f"battery profile '{BATTERY_DEFAULT_PROFILE}'" not in logged_errors[0].split( + "Using defaults" + )[1] + + def test_generic_profile_without_capacity_uses_default_capacity_only( + self, make_profiled_settings, logged_errors + ): + settings = make_profiled_settings(make_profile(nominal_capacity_wh=None)) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is True + assert parameters.capacity_wh == 8000 + # The profile still applies: 0.5 C of the default capacity. + assert parameters.max_charge_power_w == pytest.approx(4000.0) + assert len(logged_errors) == 1 + assert "profile_adjustment.nominal_capacity_wh" in logged_errors[0] + assert logged_errors[0].endswith("Using defaults for the optimization: nominal capacity 8000 Wh.") + + def test_soc_range_outside_technical_bounds_uses_technical_range( + self, make_profiled_settings, logged_errors + ): + profile = make_profile(technical_min_soc_percent=5.0, technical_max_soc_percent=90.0) + settings = make_profiled_settings( + profile, capacity_wh=10000, min_soc_percentage=95, max_soc_percentage=100 + ) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is True + assert (parameters.min_soc_percentage, parameters.max_soc_percentage) == (5, 90) + assert len(logged_errors) == 1 + assert "SoC range 5 ... 90 %" in logged_errors[0] + + def test_unconvertible_discharge_limit_gives_defaults( + self, make_profiled_settings, logged_errors + ): + """With charging inhibited, a discharge limit that cannot be converted is still reported. + + Current limits without nominal voltage cannot be converted to power. The + optimization gets the defaults instead of an exception. + """ + # Only a compiled profile that bypassed validation can miss the voltage. + profile = dataclasses.replace( + make_profile( + charge=20.0, + discharge=20.0, + units=Units(limit="A"), + nominal_voltage_v=100.0, + hard_cutoffs=charge_inhibited_below_30(), + ), + nominal_voltage_v=None, + ) + settings = make_profiled_settings(profile, capacity_wh=10000) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is False + assert parameters.max_discharge_power_w > 0 + assert len(logged_errors) == 1 + assert "nominal_voltage_v" in logged_errors[0] + + @pytest.mark.parametrize("method", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"]) + def test_conversion_uses_the_profile_of_the_snapshot(self, monkeypatch, method): + """A profile deleted between resolution and conversion does not abort the run.""" + settings = make_settings(capacity_wh=10000) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is True + expected = getattr(settings, method)(parameters, use_tables) + + def missing(document_type, name, **options): + raise FileNotFoundError(f"No battery profile for '{name}'") + + monkeypatch.setattr(get_profile_store(), "get", missing) + converted = getattr(settings, method)(parameters, use_tables) + assert converted.model_dump() == expected.model_dump() + + def test_inhibited_charging_is_no_configuration_error( + self, make_profiled_settings, logged_errors + ): + """Inhibited charging gives valid parameters with 0 W charge power and no error.""" + profile = make_profile(hard_cutoffs=charge_inhibited_below_30()) + settings = make_profiled_settings(profile, capacity_wh=10000) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is True + assert parameters.max_charge_power_w == 0.0 + assert logged_errors == [] + + @pytest.mark.parametrize("error", [PermissionError, IsADirectoryError]) + def test_unreadable_profile_uses_adjustment(self, monkeypatch, logged_errors, error): + def unreadable(document_type, name, **options): + raise error(f"Battery profile '{name}' is not readable") + + monkeypatch.setattr(get_profile_store(), "get", unreadable) + settings = make_settings(charge_limit_w=3000.0) + parameters, use_tables = settings.resolve_operating_parameters_or_defaults() + assert use_tables is False + assert parameters.max_charge_power_w == pytest.approx(3000.0) + assert len(logged_errors) == 1 + assert "is not readable" in logged_errors[0] + + def test_report_false_logs_nothing(self, broken_profile_loader, logged_errors): + make_settings().resolve_operating_parameters_or_defaults(report=False) + assert logged_errors == [] + + @pytest.mark.parametrize("method", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"]) + def test_conversion_without_profile_tables(self, broken_profile_loader, method): + settings = make_settings(battery_profile="does-not-exist") + parameters, use_tables = settings.resolve_operating_parameters_or_defaults(report=False) + converted = getattr(settings, method)(parameters, use_tables) + assert converted.capacity_wh == 8000 + assert converted.max_charge_power_w == pytest.approx(5000.0) + assert converted.charge_power_limit_w_by_soc is None + assert converted.charging_efficiency_curve is None + + @pytest.mark.asyncio + async def test_current_operating_point_variant(self, broken_profile_loader, measurements): + measurements["battery1-temperature-degc"] = 10.0 + settings = make_settings() + parameters, use_tables = await settings.resolve_current_operating_parameters_or_defaults( + report=False + ) + assert use_tables is False + assert parameters.temperature_degC == pytest.approx(10.0) diff --git a/tests/test_genetic0battery.py b/tests/test_genetic0battery.py index 42c49a7e..b0148f2c 100644 --- a/tests/test_genetic0battery.py +++ b/tests/test_genetic0battery.py @@ -311,3 +311,36 @@ def test_car_and_pv_battery_discharge_and_max_charge_power(setup_pv_battery, set assert car_battery.parameters.max_charge_power_w == 7000, ( "Car battery max charge power should remain as defined" ) + + +def _battery_with_limits(max_charge_power_w, max_discharge_power_w=None) -> Genetic0Battery: + params = Genetic0SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=10000, + initial_soc_percentage=50, + min_soc_percentage=10, + max_soc_percentage=90, + max_charge_power_w=max_charge_power_w, + max_discharge_power_w=max_discharge_power_w, + ) + battery = Genetic0Battery(params, prediction_hours=24) + battery.reset() + battery.discharge_array = np.full(battery.prediction_hours, 1) + battery.charge_array = np.full(battery.prediction_hours, 1) + return battery + + +def test_battery_that_may_not_charge_still_discharges(): + """A cold battery gets 0 W charge power but may still discharge up to its discharge limit.""" + battery = _battery_with_limits(max_charge_power_w=0.0, max_discharge_power_w=3000.0) + delivered_wh, _ = battery.discharge_energy(5000.0, 0) + assert 0.0 < delivered_wh <= 3000.0 + charged_wh, _ = battery.charge_energy(None, 0, charge_factor=1.0) + assert charged_wh == 0.0 + + +def test_discharge_limit_defaults_to_the_charge_power(): + battery = _battery_with_limits(max_charge_power_w=2000.0) + assert battery.max_discharge_power_w == 2000.0 + delivered_wh, _ = battery.discharge_energy(5000.0, 0) + assert delivered_wh <= 2000.0 diff --git a/tests/test_genetic0optimize.py b/tests/test_genetic0optimize.py index 8b2a9c9e..7e33de41 100644 --- a/tests/test_genetic0optimize.py +++ b/tests/test_genetic0optimize.py @@ -169,3 +169,62 @@ async def test_optimize( # Everything passed, remove generated files TESTDATA_FILE.unlink() + + +class TestGenetic0SocClampedOperationFactors: + """The SOC clamping uses the optimizer battery parameters, not the settings. + + Battery settings no longer hold capacity / efficiency / power fields (they + come from the battery profile), so reading them from the settings fails. + """ + + @pytest.fixture + def solution(self, config_eos: ConfigEOS) -> Genetic0Solution: + # A configured battery whose settings differ from the solution parameters. + config_eos.merge_settings_from_dict( + { + "devices": { + "max_batteries": 1, + "batteries": { + "battery1": {"device_id": "battery1", "profile_adjustment": {"nominal_capacity_wh": 8000}} + }, + "max_inverters": 0, + "inverters": None, + } + } + ) + data = json.loads((DIR_TESTDATA / "optimize_result_1.json").read_text()) + return Genetic0Solution.model_validate(data) + + def test_uses_solution_battery_parameters(self, solution): + bat = solution.parameters.pv_battery + assert bat is not None + assert bat.capacity_wh == 26400 + # 1 % headroom of 26400 Wh against one hour at 5000 W and the charging + # efficiency of the parameters (no inverter configured). + headroom_wh = 0.01 * bat.capacity_wh + expected = headroom_wh / (bat.max_charge_power_w * bat.charging_efficiency) + ac, dc, dis = solution._soc_clamped_operation_factors( + 1.0, 1.0, True, float(bat.max_soc_percentage) - 1.0 + ) + assert ac == pytest.approx(expected) + assert dc == pytest.approx(1.0) + assert dis is True + + def test_full_and_empty_battery(self, solution): + bat = solution.parameters.pv_battery + assert solution._soc_clamped_operation_factors( + 1.0, 1.0, True, float(bat.max_soc_percentage) + ) == (0.0, 0.0, True) + assert solution._soc_clamped_operation_factors( + 0.0, 0.0, True, float(bat.min_soc_percentage) + ) == (0.0, 0.0, False) + + def test_without_battery_parameters_factors_are_unchanged(self, solution): + solution.parameters.pv_battery = None + assert solution._soc_clamped_operation_factors(1.0, 0.5, True, 50.0) == (1.0, 0.5, True) + + @pytest.mark.asyncio + async def test_optimization_solution_with_configured_battery(self, solution): + optimization_solution = await solution.optimization_solution() + assert optimization_solution is not None diff --git a/tests/test_genetic0params.py b/tests/test_genetic0params.py index 1b15e54e..34351557 100644 --- a/tests/test_genetic0params.py +++ b/tests/test_genetic0params.py @@ -158,7 +158,9 @@ class TestGenetic0MeasuredSOC: "max_electric_vehicles": 0, "max_inverters": 1, "max_home_appliances": 0, - "batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}}, + "batteries": { + "storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}} + }, "inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}}, }, } @@ -203,7 +205,9 @@ class TestGenetic0MeasuredSOC: "max_electric_vehicles": 0, "max_inverters": 1, "max_home_appliances": 0, - "batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}}, + "batteries": { + "storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}} + }, "inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}}, }, } @@ -247,7 +251,9 @@ class TestGenetic0MeasuredSOC: "max_electric_vehicles": 0, "max_inverters": 1, "max_home_appliances": 0, - "batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}}, + "batteries": { + "storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}} + }, "inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}}, }, } @@ -277,3 +283,106 @@ class TestGenetic0MeasuredSOC: assert parameters is not None assert parameters.pv_battery is not None assert parameters.pv_battery.initial_soc_percentage == 42 + + +class TestGenetic0DemoData: + """Demo device data used when no battery / electric vehicle is configured.""" + + @pytest.mark.asyncio + async def test_demo_devices_use_nominal_capacity(self, config_eos): + config_eos.merge_settings_from_dict( + { + "prediction": {"hours": 48}, + "optimization": {"genetic0": {"horizon_hours": 24}}, + "elecfee": {"provider": None}, + "devices": { + "max_batteries": 1, + "max_electric_vehicles": 1, + "max_inverters": 0, + "max_home_appliances": 0, + "batteries": None, + "electric_vehicles": None, + }, + } + ) + get_ems().set_start_datetime(to_datetime("2026-08-01T14:00:00+00:00")) + tariff = pd.Series( + 0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min") + ) + + async def read_array(**kwargs): + return np.full(48, 0.0003) + + prediction = Mock( + update_data=AsyncMock(), + key_to_array=AsyncMock(side_effect=read_array), + key_to_raw_series=AsyncMock(return_value=tariff), + ) + with patch.object(Genetic0OptimizationParameters, "prediction", prediction): + parameters = await Genetic0OptimizationParameters.prepare() + + assert parameters is not None + # The demo capacities must reach the profile adjustment of the settings; + # the generic default profile provides no capacity of its own. + assert config_eos.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000 + assert ( + config_eos.devices.electric_vehicles["ev1"].profile_adjustment.nominal_capacity_wh + == 50000 + ) + assert parameters.pv_battery is not None + assert parameters.pv_battery.capacity_wh == 8000 + assert parameters.ev is not None + assert parameters.ev.capacity_wh == 50000 + + @pytest.mark.asyncio + async def test_battery_configuration_error_uses_defaults_not_demo_data(self, config_eos): + """A misconfigured battery keeps its configuration, with defaults for the rest.""" + from loguru import logger + + config_eos.merge_settings_from_dict( + { + "prediction": {"hours": 48}, + "optimization": {"genetic0": {"horizon_hours": 24}}, + "elecfee": {"provider": None}, + "devices": { + "max_batteries": 1, + "max_electric_vehicles": 0, + "max_inverters": 0, + "max_home_appliances": 0, + # generic-lfp provides no capacity and the adjustment none either. + "batteries": {"storage": {"min_soc_percentage": 20}}, + "electric_vehicles": None, + }, + } + ) + get_ems().set_start_datetime(to_datetime("2026-08-01T14:00:00+00:00")) + tariff = pd.Series( + 0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min") + ) + + async def read_array(**kwargs): + return np.full(48, 0.0003) + + prediction = Mock( + update_data=AsyncMock(), + key_to_array=AsyncMock(side_effect=read_array), + key_to_raw_series=AsyncMock(return_value=tariff), + ) + messages: list[str] = [] + sink_id = logger.add( + lambda message: messages.append(message.record["message"]), level="ERROR" + ) + try: + with patch.object(Genetic0OptimizationParameters, "prediction", prediction): + parameters = await Genetic0OptimizationParameters.prepare() + finally: + logger.remove(sink_id) + assert parameters is not None + assert parameters.pv_battery is not None + # The configured battery, not the demo battery: its own SoC minimum. + assert parameters.pv_battery.device_id == "storage" + assert parameters.pv_battery.min_soc_percentage == 20 + assert parameters.pv_battery.capacity_wh == 8000 + assert any( + "Battery 'storage'" in m and "nominal capacity 8000 Wh" in m for m in messages + ) diff --git a/tests/test_geneticbattery.py b/tests/test_geneticbattery.py index f2405d7d..a7b1c603 100644 --- a/tests/test_geneticbattery.py +++ b/tests/test_geneticbattery.py @@ -10,6 +10,7 @@ from akkudoktoreos.devices.genetic.battery import ( ElectricVehicleParameters, SolarPanelBatteryParameters, ) +from akkudoktoreos.devices.settings.batterysettings import BatteryProfileAdjustment @pytest.fixture @@ -417,6 +418,7 @@ def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery): def test_pv_converter_preserves_id_lcos_charge_and_export_rates(): settings = BatteriesCommonSettings( device_id="house", + profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8000), charge_rates=[0.0, 0.5, 1.0], grid_export_rates=[1.0, 0.25], levelized_cost_of_storage_amt_kwh=0.123, @@ -434,7 +436,8 @@ def test_pv_converter_preserves_id_lcos_charge_and_export_rates(): @pytest.mark.parametrize("target", [0, 50, 100]) def test_equal_soc_limits_are_valid(target): settings = BatteriesCommonSettings(device_id="ev", min_soc_percentage=target, - max_soc_percentage=target) + max_soc_percentage=target, + profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8000)) assert settings.to_genetic_ev_bat_param().min_soc_percentage == target assert settings.to_genetic0_ev_bat_param().min_soc_percentage == target @@ -447,9 +450,11 @@ def test_inverted_or_out_of_range_limits_still_fail(minimum, maximum): def test_full_target_charges_to_capacity_without_overcharge(): - settings = BatteriesCommonSettings(device_id="ev", capacity_wh=1000, - min_soc_percentage=100, max_soc_percentage=100, max_charge_power_w=1000, - charging_efficiency=1) + settings = BatteriesCommonSettings(device_id="ev", + profile_adjustment=BatteryProfileAdjustment( + nominal_capacity_wh=1000, max_charge_power_w=1000 + ), + min_soc_percentage=100, max_soc_percentage=100) params = settings.to_genetic_ev_bat_param() params.initial_soc_percentage = 70 battery = Battery(params, prediction_hours=4, slot_duration_h=0.25) @@ -525,3 +530,233 @@ class TestEVMinimumCurrent(unittest.TestCase): self.assertAlmostEqual(value.soc_wh, value.max_soc_wh) self.assertAlmostEqual(losses, missing / 0.95 - missing) self.assertEqual(value.charge_array[0], factor) + + +class TestMaxDischargePower: + """The discharge power limit is separate from the charge power limit.""" + + @staticmethod + def _battery(max_discharge_power_w=None, slot_duration_h=1.0): + params = SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=10_000, + initial_soc_percentage=100, + min_soc_percentage=0, + max_charge_power_w=8_000, + max_discharge_power_w=max_discharge_power_w, + charging_efficiency=1.0, + discharging_efficiency=1.0, + ) + battery = Battery(params, prediction_hours=4, slot_duration_h=slot_duration_h) + battery.set_discharge_per_hour(np.ones(4)) + return battery + + def test_defaults_to_max_charge_power(self): + battery = self._battery() + assert battery.max_discharge_power_w == 8_000 + delivered, _ = battery.discharge_energy(9_000.0, 0) + assert delivered == pytest.approx(8_000.0) + + def test_limits_discharge_not_charge(self): + battery = self._battery(max_discharge_power_w=4_000) + delivered, _ = battery.discharge_energy(9_000.0, 0) + assert delivered == pytest.approx(4_000.0) + assert battery.remaining_discharge_energy_wh(0) == pytest.approx(0.0) + assert battery.rated_discharge_energy_wh() == pytest.approx(4_000.0) + + empty = self._battery(max_discharge_power_w=4_000) + empty.soc_wh = 0.0 + empty.set_charge_per_hour(np.ones(4)) + stored, _ = empty.charge_energy(9_000.0, 0) + assert stored == pytest.approx(8_000.0) + + def test_quarter_hour_budget(self): + battery = self._battery(max_discharge_power_w=4_000, slot_duration_h=0.25) + assert battery.remaining_discharge_energy_wh(0) == pytest.approx(1_000.0) + delivered, _ = battery.discharge_energy(2_000.0, 0) + assert delivered == pytest.approx(1_000.0) + + def test_zero_inhibits_discharge(self): + battery = self._battery(max_discharge_power_w=0) + assert battery.discharge_energy(1_000.0, 0) == (0.0, 0.0) + assert battery.rated_discharge_energy_wh() == 0.0 + + def test_negative_is_rejected(self): + with pytest.raises(ValidationError): + SolarPanelBatteryParameters( + device_id="battery1", capacity_wh=10_000, max_discharge_power_w=-1 + ) + + @pytest.mark.parametrize("converter", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"]) + def test_converter_passes_profile_discharge_limit(self, converter): + """generic-lfp allows only 0.5C continuous discharge. + + Without an installation limit the generic profile is capped at the former + default of 5000 W, so 1C charging of 8 kWh gives 5000 W. + """ + settings = BatteriesCommonSettings( + device_id="battery1", profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8_000) + ) + parameters = getattr(settings, converter)() + assert parameters.max_charge_power_w == pytest.approx(5_000.0) + assert parameters.max_discharge_power_w == pytest.approx(4_000.0) + + @pytest.mark.parametrize("converter", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"]) + def test_installation_limit_lifts_the_generic_default(self, converter): + """An explicit installation limit replaces the 5000 W default; the profile C-rate applies.""" + settings = BatteriesCommonSettings( + device_id="battery1", + profile_adjustment=BatteryProfileAdjustment( + nominal_capacity_wh=8_000, max_charge_power_w=10_000, max_discharge_power_w=10_000 + ), + ) + parameters = getattr(settings, converter)() + assert parameters.max_charge_power_w == pytest.approx(8_000.0) + assert parameters.max_discharge_power_w == pytest.approx(4_000.0) + + +class TestPowerCurves: + """SoC dependent power limits and power dependent efficiencies.""" + + @staticmethod + def _battery(soc_percentage=50, slot_duration_h=1.0, **curves): + params = SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=10_000, + initial_soc_percentage=soc_percentage, + min_soc_percentage=0, + max_soc_percentage=100, + max_charge_power_w=10_000, + max_discharge_power_w=10_000, + charging_efficiency=1.0, + discharging_efficiency=1.0, + **curves, + ) + battery = Battery(params, prediction_hours=4, slot_duration_h=slot_duration_h) + battery.set_charge_per_hour(np.ones(4)) + battery.set_discharge_per_hour(np.ones(4)) + return battery + + def test_plain_battery_has_no_power_curves(self): + battery = self._battery() + assert not battery.has_power_curves + + @pytest.mark.parametrize("with_curves", [False, True]) + def test_instances_stay_below_the_attribute_threshold(self, with_curves): + """CPython reads attributes of instances with 30 or more of them slower. + + The simulation reads battery attributes for every slot of every + candidate plan, so a battery must stay below that threshold. + """ + curves = ( + dict( + charge_power_limit_w_by_soc=[1_000.0] * 101, + discharge_power_limit_w_by_soc=[1_000.0] * 101, + charging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)], + discharging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)], + ) + if with_curves + else {} + ) + battery = self._battery(**curves) + battery.reset() + battery._charge_rates_descending() + battery.charge_energy(None, 0, charge_factor=0.5) + battery.discharge_energy(100.0, 1) + assert len(vars(battery)) < 30 + + @pytest.mark.parametrize("soc, expected_wh", [(10, 3_000.0), (30, 1_000.0)]) + def test_charge_limit_at_current_soc(self, soc, expected_wh): + table = [3_000.0] * 21 + [1_000.0] * 80 + battery = self._battery(soc_percentage=soc, charge_power_limit_w_by_soc=table) + assert battery.has_power_curves + stored, _ = battery.charge_energy(None, 0, charge_factor=0.5) + assert stored == pytest.approx(expected_wh) + # The limit caps the energy; it does not lower the charge factor of the plan. + assert battery.charge_array[0] == 1.0 + + def test_charge_limit_is_shared_within_a_slot(self): + battery = self._battery( + slot_duration_h=0.25, charge_power_limit_w_by_soc=[4_000.0] * 101 + ) + first, _ = battery.charge_energy(600.0, 0) + second, _ = battery.charge_energy(600.0, 0) + assert first == pytest.approx(600.0) + assert second == pytest.approx(400.0) + + def test_discharge_limit_at_current_soc(self): + table = [500.0] * 51 + [3_000.0] * 50 + high = self._battery(soc_percentage=80, discharge_power_limit_w_by_soc=table) + low = self._battery(soc_percentage=30, discharge_power_limit_w_by_soc=table) + assert high.discharge_energy(9_000.0, 0)[0] == pytest.approx(3_000.0) + assert high.remaining_discharge_energy_wh(0) == pytest.approx(0.0) + assert low.remaining_discharge_energy_wh(0) == pytest.approx(500.0) + assert low.discharge_energy(9_000.0, 0)[0] == pytest.approx(500.0) + + def test_charging_efficiency_at_slot_power(self): + battery = self._battery( + soc_percentage=0, charging_efficiency_curve=[(0.0, 1.0), (1.0, 0.5)] + ) + stored, losses = battery.charge_energy(5_000.0, 0) + # Half the rated power: efficiency 0.75. + assert stored == pytest.approx(3_750.0) + assert losses == pytest.approx(1_250.0) + assert battery.charged_energy_wh(0) == pytest.approx(3_750.0) + + def test_discharging_efficiency_at_slot_power(self): + battery = self._battery( + soc_percentage=100, discharging_efficiency_curve=[(0.0, 1.0), (1.0, 0.5)] + ) + delivered, losses = battery.discharge_energy(5_000.0, 0) + assert delivered == pytest.approx(5_000.0) + assert losses == pytest.approx(5_000.0 / 0.75 - 5_000.0) + assert battery.discharged_energy_wh(0) == pytest.approx(5_000.0) + assert battery.soc_wh == pytest.approx(10_000.0 - 5_000.0 / 0.75) + + def test_rated_discharge_energy_uses_efficiency_at_rated_power(self): + battery = self._battery(discharging_efficiency_curve=[(0.0, 1.0), (1.0, 0.8)]) + assert battery.rated_discharge_energy_wh() == pytest.approx(8_000.0) + + def test_reset_clears_slot_energies(self): + battery = self._battery(charging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)]) + battery.charge_energy(1_000.0, 0) + battery.discharge_energy(1_000.0, 1) + battery.reset() + assert battery.charged_energy_wh(0) == 0.0 + assert battery.discharged_energy_wh(1) == 0.0 + + def test_flat_curves_match_plain_battery(self): + """Constant tables and curves reproduce the plain battery exactly.""" + plain = self._battery(soc_percentage=30) + flat = self._battery( + soc_percentage=30, + charge_power_limit_w_by_soc=[10_000.0] * 101, + discharge_power_limit_w_by_soc=[10_000.0] * 101, + charging_efficiency_curve=[(0.0, 1.0), (1.0, 1.0)], + discharging_efficiency_curve=[(0.0, 1.0), (1.0, 1.0)], + ) + for battery in (plain, flat): + battery.charge_energy(2_500.0, 0) + battery.charge_energy(None, 1, charge_factor=0.5) + battery.discharge_energy(4_000.0, 2) + assert flat.soc_wh == pytest.approx(plain.soc_wh) + for hour in range(3): + assert flat.charged_energy_wh(hour) == pytest.approx(plain.charged_energy_wh(hour)) + assert flat.discharged_energy_wh(hour) == pytest.approx( + plain.discharged_energy_wh(hour) + ) + + @pytest.mark.parametrize( + "field, value", + [ + ("charge_power_limit_w_by_soc", [1_000.0] * 100), + ("discharge_power_limit_w_by_soc", [1_000.0] * 100 + [-1.0]), + ("charge_power_limit_w_by_soc", [1_000.0] * 100 + [float("nan")]), + ("charging_efficiency_curve", [(0.0, 0.9)]), + ("discharging_efficiency_curve", [(0.5, 0.9), (0.2, 0.95)]), + ("charging_efficiency_curve", [(0.0, 0.9), (1.0, 1.2)]), + ], + ) + def test_invalid_curves_are_rejected(self, field, value): + with pytest.raises(ValidationError): + SolarPanelBatteryParameters(device_id="battery1", capacity_wh=10_000, **{field: value}) diff --git a/tests/test_geneticbatteryfullcharge.py b/tests/test_geneticbatteryfullcharge.py index 8a3b725f..fe268d42 100644 --- a/tests/test_geneticbatteryfullcharge.py +++ b/tests/test_geneticbatteryfullcharge.py @@ -1,5 +1,6 @@ """Periodic full charge of a stationary battery (e.g. LFP recalibration).""" +import dataclasses from datetime import timedelta from types import SimpleNamespace from typing import Any, Optional @@ -13,7 +14,18 @@ from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.core.cache import CacheEnergyManagementStore from akkudoktoreos.core.coreabc import get_ems from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters -from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings +from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings +from akkudoktoreos.devices.profile.batteryprofileruntime import BatteryProfile +from akkudoktoreos.devices.profile.profilestore import get_profile_store +from akkudoktoreos.devices.settings.batteryresolution import ( + BatteryFullCharge, + BatteryOperatingPoint, + resolve_full_charge, +) +from akkudoktoreos.devices.settings.batterysettings import ( + BatteriesCommonSettings, + BatteryProfileAdjustment, +) from akkudoktoreos.optimization.genetic import configrequest from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest from akkudoktoreos.optimization.genetic.fullcharge import ( @@ -103,24 +115,41 @@ def test_fractional_interval(): # --------------------------------------------------------------------------- -def test_settings_defaults_disable_the_feature(): +def test_settings_defaults_disable_the_feature(config_eos): settings = BatteriesCommonSettings(device_id="battery1") - assert settings.full_charge_interval_days is None - assert settings.cell_chemistry is None + assert settings.profile_adjustment.full_charge_interval_days is None + assert settings.profile_adjustment.full_charge_detect_percentage is None assert settings.full_charge_soc_percentage == 100 - assert settings.full_charge_detect_percentage == 99 assert settings.full_charge_allow_grid is False + # The default profile sets no interval. + assert settings.effective_full_charge_interval_days is None + assert settings.effective_full_charge_detect_percentage == 99 + + +def test_profile_adjustment_sets_the_full_charge(config_eos): + settings = BatteriesCommonSettings( + device_id="battery1", + profile_adjustment=BatteryProfileAdjustment( + full_charge_interval_days=21, full_charge_detect_percentage=98 + ), + ) + assert settings.effective_full_charge_interval_days == 21 + assert settings.effective_full_charge_detect_percentage == 98 @pytest.mark.parametrize( "extra", [ - {"full_charge_interval_days": 0}, - {"full_charge_interval_days": -1}, + {"profile_adjustment": {"full_charge_interval_days": 0}}, + {"profile_adjustment": {"full_charge_interval_days": -1}}, + {"profile_adjustment": {"full_charge_detect_percentage": 0}}, + {"profile_adjustment": {"full_charge_detect_percentage": 101}}, {"full_charge_soc_percentage": 0}, {"full_charge_soc_percentage": 101}, - {"full_charge_soc_percentage": 95, "full_charge_detect_percentage": 99}, - {"cell_chemistry": "lithium"}, + { + "full_charge_soc_percentage": 95, + "profile_adjustment": {"full_charge_detect_percentage": 99}, + }, ], ) def test_invalid_settings_are_rejected(extra): @@ -128,6 +157,53 @@ def test_invalid_settings_are_rejected(extra): BatteriesCommonSettings(device_id="battery1", **extra) +def _profile(**changes: Any) -> BatteryProfile: + """The generic LFP profile with changed fields.""" + return dataclasses.replace( + get_profile_store().get(BatteryProfileSettings, "generic-lfp"), **changes + ) + + +def test_resolve_full_charge_from_profile(config_eos): + settings = BatteriesCommonSettings(device_id="battery1") + profile = _profile(full_charge_interval_days=14.0, full_charge_detect_percentage=97) + assert resolve_full_charge(settings, profile) == BatteryFullCharge( + interval_days=14.0, soc_percentage=100, detect_percentage=97 + ) + + +def test_resolve_full_charge_adjustment_wins_over_profile(config_eos): + settings = BatteriesCommonSettings( + device_id="battery1", + profile_adjustment=BatteryProfileAdjustment( + full_charge_interval_days=30, full_charge_detect_percentage=98 + ), + ) + profile = _profile(full_charge_interval_days=14.0, full_charge_detect_percentage=97) + assert resolve_full_charge(settings, profile) == BatteryFullCharge( + interval_days=30, soc_percentage=100, detect_percentage=98 + ) + + +def test_resolve_full_charge_without_profile_uses_adjustment_and_defaults(): + settings = BatteriesCommonSettings( + device_id="battery1", + profile_adjustment=BatteryProfileAdjustment(full_charge_interval_days=21), + ) + assert resolve_full_charge(settings, None) == BatteryFullCharge( + interval_days=21, soc_percentage=100, detect_percentage=99 + ) + + +def test_resolve_full_charge_caps_target_and_threshold(config_eos): + """The target stays within the technical bounds, the threshold below the target.""" + settings = BatteriesCommonSettings(device_id="battery1") + profile = _profile(full_charge_interval_days=21.0, technical_max_soc_percent=95.5) + assert resolve_full_charge(settings, profile) == BatteryFullCharge( + interval_days=21.0, soc_percentage=95, detect_percentage=95 + ) + + def test_parameters_reject_target_above_max_soc(): with pytest.raises(ValidationError): SolarPanelBatteryParameters( @@ -181,10 +257,12 @@ def full_charge_request(config_eos, monkeypatch): "max_home_appliances": 0, "batteries": { "storage": { - "capacity_wh": 10000, - "max_charge_power_w": 5000, "max_soc_percentage": 90, - "full_charge_interval_days": 21, + "profile_adjustment": { + "nominal_capacity_wh": 10000, + "max_charge_power_w": 5000, + "full_charge_interval_days": 21, + }, } }, "electric_vehicles": {}, @@ -212,6 +290,25 @@ def full_charge_request(config_eos, monkeypatch): return config_eos, start, measurement, data + +@pytest.mark.asyncio +async def test_cold_battery_does_not_cancel_the_request(full_charge_request, monkeypatch): + """A battery the profile may not charge at its temperature still gets valid parameters.""" + config, _, _, data = full_charge_request + config.devices.batteries["storage"].profile_adjustment.full_charge_interval_days = None + + async def cold(self, target_datetime=None): + return BatteryOperatingPoint(temperature_degC=-5.0) + + monkeypatch.setattr(BatteriesCommonSettings, "measure_operating_point", cold) + + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + + assert parameters.pv_battery is not None + assert parameters.pv_battery.max_charge_power_w == 0.0 + assert parameters.pv_battery.max_discharge_power_w is not None + assert parameters.pv_battery.max_discharge_power_w > 0.0 + def _history(start: DateTime, full_days_ago: Optional[float]) -> tuple[list[DateTime], list[float]]: """Hourly SoC history over 30 days, full once ``full_days_ago`` (if given).""" dates = [start.subtract(hours=hour) for hour in range(30 * 24, 0, -1)] @@ -225,7 +322,7 @@ def _history(start: DateTime, full_days_ago: Optional[float]) -> tuple[list[Date @pytest.mark.asyncio async def test_feature_disabled_reads_no_history(full_charge_request): config, _, measurement, data = full_charge_request - config.devices.batteries["storage"].full_charge_interval_days = None + config.devices.batteries["storage"].profile_adjustment.full_charge_interval_days = None parameters = await ConfigOptimizationRequest.model_validate(data).resolve() assert parameters.pv_battery is not None assert parameters.pv_battery.max_soc_percentage == 90 @@ -303,6 +400,35 @@ async def test_missing_history_does_not_schedule_a_full_charge(full_charge_reque assert parameters.pv_battery.full_charge_soc_percentage is None +@pytest.mark.asyncio +async def test_profile_interval_schedules_the_full_charge(full_charge_request, monkeypatch): + """The interval of the profile applies without a profile adjustment.""" + config, start, measurement, data = full_charge_request + device = config.devices.batteries["storage"] + device.profile_adjustment.full_charge_interval_days = None + device.full_charge_allow_grid = True + profile = _profile(full_charge_interval_days=21.0) + monkeypatch.setattr(BatteriesCommonSettings, "get_battery_profile", lambda self: profile) + measurement.key_to_lists.return_value = _history(start, full_days_ago=None) + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.max_soc_percentage == 100 + assert parameters.pv_battery.full_charge_soc_percentage == 100 + + +@pytest.mark.asyncio +async def test_full_charge_target_stays_within_technical_bounds(full_charge_request, monkeypatch): + config, start, measurement, data = full_charge_request + config.devices.batteries["storage"].full_charge_allow_grid = True + profile = _profile(technical_max_soc_percent=95.0) + monkeypatch.setattr(BatteriesCommonSettings, "get_battery_profile", lambda self: profile) + measurement.key_to_lists.return_value = _history(start, full_days_ago=None) + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.max_soc_percentage == 95 + assert parameters.pv_battery.full_charge_soc_percentage == 95 + + # --------------------------------------------------------------------------- # Optimizer # --------------------------------------------------------------------------- diff --git a/tests/test_geneticcompletedevices.py b/tests/test_geneticcompletedevices.py index 2a146bf6..85ffce87 100644 --- a/tests/test_geneticcompletedevices.py +++ b/tests/test_geneticcompletedevices.py @@ -227,9 +227,7 @@ def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None: settings = BatteriesCommonSettings.model_validate( { "device_id": "car", - "capacity_wh": 10000, - "charging_efficiency": 0.8, - "max_charge_power_w": 4000, + "profile_adjustment": {"nominal_capacity_wh": 10000, "max_charge_power_w": 4000}, "min_soc_deadline_datetime": "2026-09-16T07:30:00+02:00", "min_soc_max_duration_h": 2.5, } @@ -238,21 +236,27 @@ def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None: assert params.min_soc_max_duration_h == 2.5 assert params.min_soc_deadline_datetime is not None assert params.min_soc_deadline_datetime.in_timezone("Europe/Berlin").hour == 7 + assert params.capacity_wh == 10000 + assert params.max_charge_power_w == pytest.approx(4000) battery = Battery(params, prediction_hours=16, slot_duration_h=0.25) battery.charge_array[0] = 1 + # 4000 W for a 0.25 h slot caps the 2000 Wh request at 1000 Wh; the charging + # efficiency comes from the battery profile. charged, losses = battery.charge_energy(2000, hour=0) - assert charged == pytest.approx(800) - assert losses == pytest.approx(200) + assert charged == pytest.approx(1000 * params.charging_efficiency) + assert losses == pytest.approx(1000 * (1 - params.charging_efficiency)) assert "min_soc_deadline_datetime" not in settings.to_genetic0_ev_bat_param().model_dump() @pytest.mark.parametrize("duration", [0.0, -1.0, float("inf"), float("nan")]) def test_ev_invalid_departure_durations_rejected(duration: float) -> None: - for model in (ElectricVehicleParameters, BatteriesCommonSettings): + # Optimizer parameters take "capacity_wh", settings a profile adjustment. + for model, capacity in ( + (ElectricVehicleParameters, {"capacity_wh": 10000}), + (BatteriesCommonSettings, {"profile_adjustment": {"nominal_capacity_wh": 10000}}), + ): with pytest.raises(ValidationError): - model.model_validate( - {"device_id": "car", "capacity_wh": 10000, "min_soc_max_duration_h": duration} - ) + model.model_validate({"device_id": "car", **capacity, "min_soc_max_duration_h": duration}) def test_completed_cycles_parameter_initializes_remaining_global_ids() -> None: diff --git a/tests/test_geneticcompletesolution.py b/tests/test_geneticcompletesolution.py index 11ccc664..b7858e37 100644 --- a/tests/test_geneticcompletesolution.py +++ b/tests/test_geneticcompletesolution.py @@ -17,7 +17,7 @@ def test_battery_discharge_allowed_remains_local_load_mode(config_eos): discharge_allowed=True, ) - assert operation_mode == BatteryOperationMode.PEAK_SHAVING + assert operation_mode == BatteryOperationMode.SELF_CONSUMPTION assert operation_mode_factor == 1.0 diff --git a/tests/test_geneticconfigoptimizerequest.py b/tests/test_geneticconfigoptimizerequest.py index a37385f1..5077cca6 100644 --- a/tests/test_geneticconfigoptimizerequest.py +++ b/tests/test_geneticconfigoptimizerequest.py @@ -61,8 +61,8 @@ def configured_request(config_eos, monkeypatch): "max_home_appliances": 2, "batteries": { "storage": { - "capacity_wh": 2000, - "max_charge_power_w": 1000, + "profile_adjustment": {"nominal_capacity_wh": 2000, + "max_charge_power_w": 1000}, "levelized_cost_of_storage_amt_kwh": 0.01, } }, @@ -534,7 +534,7 @@ async def test_missing_ev_soc_does_not_replace_explicit_battery_soc(configured_r config, _, _, data = configured_request config.merge_settings_from_dict( {"devices": {"max_electric_vehicles": 1, - "electric_vehicles": {"car": {"capacity_wh": 60000}}}} + "electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}}}} ) parameters = await ConfigOptimizationRequest.model_validate(data).resolve() assert parameters.pv_battery is not None @@ -551,8 +551,8 @@ async def test_http_config_and_real_optimizer_allow_full_ev_target(configured_re config, fake, _, data = configured_request config.merge_settings_from_dict({"devices": {"max_electric_vehicles": 1, - "electric_vehicles": {"ev": {"capacity_wh": 1000, "max_charge_power_w": 1000, - "charging_efficiency": 1, "min_soc_percentage": 0, + "electric_vehicles": {"ev": {"profile_adjustment": {"nominal_capacity_wh": 1000, + "max_charge_power_w": 1000}, "min_soc_percentage": 0, "max_soc_percentage": 100}}}}) data["soc"]["ev"] = soc ems = EnergyManagement() @@ -589,7 +589,7 @@ async def test_explicit_ev_soc_has_no_measurement_age_limit(configured_request, config, ems, measurement, data = configured_request config.merge_settings_from_dict( {"devices": {"max_electric_vehicles": 1, - "electric_vehicles": {"car": {"capacity_wh": 60000, + "electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}, "min_soc_percentage": 100, "max_soc_percentage": 100}}}} ) @@ -730,3 +730,31 @@ async def test_missing_newest_soc_value_falls_back_to_older_fresh_sample( parameters = await ConfigOptimizationRequest.model_validate(data).resolve() assert parameters.pv_battery is not None assert parameters.pv_battery.initial_soc_percentage == 42 + + +@pytest.mark.asyncio +async def test_battery_configuration_error_uses_defaults_and_logs_error(configured_request): + """A battery that cannot be resolved does not cancel the parameter preparation.""" + from loguru import logger + + config, _, _, data = configured_request + config.merge_settings_from_dict( + {"devices": {"batteries": {"storage": {"battery_profile": "does-not-exist"}}}} + ) + messages: list[str] = [] + sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR") + try: + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + finally: + logger.remove(sink_id) + assert parameters.pv_battery is not None + # The packaged generic profile applies with the adjustment. + assert parameters.pv_battery.capacity_wh == 2000 + assert parameters.pv_battery.max_charge_power_w == pytest.approx(1000.0) + assert parameters.pv_battery.charge_power_limit_w_by_soc is not None + assert any( + "Battery 'storage'" in m + and "does-not-exist" in m + and "Using defaults for the optimization: battery profile 'generic-lfp'" in m + for m in messages + ) diff --git a/tests/test_geneticinverter.py b/tests/test_geneticinverter.py index 7c0ca5f7..fa3719c7 100644 --- a/tests/test_geneticinverter.py +++ b/tests/test_geneticinverter.py @@ -498,3 +498,45 @@ class TestInverterSettings: getattr(settings, converter)() settings.max_power_w = 4200 assert getattr(settings, converter)().max_power_wh == 4200 + + +def test_battery_discharge_limit_caps_load_and_grid_export(): + """The inverter's inlined discharge uses the battery's discharge power limit.""" + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery", + capacity_wh=10000, + charging_efficiency=1.0, + discharging_efficiency=1.0, + max_charge_power_w=7000, + max_discharge_power_w=4000, + initial_soc_percentage=100, + ), + prediction_hours=1, + slot_duration_h=0.25, + ) + battery.set_discharge_per_hour(np.array([1])) + quarter_hour_inverter = Inverter( + InverterParameters( + device_id="inverter", + max_power_wh=10000, + battery_id="battery", + dc_to_ac_efficiency=1.0, + ac_to_dc_efficiency=1.0, + ), + battery=battery, + slot_duration_h=0.25, + ) + initial_soc_wh = battery.soc_wh + + grid_export, grid_import, _, _ = quarter_hour_inverter.process_energy( + generation=0.0, + consumption=1500.0, + hour=0, + allow_battery_grid_export=True, + ) + + # 4000 W for a quarter hour: 1000 Wh, all of it for the load. + assert initial_soc_wh - battery.soc_wh == pytest.approx(1000.0) + assert grid_import == pytest.approx(500.0) + assert grid_export == 0.0 diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index d38126b8..19bc232e 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -431,3 +431,118 @@ def test_simulation(genetic_simulation): ), "The sum of 'Home_appliance_wh_per_hour' should be 2000." print("All tests passed successfully.") + + +def _profile_simulation( + battery_curves: dict, initial_soc_percentage: int = 50 +) -> tuple[GeneticSimulation, Battery]: + """48 slots with a battery that has the given profile curves, if any.""" + hours = 48 + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=8000, + initial_soc_percentage=initial_soc_percentage, + min_soc_percentage=10, + max_charge_power_w=6000, + max_discharge_power_w=4000, + charging_efficiency=0.95, + discharging_efficiency=0.9, + levelized_cost_of_storage_kwh=0.05, + **battery_curves, + ), + prediction_hours=hours, + ) + inverter = Inverter( + InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id="battery1"), + battery=battery, + ) + rng = np.random.default_rng(7) + simulation = GeneticSimulation() + simulation.prepare( + GeneticEnergyManagementParameters.model_validate( + dict( + pv_prognose_wh=( + np.clip(np.sin(np.linspace(0, 4 * np.pi, hours)), 0, None) * 6000 + ).tolist(), + strompreis_euro_pro_wh=rng.uniform(0.0002, 0.0004, hours).tolist(), + einspeiseverguetung_euro_pro_wh=0.00007, + preis_euro_pro_wh_akku=0.0001, + gesamtlast=rng.uniform(300, 2500, hours).tolist(), + ) + ), + optimization_hours=24, + prediction_hours=hours, + inverter=inverter, + ) + return simulation, battery + + +def _run_plans(simulation: GeneticSimulation, battery: Battery) -> list[dict]: + rng = np.random.default_rng(11) + hours = battery.prediction_hours + results = [] + for _ in range(5): + ac = rng.choice([0.0, 0.5, 1.0], hours) + dc = rng.choice([0.0, 1.0], hours) + discharge = rng.choice([0, 1], hours) + battery.reset() + simulation.ac_charge_hours = ac + simulation.dc_charge_hours = dc + simulation.bat_discharge_hours = discharge + battery.set_charge_per_hour(dc) + battery.set_discharge_per_hour(discharge) + results.append(simulation.simulate(start_hour=0)) + return results + + +def test_flat_profile_curves_reproduce_plain_simulation(config_eos): + """Constant profile tables and curves give exactly the plain battery result. + + The battery with curves takes the generic discharge path of the inverter + and its own energy bookkeeping (LCOS); both must match the inlined path. + """ + config_eos.merge_settings_from_dict( + {"prediction": {"hours": 48}, "optimization": {"hours": 24}} + ) + plain = _run_plans(*_profile_simulation({})) + flat = _run_plans( + *_profile_simulation( + dict( + charge_power_limit_w_by_soc=[6000.0] * 101, + discharge_power_limit_w_by_soc=[4000.0] * 101, + charging_efficiency_curve=[(0.0, 0.95), (1.0, 0.95)], + discharging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)], + ) + ) + ) + for plain_result, flat_result in zip(plain, flat): + for key in ("akku_soc_pro_stunde", "Netzbezug_Wh_pro_Stunde", "Kosten_Euro_pro_Stunde"): + assert flat_result[key] == pytest.approx(plain_result[key]), key + assert flat_result["Gesamt_Verluste"] == pytest.approx(plain_result["Gesamt_Verluste"]) + assert flat_result["Gesamtbilanz_Euro"] == pytest.approx(plain_result["Gesamtbilanz_Euro"]) + + +def test_soc_taper_slows_charging_near_full(config_eos): + """Above 80 % SoC the tapered charge limit applies, below it does not.""" + config_eos.merge_settings_from_dict( + {"prediction": {"hours": 48}, "optimization": {"hours": 24}} + ) + taper = [6000.0] * 81 + [500.0] * 20 + results = {} + for name, table in (("flat", [6000.0] * 101), ("taper", taper)): + simulation, battery = _profile_simulation( + dict(charge_power_limit_w_by_soc=table), initial_soc_percentage=82 + ) + hours = battery.prediction_hours + battery.reset() + simulation.ac_charge_hours = np.ones(hours) + simulation.dc_charge_hours = np.ones(hours) + simulation.bat_discharge_hours = np.zeros(hours) + battery.set_charge_per_hour(np.ones(hours)) + battery.set_discharge_per_hour(np.zeros(hours)) + results[name] = simulation.simulate(start_hour=0)["akku_soc_pro_stunde"] + + # The tapered battery takes at most 500 W x 1 h (before efficiency) per slot. + assert results["taper"][1] < results["flat"][1] + assert results["taper"][1] - results["taper"][0] <= 500.0 * 0.95 / 8000 * 100 + 1e-9 diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py index eff56d14..6afad502 100644 --- a/tests/test_homeassistant.py +++ b/tests/test_homeassistant.py @@ -458,7 +458,7 @@ class TestHomeAssistantConfigEntityIds: "batteries": { "battery1": { "device_id": "battery1", - "capacity_wh": 8000, + "profile_adjustment": {"nominal_capacity_wh": 8000}, "min_soc_percentage": 10, } } @@ -476,10 +476,10 @@ class TestHomeAssistantConfigEntityIds: async def test_sets_value_by_device_id(self, config) -> None: await self._sync( config, - {"devices/batteries/battery1/capacity_wh": "sensor.capacity"}, + {"devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.capacity"}, {"sensor.capacity": "12000"}, ) - assert config.devices.batteries["battery1"].capacity_wh == 12000 + assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 12000 @pytest.mark.asyncio async def test_applies_zero(self, config) -> None: @@ -496,17 +496,17 @@ class TestHomeAssistantConfigEntityIds: async def test_keeps_value_of_unavailable_entity(self, config, state) -> None: await self._sync( config, - {"devices/batteries/battery1/capacity_wh": "sensor.capacity"}, + {"devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.capacity"}, {"sensor.capacity": state}, ) - assert config.devices.batteries["battery1"].capacity_wh == 8000 + assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000 @pytest.mark.asyncio @pytest.mark.parametrize( "config_key", [ - "devices/batteries/0/capacity_wh", - "devices/batteries/battery2/capacity_wh", + "devices/batteries/0/profile_adjustment/nominal_capacity_wh", + "devices/batteries/battery2/profile_adjustment/nominal_capacity_wh", "devices/batteries/battery1/no_such_setting", ], ) @@ -518,6 +518,6 @@ class TestHomeAssistantConfigEntityIds: await self._sync(config, {config_key: "sensor.capacity"}, {"sensor.capacity": "12000"}) assert list(config.devices.batteries) == ["battery1"] - assert config.devices.batteries["battery1"].capacity_wh == 8000 + assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000 mock_logger.error.assert_called_once() assert config_key in mock_logger.error.call_args.args[0] diff --git a/tests/test_loadakkudoktor.py b/tests/test_loadakkudoktor.py index f9a2ecbf..32d64b79 100644 --- a/tests/test_loadakkudoktor.py +++ b/tests/test_loadakkudoktor.py @@ -265,7 +265,7 @@ async def empty_measurement(config_eos): measurement = get_measurement() await measurement.delete_by_datetime(None, None) config_eos.merge_settings_from_dict( - {"devices": {"max_batteries": 1, "batteries": {"storage": {"capacity_wh": 2000}}}} + {"devices": {"max_batteries": 1, "batteries": {"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000}}}}} ) try: yield measurement diff --git a/tests/test_measurementbatterycapacity.py b/tests/test_measurementbatterycapacity.py index e36948f3..8e3285a6 100644 --- a/tests/test_measurementbatterycapacity.py +++ b/tests/test_measurementbatterycapacity.py @@ -4,6 +4,7 @@ import pytest_asyncio # ruff: noqa: S101 +import json from datetime import datetime, timedelta, timezone import pytest @@ -169,9 +170,7 @@ async def database_case(config_eos): "batteries": { "battery": { "device_id": "battery", - "capacity_wh": 12000, - "charging_efficiency": 1, - "discharging_efficiency": 1, + "profile_adjustment": {"nominal_capacity_wh": 12000}, "capacity_estimation": { "power_key": "battery_dc", "positive_power": "charging", @@ -217,29 +216,33 @@ def test_http_reads_database_and_stores_only_explicit_estimate(database_case): "start_soc_percentage": 20, "soc_reference": "voltage_current_anchor", } + # The charging efficiency comes from the battery profile; at efficiency 1 the + # samples give 10000 Wh. + battery = database_case.devices.batteries["battery"] + expected_wh = 10000 * battery.effective_charging_efficiency response = client.post("/v1/measurement/battery-capacity/battery", json=body) assert response.status_code == 200, response.text - assert response.json()["estimated_capacity_wh"] == pytest.approx(10000) - battery = database_case.devices.batteries["battery"] + assert response.json()["estimated_capacity_wh"] == pytest.approx(expected_wh) assert battery.capacity_estimate is None - assert battery.capacity_wh == 12000 + assert battery.effective_nominal_capacity_wh == 12000 body["store_estimate"] = True response = client.post("/v1/measurement/battery-capacity/battery", json=body) assert response.status_code == 200, response.text battery = database_case.devices.batteries["battery"] assert battery.capacity_estimate is not None - assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000) - assert battery.capacity_wh == 12000 + assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(expected_wh) + assert battery.effective_nominal_capacity_wh == 12000 database_case.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 100}}}) battery = database_case.devices.batteries["battery"] assert battery.capacity_estimate is not None - assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000) - assert battery.capacity_wh == 12000 + assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(expected_wh) + assert battery.effective_nominal_capacity_wh == 12000 - # The estimate survives normal config serialization without becoming capacity_wh. + # The estimate survives normal config serialization without replacing the nominal capacity. data = database_case.to_config_json() - assert '"estimated_capacity_wh": 10000.0' in data - assert '"capacity_wh": 12000' in data + stored = json.loads(data)["devices"]["batteries"]["battery"] + assert stored["capacity_estimate"]["estimated_capacity_wh"] == pytest.approx(expected_wh) + assert stored["profile_adjustment"]["nominal_capacity_wh"] == 12000 previous = battery.capacity_estimate body["start_soc_percentage"] = 100 assert client.post("/v1/measurement/battery-capacity/battery", json=body).status_code == 422 diff --git a/tests/test_optimizationcompatibility.py b/tests/test_optimizationcompatibility.py index 1a87fca0..e942d31f 100644 --- a/tests/test_optimizationcompatibility.py +++ b/tests/test_optimizationcompatibility.py @@ -75,8 +75,12 @@ def test_device_maps_feed_algorithm_specific_converters(algorithm): settings = DevicesCommonSettings.model_validate( { - "batteries": {"storage": {"capacity_wh": 12000, "max_charge_power_w": 3200}}, - "electric_vehicles": {"car": {"capacity_wh": 60000, "max_charge_power_w": 7000}}, + "batteries": { + "storage": {"profile_adjustment": {"nominal_capacity_wh": 12000, "max_charge_power_w": 3200}} + }, + "electric_vehicles": { + "car": {"profile_adjustment": {"nominal_capacity_wh": 60000, "max_charge_power_w": 7000}} + }, "inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}}, "home_appliances": { "washer": { diff --git a/tests/test_serverrestprofile.py b/tests/test_serverrestprofile.py new file mode 100644 index 00000000..dcbb3943 --- /dev/null +++ b/tests/test_serverrestprofile.py @@ -0,0 +1,178 @@ +"""Tests for the REST API of the device profile store (/v1/profile).""" + +import json +from pathlib import Path +from typing import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings +from akkudoktoreos.devices.profile.profilestore import ProfileStore, get_profile_store +from akkudoktoreos.server.rest.error import register_problem_handlers +from akkudoktoreos.server.rest.profile import router + + +@pytest.fixture +def client(config_eos) -> Iterator[TestClient]: + """Client of an app with only the profile router, on a fresh store.""" + ProfileStore.reset_instance() + app = FastAPI() + register_problem_handlers(app) + app.include_router(router) + with TestClient(app) as test_client: + yield test_client + ProfileStore.reset_instance() + + +@pytest.fixture +def generic_lfp() -> dict: + """The document of the packaged generic LFP battery profile.""" + store = get_profile_store() + path = store.package_dir(BatteryProfileSettings) / "generic-lfp.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def user_path(name: str) -> Path: + return get_profile_store().user_dir(BatteryProfileSettings) / f"{name}.json" + + +class TestRead: + def test_kinds(self, client): + response = client.get("/v1/profile") + assert response.status_code == 200 + assert "battery" in response.json() + + def test_list_contains_the_packaged_profile(self, client): + response = client.get("/v1/profile/battery") + assert response.status_code == 200 + assert {"name": "generic-lfp", "source": "package", "packaged": True} in response.json() + + def test_schema(self, client): + response = client.get("/v1/profile/battery/schema") + assert response.status_code == 200 + assert "charge_limit_crate" in response.json()["properties"] + + def test_get_document(self, client): + response = client.get("/v1/profile/battery/generic-lfp") + assert response.status_code == 200 + assert response.json()["chemistry"] == "LFP" + BatteryProfileSettings.model_validate(response.json()) + + def test_errors_are_problem_details(self, client): + response = client.get("/v1/profile/nope") + assert response.headers["content-type"] == "application/problem+json" + assert response.json() == { + "type": "about:blank", + "title": "Unknown profile kind", + "status": 404, + "detail": "Unknown profile kind 'nope'.", + "instance": "/v1/profile/nope", + } + + def test_unknown_kind_is_404(self, client): + assert client.get("/v1/profile/nope").status_code == 404 + assert client.get("/v1/profile/nope/x").status_code == 404 + assert client.put("/v1/profile/nope/x", json={}).status_code == 404 + + def test_missing_profile_is_404(self, client): + response = client.get("/v1/profile/battery/does-not-exist") + assert response.status_code == 404 + assert "does-not-exist" in response.json()["detail"] + + +class TestWrite: + def test_put_saves_a_user_profile_and_returns_it(self, client, generic_lfp): + document = {**generic_lfp, "battery_model": "My battery", "nominal_capacity_wh": 10240.0} + response = client.put("/v1/profile/battery/my-battery", json=document) + assert response.status_code == 200 + assert response.json()["nominal_capacity_wh"] == 10240.0 + assert user_path("my-battery").is_file() + assert client.get("/v1/profile/battery/my-battery").json()["battery_model"] == "My battery" + assert {"name": "my-battery", "source": "user", "packaged": False} in client.get( + "/v1/profile/battery" + ).json() + + def test_put_takes_effect_for_compiled_profile_users(self, client, generic_lfp): + store = get_profile_store() + before = store.get(BatteryProfileSettings, "generic-lfp") + assert before.nominal_capacity_wh is None + response = client.put( + "/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0} + ) + assert response.status_code == 200 + after = store.get(BatteryProfileSettings, "generic-lfp") + assert after is not before + assert after.nominal_capacity_wh == 5000.0 + + def test_put_of_an_invalid_document_is_422_and_saves_nothing(self, client, generic_lfp): + response = client.put( + "/v1/profile/battery/broken", json={**generic_lfp, "nominal_capacity_wh": -1} + ) + assert response.status_code == 422 + assert "nominal_capacity_wh" in response.json()["detail"] + assert not user_path("broken").exists() + + def test_put_with_an_unsafe_name_is_422(self, client, generic_lfp): + response = client.put("/v1/profile/battery/..%2F..%2Fescape", json=generic_lfp) + assert response.status_code in (404, 422) # rejected by the router or the store + data_dir = get_profile_store().user_dir(BatteryProfileSettings).parent.parent + assert not (data_dir / "escape.json").exists() + + def test_names_with_subdirectories_are_not_routed(self, client, generic_lfp): + """Profile names are flat; a path below the name matches no route.""" + assert client.put("/v1/profile/battery/vendor/model-a", json=generic_lfp).status_code == 404 + assert not user_path("vendor/model-a").exists() + + @pytest.mark.parametrize("name", ["C:evil", ".hidden"]) + def test_invalid_names_are_rejected(self, client, generic_lfp, name): + response = client.put(f"/v1/profile/battery/{name}", json=generic_lfp) + assert response.status_code == 422 + assert "Invalid battery profile name" in response.json()["detail"] + + def test_reserved_name_is_rejected(self, client, generic_lfp): + """PUT .../schema would save a profile that GET can never return.""" + response = client.put("/v1/profile/battery/schema", json=generic_lfp) + assert response.status_code == 422 + assert "reserved" in response.json()["detail"] + + +class TestDelete: + def test_delete_of_a_directory_is_an_eos_problem(self, client): + user_path("model-a").mkdir(parents=True) + response = client.delete("/v1/profile/battery/model-a") + assert response.status_code == 422 + assert "is not a file" in response.json()["detail"] + + def test_delete_user_profile(self, client, generic_lfp): + client.put("/v1/profile/battery/my-battery", json=generic_lfp) + response = client.delete("/v1/profile/battery/my-battery") + assert response.status_code == 200 + assert response.json() is None # no packaged profile of that name + assert client.get("/v1/profile/battery/my-battery").status_code == 404 + + def test_delete_of_an_override_restores_the_packaged_profile(self, client, generic_lfp): + client.put( + "/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0} + ) + response = client.delete("/v1/profile/battery/generic-lfp") + assert response.status_code == 200 + assert response.json() == {"name": "generic-lfp", "source": "package", "packaged": True} + assert client.get("/v1/profile/battery/generic-lfp").json()["nominal_capacity_wh"] is None + + def test_delete_of_a_packaged_profile_is_409(self, client): + response = client.delete("/v1/profile/battery/generic-lfp") + assert response.status_code == 409 + assert client.get("/v1/profile/battery/generic-lfp").status_code == 200 + + def test_delete_of_a_missing_profile_is_404(self, client): + assert client.delete("/v1/profile/battery/does-not-exist").status_code == 404 + + +def test_eos_app_serves_the_profile_api(config_eos): + from akkudoktoreos.server.eos import app + + paths = app.openapi()["paths"] + assert "/v1/profile/{kind}/{name}" in paths + assert set(paths["/v1/profile/{kind}/{name}"]) == {"get", "put", "delete"} diff --git a/tests/testdata/eos_config_andreas_now.json b/tests/testdata/eos_config_andreas_now.json index 97303bfa..593536ad 100644 --- a/tests/testdata/eos_config_andreas_now.json +++ b/tests/testdata/eos_config_andreas_now.json @@ -15,7 +15,9 @@ "batteries": { "pv_akku": { "device_id": "pv_akku", - "capacity_wh": 30000 + "profile_adjustment": { + "nominal_capacity_wh": 30000 + } } }, "electric_vehicles": { diff --git a/tests/testdata/eos_config_stripped.json b/tests/testdata/eos_config_stripped.json index 8f9c94ee..94c36379 100644 --- a/tests/testdata/eos_config_stripped.json +++ b/tests/testdata/eos_config_stripped.json @@ -19,7 +19,9 @@ "electric_vehicles": { "ev11": { "device_id": "ev11", - "capacity_wh": 50000, + "profile_adjustment": { + "nominal_capacity_wh": 50000 + }, "min_soc_percentage": 70 } },