feat(devices): add inverter profiles

An inverter profile describes the technical data of an inverter model: its
nominal AC power, the DC→AC and AC→DC efficiency curves over the load
fraction (AC power relative to the nominal power), the AC charge power
limit of the model, whether that limit also limits PV charging, and an
optional power derating over the ambient temperature. Curves are read
linearly and clamped at their ends, so every lookup yields a value. The
packaged profile generic-hybrid has typical hybrid inverter efficiencies,
no nominal power (the inverter device has to provide it) and full power up
to 45 degC.

Inverter settings name their profile (inverter_profile, default
generic-hybrid) and hold the differences of the unit in profile_adjustment:
nominal_power_w (replaces the profile value, required for a generic
profile), max_power_w and max_ac_charge_power_w (installation limits that
can only lower; an AC charge limit of 0 disables AC charging). The
effective parameters are resolved at the ambient temperature of the
inverter, given by the new measurement key <device_id>-temperature-degc
(45 degC without a fresh value); the power derating lowers the maximum
power and the AC charge limit.

Optimization parameters are always valid: the GENETIC and GENETIC0
parameter preparation resolve the inverter with
resolve_current_operating_parameters_or_defaults, which never raises. An
unusable profile gives the generic-hybrid profile, a missing nominal power
the fallback of 10000 W, an inverter derated to zero power the parameters
at 45 degC, and no usable profile at all the profile adjustment with
constant efficiency; each is logged as error. The GENETIC inverter takes
the AC→DC efficiency curve of the profile, solved exactly on each linear
piece of the curve (about 2 % longer runs); GENETIC0 keeps constant
efficiencies and no longer replaces the configured inverters with the demo
inverter; a battery, electric vehicle or inverter that cannot be prepared
is left out of the GENETIC0 run, never replaced by a demo device.

Configurations migrate: max_power_w becomes
profile_adjustment.nominal_power_w, max_ac_charge_power_w moves into the
adjustment, an ac_to_dc_efficiency of 0 keeps AC charging disabled, and
the efficiency fields are dropped with an error log, as for batteries, so
the profile efficiencies apply. Home Assistant config paths follow.

Additional changes
- Move the curve algorithms of the battery profile into profilecurve, so
  battery and inverter profiles share them; the battery profile keeps its
  document format and classes.
- Move ProfileBaseModel, the strict base of profile documents, into
  profilestore.
- Serve inverter profiles through the profile REST API. Refuse to delete a
  profile that a configured device uses and that has no packaged profile
  to revert to (409).
- Share the measurement access of the device settings in
  devicemeasurement.
- Rename check_battery_profiles to check_device_profiles; the device
  profile check at configuration setup covers inverters, too.
- Document inverter profiles, their configuration, defaults and sharing;
  regenerate the configuration and OpenAPI documentation.

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 e255f987bf
commit faf37abfe7
45 changed files with 3235 additions and 784 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.3.0.dev2611111415621708`
**Version**: `v0.3.0.dev2611111434732824`
<!-- 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.
@@ -2165,7 +2165,8 @@ Delete Profile
Delete a user profile.
Returns the profile that applies now (the packaged one of the same name),
or null if there is none.
or null if there is none. A profile that a configured device uses can only
be deleted if a packaged profile of the same name applies afterwards.
"""
```
<!-- pyml enable line-length -->
+54 -48
View File
@@ -282,62 +282,68 @@ that is configured, even if more devices are configured.
{
"devices": {
"max_inverters": 1,
"inverters": [
{
"device_id": "inv1",
"max_power_w": 10000,
"battery_id": "bat1",
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": 5000
}
]
}
}
```
The inverter supports separate AC↔DC conversion efficiencies:
- `ac_to_dc_efficiency`: Conversion loss when charging the battery from AC grid power (0-1).
Set to `0` to disable AC charging. Default `1.0`.
- `dc_to_ac_efficiency`: Conversion loss when discharging battery to AC load/grid (0-1).
Must be > 0. Default `1.0`.
- `max_ac_charge_power_w`: Maximum AC charging power in watts. `null` = no additional limit.
Set to `0` to disable AC charging. Default `null`.
- `dc_to_ac_efficiency_curve`: Optional load-dependent DC→AC efficiency as a list of
`[load_fraction, efficiency]` points, with the load fraction relative to `max_power_w`.
Real inverters are much less efficient at low load, e.g. when the battery serves a small
night-time base load. The points must be strictly increasing in load fraction (0-1) with
efficiencies in (0, 1]; values in between are interpolated linearly, values outside are
clamped to the first/last point. When set, the curve replaces `dc_to_ac_efficiency` for
battery discharge. The efficiency is always the one at the AC power that is delivered: if
the battery limits a discharge (low state of charge or its power limit), the conversion is
evaluated at that lower load, not at the requested one. Default `null` (constant
`dc_to_ac_efficiency`, unchanged behaviour).
- `dc_to_ac_efficiency_reference_load_fraction`: Load fraction at which the curve is evaluated
where stored energy is valued without a specific discharge, e.g. the energy left in the
battery at the end of the horizon and the AC charge break-even. Choose the typical load
the battery serves. Only used with `dc_to_ac_efficiency_curve`. Default `0.06`.
**Example with an efficiency curve:**
```json
{
"devices": {
"inverters": {
"inv1": {
"max_power_w": 10000,
"battery_id": "bat1",
"dc_to_ac_efficiency_curve": [
[0.02, 0.80], [0.07, 0.93], [0.30, 0.96], [1.00, 0.95]
],
"dc_to_ac_efficiency_reference_load_fraction": 0.06
"inverter_profile": "generic-hybrid",
"profile_adjustment": {
"nominal_power_w": 10000,
"max_ac_charge_power_w": 5000
},
"battery_id": "bat1"
}
}
}
}
```
The technical data of the inverter comes from an **inverter profile** (see
[](#inverter-profiles)): the efficiency curves for battery discharge (DC→AC) and for charging
the battery from the grid (AC→DC), the AC charge power limit of the model and the power derating
over the ambient temperature. The inverter settings select the profile and describe how the unit
differs from it:
- `inverter_profile`: Name of the inverter profile. Default `generic-hybrid`.
- `profile_adjustment.nominal_power_w`: Nominal AC power of the installed inverter [W]. Replaces
the nominal power of the profile; required for a generic profile such as `generic-hybrid`.
- `profile_adjustment.max_power_w`: AC power limit of the installation [W], e.g. a configured
output limit. Can only lower the (derated) nominal power. Default `null`.
- `profile_adjustment.max_ac_charge_power_w`: AC charge power limit of the installation [W]. Can
only lower the limit of the profile. Set to `0` to disable AC charging. Default `null`.
- `dc_to_ac_efficiency_reference_load_fraction`: Load fraction of the maximum power at which the
DC→AC efficiency curve is evaluated where stored energy is valued without a specific discharge,
e.g. the energy left in the battery at the end of the horizon and the AC charge break-even.
Choose the typical load the battery serves. Default `0.06`.
The efficiency curves are over the load fraction, the AC power relative to the nominal power.
Real inverters are much less efficient at low load, e.g. when the battery serves a small
night-time base load. The efficiency is always the one at the AC power of the conversion: if the
battery limits a discharge (low state of charge or its power limit), the conversion is evaluated
at that lower load, not at the requested one; charging from the grid draws the AC energy at the
efficiency of the AC load actually drawn.
**Power derating:** inverters reduce their power when they get hot. EOS reads the ambient
temperature of the inverter from the measurement `<device_id>-temperature-degc` (e.g.
`inv1-temperature-degc`) at the start of each optimization and limits the maximum power and the
AC charge power by the derating of the profile at that temperature. Without a measurement in the
last hour EOS assumes 45 °C, where the `generic-hybrid` profile is not derated yet.
The resolved values are shown as read-only fields of the inverter configuration
(`effective_nominal_power_w`, `effective_max_power_w`, `effective_max_ac_charge_power_w`,
`effective_dc_to_ac_efficiency`, `effective_ac_to_dc_efficiency`), evaluated at 45 °C. If the
configured inverter profile does not exist or is invalid, the optimization uses `generic-hybrid`
with the profile adjustment instead and logs an error naming the inverter.
:::{admonition} Note
:class: note
Configurations of earlier versions are migrated automatically. `max_power_w` becomes
`profile_adjustment.nominal_power_w` and `max_ac_charge_power_w` moves into the profile
adjustment; an `ac_to_dc_efficiency` of `0` keeps AC charging disabled. The former fields
`ac_to_dc_efficiency`, `dc_to_ac_efficiency`, `dc_to_ac_efficiency_curve` and
`ac_charge_limits_total_charge` come from the inverter profile now and are dropped (logged as
error), so the efficiencies of the profile apply instead of the former default `1.0` (lossless).
To keep other values, save an own inverter profile and select it.
:::
#### Electric vehicle simulation configuration
**Example:**
+62 -7
View File
@@ -221,20 +221,20 @@ Profiles can be managed with the REST API:
| **Endpoint** | **Description** |
| -------------------------------------- | ---------------------------------------------------------------- |
| **GET** `/v1/profile` | List the profile kinds (e.g. `battery`). |
| **GET** `/v1/profile` | List the profile kinds (`battery`, `inverter`). |
| **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.
The **Profiles** page of EOSdash edits battery and inverter profiles as JSON and plots them as EOS
evaluates them: for a battery the limit maps and efficiency curves, for an inverter the efficiency
curves and the power derating. 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.
To share a profile with other users, open it on the **Profiles** page and press **Share**. It
opens a new issue with the battery or inverter 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
@@ -324,6 +324,61 @@ The **operation mode factor** (0.0–1.0) specifies the normalized power rate re
battery's nominal maximum charge power. A value of 1.0 corresponds to full-rate charging, while 0.0
indicates no power transfer. Intermediate values scale the power proportionally.
## Inverter
The inverter connects the DC side (PV, battery) with the AC side (household, grid). Its
configuration (`devices/inverters/<device_id>`) is described in the automatic optimization, see
the inverter simulation configuration there.
(inverter-profiles)=
### Inverter Profiles
The technical data of an inverter comes from an **inverter profile**, a JSON document with:
- the DC→AC efficiency curve (battery discharge) and the AC→DC efficiency curve (charging the
battery from the grid) over the load fraction, the AC power relative to the nominal power,
- the nominal AC power, except for a generic profile,
- optionally the AC charge power limit of the model (`max_ac_charge_power_w`) and whether this
limit also limits PV charging of the battery (`ac_charge_limits_total_charge`, e.g. some hybrid
inverters in time-of-use grid charging),
- optionally the power derating over the ambient temperature (`power_derating`: the allowed
fraction of the nominal power at each temperature).
The curves are read linearly between their breakpoints and held at their end values beyond them.
Unknown keys are rejected.
```json
{
"inverter_model": "My hybrid inverter",
"nominal_power_w": 10000,
"dc_to_ac_efficiency": {
"load_fraction": [0.05, 0.2, 0.5, 1.0],
"efficiency": [0.88, 0.955, 0.97, 0.965]
},
"ac_to_dc_efficiency": {
"load_fraction": [0.05, 0.2, 0.5, 1.0],
"efficiency": [0.86, 0.95, 0.965, 0.96]
},
"power_derating": {
"temperature_degC": [45.0, 60.0],
"power_fraction": [1.0, 0.6]
}
}
```
EOS ships the profile `generic-hybrid` with typical efficiencies of a hybrid inverter, no nominal
power (set `profile_adjustment.nominal_power_w`) and full power up to 45 °C. EOS evaluates the
derating at the ambient temperature measured as `<device_id>-temperature-degc`, or at 45 °C
without a measurement. Inverter profiles are stored, looked up and managed like battery profiles
(kind `inverter`, user directory `<general/data_folder_path>/profile/inverter/`), with the same
REST API and the **Profiles** page of EOSdash.
The optimization always gets valid inverter parameters. If they cannot be resolved, EOS logs an
error and uses defaults: the profile `generic-hybrid` for an unusable profile, a nominal power of
10000 W if none is set, and the parameters at 45 °C if the inverter is derated to zero power at
the measured temperature.
## Home Appliance
The optimization algorithm supports one start of the home appliance within the optimization
+221 -263
View File
@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.3.0.dev2611111415621708"
"version": "v0.3.0.dev2611111434732824"
},
"paths": {
"/v1/measurement/battery-capacity/{battery_id}": {
@@ -583,7 +583,7 @@
"profile"
],
"summary": "Delete Profile",
"description": "Delete a user profile.\n\nReturns the profile that applies now (the packaged one of the same name),\nor null if there is none.",
"description": "Delete a user profile.\n\nReturns the profile that applies now (the packaged one of the same name),\nor null if there is none. A profile that a configured device uses can only\nbe deleted if a packaged profile of the same name applies afterwards.",
"operationId": "delete_profile_v1_profile__kind___name__delete",
"parameters": [
{
@@ -12414,114 +12414,39 @@
"dishwasher"
]
},
"max_power_w": {
"anyOf": [
"inverter_profile": {
"type": "string",
"minLength": 1,
"title": "Inverter Profile",
"description": "Name of the inverter profile (without '.json') providing the nominal power, efficiency curves, AC charge power limit and power derating. Differences of this unit go into 'profile_adjustment'.",
"default": "generic-hybrid",
"examples": [
"generic-hybrid"
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"profile_adjustment": {
"$ref": "#/components/schemas/InverterProfileAdjustment",
"description": "How this inverter unit differs from its inverter profile: nominal power and installation limits.",
"examples": [
{
"type": "number",
"exclusiveMinimum": 0.0
},
{
"type": "null"
"nominal_power_w": 10000
}
],
"title": "Max Power W",
"description": "Maximum AC output power [W].",
"examples": [
10000
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"ac_to_dc_efficiency": {
"type": "number",
"maximum": 1.0,
"minimum": 0.0,
"title": "Ac To Dc Efficiency",
"description": "Efficiency of AC\u2192DC conversion for grid-to-battery charging (0\u20131). Set to 0 to disable AC charging. Default 1.0.",
"default": 1.0,
"examples": [
0.95,
1.0,
0.0
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"dc_to_ac_efficiency": {
"type": "number",
"maximum": 1.0,
"exclusiveMinimum": 0.0,
"title": "Dc To Ac Efficiency",
"description": "Efficiency of DC\u2192AC conversion for battery discharging (0\u20131). Default 1.0.",
"default": 1.0,
"examples": [
0.95,
1.0
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"dc_to_ac_efficiency_curve": {
"anyOf": [
{
"items": {
"prefixItems": [
{
"type": "number"
},
{
"type": "number"
}
],
"type": "array",
"maxItems": 2,
"minItems": 2
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Dc To Ac Efficiency Curve",
"description": "Load-dependent efficiency of DC\u2192AC conversion for battery discharging as a list of [load_fraction, efficiency] points. The load fraction is the AC power relative to max_power_w (0\u20131); points must be strictly increasing in load fraction, efficiencies in (0, 1]. Values are interpolated linearly and clamped to the first/last point. When set, the curve replaces dc_to_ac_efficiency. null keeps the constant dc_to_ac_efficiency. Default null.",
"examples": [
null,
[
[
0.02,
0.8
],
[
0.07,
0.93
],
[
0.3,
0.96
],
[
1.0,
0.95
]
]
],
"x-scope": [
"GENETIC"
]
},
"dc_to_ac_efficiency_reference_load_fraction": {
"type": "number",
"maximum": 1.0,
"minimum": 0.0,
"title": "Dc To Ac Efficiency Reference Load Fraction",
"description": "Load fraction of max_power_w (0\u20131) at which dc_to_ac_efficiency_curve is evaluated to value stored battery energy without a specific conversion (remaining energy at the end of the horizon, AC charge break-even). Should represent the typical load served by the battery, e.g. the night-time base load. Only used with dc_to_ac_efficiency_curve. Default 0.06.",
"description": "Load fraction of the effective maximum power (0\u20131) at which the DC\u2192AC efficiency curve is evaluated to value stored battery energy without a specific conversion (remaining energy at the end of the horizon, AC charge break-even). Should represent the typical load served by the battery, e.g. the night-time base load. Default 0.06.",
"default": 0.06,
"examples": [
0.06,
@@ -12531,41 +12456,6 @@
"GENETIC"
]
},
"max_ac_charge_power_w": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Max Ac Charge Power W",
"description": "Maximum AC charging power [W]. null means no additional limit. 0 disables AC charging.",
"examples": [
null,
0,
5000
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"ac_charge_limits_total_charge": {
"type": "boolean",
"title": "Ac Charge Limits Total Charge",
"description": "True if the AC charge setpoint caps the battery's total charge power, PV included. Some hybrid inverters (e.g. Deye in time-of-use grid charging) limit the whole charge current to the grid charge current; PV surplus above it is exported, not stored. False keeps the default model: PV surplus charges first and the grid adds ac_charge x max_charge_power_w on top.",
"default": false,
"examples": [
false,
true
],
"x-scope": [
"GENETIC"
]
},
"battery_id": {
"anyOf": [
{
@@ -12889,114 +12779,39 @@
"dishwasher"
]
},
"max_power_w": {
"anyOf": [
"inverter_profile": {
"type": "string",
"minLength": 1,
"title": "Inverter Profile",
"description": "Name of the inverter profile (without '.json') providing the nominal power, efficiency curves, AC charge power limit and power derating. Differences of this unit go into 'profile_adjustment'.",
"default": "generic-hybrid",
"examples": [
"generic-hybrid"
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"profile_adjustment": {
"$ref": "#/components/schemas/InverterProfileAdjustment",
"description": "How this inverter unit differs from its inverter profile: nominal power and installation limits.",
"examples": [
{
"type": "number",
"exclusiveMinimum": 0.0
},
{
"type": "null"
"nominal_power_w": 10000
}
],
"title": "Max Power W",
"description": "Maximum AC output power [W].",
"examples": [
10000
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"ac_to_dc_efficiency": {
"type": "number",
"maximum": 1.0,
"minimum": 0.0,
"title": "Ac To Dc Efficiency",
"description": "Efficiency of AC\u2192DC conversion for grid-to-battery charging (0\u20131). Set to 0 to disable AC charging. Default 1.0.",
"default": 1.0,
"examples": [
0.95,
1.0,
0.0
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"dc_to_ac_efficiency": {
"type": "number",
"maximum": 1.0,
"exclusiveMinimum": 0.0,
"title": "Dc To Ac Efficiency",
"description": "Efficiency of DC\u2192AC conversion for battery discharging (0\u20131). Default 1.0.",
"default": 1.0,
"examples": [
0.95,
1.0
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"dc_to_ac_efficiency_curve": {
"anyOf": [
{
"items": {
"prefixItems": [
{
"type": "number"
},
{
"type": "number"
}
],
"type": "array",
"maxItems": 2,
"minItems": 2
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Dc To Ac Efficiency Curve",
"description": "Load-dependent efficiency of DC\u2192AC conversion for battery discharging as a list of [load_fraction, efficiency] points. The load fraction is the AC power relative to max_power_w (0\u20131); points must be strictly increasing in load fraction, efficiencies in (0, 1]. Values are interpolated linearly and clamped to the first/last point. When set, the curve replaces dc_to_ac_efficiency. null keeps the constant dc_to_ac_efficiency. Default null.",
"examples": [
null,
[
[
0.02,
0.8
],
[
0.07,
0.93
],
[
0.3,
0.96
],
[
1.0,
0.95
]
]
],
"x-scope": [
"GENETIC"
]
},
"dc_to_ac_efficiency_reference_load_fraction": {
"type": "number",
"maximum": 1.0,
"minimum": 0.0,
"title": "Dc To Ac Efficiency Reference Load Fraction",
"description": "Load fraction of max_power_w (0\u20131) at which dc_to_ac_efficiency_curve is evaluated to value stored battery energy without a specific conversion (remaining energy at the end of the horizon, AC charge break-even). Should represent the typical load served by the battery, e.g. the night-time base load. Only used with dc_to_ac_efficiency_curve. Default 0.06.",
"description": "Load fraction of the effective maximum power (0\u20131) at which the DC\u2192AC efficiency curve is evaluated to value stored battery energy without a specific conversion (remaining energy at the end of the horizon, AC charge break-even). Should represent the typical load served by the battery, e.g. the night-time base load. Default 0.06.",
"default": 0.06,
"examples": [
0.06,
@@ -13006,41 +12821,6 @@
"GENETIC"
]
},
"max_ac_charge_power_w": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Max Ac Charge Power W",
"description": "Maximum AC charging power [W]. null means no additional limit. 0 disables AC charging.",
"examples": [
null,
0,
5000
],
"x-scope": [
"GENETIC",
"GENETIC0"
]
},
"ac_charge_limits_total_charge": {
"type": "boolean",
"title": "Ac Charge Limits Total Charge",
"description": "True if the AC charge setpoint caps the battery's total charge power, PV included. Some hybrid inverters (e.g. Deye in time-of-use grid charging) limit the whole charge current to the grid charge current; PV surplus above it is exported, not stored. False keeps the default model: PV surplus charges first and the grid adds ac_charge x max_charge_power_w on top.",
"default": false,
"examples": [
false,
true
],
"x-scope": [
"GENETIC"
]
},
"battery_id": {
"anyOf": [
{
@@ -13346,18 +13126,95 @@
"UNUSED"
]
},
"effective_nominal_power_w": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Effective Nominal Power W",
"description": "Effective nominal AC power [W]: profile adjustment, else profile.",
"readOnly": true
},
"effective_max_power_w": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Effective Max Power W",
"description": "Maximum AC power [W] at the reference temperature, derated and capped.",
"readOnly": true
},
"effective_max_ac_charge_power_w": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Effective Max Ac Charge Power W",
"description": "AC charge power limit [W] at the reference temperature; 0 disables AC charging.",
"readOnly": true
},
"effective_dc_to_ac_efficiency": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Effective Dc To Ac Efficiency",
"description": "DC\u2192AC efficiency at the reference load fraction.",
"readOnly": true
},
"effective_ac_to_dc_efficiency": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Effective Ac To Dc Efficiency",
"description": "AC\u2192DC efficiency at the AC charge power limit.",
"readOnly": true
},
"measurement_key_temperature_degc": {
"type": "string",
"title": "Measurement Key Temperature Degc",
"description": "Measurement key for the ambient temperature of the inverter [degC].",
"readOnly": true
},
"measurement_keys": {
"items": {
"type": "string"
},
"type": "array",
"title": "Measurement Keys",
"description": "Measurement keys for this inverter.\n\nReturns the ``battery_initial_soc_factor_key`` if non-empty, so\nthe EMS measurement store knows to watch for this key.",
"description": "Measurement keys for this inverter.\n\nThe ambient temperature, and the ``battery_initial_soc_factor_key`` if\nnon-empty, so the EMS measurement store knows to watch for them.",
"readOnly": true
}
},
"type": "object",
"required": [
"effective_nominal_power_w",
"effective_max_power_w",
"effective_max_ac_charge_power_w",
"effective_dc_to_ac_efficiency",
"effective_ac_to_dc_efficiency",
"measurement_key_temperature_degc",
"measurement_keys"
],
"title": "InverterCommonSettings",
@@ -13496,6 +13353,48 @@
0.1
]
},
"ac_to_dc_efficiency_curve": {
"anyOf": [
{
"items": {
"prefixItems": [
{
"type": "number"
},
{
"type": "number"
}
],
"type": "array",
"maxItems": 2,
"minItems": 2
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Ac To Dc Efficiency Curve",
"description": "Load-dependent efficiency of AC to DC conversion (AC/grid charging of the battery) as a list of (load_fraction, efficiency) points. The load fraction is the AC power drawn relative to max_power_wh [0.0 ... 1.0]; same rules as dc_to_ac_efficiency_curve. When set, the curve replaces ac_to_dc_efficiency for the energy drawn from the grid; ac_to_dc_efficiency still converts the AC charge power limit and should be the efficiency at that limit. Default None (constant ac_to_dc_efficiency).",
"examples": [
null,
[
[
0.05,
0.86
],
[
0.3,
0.96
],
[
1.0,
0.96
]
]
]
},
"max_ac_charge_power_w": {
"anyOf": [
{
@@ -13534,6 +13433,65 @@
"title": "InverterParameters",
"description": "Inverter Device Simulation Configuration."
},
"InverterProfileAdjustment": {
"properties": {
"nominal_power_w": {
"anyOf": [
{
"type": "number",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Nominal Power W",
"description": "Nominal AC power of the installed inverter [W]. Replaces the nominal power of the inverter profile. Required if the profile is generic and provides no nominal power. The effective value is available as 'effective_nominal_power_w'.",
"examples": [
null,
10000
]
},
"max_power_w": {
"anyOf": [
{
"type": "number",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Max Power W",
"description": "Maximum AC power of the installation [W], e.g. a configured output limit. Can only lower the (derated) nominal power. The effective value is available as 'effective_max_power_w'.",
"examples": [
null,
8000
]
},
"max_ac_charge_power_w": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Max Ac Charge Power W",
"description": "AC charge power limit of the installation [W]. Can only lower the limit of the inverter profile. 0 disables AC charging. The effective value is available as 'effective_max_ac_charge_power_w'.",
"examples": [
null,
0,
5000
]
}
},
"type": "object",
"title": "InverterProfileAdjustment",
"description": "How an inverter unit differs from its inverter profile.\n\nNone keeps the profile value."
},
"LoadAkkudoktorCommonSettings": {
"properties": {
"loadakkudoktor_year_energy_kwh": {
+11 -11
View File
@@ -469,8 +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, ...]]] = ((), ())
# Device profile problems logged last, see _log_device_profile_problems().
_device_profile_problems: ClassVar[tuple[tuple[str, ...], tuple[str, ...]]] = ((), ())
def __hash__(self) -> int:
# ConfigEOS is a singleton
@@ -811,26 +811,26 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
# 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()
self._log_device_profile_problems()
def _log_battery_profile_problems(self) -> None:
"""Log batteries that cannot be resolved from their battery profile.
def _log_device_profile_problems(self) -> None:
"""Log devices that cannot be resolved from their device profile.
A battery profile is checked once the configuration is set up, because
Battery and inverter profiles are 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()
errors, warnings = self.devices.check_device_profiles()
except Exception:
logger.exception("Battery profile check failed.")
logger.exception("Device profile check failed.")
return
problems = (tuple(errors), tuple(warnings))
if problems == ConfigEOS._battery_profile_problems:
if problems == ConfigEOS._device_profile_problems:
return
ConfigEOS._battery_profile_problems = problems
ConfigEOS._device_profile_problems = problems
for message in errors:
logger.error(message)
for message in warnings:
@@ -923,7 +923,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
if keys and keys[0] in ("devices", "general"):
# Device settings and the data folder (user profiles) affect the batteries.
self._log_battery_profile_problems()
self._log_device_profile_problems()
def reset_settings(self) -> None:
"""Reset all changed settings to environment/config file defaults.
+73 -2
View File
@@ -178,8 +178,67 @@ def _migrate_battery_item(item: Dict[str, Any]) -> None:
item["profile_adjustment"] = adjustment
# Former inverter fields that describe how the unit differs from its inverter
# profile and move into ``profile_adjustment``: (legacy name, adjustment field).
# max_power_w was the rated AC power; with the generic profile it is the
# nominal power of the unit.
_INVERTER_FIELDS_TO_ADJUSTMENT = (
("max_power_w", "nominal_power_w"),
("max_ac_charge_power_w", "max_ac_charge_power_w"),
)
# Former inverter fields whose value the inverter profile provides now.
_INVERTER_FIELDS_NOW_IN_PROFILE = (
"ac_to_dc_efficiency",
"dc_to_ac_efficiency",
"dc_to_ac_efficiency_curve",
"ac_charge_limits_total_charge",
)
def _migrate_inverter_item(item: Dict[str, Any]) -> None:
"""Migrate one inverter settings dict in place.
- ``max_power_w`` becomes ``profile_adjustment.nominal_power_w`` and
``max_ac_charge_power_w`` moves into ``profile_adjustment``. A value
already present in ``profile_adjustment`` wins over a legacy one.
- A legacy ``ac_to_dc_efficiency`` of 0 disabled AC charging; it becomes
``profile_adjustment.max_ac_charge_power_w = 0``.
- The efficiencies and ``ac_charge_limits_total_charge`` come from the
inverter profile now and are dropped. Dropping is logged as error, like
for battery efficiencies, because the simulation then uses the profile
values.
"""
raw_adjustment = item.get("profile_adjustment")
adjustment = dict(raw_adjustment) if isinstance(raw_adjustment, dict) else {}
for legacy_name, new_name in _INVERTER_FIELDS_TO_ADJUSTMENT:
if legacy_name in item:
value = item.pop(legacy_name)
if value is not None:
adjustment.setdefault(new_name, value)
if item.get("ac_to_dc_efficiency") == 0:
adjustment["max_ac_charge_power_w"] = 0
dropped = [name for name in _INVERTER_FIELDS_NOW_IN_PROFILE if name in item]
if item.get("ac_charge_limits_total_charge") is True:
logger.warning(
f"Inverter '{item.get('device_id')}': 'ac_charge_limits_total_charge' is a "
"property of the inverter profile now. The default profile does not limit PV "
"charging by the AC charge power; select an inverter profile that does."
)
for name in dropped:
item.pop(name)
if dropped:
logger.error(
f"Inverter '{item.get('device_id')}': dropped {', '.join(dropped)}; the values "
f"of inverter profile '{item.get('inverter_profile', 'generic-hybrid')}' apply. "
"Adapt the inverter profile if needed."
)
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.
"""Rewrite Home Assistant config paths of device fields moved into the profile adjustment.
E.g. ``devices/batteries/bat0/capacity_wh`` becomes
``devices/batteries/bat0/profile_adjustment/nominal_capacity_wh``. Paths of
@@ -188,9 +247,19 @@ def _migrate_config_entity_ids(value: Any) -> Any:
if not isinstance(value, dict):
return value
moved = dict(_BATTERY_FIELDS_TO_ADJUSTMENT)
inverter_moved = dict(_INVERTER_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] == "inverters":
if parts[3] in inverter_moved:
path = "/".join([*parts[:3], "profile_adjustment", inverter_moved[parts[3]]])
elif parts[3] in _INVERTER_FIELDS_NOW_IN_PROFILE:
logger.warning(
f"Dropping Home Assistant config entity '{entity_id}' for '{path}': "
"the value comes from the inverter profile now."
)
continue
if (
len(parts) == 4
and parts[0] == "devices"
@@ -277,9 +346,11 @@ MIGRATION_MAP: Dict[
),
"devices/electric_vehicles/0/initial_soc_percentage": None,
# - inverters
# nominal power and installation limits -> profile_adjustment,
# efficiencies come from the inverter profile now
"devices/inverters": (
"devices/inverters",
_list_to_device_dict("inv"),
_list_to_device_dict("inv", _migrate_inverter_item),
),
# - home_appliances
"devices/home_appliances": (
@@ -65,14 +65,14 @@
]
},
"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).",
"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 profile (AC to DC efficiency curve).",
"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).",
"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 profile (DC to AC efficiency curve).",
"c_rate": [0.05, 0.1, 0.25, 0.5],
"efficiency": [0.975, 0.975, 0.97, 0.96],
"interpolation": "linear",
@@ -0,0 +1,22 @@
{
"schema_version": "1.0",
"inverter_model": "Generic hybrid inverter",
"nominal_power_w": null,
"max_ac_charge_power_w": null,
"ac_charge_limits_total_charge": false,
"dc_to_ac_efficiency": {
"description": "Typical battery-to-AC efficiency of a hybrid inverter over the load fraction, not the datasheet of a specific model.",
"load_fraction": [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
"efficiency": [0.88, 0.93, 0.955, 0.965, 0.97, 0.968, 0.965]
},
"ac_to_dc_efficiency": {
"description": "Typical AC-to-battery efficiency of a hybrid inverter over the load fraction, not the datasheet of a specific model.",
"load_fraction": [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0],
"efficiency": [0.86, 0.92, 0.95, 0.96, 0.965, 0.963, 0.96]
},
"power_derating": {
"description": "Typical derating: full power up to 45 degC ambient, then linearly down to 60 % at 60 degC and to 0 at 65 degC.",
"temperature_degC": [-25.0, 45.0, 60.0, 65.0],
"power_fraction": [1.0, 1.0, 0.6, 0.0]
}
}
+25 -6
View File
@@ -170,24 +170,43 @@ 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.
def check_device_profiles(self) -> tuple[list[str], list[str]]:
"""Check that every device with a profile can be resolved from it.
See ``BatteriesCommonSettings.check_profile``.
Covers batteries, electric vehicles and inverters, see
``BatteriesCommonSettings.check_profile`` and
``InverterCommonSettings.check_profile``.
Returns:
``(errors, warnings)`` of all batteries and electric vehicles, each
message naming its device.
``(errors, warnings)`` of all these devices, each message naming its
device.
"""
errors: list[str] = []
warnings: list[str] = []
for device_dict in (self.batteries, self.electric_vehicles):
for device_dict in (self.batteries, self.electric_vehicles, self.inverters):
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
def devices_using_profile(self, kind: str, name: str) -> list[str]:
"""Return the IDs of the batteries, electric vehicles and inverters that use a profile.
Args:
kind: Profile kind, e.g. ``battery``.
name: Profile name.
Returns:
The device IDs, in configuration order.
"""
return [
device.device_id
for device_dict in (self.batteries, self.electric_vehicles, self.inverters)
for device in (device_dict or {}).values()
if device.uses_profile(kind, name)
]
# Type used for indexing: (resource_id, optional actuator_id)
class ResourceKey(PydanticBaseModel):
+66
View File
@@ -265,6 +265,72 @@ def solve_ac_for_dc_energy(
return ac_wh
def solve_ac_for_charge_dc_energy(
curve: list[tuple[float, float]],
dc_wh: float,
rated_wh: float,
) -> float:
"""Return the AC energy an AC→DC conversion draws to deliver a DC energy.
The efficiency of a curve depends on the AC load drawn, so the AC energy
``a`` that delivers ``dc_wh`` is the solution of
``a * efficiency(a / rated_wh) = dc_wh``. The curve is piecewise linear and
clamped at both ends; on each piece the equation is quadratic and solved
exactly.
Args:
curve: Points as validated by ``validate_efficiency_curve``.
dc_wh: DC energy delivered by the conversion in one slot.
rated_wh: AC energy at rated power in one slot (load fraction 1.0).
Returns:
The smallest consistent AC energy, 0.0 for no DC energy.
"""
if dc_wh <= 0.0:
return 0.0
if rated_wh <= 0.0:
return dc_wh / curve[-1][1]
tolerance = 1e-9
best = float("inf")
def consider(ac_wh: float, low: float, high: float) -> None:
nonlocal best
fraction = ac_wh / rated_wh
if low - tolerance <= fraction <= high + tolerance and 0.0 < ac_wh < best:
best = ac_wh
first_fraction, first_efficiency = curve[0]
last_fraction, last_efficiency = curve[-1]
# Clamped ends: constant efficiency below the first and above the last point.
consider(dc_wh / first_efficiency, float("-inf"), first_fraction)
consider(dc_wh / last_efficiency, last_fraction, float("inf"))
for (x0, e0), (x1, e1) in zip(curve, curve[1:]):
slope = (e1 - e0) / (x1 - x0)
# a * (c + k * a) = dc with c = e0 - slope * x0 and k = slope / rated.
c = e0 - slope * x0
k = slope / rated_wh
if abs(k) < 1e-15:
if c > 0.0:
consider(dc_wh / c, x0, x1)
continue
discriminant = c * c + 4.0 * k * dc_wh
if discriminant < 0.0:
continue
root = math.sqrt(discriminant)
consider((-c + root) / (2.0 * k), x0, x1)
consider((-c - root) / (2.0 * k), x0, x1)
if best < float("inf"):
return best
# Only possible numerically at a kink: converge on it.
ac_wh = dc_wh / interpolate_efficiency_curve(curve, 1.0)
for _ in range(50):
next_ac_wh = dc_wh / interpolate_efficiency_curve(curve, ac_wh / rated_wh)
if abs(next_ac_wh - ac_wh) <= tolerance:
break
ac_wh = next_ac_wh
return ac_wh
class ConsumerScheduleMode(StrEnum):
"""Schedule mode of a flexible consumer (home appliance).
+27 -4
View File
@@ -6,6 +6,7 @@ from pydantic import Field, field_validator
from akkudoktoreos.devices.devicesabc import (
interpolate_efficiency_curve,
solve_ac_for_charge_dc_energy,
solve_ac_for_dc_energy,
validate_efficiency_curve,
)
@@ -81,6 +82,22 @@ class InverterParameters(DeviceParameters):
"examples": [0.06, 0.1],
},
)
ac_to_dc_efficiency_curve: Optional[list[tuple[float, float]]] = Field(
default=None,
json_schema_extra={
"description": (
"Load-dependent efficiency of AC to DC conversion (AC/grid charging of the "
"battery) as a list of (load_fraction, efficiency) points. The load "
"fraction is the AC power drawn relative to max_power_wh [0.0 ... 1.0]; "
"same rules as dc_to_ac_efficiency_curve. When set, the curve replaces "
"ac_to_dc_efficiency for the energy drawn from the grid; "
"ac_to_dc_efficiency still converts the AC charge power limit and should "
"be the efficiency at that limit. Default None (constant "
"ac_to_dc_efficiency)."
),
"examples": [None, [[0.05, 0.86], [0.3, 0.96], [1.0, 0.96]]],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
@@ -106,9 +123,9 @@ class InverterParameters(DeviceParameters):
},
)
@field_validator("dc_to_ac_efficiency_curve")
@field_validator("dc_to_ac_efficiency_curve", "ac_to_dc_efficiency_curve")
@classmethod
def _validate_dc_to_ac_efficiency_curve(
def _validate_efficiency_curves(
cls, value: Optional[list[tuple[float, float]]]
) -> Optional[list[tuple[float, float]]]:
return validate_efficiency_curve(value)
@@ -138,6 +155,7 @@ class Inverter:
self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency
self.dc_to_ac_efficiency_curve = self.parameters.dc_to_ac_efficiency_curve
self.ac_to_dc_efficiency_curve = self.parameters.ac_to_dc_efficiency_curve
# DC-to-AC efficiency used to value stored battery energy where no single
# conversion is known (end-of-horizon value, AC charge break-even). With
# a curve it is the efficiency at the configured reference load.
@@ -240,8 +258,13 @@ class Inverter:
stored, battery_losses = self.battery.charge_energy(None, hour, charge_factor=factor)
# DC energy entering the battery (before battery internal efficiency)
dc_energy = stored + battery_losses
# AC energy consumed from grid (accounts for AC->DC conversion loss)
ac_energy = dc_energy / self.ac_to_dc_efficiency
# AC energy consumed from grid (accounts for AC->DC conversion loss). With a
# curve the efficiency is the one at the AC load actually drawn.
curve = self.ac_to_dc_efficiency_curve
if curve is not None:
ac_energy = solve_ac_for_charge_dc_energy(curve, dc_energy, self.max_power_wh)
else:
ac_energy = dc_energy / self.ac_to_dc_efficiency
return ac_energy, battery_losses + (ac_energy - dc_energy)
def _delivered_ac_wh(
@@ -32,9 +32,8 @@ entry point is the store::
from pathlib import Path
from typing import Any, ClassVar, List, Literal, Optional, Union
from pydantic import ConfigDict, Field, field_validator, model_validator
from pydantic import Field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.devices.profile.batteryprofileruntime import (
BatteryProfile as BatteryProfile,
)
@@ -60,22 +59,15 @@ from akkudoktoreos.devices.profile.batteryprofileruntime import (
reference_max_charge_crate,
reference_max_discharge_crate,
)
from akkudoktoreos.devices.profile.profilestore import (
ProfileBaseModel as ProfileBaseModel,
)
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.
@@ -12,17 +12,27 @@ simulation:
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.
limit map interpolation enum lives here because the algorithms interpret it; the
efficiency curves use the shared curve support of ``profilecurve``. The
document module re-exports the enums.
"""
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
from akkudoktoreos.devices.profile.profilecurve import (
CurveInterpolation,
OutOfRangeBehavior,
PrecomputedCurve,
reference_curve_lookup,
)
# Name of the curve interpolation in the battery profile documents.
EfficiencyInterpolation = CurveInterpolation
if TYPE_CHECKING:
from akkudoktoreos.devices.profile.batteryprofile import (
BatteryProfileSettings,
@@ -45,96 +55,35 @@ class Interpolation(str, Enum):
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:
class PrecomputedEfficiencyCurve(PrecomputedCurve):
"""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.
A ``PrecomputedCurve`` over the absolute C-rate. The C-rate axis remains
independent of installed capacity. ``c_rate`` and ``efficiency`` name the
breakpoints and values.
"""
c_rate: tuple[float, ...]
efficiency: tuple[float, ...]
interpolation: EfficiencyInterpolation
out_of_range_behavior: OutOfRangeBehavior
slopes: tuple[float, ...]
intercepts: tuple[float, ...]
midpoints: tuple[float, ...]
@property
def c_rate(self) -> tuple[float, ...]:
"""C-rate breakpoints."""
return self.x
@property
def efficiency(self) -> tuple[float, ...]:
"""Efficiency at each C-rate breakpoint."""
return self.y
@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)
return cls.from_points(
curve.c_rate, curve.efficiency, curve.interpolation, curve.out_of_range_behavior
)
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)
@@ -363,7 +312,8 @@ class BatteryProfile:
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``.
Reference semantics for ``PrecomputedEfficiencyCurve.lookup``, see
``profilecurve.reference_curve_lookup``.
Args:
curve: Validated efficiency curve document.
@@ -374,36 +324,9 @@ def reference_efficiency_lookup(curve: "EfficiencyCurve", c_rate: float) -> Opti
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]
return reference_curve_lookup(
curve.c_rate, curve.efficiency, curve.interpolation, curve.out_of_range_behavior, c_rate
)
def _bracket(axis: List[float], x: float) -> tuple[int, int, float]:
@@ -0,0 +1,339 @@
"""Inverter profile documents and their runtime form.
An inverter profile describes the technical data of an inverter model: its
nominal AC power, the efficiency of the DC→AC conversion (battery discharge)
and of the AC→DC conversion (battery charge from the grid) over the load
fraction, the AC charge power limit of the model and an optional power derating
over the ambient temperature.
The load fraction of a conversion is its AC power relative to the nominal power
of the inverter. Efficiency curves and the derating curve are read linearly
between their breakpoints and held at their end values beyond them, so every
lookup yields a value.
``InverterProfileSettings`` is the inverter profile type of the profile store
(kind ``"inverter"``). The store compiles a document into the immutable
``InverterProfile`` that the device settings and the simulation query::
from akkudoktoreos.devices.profile.profilestore import get_profile_store
profile = get_profile_store().get(InverterProfileSettings, "generic-hybrid")
efficiency = profile.dc_to_ac_efficiency_at(load_fraction=0.3)
power_fraction = profile.power_fraction_at(temperature_degC=50.0)
"""
from dataclasses import dataclass
from typing import Any, ClassVar, List, Optional
from pydantic import Field, field_validator, model_validator
from akkudoktoreos.devices.profile.profilecurve import (
PrecomputedCurve,
validate_curve_points,
)
from akkudoktoreos.devices.profile.profilestore import (
ProfileBaseModel,
ProfileDocument,
)
# ---------------------------------------------------------------------------
# Runtime form
# ---------------------------------------------------------------------------
def _curve_value(curve: PrecomputedCurve, query: float) -> float:
"""Return the value of a linear, clamped curve, which always exists."""
value = curve.lookup(query)
if value is None: # Only possible for REJECT or NONE curves, never built here.
raise ValueError(f"Curve has no value at {query}.")
return value
@dataclass(frozen=True)
class InverterProfile:
"""Ready-to-use technical data of an inverter model.
Instances are produced by ``compile_inverter_profile``, usually through the
profile store.
Attributes:
inverter_model: Model name of the inverter.
nominal_power_w: Nominal AC power of the model [W], None for a generic
profile.
max_ac_charge_power_w: AC charge power limit of the model [W], None if
only the nominal power limits AC charging.
ac_charge_limits_total_charge: Whether the AC charge power limit also
limits PV charging of the battery.
dc_to_ac_efficiency: DC→AC efficiency over the load fraction.
ac_to_dc_efficiency: AC→DC efficiency over the load fraction.
power_derating: Allowed fraction of the nominal power over the ambient
temperature [degC], None without derating.
"""
inverter_model: str
nominal_power_w: Optional[float]
max_ac_charge_power_w: Optional[float]
ac_charge_limits_total_charge: bool
dc_to_ac_efficiency: PrecomputedCurve
ac_to_dc_efficiency: PrecomputedCurve
power_derating: Optional[PrecomputedCurve] = None
def dc_to_ac_efficiency_at(self, load_fraction: float) -> float:
"""Return the DC→AC efficiency at a load fraction."""
return _curve_value(self.dc_to_ac_efficiency, load_fraction)
def ac_to_dc_efficiency_at(self, load_fraction: float) -> float:
"""Return the AC→DC efficiency at a load fraction."""
return _curve_value(self.ac_to_dc_efficiency, load_fraction)
def power_fraction_at(self, temperature_degC: float) -> float:
"""Return the allowed fraction of the nominal power at an ambient temperature.
1.0 without a derating curve.
"""
if self.power_derating is None:
return 1.0
return _curve_value(self.power_derating, temperature_degC)
def compile_inverter_profile(document: "InverterProfileSettings") -> InverterProfile:
"""Precompute an inverter profile document into an ``InverterProfile``."""
derating = document.power_derating
return InverterProfile(
inverter_model=document.inverter_model,
nominal_power_w=document.nominal_power_w,
max_ac_charge_power_w=document.max_ac_charge_power_w,
ac_charge_limits_total_charge=document.ac_charge_limits_total_charge,
dc_to_ac_efficiency=document.dc_to_ac_efficiency.to_curve(),
ac_to_dc_efficiency=document.ac_to_dc_efficiency.to_curve(),
power_derating=derating.to_curve() if derating is not None else None,
)
# ---------------------------------------------------------------------------
# Document
# ---------------------------------------------------------------------------
class InverterEfficiencyCurve(ProfileBaseModel):
"""Conversion efficiency of an inverter over its load fraction.
The load fraction is the AC power of the conversion relative to the nominal
power of the inverter. The efficiency is read linearly between the
breakpoints and held at the end values beyond them.
"""
description: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Free-form note about the source or meaning of this curve.",
"examples": ["Datasheet efficiency curve at nominal DC voltage"],
},
)
load_fraction: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": (
"Strictly increasing load fraction breakpoints: AC power relative to "
"the nominal power [0.0 ... ]."
),
"examples": [[0.05, 0.1, 0.2, 0.5, 1.0]],
},
)
efficiency: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": "Efficiency at each load fraction breakpoint (0.0 ... 1.0].",
"examples": [[0.88, 0.93, 0.96, 0.97, 0.96]],
},
)
@field_validator("load_fraction")
@classmethod
def _valid_load_fractions(cls, values: List[float]) -> List[float]:
if any(value < 0 for value in values):
raise ValueError("load fraction breakpoints must be non-negative")
return values
@field_validator("efficiency")
@classmethod
def _valid_efficiencies(cls, values: List[float]) -> List[float]:
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 _valid_points(self) -> "InverterEfficiencyCurve":
validate_curve_points(self.load_fraction, self.efficiency, "load_fraction", "efficiency")
return self
def to_curve(self) -> PrecomputedCurve:
"""Return the runtime curve."""
return PrecomputedCurve.from_points(self.load_fraction, self.efficiency)
class PowerDeratingCurve(ProfileBaseModel):
"""Allowed fraction of the nominal power of an inverter over the ambient temperature.
The fraction is read linearly between the breakpoints and held at the end
values beyond them.
"""
description: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Free-form note about the source or meaning of this curve.",
"examples": ["Datasheet derating at nominal DC voltage"],
},
)
temperature_degC: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": "Strictly increasing ambient temperature breakpoints [degC].",
"examples": [[45.0, 60.0]],
},
)
power_fraction: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": (
"Allowed fraction of the nominal power at each temperature breakpoint "
"[0.0 ... 1.0]."
),
"examples": [[1.0, 0.5]],
},
)
@field_validator("power_fraction")
@classmethod
def _valid_power_fractions(cls, values: List[float]) -> List[float]:
if any(not 0.0 <= value <= 1.0 for value in values):
raise ValueError("power fraction values must be in the range [0, 1]")
return values
@model_validator(mode="after")
def _valid_points(self) -> "PowerDeratingCurve":
validate_curve_points(
self.temperature_degC, self.power_fraction, "temperature_degC", "power_fraction"
)
return self
def to_curve(self) -> PrecomputedCurve:
"""Return the runtime curve."""
return PrecomputedCurve.from_points(self.temperature_degC, self.power_fraction)
class InverterProfileSettings(ProfileBaseModel, ProfileDocument[InverterProfile]):
"""Technical data of an inverter model.
A profile contains every value the computation uses. The one exception is
the nominal power of a generic profile: its efficiency curves are given over
the load fraction, which scales with any nominal power, so it may leave the
nominal power out. The inverter device then has to provide it in its
``profile_adjustment``. Unknown keys are rejected.
Use ``get_profile_store().get(InverterProfileSettings, name)`` to obtain the
compiled ``InverterProfile``.
Attributes:
schema_version: Version of the JSON document format.
inverter_model: Model name of the inverter.
nominal_power_w: Nominal AC power of the model, None for a generic
profile.
max_ac_charge_power_w: AC charge power limit of the model.
ac_charge_limits_total_charge: Whether the AC charge power limit also
limits PV charging of the battery.
dc_to_ac_efficiency: DC→AC efficiency curve over the load fraction.
ac_to_dc_efficiency: AC→DC efficiency curve over the load fraction.
power_derating: Optional power derating over the ambient temperature.
"""
profile_kind: ClassVar[str] = "inverter"
schema_version: str = Field(
default="1.0",
json_schema_extra={
"description": "Version of this schema, for forward-compatibility.",
"examples": ["1.0"],
},
)
inverter_model: str = Field(
...,
min_length=1,
json_schema_extra={
"description": "Model name of the inverter.",
"examples": ["Generic hybrid inverter"],
},
)
nominal_power_w: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Nominal AC power of this inverter model [W]. Required for the profile "
"of a specific model. A generic profile leaves it out; the inverter "
"device then has to set 'profile_adjustment.nominal_power_w'."
),
"examples": [None, 10000.0],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"AC charge power limit of this inverter model [W]: the maximum AC power "
"drawn from the grid to charge the battery. None if only the nominal "
"power limits AC charging."
),
"examples": [None, 5000.0],
},
)
ac_charge_limits_total_charge: bool = Field(
default=False,
json_schema_extra={
"description": (
"Whether the AC charge power limit also limits PV charging of the "
"battery in the same slot. False if PV can charge the battery on top "
"of the AC charge power."
),
"examples": [False],
},
)
dc_to_ac_efficiency: InverterEfficiencyCurve = Field(
...,
json_schema_extra={
"description": "DC→AC efficiency (battery discharge) over the load fraction."
},
)
ac_to_dc_efficiency: InverterEfficiencyCurve = Field(
...,
json_schema_extra={
"description": "AC→DC efficiency (battery charge from the grid) over the load fraction."
},
)
power_derating: Optional[PowerDeratingCurve] = Field(
default=None,
json_schema_extra={
"description": (
"Allowed fraction of the nominal power over the ambient temperature. "
"None if the inverter is not derated."
)
},
)
def compile_profile(self, **options: Any) -> InverterProfile:
"""Compile this document for the profile store.
Raises:
TypeError: If an option is given; inverter profiles take none.
"""
if options:
raise TypeError(f"Unknown inverter profile options: {sorted(options)}")
return compile_inverter_profile(self)
@@ -0,0 +1,203 @@
"""One-dimensional curves of device profiles.
Device profiles describe values that depend on one quantity as curves of
breakpoints: the efficiency of a battery over the C-rate, the efficiency of an
inverter over its load fraction, the power derating of an inverter over the
ambient temperature. This module holds what all of them share:
- ``CurveInterpolation`` and ``OutOfRangeBehavior``: how a curve is read between
and beyond its breakpoints.
- ``reference_curve_lookup``: the slow per-query reading that defines the
semantics of a curve.
- ``PrecomputedCurve``: the immutable runtime form with precomputed segment
coefficients, used by controllers and the simulation.
- ``validate_curve_points``: the checks every curve document applies.
The document models name their axes after the quantity (e.g. ``c_rate``,
``load_fraction``, ``temperature_degC``) and convert to the neutral ``x`` / ``y``
form of this module.
"""
from bisect import bisect_left, bisect_right
from dataclasses import dataclass
from enum import Enum
from typing import Iterable, Optional, Self, Sequence
class CurveInterpolation(str, Enum):
"""Interpolation strategy of a one-dimensional curve.
Attributes:
LINEAR: Interpolate linearly between neighbouring breakpoints.
NEAREST: Take the value of the nearest breakpoint; a query exactly
between two breakpoints takes the lower one.
NONE: Only exact breakpoints yield a value; anything else yields None.
"""
LINEAR = "linear"
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.
REJECT: Return None for the out-of-range query.
"""
CLAMP_TO_NEAREST = "clamp_to_nearest"
REJECT = "reject"
def validate_curve_points(x: Sequence[float], y: Sequence[float], x_name: str, y_name: str) -> None:
"""Check that a curve has strictly increasing breakpoints and one value each.
Args:
x: Breakpoints.
y: Values at the breakpoints.
x_name: Name of the breakpoint field, for error messages.
y_name: Name of the value field, for error messages.
Raises:
ValueError: If the breakpoints are not strictly increasing or the
lengths differ.
"""
if any(right <= left for left, right in zip(x, x[1:])):
raise ValueError(f"{x_name} breakpoints must be strictly increasing")
if len(x) != len(y):
raise ValueError(f"{x_name} and {y_name} must contain the same number of values")
def reference_curve_lookup(
x: Sequence[float],
y: Sequence[float],
interpolation: CurveInterpolation,
out_of_range_behavior: OutOfRangeBehavior,
query: float,
) -> Optional[float]:
"""Return the value of a curve at ``query``.
Reference semantics for ``PrecomputedCurve.lookup``.
Args:
x: Strictly increasing breakpoints.
y: Values at the breakpoints.
interpolation: Interpolation between breakpoints.
out_of_range_behavior: Behavior outside the breakpoints.
query: Point to evaluate.
Returns:
The value, 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 query < x[0] or query > x[-1]:
if out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return y[0] if query < x[0] else y[-1]
if interpolation == CurveInterpolation.NONE:
try:
return y[list(x).index(query)]
except ValueError:
return None
if interpolation == CurveInterpolation.NEAREST:
index = min(range(len(x)), key=lambda i: abs(x[i] - query))
return y[index]
for index in range(len(x) - 1):
lower = x[index]
upper = x[index + 1]
if lower <= query <= upper:
fraction = (query - lower) / (upper - lower)
return y[index] + (y[index + 1] - y[index]) * fraction
return y[-1]
@dataclass(frozen=True)
class PrecomputedCurve:
"""Immutable runtime representation of a one-dimensional curve.
Segment slopes/intercepts and nearest-neighbor midpoints are calculated
once, so runtime lookups avoid recalculating interpolation coefficients or
scanning every breakpoint.
Attributes:
x: Strictly increasing breakpoints.
y: Values at the breakpoints.
interpolation: Interpolation between breakpoints.
out_of_range_behavior: Behavior outside the breakpoints.
slopes: Slope of each linear segment.
intercepts: Intercept of each linear segment.
midpoints: Midpoints between neighbouring breakpoints.
"""
x: tuple[float, ...]
y: tuple[float, ...]
interpolation: CurveInterpolation
out_of_range_behavior: OutOfRangeBehavior
slopes: tuple[float, ...]
intercepts: tuple[float, ...]
midpoints: tuple[float, ...]
@classmethod
def from_points(
cls,
x: Iterable[float],
y: Iterable[float],
interpolation: CurveInterpolation = CurveInterpolation.LINEAR,
out_of_range_behavior: OutOfRangeBehavior = OutOfRangeBehavior.CLAMP_TO_NEAREST,
) -> Self:
"""Build the lookup data from validated breakpoints and values.
Raises:
ValueError: If the points are not a valid curve, see
``validate_curve_points``, or there are fewer than two.
"""
xs = tuple(float(value) for value in x)
ys = tuple(float(value) for value in y)
if len(xs) < 2:
raise ValueError("a curve needs at least two breakpoints")
validate_curve_points(xs, ys, "x", "y")
slopes = tuple((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]) for i in range(len(xs) - 1))
intercepts = tuple(ys[i] - slopes[i] * xs[i] for i in range(len(slopes)))
midpoints = tuple((xs[i] + xs[i + 1]) / 2.0 for i in range(len(xs) - 1))
return cls(
x=xs,
y=ys,
interpolation=interpolation,
out_of_range_behavior=out_of_range_behavior,
slopes=slopes,
intercepts=intercepts,
midpoints=midpoints,
)
def lookup(self, query: float) -> Optional[float]:
"""Return the value at ``query``, see ``reference_curve_lookup``."""
if query < self.x[0]:
if self.out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return self.y[0]
if query > self.x[-1]:
if self.out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return self.y[-1]
if self.interpolation == CurveInterpolation.NONE:
index = bisect_left(self.x, query)
if index < len(self.x) and self.x[index] == query:
return self.y[index]
return None
if self.interpolation == CurveInterpolation.NEAREST:
# bisect_left preserves the min() tie-break of the reference: lower point.
index = bisect_left(self.midpoints, query)
return self.y[index]
if query == self.x[-1]:
return self.y[-1]
index = bisect_right(self.x, query) - 1
return self.slopes[index] * query + self.intercepts[index]
@@ -56,8 +56,9 @@ from typing import (
import cachebox
from loguru import logger
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin, get_config
CompiledT = TypeVar("CompiledT")
@@ -108,6 +109,16 @@ def _validated_kind(kind: Any, owner: str) -> str:
return kind
class ProfileBaseModel(SettingsBaseModel):
"""Base of all 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 ProfileDocument(BaseModel, Generic[CompiledT]):
"""Base class of profile documents, parametrized by the compiled form.
@@ -2,7 +2,7 @@
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
``get_measured_value`` (from ``devicemeasurement``) talks to the measurement
subsystem; ``measure_operating_point`` turns the measured values of one battery
into a ``BatteryOperatingPoint``.
"""
@@ -10,46 +10,25 @@ 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
from akkudoktoreos.devices.settings.devicemeasurement import (
DEVICE_MEASUREMENT_MAX_AGE,
)
from akkudoktoreos.devices.settings.devicemeasurement import (
get_measured_value as get_measured_value,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
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
BATTERY_MEASUREMENT_MAX_AGE: str = DEVICE_MEASUREMENT_MAX_AGE
def _soc_factor_or_default(value: Optional[float]) -> float:
@@ -690,6 +690,12 @@ class BatteriesCommonSettings(DevicesBaseSettings):
operating_point = await self.measure_operating_point(target_datetime)
return self.resolve_operating_parameters_or_defaults(operating_point, report)
def uses_profile(self, kind: str, name: str) -> bool:
"""Return whether this battery uses the profile of the given kind and name."""
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
return kind == BatteryProfileSettings.profile_kind and name == self.battery_profile
def check_profile(self) -> tuple[list[str], list[str]]:
"""Check that the battery can be resolved from its profile and adjustment.
@@ -0,0 +1,42 @@
"""Reading device measurements for the device settings.
Measurement access is asynchronous (``DataSequence.key_to_value``), so it is
kept out of the device settings models and out of the parameter resolution.
``get_measured_value`` is the only place where device settings talk to the
measurement subsystem; the device specific modules (e.g. ``batterymeasurement``,
``invertermeasurement``) turn the measured values into operating points.
"""
from typing import Optional
from loguru import logger
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
# Maximum age of a measurement that is still taken as "current".
DEVICE_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 DEVICE_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(DEVICE_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"Device measurement '{key}' not available: {exc}")
return None
@@ -0,0 +1,49 @@
"""Reading the inverter operating point from measurements.
``measure_operating_point`` turns the measured ambient temperature of one
inverter into an ``InverterOperatingPoint``. ``get_measured_value`` (from
``devicemeasurement``) talks to the measurement subsystem.
"""
import math
from typing import TYPE_CHECKING, Optional
from akkudoktoreos.devices.settings.devicemeasurement import (
get_measured_value as get_measured_value,
)
from akkudoktoreos.devices.settings.inverterresolution import (
INVERTER_DEFAULT_TEMPERATURE_DEGC,
InverterOperatingPoint,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
if TYPE_CHECKING:
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
def _temperature_or_default(value: Optional[float]) -> float:
"""Return a measured ambient temperature [degC], or the default."""
if value is None or not math.isfinite(value):
return INVERTER_DEFAULT_TEMPERATURE_DEGC
return value
async def measure_operating_point(
settings: "InverterCommonSettings", target_datetime: Optional[DateTime] = None
) -> InverterOperatingPoint:
"""Read the current operating point of an inverter from its measurements.
A missing, stale, non-finite or unavailable temperature measurement falls back
to ``INVERTER_DEFAULT_TEMPERATURE_DEGC`` (45 degC). This function never raises
for missing measurements.
Args:
settings: Inverter device settings, providing the measurement key.
target_datetime: Datetime the measurement is 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)
return InverterOperatingPoint(temperature_degC=_temperature_or_default(temperature))
@@ -0,0 +1,284 @@
"""Resolution of the effective inverter parameters.
Every effective inverter parameter (nominal and maximum power, AC charge power
limit, efficiencies) comes from exactly two sources:
- the compiled inverter profile (``InverterProfile``): efficiency curves over the
load fraction, the AC charge power limit of the model, the power derating over
the ambient temperature and, unless it is a generic profile, the nominal power,
- the inverter device settings (``InverterCommonSettings``): the
``profile_adjustment`` of this unit (nominal power, installation limits).
The profile is evaluated at an operating point (``InverterOperatingPoint``), the
ambient temperature of the inverter. Without a temperature measurement it is
``INVERTER_DEFAULT_TEMPERATURE_DEGC`` (45 degC). If the parameters cannot be
resolved, an ``InverterResolutionError`` names the inverter and the reason.
The parameter preparation of the optimizations never fails: it falls back to
defaults (``INVERTER_FALLBACK_*``) and reports them, see
``InverterCommonSettings.resolve_operating_parameters_or_defaults``.
The functions here are pure: they neither load profiles nor read measurements.
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from akkudoktoreos.devices.profile.inverterprofile import InverterProfile
from akkudoktoreos.devices.profile.profilecurve import PrecomputedCurve
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
# Ambient temperature of the inverter assumed when no measurement is available.
INVERTER_DEFAULT_TEMPERATURE_DEGC: float = 45.0
# Values for the optimization if the configuration does not allow to resolve
# them: the example power and the default efficiency of the former settings.
INVERTER_FALLBACK_NOMINAL_POWER_W: float = 10000.0
INVERTER_FALLBACK_EFFICIENCY: float = 1.0
class InverterResolutionError(ValueError):
"""The effective parameters of an inverter cannot be resolved.
The message names the inverter and the reason.
"""
@dataclass(frozen=True)
class InverterOperatingPoint:
"""Conditions the inverter profile is evaluated at.
Attributes:
temperature_degC: Ambient temperature of the inverter [degC].
"""
temperature_degC: float = INVERTER_DEFAULT_TEMPERATURE_DEGC
@dataclass(frozen=True)
class InverterOperatingParameters:
"""Consistent snapshot of the effective inverter parameters.
All values belong to the same operating point.
Attributes:
temperature_degC: Ambient temperature the profile was evaluated at.
nominal_power_w: Effective nominal AC power [W]: profile adjustment, else
profile.
power_fraction: Derating of the nominal power at the temperature
[0.0 ... 1.0].
max_power_w: Effective maximum AC power [W]: the derated nominal power,
capped by the installation limit of the profile adjustment.
max_ac_charge_power_w: Effective AC charge power limit [W]: the smallest
of the maximum power, the derated limit of the model and the
installation limit. 0 disables AC charging.
ac_charge_limits_total_charge: Whether the AC charge power limit also
limits PV charging of the battery.
dc_to_ac_efficiency_curve: DC→AC efficiency over the load fraction of
``max_power_w`` as ``(load_fraction, efficiency)`` points within
[0, 1].
ac_to_dc_efficiency_curve: AC→DC efficiency over the load fraction of
``max_power_w`` as ``(load_fraction, efficiency)`` points within
[0, 1].
dc_to_ac_efficiency: DC→AC efficiency at the reference load fraction of
the settings.
ac_to_dc_efficiency: AC→DC efficiency at the AC charge power limit (at
the maximum power if AC charging is disabled).
"""
temperature_degC: float
nominal_power_w: float
power_fraction: float
max_power_w: float
max_ac_charge_power_w: float
ac_charge_limits_total_charge: bool
dc_to_ac_efficiency_curve: tuple[tuple[float, float], ...]
ac_to_dc_efficiency_curve: tuple[tuple[float, float], ...]
dc_to_ac_efficiency: float
ac_to_dc_efficiency: float
def _resolution_error(settings: "InverterCommonSettings", reason: str) -> InverterResolutionError:
"""Return an error naming the inverter."""
return InverterResolutionError(f"Inverter '{settings.device_id}': {reason}")
def _curve_over_max_power(
curve: "PrecomputedCurve", scale: float
) -> tuple[tuple[float, float], ...]:
"""Return a profile efficiency curve over the load fraction of the maximum power.
The profile curve is over the load fraction of the nominal power; ``scale``
is nominal power / maximum power (>= 1). Loads above the maximum power cannot
occur, so the curve ends at load fraction 1.0 with the value there. It always
has at least two points within [0, 1].
Args:
curve: Profile curve over the load fraction of the nominal power.
scale: Nominal power divided by maximum power.
Returns:
The ``(load_fraction, efficiency)`` points.
"""
points = [(x * scale, y) for x, y in zip(curve.x, curve.y) if x * scale < 1.0]
end_value = curve.lookup(1.0 / scale)
points.append((1.0, end_value if end_value is not None else curve.y[-1]))
if len(points) < 2:
points.insert(0, (0.0, curve.y[0]))
return tuple(points)
def resolve_nominal_power_w(
settings: "InverterCommonSettings", profile: "InverterProfile"
) -> float:
"""Resolve the nominal AC power [W]: profile adjustment, else profile.
Raises:
InverterResolutionError: If neither the profile adjustment nor the
profile provides a nominal power (a generic profile without
adjustment).
"""
adjusted_w = settings.profile_adjustment.nominal_power_w
if adjusted_w is not None:
return adjusted_w
if profile.nominal_power_w is not None:
return profile.nominal_power_w
raise _resolution_error(
settings,
f"inverter profile '{settings.inverter_profile}' is generic and provides no "
"nominal power; set 'profile_adjustment.nominal_power_w'.",
)
def resolve_inverter_parameters(
settings: "InverterCommonSettings",
profile: "InverterProfile",
operating_point: InverterOperatingPoint,
) -> InverterOperatingParameters:
"""Resolve the effective inverter parameters at an operating point.
Args:
settings: Inverter device settings.
profile: Compiled inverter profile.
operating_point: Conditions to evaluate the profile at.
Returns:
The effective parameters.
Raises:
InverterResolutionError: If the nominal power cannot be resolved or the
inverter is derated to zero power at the operating point.
"""
adjustment = settings.profile_adjustment
nominal_power_w = resolve_nominal_power_w(settings, profile)
power_fraction = profile.power_fraction_at(operating_point.temperature_degC)
max_power_w = nominal_power_w * power_fraction
if adjustment.max_power_w is not None:
max_power_w = min(max_power_w, adjustment.max_power_w)
if max_power_w <= 0.0:
raise _resolution_error(
settings,
f"inverter profile '{settings.inverter_profile}' allows no power at "
f"{operating_point.temperature_degC} degC.",
)
max_ac_charge_power_w = max_power_w
if profile.max_ac_charge_power_w is not None:
max_ac_charge_power_w = min(
max_ac_charge_power_w, profile.max_ac_charge_power_w * power_fraction
)
if adjustment.max_ac_charge_power_w is not None:
max_ac_charge_power_w = min(max_ac_charge_power_w, adjustment.max_ac_charge_power_w)
# The profile curves are over the load fraction of the nominal power, the
# optimizers read them over the load fraction of the maximum power.
scale = nominal_power_w / max_power_w
dc_to_ac_curve = _curve_over_max_power(profile.dc_to_ac_efficiency, scale)
ac_to_dc_curve = _curve_over_max_power(profile.ac_to_dc_efficiency, scale)
reference_power_w = settings.dc_to_ac_efficiency_reference_load_fraction * max_power_w
ac_charge_power_w = max_ac_charge_power_w if max_ac_charge_power_w > 0.0 else max_power_w
return InverterOperatingParameters(
temperature_degC=operating_point.temperature_degC,
nominal_power_w=nominal_power_w,
power_fraction=power_fraction,
max_power_w=max_power_w,
max_ac_charge_power_w=max_ac_charge_power_w,
ac_charge_limits_total_charge=profile.ac_charge_limits_total_charge,
dc_to_ac_efficiency_curve=dc_to_ac_curve,
ac_to_dc_efficiency_curve=ac_to_dc_curve,
dc_to_ac_efficiency=profile.dc_to_ac_efficiency_at(reference_power_w / nominal_power_w),
ac_to_dc_efficiency=profile.ac_to_dc_efficiency_at(ac_charge_power_w / nominal_power_w),
)
def resolve_inverter_parameters_without_profile(
settings: "InverterCommonSettings", operating_point: InverterOperatingPoint
) -> tuple[InverterOperatingParameters, list[str]]:
"""Resolve the parameters from the profile adjustment and the fallback values.
Used if no inverter profile can be used. Never raises.
Args:
settings: Inverter device settings.
operating_point: Operating point the parameters are for.
Returns:
``(parameters, defaults)``: the parameters and descriptions of the
fallback values used, for the error log.
"""
adjustment = settings.profile_adjustment
defaults: list[str] = []
nominal_power_w = adjustment.nominal_power_w
if nominal_power_w is None:
nominal_power_w = INVERTER_FALLBACK_NOMINAL_POWER_W
defaults.append(f"nominal power {nominal_power_w:g} W")
max_power_w = nominal_power_w
if adjustment.max_power_w is not None:
max_power_w = min(max_power_w, adjustment.max_power_w)
max_ac_charge_power_w = max_power_w
if adjustment.max_ac_charge_power_w is not None:
max_ac_charge_power_w = min(max_ac_charge_power_w, adjustment.max_ac_charge_power_w)
efficiency = INVERTER_FALLBACK_EFFICIENCY
defaults.append(f"constant efficiency {efficiency:g}")
flat = ((0.0, efficiency), (1.0, efficiency))
parameters = InverterOperatingParameters(
temperature_degC=operating_point.temperature_degC,
nominal_power_w=nominal_power_w,
power_fraction=1.0,
max_power_w=max_power_w,
max_ac_charge_power_w=max_ac_charge_power_w,
ac_charge_limits_total_charge=False,
dc_to_ac_efficiency_curve=flat,
ac_to_dc_efficiency_curve=flat,
dc_to_ac_efficiency=efficiency,
ac_to_dc_efficiency=efficiency,
)
return parameters, defaults
def check_inverter_adjustment(
settings: "InverterCommonSettings", parameters: InverterOperatingParameters
) -> list[str]:
"""Return warnings for installation limits of the adjustment that have no effect.
Args:
settings: Inverter device settings.
parameters: Resolved parameters; only the nominal power is used.
Returns:
One warning per installation limit above the nominal power.
"""
warnings: list[str] = []
adjustment = settings.profile_adjustment
for name, limit_w in (
("max_power_w", adjustment.max_power_w),
("max_ac_charge_power_w", adjustment.max_ac_charge_power_w),
):
if limit_w is not None and limit_w > parameters.nominal_power_w:
warnings.append(
f"Inverter '{settings.device_id}': 'profile_adjustment.{name}' {limit_w} W is "
f"above the nominal power {parameters.nominal_power_w} W and has no effect."
)
return warnings
@@ -1,20 +1,105 @@
"""Inverter device settings."""
"""Inverter device settings.
The technical inverter data (nominal power, efficiency curves, AC charge power
limit of the model, power derating) comes from an inverter profile (see
``InverterProfileSettings``). The user selects the profile by name via
``inverter_profile`` (default ``generic-hybrid``) and describes how the
installed unit differs from it in ``profile_adjustment``.
``InverterCommonSettings`` holds the configuration fields, their validation and
the measurement keys. The other concerns live in their own modules:
- ``profilestore``: loading (and caching) the compiled inverter profile.
- ``invertermeasurement``: reading the ambient temperature from the
measurements.
- ``inverterresolution``: resolving the effective parameters from profile,
profile adjustment and operating point (pure functions).
The computed ``effective_*`` fields are evaluated at the reference operating
point (``InverterOperatingPoint()``: 45 degC ambient temperature). They never
raise; they are None if the inverter cannot be resolved.
"""
from typing import TYPE_CHECKING, Optional
from pydantic import Field, computed_field, field_validator, model_validator
from loguru import logger
from pydantic import Field, computed_field, model_validator
from akkudoktoreos.config.configabc import ConfigScope
from akkudoktoreos.devices.devicesabc import validate_efficiency_curve
from akkudoktoreos.config.configabc import ConfigScope, SettingsBaseModel
from akkudoktoreos.devices.settings import invertermeasurement
from akkudoktoreos.devices.settings.devicebasesettings import (
DevicesBaseSettings,
)
from akkudoktoreos.devices.settings.inverterresolution import (
INVERTER_FALLBACK_NOMINAL_POWER_W,
InverterOperatingParameters,
InverterOperatingPoint,
InverterResolutionError,
check_inverter_adjustment,
resolve_inverter_parameters,
resolve_inverter_parameters_without_profile,
resolve_nominal_power_w,
)
from akkudoktoreos.utils.datetimeutil import DateTime
if TYPE_CHECKING:
from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0InverterParameters,
)
from akkudoktoreos.devices.genetic.inverter import InverterParameters
from akkudoktoreos.devices.profile.inverterprofile import InverterProfile
# Name of the inverter profile used when the user selects none.
INVERTER_DEFAULT_PROFILE: str = "generic-hybrid"
class InverterProfileAdjustment(SettingsBaseModel):
"""How an inverter unit differs from its inverter profile.
None keeps the profile value.
"""
nominal_power_w: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Nominal AC power of the installed inverter [W]. Replaces the nominal "
"power of the inverter profile. Required if the profile is generic and "
"provides no nominal power. The effective value is available as "
"'effective_nominal_power_w'."
),
"examples": [None, 10000],
},
)
max_power_w: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Maximum AC power of the installation [W], e.g. a configured output "
"limit. Can only lower the (derated) nominal power. The effective value "
"is available as 'effective_max_power_w'."
),
"examples": [None, 8000],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"AC charge power limit of the installation [W]. Can only lower the limit "
"of the inverter profile. 0 disables AC charging. The effective value is "
"available as 'effective_max_ac_charge_power_w'."
),
"examples": [None, 0, 5000],
},
)
class InverterCommonSettings(DevicesBaseSettings):
@@ -29,99 +114,46 @@ class InverterCommonSettings(DevicesBaseSettings):
# Shared fields (GENETIC + GENETIC0)
# ------------------------------------------------------------------
max_power_w: Optional[float] = Field(
default=None,
gt=0,
inverter_profile: str = Field(
default=INVERTER_DEFAULT_PROFILE,
min_length=1,
json_schema_extra={
"description": "Maximum AC output power [W].",
"examples": [10000],
"description": (
"Name of the inverter profile (without '.json') providing the nominal "
"power, efficiency curves, AC charge power limit and power derating. "
"Differences of this unit go into 'profile_adjustment'."
),
"examples": [INVERTER_DEFAULT_PROFILE],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
ac_to_dc_efficiency: float = Field(
default=1.0,
ge=0,
le=1,
profile_adjustment: InverterProfileAdjustment = Field(
default_factory=InverterProfileAdjustment,
json_schema_extra={
"description": (
"Efficiency of AC→DC conversion for grid-to-battery charging (0–1). "
"Set to 0 to disable AC charging. Default 1.0."
"How this inverter unit differs from its inverter profile: nominal power "
"and installation limits."
),
"examples": [0.95, 1.0, 0.0],
"examples": [{"nominal_power_w": 10000}],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
dc_to_ac_efficiency: float = Field(
default=1.0,
gt=0,
le=1,
json_schema_extra={
"description": (
"Efficiency of DC→AC conversion for battery discharging (0–1). Default 1.0."
),
"examples": [0.95, 1.0],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
dc_to_ac_efficiency_curve: Optional[list[tuple[float, float]]] = Field(
default=None,
json_schema_extra={
"description": (
"Load-dependent efficiency of DC→AC conversion for battery discharging as "
"a list of [load_fraction, efficiency] points. The load fraction is the AC "
"power relative to max_power_w (0–1); points must be strictly increasing in "
"load fraction, efficiencies in (0, 1]. Values are interpolated linearly and "
"clamped to the first/last point. When set, the curve replaces "
"dc_to_ac_efficiency. null keeps the constant dc_to_ac_efficiency. "
"Default null."
),
"examples": [None, [[0.02, 0.8], [0.07, 0.93], [0.3, 0.96], [1.0, 0.95]]],
"x-scope": [str(ConfigScope.GENETIC)],
},
)
dc_to_ac_efficiency_reference_load_fraction: float = Field(
default=0.06,
ge=0,
le=1,
json_schema_extra={
"description": (
"Load fraction of max_power_w (0–1) at which dc_to_ac_efficiency_curve is "
"evaluated to value stored battery energy without a specific conversion "
"(remaining energy at the end of the horizon, AC charge break-even). Should "
"represent the typical load served by the battery, e.g. the night-time base "
"load. Only used with dc_to_ac_efficiency_curve. Default 0.06."
"Load fraction of the effective maximum power (0–1) at which the DC→AC "
"efficiency curve is evaluated to value stored battery energy without a "
"specific conversion (remaining energy at the end of the horizon, AC "
"charge break-even). Should represent the typical load served by the "
"battery, e.g. the night-time base load. Default 0.06."
),
"examples": [0.06, 0.1],
"x-scope": [str(ConfigScope.GENETIC)],
},
)
max_ac_charge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum AC charging power [W]. "
"null means no additional limit. 0 disables AC charging."
),
"examples": [None, 0, 5000],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
ac_charge_limits_total_charge: bool = Field(
default=False,
json_schema_extra={
"description": (
"True if the AC charge setpoint caps the battery's total charge power, "
"PV included. Some hybrid inverters (e.g. Deye in time-of-use grid "
"charging) limit the whole charge current to the grid charge current; "
"PV surplus above it is exported, not stored. False keeps the default "
"model: PV surplus charges first and the grid adds "
"ac_charge x max_charge_power_w on top."
),
"examples": [False, True],
"x-scope": [str(ConfigScope.GENETIC)],
},
)
battery_id: Optional[str] = Field(
default=None,
json_schema_extra={
@@ -384,13 +416,6 @@ class InverterCommonSettings(DevicesBaseSettings):
# Validators
# ------------------------------------------------------------------
@field_validator("dc_to_ac_efficiency_curve")
@classmethod
def _validate_dc_to_ac_efficiency_curve(
cls, value: Optional[list[tuple[float, float]]]
) -> Optional[list[tuple[float, float]]]:
return validate_efficiency_curve(value)
@model_validator(mode="after")
def _validate_soc_factors(self) -> "InverterCommonSettings":
if self.battery_min_soc_factor >= self.battery_max_soc_factor:
@@ -399,59 +424,339 @@ class InverterCommonSettings(DevicesBaseSettings):
)
return self
# ------------------------------------------------------------------
# Inverter profile loading (see profilestore)
# ------------------------------------------------------------------
def get_inverter_profile(self) -> "InverterProfile":
"""Return the compiled inverter profile for ``inverter_profile``.
Raises:
FileNotFoundError: If the configured inverter profile does not exist.
ValueError: If the profile document fails validation.
"""
from akkudoktoreos.devices.profile.inverterprofile import (
InverterProfileSettings,
)
from akkudoktoreos.devices.profile.profilestore import get_profile_store
return get_profile_store().get(InverterProfileSettings, self.inverter_profile)
def _load_inverter_profile(self) -> "InverterProfile":
"""Return the compiled inverter profile, or raise an error naming the inverter.
Raises:
InverterResolutionError: If the profile does not exist, cannot be
read or fails validation.
"""
try:
return self.get_inverter_profile()
except InverterResolutionError:
raise
except (OSError, ValueError) as exc:
raise InverterResolutionError(
f"Inverter '{self.device_id}': inverter profile '{self.inverter_profile}' "
f"is not usable: {exc}"
) from exc
# ------------------------------------------------------------------
# Measurement access (see invertermeasurement)
# ------------------------------------------------------------------
async def measure_operating_point(
self, target_datetime: Optional[DateTime] = None
) -> InverterOperatingPoint:
"""Read the ambient temperature from the measurements, 45 degC if unavailable."""
return await invertermeasurement.measure_operating_point(self, target_datetime)
# ------------------------------------------------------------------
# Parameter resolution (see inverterresolution)
# ------------------------------------------------------------------
def resolve_operating_parameters(
self, operating_point: Optional[InverterOperatingPoint] = None
) -> InverterOperatingParameters:
"""Resolve the effective parameters at an operating point.
Args:
operating_point: Conditions to evaluate the profile at, the reference
operating point (45 degC) if None.
Raises:
InverterResolutionError: If the profile is not usable or the
parameters cannot be resolved.
"""
return resolve_inverter_parameters(
self, self._load_inverter_profile(), operating_point or InverterOperatingPoint()
)
async def resolve_current_operating_parameters(
self, target_datetime: Optional[DateTime] = None
) -> InverterOperatingParameters:
"""Resolve the effective parameters at the measured ambient temperature.
Raises:
InverterResolutionError: See ``resolve_operating_parameters``.
"""
operating_point = await self.measure_operating_point(target_datetime)
return self.resolve_operating_parameters(operating_point)
def resolve_operating_parameters_or_defaults(
self, operating_point: Optional[InverterOperatingPoint] = None, report: bool = True
) -> InverterOperatingParameters:
"""Resolve for the optimizations; never raises.
Optimization parameters must always be valid, so a configuration that
cannot be resolved gives defaults and an error log:
- an unusable inverter profile is replaced by the packaged
``generic-hybrid`` profile with this unit's profile adjustment,
- a missing nominal power, or no usable profile at all, gives the
fallback values (``INVERTER_FALLBACK_*``),
- an inverter derated to zero power at the operating point is resolved
at the reference temperature instead.
Args:
operating_point: Operating point to evaluate the profile at, the
reference operating point if None.
report: Whether to log an error naming the inverter, the problem
and the defaults used.
Returns:
The parameters.
"""
if operating_point is None:
operating_point = InverterOperatingPoint()
problems: list[str] = []
defaults: list[str] = []
profile: Optional["InverterProfile"] = None
try:
profile = self._load_inverter_profile()
except InverterResolutionError as exc:
problems.append(str(exc))
if self.inverter_profile != INVERTER_DEFAULT_PROFILE:
try:
from akkudoktoreos.devices.profile.inverterprofile import (
InverterProfileSettings,
)
from akkudoktoreos.devices.profile.profilestore import (
get_profile_store,
)
profile = get_profile_store().get(
InverterProfileSettings, INVERTER_DEFAULT_PROFILE
)
defaults.append(f"inverter profile '{INVERTER_DEFAULT_PROFILE}'")
except (OSError, ValueError) as default_exc:
problems.append(str(default_exc))
parameters: Optional[InverterOperatingParameters] = None
if profile is not None:
settings: InverterCommonSettings = self
try:
resolve_nominal_power_w(settings, profile)
except InverterResolutionError as exc:
problems.append(str(exc))
defaults.append(f"nominal power {INVERTER_FALLBACK_NOMINAL_POWER_W:g} W")
settings = self.model_copy(
update={
"profile_adjustment": self.profile_adjustment.model_copy(
update={"nominal_power_w": INVERTER_FALLBACK_NOMINAL_POWER_W}
)
}
)
try:
parameters = resolve_inverter_parameters(settings, profile, operating_point)
except InverterResolutionError as exc:
# Derated to zero power at the operating point.
problems.append(str(exc))
reference = InverterOperatingPoint()
defaults.append(f"the parameters at {reference.temperature_degC:g} degC")
try:
parameters = resolve_inverter_parameters(settings, profile, reference)
except InverterResolutionError as reference_exc:
problems.append(str(reference_exc))
if parameters is None:
parameters, fallback_defaults = resolve_inverter_parameters_without_profile(
self, operating_point
)
defaults.extend(fallback_defaults)
if problems and report:
logger.error(
"{} Using defaults for the optimization: {}.",
" ".join(problems),
", ".join(defaults),
)
return parameters
async def resolve_current_operating_parameters_or_defaults(
self, target_datetime: Optional[DateTime] = None, report: bool = True
) -> InverterOperatingParameters:
"""Resolve like ``resolve_operating_parameters_or_defaults`` at the measured temperature.
Args:
target_datetime: Datetime the measurement is looked up for, now if None.
report: Whether to log the configuration error and the defaults used.
Returns:
The parameters.
"""
operating_point = await self.measure_operating_point(target_datetime)
parameters = self.resolve_operating_parameters_or_defaults(operating_point, report)
logger.debug(
f"Inverter '{self.device_id}' at {parameters.temperature_degC} degC: "
f"maximum power {parameters.max_power_w} W."
)
return parameters
def uses_profile(self, kind: str, name: str) -> bool:
"""Return whether this inverter uses the profile of the given kind and name."""
from akkudoktoreos.devices.profile.inverterprofile import (
InverterProfileSettings,
)
return kind == InverterProfileSettings.profile_kind and name == self.inverter_profile
def check_profile(self) -> tuple[list[str], list[str]]:
"""Check that the inverter can be resolved from its profile.
Returns:
``(errors, warnings)``: an error if the inverter cannot be resolved,
warnings for installation limits without effect.
"""
try:
parameters = self.resolve_operating_parameters()
except InverterResolutionError as exc:
return [str(exc)], []
return [], check_inverter_adjustment(self, parameters)
def _effective_parameters(self) -> Optional[InverterOperatingParameters]:
"""Resolve at the reference operating point, None if that is not possible."""
try:
return self.resolve_operating_parameters()
except ValueError as exc:
logger.debug(f"Inverter '{self.device_id}' not resolvable: {exc}")
return None
# ------------------------------------------------------------------
# Computed fields, evaluated on every access at the reference operating point
# ------------------------------------------------------------------
@computed_field # type: ignore[prop-decorator]
@property
def effective_nominal_power_w(self) -> Optional[float]:
"""Effective nominal AC power [W]: profile adjustment, else profile."""
p = self._effective_parameters()
return p.nominal_power_w if p is not None else None
@computed_field # type: ignore[prop-decorator]
@property
def effective_max_power_w(self) -> Optional[float]:
"""Maximum AC power [W] at the reference temperature, derated and capped."""
p = self._effective_parameters()
return p.max_power_w if p is not None else None
@computed_field # type: ignore[prop-decorator]
@property
def effective_max_ac_charge_power_w(self) -> Optional[float]:
"""AC charge power limit [W] at the reference temperature; 0 disables AC charging."""
p = self._effective_parameters()
return p.max_ac_charge_power_w if p is not None else None
@computed_field # type: ignore[prop-decorator]
@property
def effective_dc_to_ac_efficiency(self) -> Optional[float]:
"""DC→AC efficiency at the reference load fraction."""
p = self._effective_parameters()
return p.dc_to_ac_efficiency if p is not None else None
@computed_field # type: ignore[prop-decorator]
@property
def effective_ac_to_dc_efficiency(self) -> Optional[float]:
"""AC→DC efficiency at the AC charge power limit."""
p = self._effective_parameters()
return p.ac_to_dc_efficiency if p is not None else None
# ------------------------------------------------------------------
# GENETIC domain conversion
# ------------------------------------------------------------------
def to_genetic_param(self) -> "InverterParameters":
"""Return InverterParameters for the GENETIC optimizer."""
def to_genetic_param(
self, operating_parameters: Optional[InverterOperatingParameters] = None
) -> "InverterParameters":
"""Return InverterParameters for the GENETIC optimizer.
Args:
operating_parameters: Resolved snapshot to convert, e.g. from
``resolve_current_operating_parameters``. None resolves at the
reference operating point.
Raises:
InverterResolutionError: If the snapshot is resolved here and the
inverter cannot be resolved.
"""
from akkudoktoreos.devices.genetic.inverter import InverterParameters
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
p = operating_parameters or self.resolve_operating_parameters()
return InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
max_power_wh=p.max_power_w,
battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
dc_to_ac_efficiency_curve=self.dc_to_ac_efficiency_curve,
ac_to_dc_efficiency=p.ac_to_dc_efficiency,
dc_to_ac_efficiency=p.dc_to_ac_efficiency,
dc_to_ac_efficiency_curve=list(p.dc_to_ac_efficiency_curve),
ac_to_dc_efficiency_curve=list(p.ac_to_dc_efficiency_curve),
dc_to_ac_efficiency_reference_load_fraction=(
self.dc_to_ac_efficiency_reference_load_fraction
),
max_ac_charge_power_w=self.max_ac_charge_power_w,
ac_charge_limits_total_charge=self.ac_charge_limits_total_charge,
max_ac_charge_power_w=p.max_ac_charge_power_w,
ac_charge_limits_total_charge=p.ac_charge_limits_total_charge,
)
# ------------------------------------------------------------------
# GENETIC0 domain conversion
# ------------------------------------------------------------------
def to_genetic0_param(self) -> "Genetic0InverterParameters":
"""Return Genetic0InverterParameters for the GENETIC0 optimizer."""
def to_genetic0_param(
self, operating_parameters: Optional[InverterOperatingParameters] = None
) -> "Genetic0InverterParameters":
"""Return Genetic0InverterParameters for the GENETIC0 optimizer.
GENETIC0 uses constant efficiencies: the DC→AC efficiency at the
reference load fraction and the AC→DC efficiency at the AC charge limit.
Raises:
InverterResolutionError: See ``to_genetic_param``.
"""
from akkudoktoreos.devices.genetic0.genetic0inverter import (
Genetic0InverterParameters,
)
if self.max_power_w is None:
raise ValueError("Inverter max_power_w is required for optimization")
p = operating_parameters or self.resolve_operating_parameters()
return Genetic0InverterParameters(
device_id=self.device_id,
max_power_wh=self.max_power_w,
max_power_wh=p.max_power_w,
battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
max_ac_charge_power_w=self.max_ac_charge_power_w,
ac_to_dc_efficiency=p.ac_to_dc_efficiency,
dc_to_ac_efficiency=p.dc_to_ac_efficiency,
max_ac_charge_power_w=p.max_ac_charge_power_w,
)
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_temperature_degc(self) -> str:
"""Measurement key for the ambient temperature of the inverter [degC]."""
return f"{self.device_id}-temperature-degc"
@computed_field # type: ignore[prop-decorator]
@property
def measurement_keys(self) -> list[str]:
"""Measurement keys for this inverter.
Returns the ``battery_initial_soc_factor_key`` if non-empty, so
the EMS measurement store knows to watch for this key.
The ambient temperature, and the ``battery_initial_soc_factor_key`` if
non-empty, so the EMS measurement store knows to watch for them.
"""
keys = [self.measurement_key_temperature_degc]
if self.battery_initial_soc_factor_key:
return [self.battery_initial_soc_factor_key]
return []
keys.append(self.battery_initial_soc_factor_key)
return keys
@@ -219,7 +219,11 @@ class ConfigOptimizationRequest(
device = groups["inverters"][0]
if device.battery_id != (battery.device_id if battery else None):
raise ValueError("Inverter battery_id must match the configured battery.")
inverter = device.to_genetic_param()
# The derating of the inverter profile applies at the measured
# ambient temperature, 45 degC without a measurement.
inverter = device.to_genetic_param(
await device.resolve_current_operating_parameters_or_defaults(observation_time)
)
if inverter is None:
raise ValueError("Configure an inverter to model PV and grid energy flows.")
appliances = [device.to_genetic_param() for device in groups["home_appliances"]]
@@ -26,6 +26,7 @@ from akkudoktoreos.devices.devicesabc import (
BatteryOperationMode,
)
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import InverterParameters
from akkudoktoreos.optimization.genetic.geneticdevices import GeneticParametersBaseModel
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
@@ -593,6 +594,26 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# Fallback → safe idle
return BatteryOperationMode.IDLE, 1.0
def _inverter_parameters_list(
self, parameters: Optional[GeneticOptimizationParameters]
) -> list[InverterParameters]:
"""Return the inverter parameters the solution was computed with, as a list.
Without stored parameters, the first configured inverter is resolved from
its inverter profile. An inverter that cannot be resolved is treated as
no inverter; the configuration check already reported it.
"""
if parameters is not None:
return [parameters.inverter] if parameters.inverter is not None else []
inverters = self.config.devices.inverters or {}
if not inverters:
return []
try:
return [list(inverters.values())[0].to_genetic_param()]
except ValueError as exc:
logger.debug(f"Inverter not resolvable for the solution: {exc}")
return []
def _soc_clamped_operation_factors(
self,
ac_charge: float,
@@ -649,13 +670,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if headroom_wh <= 0.0:
effective_ac = 0.0
else:
inv_list = (
[parameters.inverter]
if parameters is not None and parameters.inverter is not None
else list((self.config.devices.inverters or {}).values())
if parameters is None
else []
)
inv_list = self._inverter_parameters_list(parameters)
ac_to_dc_eff = float(inv_list[0].ac_to_dc_efficiency) if inv_list else 1.0
max_ac_cp_w = (
float(inv_list[0].max_ac_charge_power_w)
@@ -37,7 +37,6 @@ 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
@@ -47,12 +46,15 @@ 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 devices: used if no device of a kind is configured.
DEMO_BATTERY_SETTINGS: dict = {
"device_id": "battery1",
"profile_adjustment": {"nominal_capacity_wh": 8000},
}
DEMO_INVERTER_SETTINGS: dict = {
"device_id": "inverter1",
"profile_adjustment": {"nominal_power_w": 10000},
}
DEMO_ELECTRIC_VEHICLE_SETTINGS: dict = {
"device_id": "ev1",
"profile_adjustment": {"nominal_capacity_wh": 50000},
@@ -603,47 +605,45 @@ class Genetic0OptimizationParameters(
) = await battery_config.resolve_current_operating_parameters_or_defaults()
battery_params = battery_config.to_genetic0_pv_bat_param(battery_parameters)
except Exception as e:
# Never cancel the run and never change the user's configuration:
# optimize with the demo battery for this run only.
# Never cancel the run, never change the user's configuration and
# never invent a device: optimize without this battery.
logger.error(
"Battery '{}' cannot be prepared for the optimization: {} "
"Using the demo battery for this run.",
"The battery is left out of this run.",
battery_config.device_id,
e,
)
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(
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
attempt,
)
battery_config.levelized_cost_of_storage_amt_kwh = 0
battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC
try:
initial_soc_factor = await cls._nearest_soc_factor(
battery_config.measurement_key_soc_factor, ems.observation_datetime
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid battery initial SoC factor {initial_soc_factor} - defaulting to 0.0."
battery_params = None
battery_lcos_kwh = 0
if battery_params is not None:
# Levelized cost of ownership
if battery_config.levelized_cost_of_storage_amt_kwh is None:
logger.info(
"No battery device LCOS data available - defaulting to 0 [amount/kWh]. Parameter preparation attempt {}.",
attempt,
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No battery device SoC data (measurement key = '{battery_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
battery_params.initial_soc_percentage = initial_soc_percentage
battery_config.levelized_cost_of_storage_amt_kwh = 0
battery_lcos_kwh = battery_config.levelized_cost_of_storage_amt_kwh
# Initial SOC
try:
initial_soc_factor = await cls._nearest_soc_factor(
battery_config.measurement_key_soc_factor, ems.observation_datetime
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid battery initial SoC factor {initial_soc_factor} - defaulting to 0.0."
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No battery device SoC data (measurement key = '{battery_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
battery_params.initial_soc_percentage = initial_soc_percentage
# Electric Vehicles
# -----------------
@@ -673,44 +673,37 @@ class Genetic0OptimizationParameters(
ev_parameters
)
except Exception as e:
# Never cancel the run and never change the user's configuration:
# optimize with the demo electric vehicle for this run only.
# Never cancel the run, never change the user's configuration and
# never invent a device: optimize without this electric vehicle.
logger.error(
"Electric vehicle '{}' cannot be prepared for the optimization: {} "
"Using the demo electric vehicle for this run.",
"The electric vehicle is left out of this run.",
electric_vehicle_config.device_id,
e,
)
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(
electric_vehicle_config.measurement_key_soc_factor, ems.observation_datetime
)
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid electric vehicle initial SoC factor {initial_soc_factor} - defaulting to 0.0."
electric_vehicle_params = None
if electric_vehicle_params is not None:
# Initial SOC
try:
initial_soc_factor = await cls._nearest_soc_factor(
electric_vehicle_config.measurement_key_soc_factor,
ems.observation_datetime,
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No electric vehicle device SoC data (measurement key = '{electric_vehicle_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
electric_vehicle_params.initial_soc_percentage = initial_soc_percentage
if initial_soc_factor > 1.0 or initial_soc_factor < 0.0:
logger.error(
f"Invalid electric vehicle initial SoC factor {initial_soc_factor} - defaulting to 0.0."
)
initial_soc_factor = 0.0
# genetic parameter is 0..100 as int
initial_soc_percentage = int(initial_soc_factor * 100)
except Exception:
initial_soc_percentage = None
if initial_soc_percentage is None:
logger.info(
f"No electric vehicle device SoC data (measurement key = '{electric_vehicle_config.measurement_key_soc_factor}') available - defaulting to 0."
)
initial_soc_percentage = 0
electric_vehicle_params.initial_soc_percentage = initial_soc_percentage
# Inverters
# ---------
@@ -724,30 +717,27 @@ class Genetic0OptimizationParameters(
logger.info("No inverter device data available - defaulting to demo data.")
cls.config.devices.inverters = {
"inverter1": {
"device_id": "inverter1",
"max_power_w": 10000,
**DEMO_INVERTER_SETTINGS,
"battery_id": battery_config.device_id,
},
}
# Take first inverter
inverter_config = list(cls.config.devices.inverters.values())[0]
try:
# Take first inverter
inverter_config = list(cls.config.devices.inverters.values())[0]
inverter_params = inverter_config.to_genetic0_param()
# A configuration error gives default values, reported as error.
inverter_params = inverter_config.to_genetic0_param(
await inverter_config.resolve_current_operating_parameters_or_defaults()
)
except Exception as e:
logger.info(
"No inverter device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
# Never cancel the run, never change the user's configuration and
# never invent a device: optimize without this inverter.
logger.error(
"Inverter '{}' cannot be prepared for the optimization: {} "
"The inverter is left out of this run.",
inverter_config.device_id,
e,
)
cls.config.devices.inverters = {
"inverter1": {
"device_id": "inverter1",
"max_power_w": 10000,
"battery_id": battery_config.device_id,
},
}
# Retry
continue
inverter_params = None
# Home Appliances
# ---------------
@@ -26,6 +26,7 @@ from akkudoktoreos.devices.devicesabc import (
BatteryOperationMode,
)
from akkudoktoreos.devices.genetic0.genetic0battery import Genetic0Battery
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.optimization.genetic0.genetic0devices import (
Genetic0ParametersBaseModel,
)
@@ -507,6 +508,26 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
# Fallback → safe idle
return BatteryOperationMode.IDLE, 1.0
def _inverter_parameters(
self, parameters: Optional[Genetic0OptimizationParameters]
) -> Optional[Genetic0InverterParameters]:
"""Return the inverter parameters the solution was computed with.
Without stored parameters, the first configured inverter is resolved from
its inverter profile. An inverter that cannot be resolved is treated as
no inverter; the configuration check already reported it.
"""
if parameters is not None:
return parameters.inverter
inverters = self.config.devices.inverters or {}
if not inverters:
return None
try:
return list(inverters.values())[0].to_genetic0_param()
except ValueError as exc:
logger.debug(f"Inverter not resolvable for the solution: {exc}")
return None
def _soc_clamped_operation_factors(
self,
ac_charge: float,
@@ -565,8 +586,7 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
if headroom_wh <= 0.0:
effective_ac = 0.0
else:
inv_dict = self.config.devices.inverters
inv = list(inv_dict.values())[0] if inv_dict else None
inv = self._inverter_parameters(parameters)
ac_to_dc_eff = float(inv.ac_to_dc_efficiency) if inv else 1.0
max_ac_cp_w = (
float(inv.max_ac_charge_power_w)
+17 -2
View File
@@ -29,7 +29,8 @@ 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.core.coreabc import get_config
from akkudoktoreos.devices.profile import batteryprofile, inverterprofile # noqa: F401
from akkudoktoreos.devices.profile.profilestore import (
ProfileDocument,
ProfileEntry,
@@ -123,10 +124,24 @@ 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.
or null if there is none. A profile that a configured device uses can only
be deleted if a packaged profile of the same name applies afterwards.
"""
document_type = _document_type(kind)
store = get_profile_store()
entries = await asyncio.to_thread(store.list_profiles, document_type)
entry = next((entry for entry in entries if entry.name == name), None)
if entry is not None and not entry.packaged:
users = get_config().devices.devices_using_profile(kind, name)
if users:
raise EOSProblem(
status=409,
title="Profile in use",
detail=(
f"The {kind} profile '{name}' is used by {', '.join(users)} and has no "
"packaged profile to revert to. Select another profile for these devices first."
),
)
try:
await asyncio.to_thread(store.delete, document_type, name)
except PermissionError as exc:
+19 -5
View File
@@ -855,7 +855,7 @@ 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):
def test_check_device_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(
{
@@ -863,15 +863,29 @@ class TestDeviceSettings:
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}},
}
)
errors, warnings = devices.check_battery_profiles()
errors, warnings = devices.check_device_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):
def test_check_device_profiles_covers_inverters(self, config_eos):
# generic-hybrid provides no nominal power.
devices = DevicesCommonSettings.model_validate(
{
"inverters": {
"inv1": {},
"inv2": {"profile_adjustment": {"nominal_power_w": 5000, "max_power_w": 8000}},
}
}
)
errors, warnings = devices.check_device_profiles()
assert len(errors) == 1 and "Inverter 'inv1'" in errors[0]
assert len(warnings) == 1 and "Inverter 'inv2'" in warnings[0]
def test_config_logs_device_profile_problems_once(self, config_eos, monkeypatch):
from loguru import logger
monkeypatch.setattr(ConfigEOS, "_battery_profile_problems", ((), ()))
monkeypatch.setattr(ConfigEOS, "_device_profile_problems", ((), ()))
messages: list[str] = []
sink_id = logger.add(
lambda message: messages.append(message.record["message"]), level="WARNING"
@@ -888,7 +902,7 @@ class TestDeviceSettings:
"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 == ((), ())
assert ConfigEOS._device_profile_problems == ((), ())
finally:
logger.remove(sink_id)
+79
View File
@@ -416,6 +416,85 @@ class TestDeviceMigration:
),
}
def test_migration_logs_dropped_inverter_efficiencies_as_error(self):
"""Like battery efficiencies, dropped inverter efficiencies are logged as error."""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
try:
inverter = {
"device_id": "inv1",
"max_power_w": 8000,
"dc_to_ac_efficiency_curve": [[0.1, 0.9], [1.0, 0.97]],
}
migrate_config_data({"devices": {"inverters": {"inv1": inverter}}})
finally:
logger.remove(sink_id)
assert len(messages) == 1
assert "Inverter 'inv1'" in messages[0]
assert "dc_to_ac_efficiency_curve" in messages[0]
def test_migration_of_legacy_inverter_fields(self):
"""Inverter power limits move into the adjustment, efficiencies are dropped."""
inverter = {
"device_id": "inv1",
"max_power_w": 8000,
"max_ac_charge_power_w": 3000,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.96,
"dc_to_ac_efficiency_curve": [[0.1, 0.9], [1.0, 0.96]],
"ac_charge_limits_total_charge": True,
"battery_id": "bat1",
}
migrated = migrate_config_data({"devices": {"inverters": {"inv1": inverter}}})
assert migrated.devices.inverters is not None
settings = migrated.devices.inverters["inv1"]
assert settings.profile_adjustment.nominal_power_w == 8000
assert settings.profile_adjustment.max_ac_charge_power_w == 3000
assert settings.profile_adjustment.max_power_w is None
assert settings.battery_id == "bat1"
dumped = settings.model_dump(exclude_computed_fields=True)
for legacy in ("max_power_w", "max_ac_charge_power_w", "ac_to_dc_efficiency",
"dc_to_ac_efficiency", "dc_to_ac_efficiency_curve",
"ac_charge_limits_total_charge"):
assert legacy not in dumped
def test_migration_keeps_ac_charging_disabled(self):
"""A legacy AC→DC efficiency of 0 disabled AC charging."""
inverter = {"device_id": "inv1", "max_power_w": 8000, "ac_to_dc_efficiency": 0.0}
migrated = migrate_config_data({"devices": {"inverters": [inverter]}})
assert migrated.devices.inverters is not None
assert migrated.devices.inverters["inv1"].profile_adjustment.max_ac_charge_power_w == 0
def test_migration_keeps_present_inverter_adjustment(self):
inverter = {
"device_id": "inv1",
"max_power_w": 8000,
"profile_adjustment": {"nominal_power_w": 6000},
}
migrated = migrate_config_data({"devices": {"inverters": {"inv1": inverter}}})
assert migrated.devices.inverters is not None
assert migrated.devices.inverters["inv1"].profile_adjustment.nominal_power_w == 6000
def test_migration_of_inverter_home_assistant_config_paths(self):
raw = {
"adapter": {
"homeassistant": {
"config_entity_ids": {
"devices/inverters/inv1/max_power_w": "sensor.inverter_power",
"devices/inverters/inv1/dc_to_ac_efficiency": "sensor.efficiency",
"devices/inverters/inv1/battery_id": "sensor.battery",
}
}
}
}
migrated = migrate_config_data(raw)
assert migrated.adapter.homeassistant.config_entity_ids == {
"devices/inverters/inv1/profile_adjustment/nominal_power_w": "sensor.inverter_power",
"devices/inverters/inv1/battery_id": "sensor.battery",
}
def test_migration_keeps_a_migrated_battery(self):
"""A battery in the current layout is not changed, e.g. on a version bump."""
battery = {
+92
View File
@@ -0,0 +1,92 @@
"""Tests for the one-dimensional curves shared by the device profiles."""
import numpy as np
import pytest
from akkudoktoreos.devices.profile.profilecurve import (
CurveInterpolation,
OutOfRangeBehavior,
PrecomputedCurve,
reference_curve_lookup,
validate_curve_points,
)
X = (0.0, 0.2, 0.5, 1.0)
Y = (0.80, 0.95, 0.97, 0.96)
class TestValidateCurvePoints:
def test_accepts_increasing_points(self):
validate_curve_points(X, Y, "load_fraction", "efficiency")
@pytest.mark.parametrize("x", [(0.0, 0.0, 1.0), (0.0, 0.5, 0.4)])
def test_rejects_not_strictly_increasing(self, x):
with pytest.raises(ValueError, match="load_fraction breakpoints must be strictly"):
validate_curve_points(x, (1.0, 1.0, 1.0), "load_fraction", "efficiency")
def test_rejects_mismatched_lengths(self):
with pytest.raises(ValueError, match="same number of values"):
validate_curve_points(X, Y[:-1], "load_fraction", "efficiency")
class TestPrecomputedCurve:
@pytest.mark.parametrize("interpolation", list(CurveInterpolation))
@pytest.mark.parametrize("behavior", list(OutOfRangeBehavior))
def test_lookup_matches_reference(self, interpolation, behavior):
curve = PrecomputedCurve.from_points(X, Y, interpolation, behavior)
queries = [-0.5, 0.0, 0.1, 0.2, 0.35, 0.5, 0.75, 1.0, 1.5, 0.6]
for query in queries:
expected = reference_curve_lookup(X, Y, interpolation, behavior, query)
actual = curve.lookup(query)
if expected is None:
assert actual is None
else:
assert actual == pytest.approx(expected)
def test_linear_interpolation(self):
curve = PrecomputedCurve.from_points(X, Y)
assert curve.lookup(0.1) == pytest.approx(0.875)
assert curve.lookup(0.75) == pytest.approx(0.965)
def test_clamps_by_default(self):
curve = PrecomputedCurve.from_points(X, Y)
assert curve.lookup(-1.0) == pytest.approx(0.80)
assert curve.lookup(2.0) == pytest.approx(0.96)
def test_nearest_tie_takes_lower_breakpoint(self):
curve = PrecomputedCurve.from_points(X, Y, CurveInterpolation.NEAREST)
assert curve.lookup(0.1) == pytest.approx(0.80)
def test_none_interpolation_only_at_breakpoints(self):
curve = PrecomputedCurve.from_points(X, Y, CurveInterpolation.NONE)
assert curve.lookup(0.5) == pytest.approx(0.97)
assert curve.lookup(0.4) is None
def test_reject_out_of_range(self):
curve = PrecomputedCurve.from_points(X, Y, out_of_range_behavior=OutOfRangeBehavior.REJECT)
assert curve.lookup(-0.1) is None
assert curve.lookup(1.1) is None
def test_precomputes_coefficients(self):
curve = PrecomputedCurve.from_points(X, Y)
assert len(curve.slopes) == len(X) - 1
assert curve.midpoints == pytest.approx((0.1, 0.35, 0.75))
for i, slope in enumerate(curve.slopes):
assert slope * X[i] + curve.intercepts[i] == pytest.approx(Y[i])
def test_accepts_numpy_input(self):
curve = PrecomputedCurve.from_points(np.array(X), np.array(Y))
assert curve.x == X
@pytest.mark.parametrize(
"x, y",
[((0.0,), (1.0,)), ((0.0, 0.0), (1.0, 1.0)), ((0.0, 1.0), (1.0,))],
)
def test_rejects_invalid_points(self, x, y):
with pytest.raises(ValueError):
PrecomputedCurve.from_points(x, y)
def test_is_immutable(self):
curve = PrecomputedCurve.from_points(X, Y)
with pytest.raises(AttributeError):
curve.x = (0.0, 1.0) # type: ignore[misc]
+165
View File
@@ -0,0 +1,165 @@
"""Tests for inverter profile documents and their runtime form."""
import copy
from typing import Any, Iterator
import pytest
from pydantic import ValidationError
from akkudoktoreos.devices.profile.inverterprofile import (
InverterProfile,
InverterProfileSettings,
)
from akkudoktoreos.devices.profile.profilestore import (
ProfileStore,
get_profile_store,
profile_type,
)
DOCUMENT: dict[str, Any] = {
"inverter_model": "Test inverter",
"nominal_power_w": 10000.0,
"max_ac_charge_power_w": 6000.0,
"dc_to_ac_efficiency": {
"load_fraction": [0.1, 0.5, 1.0],
"efficiency": [0.90, 0.97, 0.96],
},
"ac_to_dc_efficiency": {
"load_fraction": [0.1, 1.0],
"efficiency": [0.88, 0.95],
},
"power_derating": {
"temperature_degC": [40.0, 60.0],
"power_fraction": [1.0, 0.5],
},
}
def make_document(**changes: Any) -> dict[str, Any]:
document = copy.deepcopy(DOCUMENT)
document.update(changes)
return document
def make_profile(**changes: Any) -> InverterProfile:
return InverterProfileSettings.model_validate(make_document(**changes)).compile_profile()
class TestDocument:
def test_registered_as_inverter_kind(self):
assert profile_type("inverter") is InverterProfileSettings
def test_minimal_generic_document(self):
document = make_document(nominal_power_w=None, max_ac_charge_power_w=None)
del document["power_derating"]
settings = InverterProfileSettings.model_validate(document)
assert settings.nominal_power_w is None
assert settings.power_derating is None
assert settings.ac_charge_limits_total_charge is False
@pytest.mark.parametrize("field", ["dc_to_ac_efficiency", "ac_to_dc_efficiency"])
def test_efficiency_curves_are_required(self, field):
document = make_document()
del document[field]
with pytest.raises(ValidationError, match=field):
InverterProfileSettings.model_validate(document)
def test_rejects_unknown_keys(self):
with pytest.raises(ValidationError, match="nominal_power_kw"):
InverterProfileSettings.model_validate(make_document(nominal_power_kw=10.0))
@pytest.mark.parametrize(
"curve",
[
{"load_fraction": [0.5, 0.1], "efficiency": [0.9, 0.9]},
{"load_fraction": [-0.1, 1.0], "efficiency": [0.9, 0.9]},
{"load_fraction": [0.1, 1.0], "efficiency": [0.0, 0.9]},
{"load_fraction": [0.1, 1.0], "efficiency": [0.9, 1.1]},
{"load_fraction": [0.1, 0.5, 1.0], "efficiency": [0.9, 0.9]},
{"load_fraction": [0.1], "efficiency": [0.9]},
],
)
def test_rejects_invalid_efficiency_curves(self, curve):
with pytest.raises(ValidationError):
InverterProfileSettings.model_validate(make_document(dc_to_ac_efficiency=curve))
@pytest.mark.parametrize(
"curve",
[
{"temperature_degC": [60.0, 40.0], "power_fraction": [1.0, 0.5]},
{"temperature_degC": [40.0, 60.0], "power_fraction": [1.0, 1.5]},
{"temperature_degC": [40.0, 60.0], "power_fraction": [1.0, -0.1]},
],
)
def test_rejects_invalid_derating(self, curve):
with pytest.raises(ValidationError):
InverterProfileSettings.model_validate(make_document(power_derating=curve))
@pytest.mark.parametrize("field", ["nominal_power_w", "max_ac_charge_power_w"])
@pytest.mark.parametrize("value", [0.0, -1.0, float("inf")])
def test_rejects_invalid_powers(self, field, value):
with pytest.raises(ValidationError):
InverterProfileSettings.model_validate(make_document(**{field: value}))
def test_compile_rejects_options(self):
with pytest.raises(TypeError):
InverterProfileSettings.model_validate(make_document()).compile_profile(t_step=1.0)
class TestRuntime:
def test_carries_the_model_data(self):
profile = make_profile(ac_charge_limits_total_charge=True)
assert profile.inverter_model == "Test inverter"
assert profile.nominal_power_w == pytest.approx(10000.0)
assert profile.max_ac_charge_power_w == pytest.approx(6000.0)
assert profile.ac_charge_limits_total_charge is True
def test_efficiencies_interpolate_linearly(self):
profile = make_profile()
assert profile.dc_to_ac_efficiency_at(0.3) == pytest.approx(0.935)
assert profile.ac_to_dc_efficiency_at(0.55) == pytest.approx(0.915)
def test_efficiencies_clamp_beyond_the_curve(self):
profile = make_profile()
assert profile.dc_to_ac_efficiency_at(0.0) == pytest.approx(0.90)
assert profile.dc_to_ac_efficiency_at(1.5) == pytest.approx(0.96)
def test_derating(self):
profile = make_profile()
assert profile.power_fraction_at(20.0) == pytest.approx(1.0)
assert profile.power_fraction_at(50.0) == pytest.approx(0.75)
assert profile.power_fraction_at(80.0) == pytest.approx(0.5)
def test_without_derating_full_power(self):
document = make_document()
del document["power_derating"]
profile = InverterProfileSettings.model_validate(document).compile_profile()
assert profile.power_fraction_at(70.0) == pytest.approx(1.0)
def test_is_immutable(self):
profile = make_profile()
with pytest.raises(AttributeError):
profile.nominal_power_w = 1.0 # type: ignore[misc]
class TestPackagedGenericHybrid:
@pytest.fixture
def profile(self, config_eos) -> Iterator[InverterProfile]:
ProfileStore.reset_instance()
yield get_profile_store().get(InverterProfileSettings, "generic-hybrid")
ProfileStore.reset_instance()
def test_is_generic(self, profile):
assert profile.nominal_power_w is None
assert profile.max_ac_charge_power_w is None
def test_full_power_up_to_45_degc(self, profile):
assert profile.power_fraction_at(-30.0) == pytest.approx(1.0)
assert profile.power_fraction_at(45.0) == pytest.approx(1.0)
assert profile.power_fraction_at(60.0) == pytest.approx(0.6)
assert profile.power_fraction_at(65.0) == pytest.approx(0.0)
def test_realistic_efficiencies(self, profile):
for load_fraction in (0.05, 0.1, 0.3, 0.5, 1.0):
assert 0.85 <= profile.dc_to_ac_efficiency_at(load_fraction) < 1.0
assert 0.85 <= profile.ac_to_dc_efficiency_at(load_fraction) < 1.0
+317
View File
@@ -0,0 +1,317 @@
"""Tests for the inverter device settings resolved from inverter profiles."""
import dataclasses
import math
from typing import Any, Optional
import pytest
from akkudoktoreos.devices.profile.inverterprofile import (
InverterProfile,
InverterProfileSettings,
)
from akkudoktoreos.devices.profile.profilecurve import PrecomputedCurve
from akkudoktoreos.devices.profile.profilestore import get_profile_store
from akkudoktoreos.devices.settings import invertermeasurement
from akkudoktoreos.devices.settings.inverterresolution import (
INVERTER_DEFAULT_TEMPERATURE_DEGC,
InverterOperatingPoint,
InverterResolutionError,
)
from akkudoktoreos.devices.settings.invertersettings import (
INVERTER_DEFAULT_PROFILE,
InverterCommonSettings,
InverterProfileAdjustment,
)
def make_settings(**adjustment: Any) -> InverterCommonSettings:
return InverterCommonSettings(
device_id="inverter1",
battery_id="battery1",
profile_adjustment=InverterProfileAdjustment(**adjustment),
)
@pytest.fixture
def generic(config_eos) -> InverterProfile:
"""The packaged generic hybrid inverter profile."""
return get_profile_store().get(InverterProfileSettings, "generic-hybrid")
@pytest.fixture
def use_profile(monkeypatch):
"""Let all inverter settings use the given compiled profile."""
def _use(profile: InverterProfile) -> None:
monkeypatch.setattr(InverterCommonSettings, "get_inverter_profile", lambda self: profile)
return _use
@pytest.fixture
def measured(monkeypatch) -> dict[str, Optional[float]]:
"""Measured values by key for the inverter measurement access."""
values: dict[str, Optional[float]] = {}
async def fake_get_measured_value(key: str, target_datetime: Any) -> Optional[float]:
return values.get(key)
monkeypatch.setattr(invertermeasurement, "get_measured_value", fake_get_measured_value)
return values
class TestDefaults:
def test_default_profile(self):
settings = InverterCommonSettings(device_id="inverter1")
assert settings.inverter_profile == INVERTER_DEFAULT_PROFILE == "generic-hybrid"
assert settings.profile_adjustment.model_dump() == InverterProfileAdjustment().model_dump()
def test_generic_profile_needs_a_nominal_power(self, generic):
settings = InverterCommonSettings(device_id="inverter1")
with pytest.raises(InverterResolutionError, match="inverter1.*nominal_power_w"):
settings.resolve_operating_parameters()
assert settings.effective_nominal_power_w is None
assert settings.effective_max_power_w is None
errors, warnings = settings.check_profile()
assert len(errors) == 1 and "inverter1" in errors[0]
assert warnings == []
@pytest.mark.parametrize(
"adjustment",
[{"nominal_power_w": 0}, {"max_power_w": 0}, {"max_ac_charge_power_w": -1}],
)
def test_rejects_invalid_adjustment(self, adjustment):
with pytest.raises(ValueError):
InverterProfileAdjustment(**adjustment)
class TestResolution:
def test_reference_point_is_45_degc(self, generic):
settings = make_settings(nominal_power_w=10000)
parameters = settings.resolve_operating_parameters()
assert parameters.temperature_degC == INVERTER_DEFAULT_TEMPERATURE_DEGC == 45.0
# The generic profile is not derated at 45 degC.
assert parameters.power_fraction == pytest.approx(1.0)
assert parameters.nominal_power_w == pytest.approx(10000)
assert parameters.max_power_w == pytest.approx(10000)
assert parameters.max_ac_charge_power_w == pytest.approx(10000)
assert settings.effective_max_power_w == pytest.approx(10000)
def test_efficiencies_come_from_the_profile(self, generic):
settings = make_settings(nominal_power_w=10000)
parameters = settings.resolve_operating_parameters()
# DC→AC at the reference load fraction 0.06 of the maximum power.
assert parameters.dc_to_ac_efficiency == pytest.approx(generic.dc_to_ac_efficiency_at(0.06))
# AC→DC at the AC charge limit, here the full nominal power.
assert parameters.ac_to_dc_efficiency == pytest.approx(generic.ac_to_dc_efficiency_at(1.0))
assert settings.effective_dc_to_ac_efficiency == pytest.approx(
parameters.dc_to_ac_efficiency
)
assert settings.effective_ac_to_dc_efficiency == pytest.approx(
parameters.ac_to_dc_efficiency
)
def test_derating_at_the_operating_point(self, generic):
settings = make_settings(nominal_power_w=10000)
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
assert parameters.power_fraction == pytest.approx(0.6)
assert parameters.max_power_w == pytest.approx(6000)
assert parameters.max_ac_charge_power_w == pytest.approx(6000)
def test_curves_are_over_the_load_fraction_of_the_maximum_power(self, generic):
settings = make_settings(nominal_power_w=10000)
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
# 3000 W are 0.3 of the nominal and 0.5 of the derated maximum power.
curve = PrecomputedCurve.from_points(
[x for x, _ in parameters.dc_to_ac_efficiency_curve],
[y for _, y in parameters.dc_to_ac_efficiency_curve],
)
assert curve.lookup(0.5) == pytest.approx(generic.dc_to_ac_efficiency_at(0.3))
# The curve ends at the maximum power, 0.6 of the nominal power.
assert parameters.dc_to_ac_efficiency_curve[-1] == pytest.approx(
(1.0, generic.dc_to_ac_efficiency_at(0.6))
)
assert all(0.0 <= x <= 1.0 for x, _ in parameters.dc_to_ac_efficiency_curve)
def test_curve_keeps_two_points_when_heavily_limited(self, generic):
# 400 W are below the first breakpoint (0.05 of 10 kW = 500 W).
settings = make_settings(nominal_power_w=10000, max_power_w=400)
curve = settings.resolve_operating_parameters().dc_to_ac_efficiency_curve
assert [x for x, _ in curve] == [0.0, 1.0]
assert [y for _, y in curve] == pytest.approx([0.88, 0.88])
settings.to_genetic_param()
def test_no_power_at_the_operating_point(self, generic):
settings = make_settings(nominal_power_w=10000)
with pytest.raises(InverterResolutionError, match="allows no power"):
settings.resolve_operating_parameters(InverterOperatingPoint(70.0))
def test_installation_limits_only_lower(self, generic):
settings = make_settings(nominal_power_w=10000, max_power_w=8000, max_ac_charge_power_w=3000)
parameters = settings.resolve_operating_parameters()
assert parameters.max_power_w == pytest.approx(8000)
assert parameters.max_ac_charge_power_w == pytest.approx(3000)
assert parameters.ac_to_dc_efficiency == pytest.approx(generic.ac_to_dc_efficiency_at(0.3))
assert settings.check_profile() == ([], [])
def test_derating_below_the_installation_limit(self, generic):
settings = make_settings(nominal_power_w=10000, max_power_w=8000)
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
assert parameters.max_power_w == pytest.approx(6000)
def test_zero_ac_charge_limit_disables_ac_charging(self, generic):
settings = make_settings(nominal_power_w=10000, max_ac_charge_power_w=0)
parameters = settings.resolve_operating_parameters()
assert parameters.max_ac_charge_power_w == 0
assert math.isfinite(parameters.ac_to_dc_efficiency)
assert settings.to_genetic_param().max_ac_charge_power_w == 0
def test_model_ac_charge_limit_is_derated(self, generic, use_profile):
use_profile(dataclasses.replace(generic, nominal_power_w=10000, max_ac_charge_power_w=5000))
settings = make_settings()
assert settings.resolve_operating_parameters().max_ac_charge_power_w == pytest.approx(5000)
derated = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
assert derated.max_ac_charge_power_w == pytest.approx(3000)
def test_profile_nominal_power_and_adjustment(self, generic, use_profile):
use_profile(dataclasses.replace(generic, nominal_power_w=6000))
assert make_settings().effective_nominal_power_w == pytest.approx(6000)
assert make_settings(nominal_power_w=8000).effective_nominal_power_w == pytest.approx(8000)
def test_missing_profile(self, config_eos):
settings = InverterCommonSettings(
device_id="inverter1",
inverter_profile="does-not-exist",
profile_adjustment=InverterProfileAdjustment(nominal_power_w=10000),
)
with pytest.raises(InverterResolutionError, match="does-not-exist.*not usable"):
settings.resolve_operating_parameters()
assert settings.effective_max_power_w is None
errors, _ = settings.check_profile()
assert "does-not-exist" in errors[0]
def test_warns_about_limits_without_effect(self, generic):
settings = make_settings(nominal_power_w=5000, max_power_w=8000, max_ac_charge_power_w=6000)
errors, warnings = settings.check_profile()
assert errors == []
assert len(warnings) == 2
assert "profile_adjustment.max_power_w" in warnings[0]
assert "profile_adjustment.max_ac_charge_power_w" in warnings[1]
class TestMeasurement:
def test_temperature_measurement_key(self):
settings = make_settings(nominal_power_w=10000)
assert settings.measurement_key_temperature_degc == "inverter1-temperature-degc"
assert "inverter1-temperature-degc" in settings.measurement_keys
@pytest.mark.asyncio
async def test_measured_temperature(self, measured):
measured["inverter1-temperature-degc"] = 52.5
point = await make_settings().measure_operating_point()
assert point == InverterOperatingPoint(temperature_degC=52.5)
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [None, float("nan"), float("inf")])
async def test_missing_temperature_defaults_to_45_degc(self, measured, value):
measured["inverter1-temperature-degc"] = value
point = await make_settings().measure_operating_point()
assert point.temperature_degC == 45.0
@pytest.mark.asyncio
async def test_resolution_at_the_measured_temperature(self, generic, measured):
settings = make_settings(nominal_power_w=10000)
measured["inverter1-temperature-degc"] = 60.0
parameters = await settings.resolve_current_operating_parameters()
assert parameters.max_power_w == pytest.approx(6000)
class TestConversion:
def test_genetic_parameters(self, generic):
settings = make_settings(nominal_power_w=10000, max_ac_charge_power_w=4000)
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
genetic = settings.to_genetic_param(parameters)
assert genetic.device_id == "inverter1"
assert genetic.battery_id == "battery1"
assert genetic.max_power_wh == pytest.approx(6000)
assert genetic.max_ac_charge_power_w == pytest.approx(4000)
assert genetic.dc_to_ac_efficiency == pytest.approx(parameters.dc_to_ac_efficiency)
assert genetic.ac_to_dc_efficiency == pytest.approx(parameters.ac_to_dc_efficiency)
assert genetic.dc_to_ac_efficiency_curve == [
tuple(point) for point in parameters.dc_to_ac_efficiency_curve
]
assert genetic.ac_charge_limits_total_charge is False
def test_genetic0_parameters(self, generic):
settings = make_settings(nominal_power_w=10000)
genetic0 = settings.to_genetic0_param()
assert genetic0.max_power_wh == pytest.approx(10000)
assert genetic0.dc_to_ac_efficiency == pytest.approx(settings.effective_dc_to_ac_efficiency)
assert genetic0.ac_to_dc_efficiency == pytest.approx(settings.effective_ac_to_dc_efficiency)
@pytest.fixture
def logged_errors():
"""Collect the messages logged at ERROR level."""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
yield messages
logger.remove(sink_id)
class TestOptimizationDefaults:
"""The parameter preparation of the optimizations never fails (defaults with an error log)."""
@pytest.mark.asyncio
async def test_derated_to_zero_uses_the_reference_temperature(
self, generic, measured, logged_errors
):
"""generic-hybrid derates to 0 W at 65 degC; the run gets the 45 degC parameters."""
settings = make_settings(nominal_power_w=10000)
measured["inverter1-temperature-degc"] = 70.0
parameters = await settings.resolve_current_operating_parameters_or_defaults()
assert parameters.max_power_w == pytest.approx(10000)
assert parameters.temperature_degC == INVERTER_DEFAULT_TEMPERATURE_DEGC
assert len(logged_errors) == 1
assert "allows no power at 70.0 degC" in logged_errors[0]
# The conversion gets valid parameters.
assert settings.to_genetic_param(parameters).max_power_wh == pytest.approx(10000)
def test_missing_nominal_power_gives_the_fallback(self, generic, logged_errors):
parameters = make_settings().resolve_operating_parameters_or_defaults()
assert parameters.nominal_power_w == pytest.approx(10000)
assert len(logged_errors) == 1
assert "nominal power 10000 W" in logged_errors[0]
def test_unusable_profile_uses_the_generic_profile(self, generic, logged_errors):
settings = InverterCommonSettings(
device_id="inverter1",
battery_id="battery1",
inverter_profile="does-not-exist",
profile_adjustment=InverterProfileAdjustment(nominal_power_w=8000),
)
parameters = settings.resolve_operating_parameters_or_defaults()
assert parameters.nominal_power_w == pytest.approx(8000)
assert parameters.dc_to_ac_efficiency < 1.0 # from the generic profile curves
assert len(logged_errors) == 1
assert f"inverter profile '{INVERTER_DEFAULT_PROFILE}'" in logged_errors[0]
@pytest.mark.parametrize("error", [FileNotFoundError, PermissionError, IsADirectoryError])
def test_without_any_usable_profile(self, monkeypatch, logged_errors, error):
def missing(document_type, name, **options):
raise error(f"Inverter profile '{name}' is not readable")
monkeypatch.setattr(get_profile_store(), "get", missing)
settings = make_settings(nominal_power_w=8000, max_power_w=6000)
parameters = settings.resolve_operating_parameters_or_defaults()
assert parameters.max_power_w == pytest.approx(6000)
assert parameters.dc_to_ac_efficiency == pytest.approx(1.0)
assert len(logged_errors) == 1
assert "constant efficiency 1" in logged_errors[0]
def test_report_false_logs_nothing(self, generic, logged_errors):
make_settings().resolve_operating_parameters_or_defaults(report=False)
assert logged_errors == []
+71
View File
@@ -228,3 +228,74 @@ class TestGenetic0SocClampedOperationFactors:
async def test_optimization_solution_with_configured_battery(self, solution):
optimization_solution = await solution.optimization_solution()
assert optimization_solution is not None
class TestGenetic0SolutionWithProfileInverter:
"""The solution uses the inverter parameters of the run, not the inverter settings.
Inverter settings no longer hold efficiencies or power fields (they come
from the inverter profile), so reading them from the settings fails.
"""
@pytest.fixture
def solution(self, config_eos: ConfigEOS) -> Genetic0Solution:
config_eos.merge_settings_from_dict(
{
"devices": {
"max_batteries": 1,
"batteries": {
"battery1": {
"device_id": "battery1",
"profile_adjustment": {"nominal_capacity_wh": 8000},
}
},
"max_inverters": 1,
"inverters": {
"inverter1": {
"device_id": "inverter1",
"battery_id": "battery1",
"profile_adjustment": {
"nominal_power_w": 10000,
"max_ac_charge_power_w": 2000,
},
}
},
}
}
)
data = json.loads((DIR_TESTDATA / "optimize_result_1.json").read_text())
return Genetic0Solution.model_validate(data)
def test_uses_solution_inverter_parameters(self, solution):
bat = solution.parameters.pv_battery
inverter = solution.parameters.inverter
assert bat is not None and inverter is not None
# The stored inverter has no AC charge limit and efficiency 1.0, unlike
# the configured one: the battery charge power limits the AC charge.
headroom_wh = 0.01 * bat.capacity_wh
expected = headroom_wh / (
bat.max_charge_power_w * inverter.ac_to_dc_efficiency * bat.charging_efficiency
)
ac, _, _ = solution._soc_clamped_operation_factors(
1.0, 1.0, True, float(bat.max_soc_percentage) - 1.0
)
assert ac == pytest.approx(expected)
def test_without_stored_parameters_the_configured_inverter_is_resolved(self, solution):
inverter = solution._inverter_parameters(None)
assert inverter is not None
assert inverter.max_power_wh == pytest.approx(10000)
assert inverter.max_ac_charge_power_w == pytest.approx(2000)
assert 0.0 < inverter.ac_to_dc_efficiency < 1.0
def test_unresolvable_inverter_counts_as_no_inverter(self, solution, config_eos):
config_eos.merge_settings_from_dict(
{"devices": {"inverters": {"inverter1": {"profile_adjustment": {"nominal_power_w": None}}}}}
)
assert solution._inverter_parameters(None) is None
@pytest.mark.asyncio
async def test_optimization_solution_with_configured_inverter(self, solution):
"""Regression: AttributeError 'ac_to_dc_efficiency' on InverterCommonSettings."""
optimization_solution = await solution.optimization_solution()
assert optimization_solution is not None
+83 -3
View File
@@ -161,7 +161,7 @@ class TestGenetic0MeasuredSOC:
"batteries": {
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "profile_adjustment": {"nominal_power_w": 1000}}},
},
}
)
@@ -208,7 +208,7 @@ class TestGenetic0MeasuredSOC:
"batteries": {
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "profile_adjustment": {"nominal_power_w": 1000}}},
},
}
)
@@ -254,7 +254,7 @@ class TestGenetic0MeasuredSOC:
"batteries": {
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
"inverters": {"inverter": {"battery_id": "storage", "profile_adjustment": {"nominal_power_w": 1000}}},
},
}
)
@@ -386,3 +386,83 @@ class TestGenetic0DemoData:
assert any(
"Battery 'storage'" in m and "nominal capacity 8000 Wh" in m for m in messages
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"settings_class, method, field, device_id",
[
("BatteriesCommonSettings", "to_genetic0_pv_bat_param", "pv_battery", "storage"),
("BatteriesCommonSettings", "to_genetic0_ev_bat_param", "ev", "car"),
("InverterCommonSettings", "to_genetic0_param", "inverter", "hybrid"),
],
)
async def test_device_that_cannot_be_prepared_is_left_out(
self, config_eos, monkeypatch, settings_class, method, field, device_id
):
"""No demo device replaces a configured device that cannot be prepared."""
from loguru import logger
from akkudoktoreos.devices.settings import batterysettings, invertersettings
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": 1,
"max_home_appliances": 0,
"batteries": {
"storage": {"profile_adjustment": {"nominal_capacity_wh": 5000}}
},
"electric_vehicles": {
"car": {"profile_adjustment": {"nominal_capacity_wh": 40000}}
},
"inverters": {
"hybrid": {
"battery_id": "storage",
"profile_adjustment": {"nominal_power_w": 6000},
}
},
},
}
)
configured = config_eos.devices.model_dump(exclude_computed_fields=True)
module = batterysettings if settings_class == "BatteriesCommonSettings" else invertersettings
def broken(self, *args, **kwargs):
raise RuntimeError("conversion failed")
monkeypatch.setattr(getattr(module, settings_class), method, broken)
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 getattr(parameters, field) is None
assert any(f"'{device_id}'" in m and "left out" in m for m in messages)
assert not any("demo" in m for m in messages)
# The other devices are prepared, the configuration is unchanged.
for other in {"pv_battery", "ev", "inverter"} - {field}:
assert getattr(parameters, other) is not None
assert config_eos.devices.model_dump(exclude_computed_fields=True) == configured
+18 -8
View File
@@ -7,6 +7,7 @@ leaves of the cap. Without the flag the default model applies: PV charges first
and the grid adds ``ac_charge`` of the remaining charge power on top.
"""
import dataclasses
from unittest.mock import Mock, patch
import numpy as np
@@ -14,7 +15,12 @@ import pytest
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
from akkudoktoreos.devices.profile.inverterprofile import InverterProfileSettings
from akkudoktoreos.devices.profile.profilestore import get_profile_store
from akkudoktoreos.devices.settings.invertersettings import (
InverterCommonSettings,
InverterProfileAdjustment,
)
MAX_CHARGE_W = 5000
@@ -226,19 +232,23 @@ class TestGeneticSimulation:
class TestConfigOption:
"""The flag is a device config option that reaches the GENETIC optimizer."""
"""The flag is a property of the inverter profile that reaches the GENETIC optimizer."""
@pytest.mark.parametrize("enabled", [False, True])
def test_config_passes_the_flag_to_the_optimizer(self, enabled):
def test_profile_passes_the_flag_to_the_optimizer(self, config_eos, monkeypatch, enabled):
generic = get_profile_store().get(InverterProfileSettings, "generic-hybrid")
profile = dataclasses.replace(generic, ac_charge_limits_total_charge=enabled)
monkeypatch.setattr(InverterCommonSettings, "get_inverter_profile", lambda self: profile)
settings = InverterCommonSettings(
device_id="inverter",
max_power_w=10000,
battery_id="battery1",
ac_charge_limits_total_charge=enabled,
profile_adjustment=InverterProfileAdjustment(nominal_power_w=10000),
)
assert settings.to_genetic_param().ac_charge_limits_total_charge is enabled
def test_default_keeps_the_existing_model(self):
settings = InverterCommonSettings(device_id="inverter", max_power_w=10000)
assert settings.ac_charge_limits_total_charge is False
def test_default_profile_keeps_the_existing_model(self, config_eos):
settings = InverterCommonSettings(
device_id="inverter",
profile_adjustment=InverterProfileAdjustment(nominal_power_w=10000),
)
assert settings.to_genetic_param().ac_charge_limits_total_charge is False
+21 -1
View File
@@ -266,7 +266,7 @@ def full_charge_request(config_eos, monkeypatch):
}
},
"electric_vehicles": {},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
"inverters": {"inverter": {"battery_id": "storage", "profile_adjustment": {"nominal_power_w": 5000}}},
"home_appliances": {},
},
}
@@ -309,6 +309,26 @@ async def test_cold_battery_does_not_cancel_the_request(full_charge_request, mon
assert parameters.pv_battery.max_discharge_power_w is not None
assert parameters.pv_battery.max_discharge_power_w > 0.0
@pytest.mark.asyncio
async def test_hot_inverter_does_not_cancel_the_request(full_charge_request, monkeypatch):
"""An inverter derated to zero power at its temperature still gets valid parameters."""
from akkudoktoreos.devices.settings.inverterresolution import InverterOperatingPoint
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
config, _, _, data = full_charge_request
config.devices.batteries["storage"].profile_adjustment.full_charge_interval_days = None
async def hot(self, target_datetime=None):
return InverterOperatingPoint(temperature_degC=70.0)
monkeypatch.setattr(InverterCommonSettings, "measure_operating_point", hot)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.inverter is not None
assert parameters.inverter.max_power_wh > 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)]
+78 -1
View File
@@ -1,6 +1,7 @@
"""Configuration-owned requests and the real automatic GENETIC path."""
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import AsyncMock, Mock, patch
import httpx
@@ -67,7 +68,12 @@ def configured_request(config_eos, monkeypatch):
}
},
"electric_vehicles": {},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
"inverters": {
"inverter": {
"battery_id": "storage",
"profile_adjustment": {"nominal_power_w": 1000},
}
},
"home_appliances": {},
},
}
@@ -758,3 +764,74 @@ async def test_battery_configuration_error_uses_defaults_and_logs_error(configur
and "Using defaults for the optimization: battery profile 'generic-lfp'" in m
for m in messages
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"temperature_degc, expected_max_power_w",
[(None, 1000.0), (45.0, 1000.0), (60.0, 600.0)],
)
async def test_inverter_is_derated_at_measured_temperature(
configured_request, monkeypatch, temperature_degc, expected_max_power_w
):
"""The generic profile derates above 45 degC; without a measurement 45 degC applies."""
from akkudoktoreos.devices.settings import invertermeasurement
_, _, _, data = configured_request
async def measured(key: str, target_datetime: Any) -> Optional[float]:
return temperature_degc if key == "inverter-temperature-degc" else None
monkeypatch.setattr(invertermeasurement, "get_measured_value", measured)
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert parameters.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(expected_max_power_w)
assert parameters.inverter.max_ac_charge_power_w == pytest.approx(expected_max_power_w)
assert parameters.inverter.ac_to_dc_efficiency_curve is not None
@pytest.mark.asyncio
async def test_unusable_inverter_profile_falls_back_to_generic(configured_request):
"""A missing inverter profile does not cancel the parameter preparation."""
from loguru import logger
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"inverters": {"inverter": {"inverter_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.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(1000.0)
assert any(
"Inverter 'inverter'" in m and "inverter profile 'generic-hybrid'" in m
for m in messages
)
@pytest.mark.asyncio
async def test_inverter_without_nominal_power_uses_the_fallback_power(configured_request):
"""The run is not cancelled: the fallback nominal power applies, reported as error."""
from loguru import logger
from akkudoktoreos.devices.settings.inverterresolution import (
INVERTER_FALLBACK_NOMINAL_POWER_W,
)
config, _, _, data = configured_request
config.merge_settings_from_dict(
{"devices": {"inverters": {"inverter": {"profile_adjustment": {"nominal_power_w": None}}}}}
)
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.inverter is not None
assert parameters.inverter.max_power_wh == pytest.approx(INVERTER_FALLBACK_NOMINAL_POWER_W)
assert any("nominal_power_w" in m for m in messages)
+2 -2
View File
@@ -318,7 +318,7 @@ async def test_real_measurement_completed_cycles_reach_request_and_optimizer(
"max_electric_vehicles": 0,
"electric_vehicles": {},
"max_inverters": 1,
"inverters": {"inv": {"max_power_w": 10000}},
"inverters": {"inv": {"profile_adjustment": {"nominal_power_w": 10000}}},
"max_home_appliances": 1,
"home_appliances": {
"washer": profile(
@@ -432,7 +432,7 @@ async def test_meter_only_record_does_not_hide_completed_cycles(
"max_electric_vehicles": 0,
"electric_vehicles": {},
"max_inverters": 1,
"inverters": {"inv": {"max_power_w": 10000}},
"inverters": {"inv": {"profile_adjustment": {"nominal_power_w": 10000}}},
"max_home_appliances": 1,
"home_appliances": {
"washer": profile(
+4 -3
View File
@@ -492,11 +492,12 @@ def test_process_energy_zero_generation_full_battery_high_consumption(inverter,
class TestInverterSettings:
@pytest.mark.parametrize("converter", ["to_genetic_param", "to_genetic0_param"])
def test_inverter_conversion_requires_output_limit(self, converter):
def test_inverter_conversion_requires_nominal_power(self, config_eos, converter):
# The generic inverter profile provides no nominal power.
settings = InverterCommonSettings(device_id="inverter")
with pytest.raises(ValueError, match="max_power_w"):
with pytest.raises(ValueError, match="nominal_power_w"):
getattr(settings, converter)()
settings.max_power_w = 4200
settings.profile_adjustment.nominal_power_w = 4200
assert getattr(settings, converter)().max_power_wh == 4200
+25
View File
@@ -172,3 +172,28 @@ async def test_optimize(
reader = PdfReader(BytesIO(pdf))
assert len(reader.pages) >= 6
def test_solution_inverter_parameters_come_from_the_run_or_the_profile(config_eos: ConfigEOS):
"""Inverter settings hold no efficiencies; the solution never reads them directly."""
config_eos.merge_settings_from_dict(
{
"devices": {
"max_inverters": 1,
"inverters": {
"inverter1": {
"device_id": "inverter1",
"profile_adjustment": {"nominal_power_w": 8000},
}
},
}
}
)
with (DIR_TESTDATA / "optimize_result_1.json").open("r") as f_in:
solution = GeneticSolution.model_validate(json.load(f_in))
stored = solution._inverter_parameters_list(solution.parameters)
assert stored == ([solution.parameters.inverter] if solution.parameters.inverter else [])
resolved = solution._inverter_parameters_list(None)
assert len(resolved) == 1
assert resolved[0].max_power_wh == pytest.approx(8000)
assert 0.0 < resolved[0].ac_to_dc_efficiency < 1.0
+1 -1
View File
@@ -37,7 +37,7 @@ def prepare_tariffs(config_eos):
"max_batteries": 0,
"max_electric_vehicles": 0,
"max_inverters": 1,
"inverters": {"inverter1": {"max_power_w": 10000}},
"inverters": {"inverter1": {"profile_adjustment": {"nominal_power_w": 10000}}},
"max_home_appliances": 0,
},
}
+103 -56
View File
@@ -1,9 +1,10 @@
"""Tests for the load-dependent DC-to-AC efficiency curve of the GENETIC inverter.
"""Tests for the load-dependent efficiency curves of the GENETIC inverter.
``dc_to_ac_efficiency_curve`` replaces the constant ``dc_to_ac_efficiency`` by a
curve over the load fraction (AC power / rated power). Real inverters are much
less efficient at low load, which matters for night-time discharge into a small
base load. Without a curve the behaviour must stay exactly the legacy one.
base load. ``ac_to_dc_efficiency_curve`` does the same for charging the battery
from the grid. Without a curve the behaviour must stay exactly the legacy one.
"""
from types import SimpleNamespace
@@ -15,12 +16,16 @@ from pydantic import ValidationError
from akkudoktoreos.devices.devicesabc import (
interpolate_efficiency_curve,
solve_ac_for_charge_dc_energy,
solve_ac_for_dc_energy,
validate_efficiency_curve,
)
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
from akkudoktoreos.devices.settings.invertersettings import (
InverterCommonSettings,
InverterProfileAdjustment,
)
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticEnergyManagementParameters,
@@ -113,10 +118,6 @@ class TestCurveValidation:
InverterParameters(
device_id="inverter1", max_power_wh=1000, dc_to_ac_efficiency_curve=curve
)
with pytest.raises(ValidationError):
InverterCommonSettings(
device_id="inverter1", max_power_w=1000, dc_to_ac_efficiency_curve=curve
)
@pytest.mark.parametrize("fraction", [-0.1, 1.1])
def test_reference_load_fraction_range(self, fraction):
@@ -416,62 +417,36 @@ def test_low_load_curve_increases_losses(config_eos):
class TestSettings:
def test_settings_pass_curve_to_genetic_parameters(self):
settings = InverterCommonSettings.model_validate(
{
"device_id": "inverter1",
"max_power_w": 5000,
"dc_to_ac_efficiency_curve": [[0.02, 0.8], [0.07, 0.93], [1.0, 0.95]],
"dc_to_ac_efficiency_reference_load_fraction": 0.1,
}
)
params = settings.to_genetic_param()
assert params.dc_to_ac_efficiency_curve == [(0.02, 0.8), (0.07, 0.93), (1.0, 0.95)]
assert params.dc_to_ac_efficiency_reference_load_fraction == 0.1
"""The DC→AC curve of the GENETIC parameters comes from the inverter profile."""
def test_defaults_keep_constant_efficiency(self):
params = InverterCommonSettings(device_id="inverter1", max_power_w=5000).to_genetic_param()
assert params.dc_to_ac_efficiency_curve is None
assert params.dc_to_ac_efficiency_reference_load_fraction == pytest.approx(0.06)
def test_genetic0_is_unaffected(self):
def test_settings_pass_profile_curve_to_genetic_parameters(self, config_eos):
settings = InverterCommonSettings(
device_id="inverter1",
max_power_w=5000,
dc_to_ac_efficiency_curve=[(0.0, 0.8), (1.0, 0.95)],
profile_adjustment=InverterProfileAdjustment(nominal_power_w=5000),
dc_to_ac_efficiency_reference_load_fraction=0.1,
)
profile = settings.get_inverter_profile()
params = settings.to_genetic_param()
assert params.dc_to_ac_efficiency_curve is not None
# Not derated at 45 degC: the curve is the profile curve over [0, 1].
expected = list(zip(profile.dc_to_ac_efficiency.x, profile.dc_to_ac_efficiency.y))
assert [x for x, _ in params.dc_to_ac_efficiency_curve] == pytest.approx(
[x for x, _ in expected]
)
assert [y for _, y in params.dc_to_ac_efficiency_curve] == pytest.approx(
[y for _, y in expected]
)
assert params.dc_to_ac_efficiency_reference_load_fraction == 0.1
assert params.dc_to_ac_efficiency == pytest.approx(profile.dc_to_ac_efficiency_at(0.1))
def test_genetic0_has_no_curve(self, config_eos):
settings = InverterCommonSettings(
device_id="inverter1",
profile_adjustment=InverterProfileAdjustment(nominal_power_w=5000),
)
params = settings.to_genetic0_param()
assert not hasattr(params, "dc_to_ac_efficiency_curve")
def test_config_accepts_curve(self, config_eos):
config_eos.merge_settings_from_dict(
{
"devices": {
"inverters": {
"inverter1": {
"max_power_w": 5000,
"dc_to_ac_efficiency_curve": [[0.02, 0.8], [0.07, 0.93], [1.0, 0.95]],
}
}
}
}
)
inverters = config_eos.devices.inverters
assert inverters is not None
assert inverters["inverter1"].dc_to_ac_efficiency_curve == [
(0.02, 0.8),
(0.07, 0.93),
(1.0, 0.95),
]
config_eos.set_nested_value(
"devices/inverters/inverter1/dc_to_ac_efficiency_curve", [[0.0, 0.85], [1.0, 0.95]]
)
assert inverters["inverter1"].dc_to_ac_efficiency_curve == [(0.0, 0.85), (1.0, 0.95)]
with pytest.raises(ValueError, match="at least two points"):
config_eos.set_nested_value(
"devices/inverters/inverter1/dc_to_ac_efficiency_curve", [[0.5, 0.9]]
)
# ---------------------------------------------------------------------------
# Discharge limited by the battery (low state of charge, power limit)
@@ -664,3 +639,75 @@ class TestBatteryLimitedDischarge:
)
ac, losses = inverter._discharge_battery_to_ac(500.0 * slot_duration_h, 0)
assert ac == pytest.approx(200.0 * slot_duration_h * efficiency)
# ---------------------------------------------------------------------------
# AC→DC curve for charging the battery from the grid
# ---------------------------------------------------------------------------
CHARGE_CURVE = [(0.05, 0.86), (0.3, 0.96), (1.0, 0.95)]
class TestSolveAcForChargeDcEnergy:
@pytest.mark.parametrize("dc_wh", [1.0, 30.0, 100.0, 250.0, 500.0, 900.0, 2000.0])
def test_solution_is_consistent(self, dc_wh):
ac_wh = solve_ac_for_charge_dc_energy(CHARGE_CURVE, dc_wh, 1000.0)
efficiency = interpolate_efficiency_curve(CHARGE_CURVE, ac_wh / 1000.0)
assert ac_wh * efficiency == pytest.approx(dc_wh, rel=1e-9)
def test_clamped_ends(self):
# Below the first point the first efficiency applies, above the last the last.
assert solve_ac_for_charge_dc_energy(CHARGE_CURVE, 8.6, 1000.0) == pytest.approx(10.0)
assert solve_ac_for_charge_dc_energy(CHARGE_CURVE, 1900.0, 1000.0) == pytest.approx(2000.0)
def test_flat_curve_equals_constant_efficiency(self):
flat = [(0.0, 0.9), (1.0, 0.9)]
assert solve_ac_for_charge_dc_energy(flat, 450.0, 1000.0) == pytest.approx(500.0)
def test_no_energy(self):
assert solve_ac_for_charge_dc_energy(CHARGE_CURVE, 0.0, 1000.0) == 0.0
class TestChargeFromGridWithCurve:
def test_curve_is_validated(self):
with pytest.raises(ValidationError):
InverterParameters(
device_id="inverter1", max_power_wh=1000, ac_to_dc_efficiency_curve=[(0.5, 0.9)]
)
@staticmethod
def _empty_battery() -> Battery:
battery = _real_battery(soc_percentage=0)
battery.charge_array = np.ones(1)
return battery
def test_low_load_charge_draws_more_ac(self):
flat = _real_inverter(self._empty_battery(), ac_to_dc_efficiency=0.96)
curved = _real_inverter(
self._empty_battery(),
ac_to_dc_efficiency=0.96,
ac_to_dc_efficiency_curve=CHARGE_CURVE,
)
# 5 % of the 1 kW battery charge power: a low AC load, efficiency ~0.87.
flat_ac, flat_losses = flat.charge_battery_from_grid(0, 0.05)
curved_ac, curved_losses = curved.charge_battery_from_grid(0, 0.05)
assert flat_ac == pytest.approx(50.0 / 0.96)
assert curved_ac > flat_ac
assert curved_ac * interpolate_efficiency_curve(
CHARGE_CURVE, curved_ac / 1000.0
) == pytest.approx(50.0)
assert curved_losses == pytest.approx(curved_ac - 50.0)
def test_settings_pass_the_profile_curve(self, config_eos):
settings = InverterCommonSettings(
device_id="inverter1",
profile_adjustment=InverterProfileAdjustment(nominal_power_w=5000),
)
profile = settings.get_inverter_profile()
params = settings.to_genetic_param()
assert params.ac_to_dc_efficiency_curve is not None
assert [y for _, y in params.ac_to_dc_efficiency_curve] == pytest.approx(
list(profile.ac_to_dc_efficiency.y)
)
# The constant is the efficiency at the AC charge limit, here the nominal power.
assert params.ac_to_dc_efficiency == pytest.approx(profile.ac_to_dc_efficiency_at(1.0))
+1 -1
View File
@@ -81,7 +81,7 @@ def test_device_maps_feed_algorithm_specific_converters(algorithm):
"electric_vehicles": {
"car": {"profile_adjustment": {"nominal_capacity_wh": 60000, "max_charge_power_w": 7000}}
},
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
"inverters": {"inverter": {"battery_id": "storage", "profile_adjustment": {"nominal_power_w": 5000}}},
"home_appliances": {
"washer": {
"consumption_wh": 1800,
+53
View File
@@ -9,6 +9,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
from akkudoktoreos.devices.profile.inverterprofile import InverterProfileSettings
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
@@ -60,6 +61,16 @@ class TestRead:
assert response.json()["chemistry"] == "LFP"
BatteryProfileSettings.model_validate(response.json())
def test_inverter_profiles(self, client):
assert "inverter" in client.get("/v1/profile").json()
listed = client.get("/v1/profile/inverter").json()
assert {"name": "generic-hybrid", "source": "package", "packaged": True} in listed
schema = client.get("/v1/profile/inverter/schema").json()
assert "power_derating" in schema["properties"]
response = client.get("/v1/profile/inverter/generic-hybrid")
assert response.status_code == 200
InverterProfileSettings.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"
@@ -169,6 +180,48 @@ class TestDelete:
def test_delete_of_a_missing_profile_is_404(self, client):
assert client.delete("/v1/profile/battery/does-not-exist").status_code == 404
@pytest.mark.parametrize("devices", ["batteries", "electric_vehicles"])
def test_delete_of_a_battery_profile_in_use_is_409(
self, client, config_eos, generic_lfp, devices
):
client.put("/v1/profile/battery/my-battery", json=generic_lfp)
config_eos.merge_settings_from_dict(
{"devices": {devices: {"unit1": {"device_id": "unit1", "battery_profile": "my-battery"}}}}
)
response = client.delete("/v1/profile/battery/my-battery")
assert response.status_code == 409
assert response.json()["title"] == "Profile in use"
assert "unit1" in response.json()["detail"]
assert client.get("/v1/profile/battery/my-battery").status_code == 200
def test_delete_of_an_inverter_profile_in_use_is_409(self, client, config_eos):
generic_hybrid = client.get("/v1/profile/inverter/generic-hybrid").json()
client.put("/v1/profile/inverter/my-inverter", json=generic_hybrid)
config_eos.merge_settings_from_dict(
{
"devices": {
"inverters": {
"inverter1": {"device_id": "inverter1", "inverter_profile": "my-inverter"}
}
}
}
)
response = client.delete("/v1/profile/inverter/my-inverter")
assert response.status_code == 409
assert "inverter1" in response.json()["detail"]
# A battery profile of the same name is not in use.
assert client.delete("/v1/profile/battery/my-inverter").status_code == 404
def test_delete_of_an_override_in_use_reverts_to_the_packaged_profile(
self, client, config_eos, generic_lfp
):
client.put("/v1/profile/battery/generic-lfp", json=generic_lfp)
config_eos.merge_settings_from_dict(
{"devices": {"batteries": {"battery1": {"device_id": "battery1"}}}}
)
assert config_eos.devices.batteries["battery1"].battery_profile == "generic-lfp"
assert client.delete("/v1/profile/battery/generic-lfp").status_code == 200
def test_eos_app_serves_the_profile_api(config_eos):
from akkudoktoreos.server.eos import app
+3 -1
View File
@@ -29,7 +29,9 @@
"inverters": {
"inverter1": {
"device_id": "inverter1",
"max_power_w": 10000.0,
"profile_adjustment": {
"nominal_power_w": 10000.0
},
"battery_id": "battery1"
}
},