mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
co-authored by
Claude Opus 5.5
parent
e255f987bf
commit
faf37abfe7
@@ -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 -->
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -29,7 +29,9 @@
|
||||
"inverters": {
|
||||
"inverter1": {
|
||||
"device_id": "inverter1",
|
||||
"max_power_w": 10000.0,
|
||||
"profile_adjustment": {
|
||||
"nominal_power_w": 10000.0
|
||||
},
|
||||
"battery_id": "battery1"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user