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
@@ -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