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:
Bobby Noelte
2026-11-11 22:08:18 +01:00
co-authored by Claude Opus 5.5
parent dd41e5d57c
commit e255f987bf
56 changed files with 11381 additions and 619 deletions
+2
View File
@@ -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/
+3 -3
View File
@@ -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"
+68 -28
View File
@@ -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",
+16 -14
View File
@@ -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
View File
@@ -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
+49 -34
View File
@@ -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", ...
}
]
}
}
}
```
+133
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
}
],
},
+33 -1
View File
@@ -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.
+162 -2
View File
@@ -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
+21 -2
View File
@@ -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_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."
}
}
+36 -2
View File
@@ -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):
+347 -24
View File
@@ -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
+10 -5
View File
@@ -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,6 +299,10 @@ class Inverter:
dc_to_ac_efficiency = self.dc_to_ac_efficiency_at(requested_ac_wh)
dc_request = requested_ac_wh / dc_to_ac_efficiency
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
@@ -308,7 +312,8 @@ class Inverter:
# 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
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)
@@ -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
+6 -3
View File
@@ -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
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,
},
}
try:
cls.config.devices.batteries = {"battery1": dict(DEMO_BATTERY_SETTINGS)}
# Take first battery
battery_config = list(cls.config.devices.batteries.values())[0]
battery_params = battery_config.to_genetic0_pv_bat_param()
try:
# 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)
}
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()
try:
# 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:
+2
View File
@@ -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)
+139
View File
@@ -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
View File
@@ -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)
+243
View File
@@ -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
+472
View File
@@ -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
+33
View File
@@ -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
+59
View File
@@ -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
+112 -3
View File
@@ -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
)
+239 -4
View File
@@ -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})
+138 -12
View File
@@ -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,
"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
# ---------------------------------------------------------------------------
+13 -9
View File
@@ -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:
+1 -1
View File
@@ -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
+34 -6
View File
@@ -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
)
+42
View File
@@ -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
+115
View File
@@ -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
+8 -8
View File
@@ -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]
+1 -1
View File
@@ -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
+16 -13
View File
@@ -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
+6 -2
View File
@@ -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": {
+178
View File
@@ -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
View File
@@ -15,7 +15,9 @@
"batteries": {
"pv_akku": {
"device_id": "pv_akku",
"capacity_wh": 30000
"profile_adjustment": {
"nominal_capacity_wh": 30000
}
}
},
"electric_vehicles": {
+3 -1
View File
@@ -19,7 +19,9 @@
"electric_vehicles": {
"ev11": {
"device_id": "ev11",
"capacity_wh": 50000,
"profile_adjustment": {
"nominal_capacity_wh": 50000
},
"min_soc_percentage": 70
}
},