mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 01:03:45 +01:00
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 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dd41e5d57c
commit
e255f987bf
@@ -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/
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
+183
-1
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.3.0.dev2611091577972738`
|
||||
**Version**: `v0.3.0.dev2611111415621708`
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
||||
@@ -2073,6 +2073,188 @@ Args:
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/profile
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Get Profile Kinds
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Return the registered profile kinds.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/profile/{kind}
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Get Profiles
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Return the profiles of a kind, from the user and the packaged data directory.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `kind` (path, required): Profile kind, e.g. 'battery'.
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/profile/{kind}/schema
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Get Profile Schema
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Return the JSON schema of the profile documents of a kind.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Parameters**:
|
||||
|
||||
- `kind` (path, required): Profile kind, e.g. 'battery'.
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
- **422**: Validation Error
|
||||
|
||||
---
|
||||
|
||||
## DELETE /v1/profile/{kind}/{name}
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Delete Profile
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Delete a user profile.
|
||||
|
||||
Returns the profile that applies now (the packaged one of the same name),
|
||||
or null if there is none.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**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}
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Get Profile
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Return the document of a profile (user profile first, then packaged).
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**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}
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**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)
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
Put Profile
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Validate a profile document and save it to the user data directory.
|
||||
|
||||
Returns the saved (validated, defaults filled in) document.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**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
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
(`<device_id>-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", ...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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/<device_id>` or
|
||||
`devices/electric_vehicles/<device_id>`) 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 `<general/data_folder_path>/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
|
||||
|
||||
+963
-229
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ``<name>.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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ``<name>.json``, searching in order
|
||||
1. the user data directory: ``<general.data_folder_path>/profile/<kind>``
|
||||
2. the packaged data directory: ``<package_root_path>/data/profile/<kind>``
|
||||
- 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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
+105
-1
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}
|
||||
+3
-1
@@ -15,7 +15,9 @@
|
||||
"batteries": {
|
||||
"pv_akku": {
|
||||
"device_id": "pv_akku",
|
||||
"capacity_wh": 30000
|
||||
"profile_adjustment": {
|
||||
"nominal_capacity_wh": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@
|
||||
"electric_vehicles": {
|
||||
"ev11": {
|
||||
"device_id": "ev11",
|
||||
"capacity_wh": 50000,
|
||||
"profile_adjustment": {
|
||||
"nominal_capacity_wh": 50000
|
||||
},
|
||||
"min_soc_percentage": 70
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user