feat(devices): add load-dependent DC-to-AC efficiency curve for GENETIC inverters (#1375)

* feat(devices): add load-dependent DC-to-AC efficiency curve for GENETIC inverters

Real inverters are much less efficient at low load than at rated power
(e.g. 0.80 at 2 % vs. 0.93 at 7 % load). A constant dc_to_ac_efficiency
overrates night-time battery discharge into a small base load and, with
it, arbitrage and grid charging.

Add two optional inverter settings for the GENETIC optimizer:

- dc_to_ac_efficiency_curve: list of (load_fraction, efficiency) points,
  load fraction = AC power / max_power_w. Points must be strictly
  increasing in [0, 1] with efficiencies in (0, 1]; at least two points.
  Linear interpolation, clamped at both ends. When set, the curve
  replaces dc_to_ac_efficiency.
- dc_to_ac_efficiency_reference_load_fraction (default 0.06): load at
  which the curve values stored energy without a specific conversion.

Battery discharges in Inverter.process_energy() use the efficiency at
the AC energy of each conversion. The end-of-horizon value, the terminal
and tail value curves and the AC charge break-even penalty use
Inverter.reference_dc_to_ac_efficiency.

Without a curve, dc_to_ac_efficiency_at() and the reference efficiency
return dc_to_ac_efficiency itself, so results are bit-identical to the
previous model. GENETIC0 is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(devices): evaluate the efficiency curve at the delivered AC load

When the battery limits a discharge (low state of charge or its power
limit), the conversion ran with the efficiency of the requested AC energy.
With a 1000 Wh battery at 10 % SoC, a 1000 W inverter and the curve
[(0, .8), (.5, .95), (1, .95)], a request of 500 Wh AC returned 95 Wh AC
from the available 100 Wh DC, although the curve gives 82.5 % at that load.

The delivered AC energy now solves a = dc * efficiency(a / rated) exactly on
the piecewise-linear curve (solve_ac_for_dc_energy), bounded by the request.
Unlimited discharges and inverters without a curve are unchanged.

Regression tests use a real Battery: low SoC and a power-limited discharge,
each with 60- and 15-minute slots, battery discharge losses, the load path
through process_energy, and constant efficiency.

* docs: regenerate OpenAPI for the efficiency-curve fix

---------

Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Andreas <drbacke@gmx.de>
This commit is contained in:
Christin
2026-10-11 10:45:44 +02:00
committed by GitHub
co-authored by Christin Claude Opus 5.5 Andreas
parent 560370281f
commit 7f26ea3f3f
10 changed files with 1175 additions and 22 deletions
+6 -5
View File
@@ -38,6 +38,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta
and coverage information. A capacity estimate does not overwrite configured capacity. and coverage information. A capacity estimate does not overwrite configured capacity.
- Optional local, calibratable forecasting within `PVForecastAkkudoktor`; the remote - Optional local, calibratable forecasting within `PVForecastAkkudoktor`; the remote
backend remains the default. backend remains the default.
- Optional load-dependent inverter efficiency for GENETIC: `dc_to_ac_efficiency_curve`
models the lower DC-to-AC efficiency at low load. Without it the constant
`dc_to_ac_efficiency` applies unchanged.
- Optional size limit for the GENETIC fitness cache: `optimization.genetic.fitness_cache_max_entries` - Optional size limit for the GENETIC fitness cache: `optimization.genetic.fitness_cache_max_entries`
(`null` = unbounded, the default; `0` = off; N = at most N keys). Bounding it lowers peak memory of (`null` = unbounded, the default; `0` = off; N = at most N keys). Bounding it lowers peak memory of
a run by up to ~150 MB on small devices without changing the plan. a run by up to ~150 MB on small devices without changing the plan.
@@ -65,6 +68,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta
values. Runtime forecast energy is Wh per slot and prices are currency per Wh. values. Runtime forecast energy is Wh per slot and prices are currency per Wh.
- Runtime configuration changes now take priority over environment and file values for - Runtime configuration changes now take priority over environment and file values for
explicitly updated keys; command-line settings retain higher priority. explicitly updated keys; command-line settings retain higher priority.
- Lower idle memory: matplotlib (PDF reports), statsmodels (price fallback forecast), pvlib,
bs4/lxml, scipy (self-consumption table), the GENETIC0 optimizer and the timezone lookup
data are imported on first use instead of at server start. Results are unchanged.
- New setting `optimization.self_consumption_interpolator` (`scipy` by default, `numpy`). - New setting `optimization.self_consumption_interpolator` (`scipy` by default, `numpy`).
With `numpy` the self-consumption probability tables are evaluated without SciPy, so a With `numpy` the self-consumption probability tables are evaluated without SciPy, so a
GENETIC run no longer loads SciPy (about 35 MB less memory). Results are bit-identical to GENETIC run no longer loads SciPy (about 35 MB less memory). Results are bit-identical to
@@ -283,7 +289,6 @@ In addition, bugs were fixed and new features were added:
- API to list backups - API to list backups
- API to revert to a specific backup - API to revert to a specific backup
- EOSdash Admin tab integration - EOSdash Admin tab integration
- Pendulum date types via `pydantic_extra_types.pendulum_dt` - Pendulum date types via `pydantic_extra_types.pendulum_dt`
- `Time`, `TimeWindow`, `TimeWindowSequence`, and `to_time` helpers in `datetimeutil` - `Time`, `TimeWindow`, `TimeWindowSequence`, and `to_time` helpers in `datetimeutil`
- Extended `DataRecord` with configurable field-like semantics - Extended `DataRecord` with configurable field-like semantics
@@ -294,7 +299,6 @@ In addition, bugs were fixed and new features were added:
- Predictions tab - Predictions tab
- Cache management in Admin tab - Cache management in Admin tab
- About tab - About tab
- Pydantic merge model tests - Pydantic merge model tests
- Developer profiling entry in Makefile - Developer profiling entry in Makefile
- Changelog & docs updated for commitizen release flow - Changelog & docs updated for commitizen release flow
@@ -307,11 +311,9 @@ In addition, bugs were fixed and new features were added:
- Performance improvements - Performance improvements
- Charge + start times now reflect realistic simulation - Charge + start times now reflect realistic simulation
- Appliance simulation: - Appliance simulation:
- Time windows may roll over to next day - Time windows may roll over to next day
- Revised load prediction by splitting original `LoadAkkudoktor` into: - Revised load prediction by splitting original `LoadAkkudoktor` into:
- `LoadAkkudoktor` - `LoadAkkudoktor`
@@ -326,7 +328,6 @@ In addition, bugs were fixed and new features were added:
- Interval execution + locking + new endpoints - Interval execution + locking + new endpoints
- Properly loads required data - Properly loads required data
- EV charge-rate migration for proper availability - EV charge-rate migration for proper availability
- Genetic common settings consistently available - Genetic common settings consistently available
- Config markdown generation - Config markdown generation
- Recognize environment variables on EOS server startup - Recognize environment variables on EOS server startup
+33
View File
@@ -299,6 +299,39 @@ The inverter supports separate AC↔DC conversion efficiencies:
Must be > 0. Default `1.0`. Must be > 0. Default `1.0`.
- `max_ac_charge_power_w`: Maximum AC charging power in watts. `null` = no additional limit. - `max_ac_charge_power_w`: Maximum AC charging power in watts. `null` = no additional limit.
Set to `0` to disable AC charging. Default `null`. 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
}
}
}
}
```
#### Electric vehicle simulation configuration #### Electric vehicle simulation configuration
+186
View File
@@ -11304,6 +11304,70 @@
"GENETIC0" "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.",
"default": 0.06,
"examples": [
0.06,
0.1
],
"x-scope": [
"GENETIC"
]
},
"max_ac_charge_power_w": { "max_ac_charge_power_w": {
"anyOf": [ "anyOf": [
{ {
@@ -11715,6 +11779,70 @@
"GENETIC0" "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.",
"default": 0.06,
"examples": [
0.06,
0.1
],
"x-scope": [
"GENETIC"
]
},
"max_ac_charge_power_w": { "max_ac_charge_power_w": {
"anyOf": [ "anyOf": [
{ {
@@ -12147,6 +12275,64 @@
1.0 1.0
] ]
}, },
"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 to AC conversion as a list of (load_fraction, efficiency) points. The load fraction is the AC power of the conversion relative to max_power_wh [0.0 ... 1.0]; 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. Default None (constant dc_to_ac_efficiency).",
"examples": [
null,
[
[
0.02,
0.8
],
[
0.07,
0.93
],
[
0.3,
0.96
],
[
1.0,
0.95
]
]
]
},
"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 at which dc_to_ac_efficiency_curve is evaluated where stored battery energy is valued without a specific conversion (e.g. remaining battery energy at the end of the horizon, AC charge break-even). Should represent the typical load the battery serves, e.g. the night-time base load. Only used with dc_to_ac_efficiency_curve. Default 0.06.",
"default": 0.06,
"examples": [
0.06,
0.1
]
},
"max_ac_charge_power_w": { "max_ac_charge_power_w": {
"anyOf": [ "anyOf": [
{ {
+131
View File
@@ -134,6 +134,137 @@ def validate_home_appliance_load_definition(
raise ValueError("load_profile_power_w must not contain negative values.") raise ValueError("load_profile_power_w must not contain negative values.")
def validate_efficiency_curve(
curve: Optional[list[tuple[float, float]]],
) -> Optional[list[tuple[float, float]]]:
"""Validate a load-dependent conversion efficiency curve.
A curve is a list of ``(load_fraction, efficiency)`` points. The load
fraction is the converted power relative to the rated power of the device
(0.0 = idle, 1.0 = rated power), the efficiency is the conversion
efficiency at that load.
Rules:
- At least two points.
- Load fractions are finite, within [0, 1] and strictly increasing
(sorted, no duplicates).
- Efficiencies are finite and within (0, 1].
Args:
curve: The curve points, or None for no curve.
Returns:
The unchanged curve, or None.
Raises:
ValueError: If the curve violates one of the rules.
"""
if curve is None:
return None
if len(curve) < 2:
raise ValueError("An efficiency curve needs at least two points.")
previous_fraction: Optional[float] = None
for fraction, efficiency in curve:
if not math.isfinite(fraction) or not 0.0 <= fraction <= 1.0:
raise ValueError(f"Efficiency curve load fraction {fraction} is outside of [0, 1].")
if not math.isfinite(efficiency) or not 0.0 < efficiency <= 1.0:
raise ValueError(f"Efficiency curve efficiency {efficiency} is outside of (0, 1].")
if previous_fraction is not None and fraction <= previous_fraction:
raise ValueError(
"Efficiency curve load fractions must be strictly increasing "
f"(sorted, no duplicates); got {fraction} after {previous_fraction}."
)
previous_fraction = fraction
return curve
def interpolate_efficiency_curve(curve: list[tuple[float, float]], load_fraction: float) -> float:
"""Return the efficiency of a curve at a load fraction.
Interpolates linearly between the curve points. Load fractions below the
first or above the last point are clamped to the efficiency of that point.
Args:
curve: Points as validated by ``validate_efficiency_curve``.
load_fraction: Converted power relative to the rated power.
Returns:
The efficiency at ``load_fraction``.
"""
first_fraction, first_efficiency = curve[0]
if load_fraction <= first_fraction:
return first_efficiency
for fraction, efficiency in curve[1:]:
if load_fraction <= fraction:
return first_efficiency + (load_fraction - first_fraction) / (
fraction - first_fraction
) * (efficiency - first_efficiency)
first_fraction, first_efficiency = fraction, efficiency
return first_efficiency
def solve_ac_for_dc_energy(
curve: list[tuple[float, float]],
dc_wh: float,
rated_wh: float,
upper_ac_wh: float,
) -> float:
"""Return the AC energy a DC energy yields through a load-dependent conversion.
The efficiency of a curve depends on the AC load actually delivered, so for a
given DC energy the delivered AC energy ``a`` is the solution of
``a = dc_wh * efficiency(a / rated_wh)``. The curve is piecewise linear and
clamped at both ends, so the equation is solved exactly on each piece.
Args:
curve: Points as validated by ``validate_efficiency_curve``.
dc_wh: DC energy available to the conversion in one slot.
rated_wh: AC energy at rated power in one slot (load fraction 1.0).
upper_ac_wh: Upper bound of the result, e.g. the requested AC energy.
Returns:
The largest consistent AC energy that does not exceed ``upper_ac_wh``.
"""
if dc_wh <= 0.0 or rated_wh <= 0.0 or upper_ac_wh <= 0.0:
return 0.0
tolerance = 1e-9
best = -1.0
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 <= upper_ac_wh * (1.0 + tolerance)
and 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 = dc * (e0 + slope * (a / rated - x0)) => a * (1 - dc*slope/rated) = dc * (e0 - slope*x0)
denominator = 1.0 - dc_wh * slope / rated_wh
if abs(denominator) < 1e-12:
continue
consider(dc_wh * (e0 - slope * x0) / denominator, x0, x1)
if best >= 0.0:
return min(best, upper_ac_wh)
# No piece holds a solution within the bound (only possible numerically at a
# kink): converge on it from the bound.
ac_wh = min(upper_ac_wh, dc_wh)
for _ in range(50):
next_ac_wh = min(dc_wh * interpolate_efficiency_curve(curve, ac_wh / rated_wh), upper_ac_wh)
if abs(next_ac_wh - ac_wh) <= tolerance:
break
ac_wh = next_ac_wh
return ac_wh
class ConsumerScheduleMode(StrEnum): class ConsumerScheduleMode(StrEnum):
"""Schedule mode of a flexible consumer (home appliance). """Schedule mode of a flexible consumer (home appliance).
+93 -8
View File
@@ -1,8 +1,13 @@
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.devices.devicesabc import (
interpolate_efficiency_curve,
solve_ac_for_dc_energy,
validate_efficiency_curve,
)
from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
@@ -44,6 +49,37 @@ class InverterParameters(DeviceParameters):
"examples": [0.95, 1.0], "examples": [0.95, 1.0],
}, },
) )
dc_to_ac_efficiency_curve: Optional[list[tuple[float, float]]] = Field(
default=None,
json_schema_extra={
"description": (
"Load-dependent efficiency of DC to AC conversion as a list of "
"(load_fraction, efficiency) points. The load fraction is the AC power "
"of the conversion relative to max_power_wh [0.0 ... 1.0]; 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. "
"Default None (constant dc_to_ac_efficiency)."
),
"examples": [None, [[0.02, 0.8], [0.07, 0.93], [0.3, 0.96], [1.0, 0.95]]],
},
)
dc_to_ac_efficiency_reference_load_fraction: float = Field(
default=0.06,
ge=0,
le=1,
json_schema_extra={
"description": (
"Load fraction at which dc_to_ac_efficiency_curve is evaluated where "
"stored battery energy is valued without a specific conversion "
"(e.g. remaining battery energy at the end of the horizon, AC charge "
"break-even). Should represent the typical load the battery serves, "
"e.g. the night-time base load. Only used with dc_to_ac_efficiency_curve. "
"Default 0.06."
),
"examples": [0.06, 0.1],
},
)
max_ac_charge_power_w: Optional[float] = Field( max_ac_charge_power_w: Optional[float] = Field(
default=None, default=None,
ge=0, ge=0,
@@ -69,6 +105,13 @@ class InverterParameters(DeviceParameters):
}, },
) )
@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)
class Inverter: class Inverter:
def __init__( def __init__(
@@ -93,11 +136,32 @@ class Inverter:
self.max_power_wh = self.parameters.max_power_wh * self.slot_duration_h self.max_power_wh = self.parameters.max_power_wh * self.slot_duration_h
self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_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
# 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.
self.reference_dc_to_ac_efficiency = self.dc_to_ac_efficiency_at(
self.parameters.dc_to_ac_efficiency_reference_load_fraction * self.max_power_wh
)
# This value remains a power [W]. GeneticSimulation converts it into a # This value remains a power [W]. GeneticSimulation converts it into a
# slot-independent charge-factor limit. # slot-independent charge-factor limit.
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w
self.ac_charge_limits_total_charge = self.parameters.ac_charge_limits_total_charge self.ac_charge_limits_total_charge = self.parameters.ac_charge_limits_total_charge
def dc_to_ac_efficiency_at(self, ac_wh: float) -> float:
"""Return the DC-to-AC efficiency of a conversion delivering ``ac_wh`` in one slot.
Without ``dc_to_ac_efficiency_curve`` this is the constant
``dc_to_ac_efficiency``. With a curve, the efficiency is interpolated at
the load fraction ``ac_wh / max_power_wh`` of this slot. ``max_power_wh``
is already scaled to the slot duration, so the fraction equals the mean
AC power relative to the rated power.
"""
if self.dc_to_ac_efficiency_curve is None:
return self.dc_to_ac_efficiency
load_fraction = ac_wh / self.max_power_wh if self.max_power_wh > 0 else 0.0
return interpolate_efficiency_curve(self.dc_to_ac_efficiency_curve, load_fraction)
def ac_charge_factor(self, factor: float) -> float: def ac_charge_factor(self, factor: float) -> float:
"""Return the AC charge factor the inverter can actually execute. """Return the AC charge factor the inverter can actually execute.
@@ -161,9 +225,26 @@ class Inverter:
if not self.battery or requested_ac_wh <= 0.0: if not self.battery or requested_ac_wh <= 0.0:
return 0.0, 0.0 return 0.0, 0.0
dc_request = requested_ac_wh / self.dc_to_ac_efficiency # With an efficiency curve, the efficiency is taken at the requested AC
# energy of this conversion.
dc_to_ac_efficiency = self.dc_to_ac_efficiency_at(requested_ac_wh)
dc_request = requested_ac_wh / dc_to_ac_efficiency
battery_discharge_dc, discharge_losses = self.battery.discharge_energy(dc_request, hour) battery_discharge_dc, discharge_losses = self.battery.discharge_energy(dc_request, hour)
battery_discharge_ac = battery_discharge_dc * self.dc_to_ac_efficiency if self.dc_to_ac_efficiency_curve is not None and battery_discharge_dc < dc_request * (
1.0 - 1e-12
):
# The battery delivered less than requested (low state of charge or
# its power limit). The conversion then runs at a lower AC load than
# requested, so the efficiency must be the one at the AC energy that
# is actually delivered, not at the request.
battery_discharge_ac = solve_ac_for_dc_energy(
self.dc_to_ac_efficiency_curve,
battery_discharge_dc,
self.max_power_wh,
requested_ac_wh,
)
else:
battery_discharge_ac = battery_discharge_dc * dc_to_ac_efficiency
inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac
return battery_discharge_ac, discharge_losses + inverter_discharge_losses return battery_discharge_ac, discharge_losses + inverter_discharge_losses
@@ -258,16 +339,20 @@ class Inverter:
if allow_battery_grid_export and self.battery and remaining_inverter_ac_capacity > 0.0: if allow_battery_grid_export and self.battery and remaining_inverter_ac_capacity > 0.0:
export_factor = min(max(float(battery_grid_export_factor), 0.0), 1.0) export_factor = min(max(float(battery_grid_export_factor), 0.0), 1.0)
remaining_battery_ac = ( # Upper bounds of the export. With an efficiency curve, the
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency # efficiency is estimated at the DC energy of each bound; the
# conversion itself in _discharge_battery_to_ac() is exact and is
# limited by the energy the battery can actually deliver.
remaining_battery_dc = self.battery.remaining_discharge_energy_wh(hour)
remaining_battery_ac = remaining_battery_dc * self.dc_to_ac_efficiency_at(
remaining_battery_dc
) )
# The rate caps the export against the battery's *rated* discharge # The rate caps the export against the battery's *rated* discharge
# power, so it stays a plain power setpoint ("export at 50 %") that # power, so it stays a plain power setpoint ("export at 50 %") that
# does not silently grow when self-consumption used less of the slot. # does not silently grow when self-consumption used less of the slot.
# At factor 1.0 this bound never binds; behaviour is unchanged. # At factor 1.0 this bound never binds; behaviour is unchanged.
rated_export_ac = ( rated_export_dc = self.battery.rated_discharge_energy_wh() * export_factor
self.battery.rated_discharge_energy_wh() * export_factor * self.dc_to_ac_efficiency rated_export_ac = rated_export_dc * self.dc_to_ac_efficiency_at(rated_export_dc)
)
export_capacity = min( export_capacity = min(
remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac
) )
@@ -2,9 +2,10 @@
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from pydantic import Field, computed_field, model_validator from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import ConfigScope from akkudoktoreos.config.configabc import ConfigScope
from akkudoktoreos.devices.devicesabc import validate_efficiency_curve
from akkudoktoreos.devices.settings.devicebasesettings import ( from akkudoktoreos.devices.settings.devicebasesettings import (
DevicesBaseSettings, DevicesBaseSettings,
) )
@@ -62,6 +63,38 @@ class InverterCommonSettings(DevicesBaseSettings):
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)], "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."
),
"examples": [0.06, 0.1],
"x-scope": [str(ConfigScope.GENETIC)],
},
)
max_ac_charge_power_w: Optional[float] = Field( max_ac_charge_power_w: Optional[float] = Field(
default=None, default=None,
ge=0, ge=0,
@@ -351,6 +384,13 @@ class InverterCommonSettings(DevicesBaseSettings):
# Validators # 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") @model_validator(mode="after")
def _validate_soc_factors(self) -> "InverterCommonSettings": def _validate_soc_factors(self) -> "InverterCommonSettings":
if self.battery_min_soc_factor >= self.battery_max_soc_factor: if self.battery_min_soc_factor >= self.battery_max_soc_factor:
@@ -375,6 +415,10 @@ class InverterCommonSettings(DevicesBaseSettings):
battery_id=self.battery_id, battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency, ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency, dc_to_ac_efficiency=self.dc_to_ac_efficiency,
dc_to_ac_efficiency_curve=self.dc_to_ac_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, max_ac_charge_power_w=self.max_ac_charge_power_w,
ac_charge_limits_total_charge=self.ac_charge_limits_total_charge, ac_charge_limits_total_charge=self.ac_charge_limits_total_charge,
) )
@@ -1002,7 +1002,7 @@ class GeneticOptimization(OptimizationBase):
self._terminal_value_reason = "terminal_value_mode is FIXED" self._terminal_value_reason = "terminal_value_mode is FIXED"
return None return None
dc_to_ac = inverter.dc_to_ac_efficiency if inverter else 1.0 dc_to_ac = inverter.reference_dc_to_ac_efficiency if inverter else 1.0
# A full battery, expressed in the same unit as the curve: AC energy # A full battery, expressed in the same unit as the curve: AC energy
# that can actually leave the house. # that can actually leave the house.
max_energy_wh = ( max_energy_wh = (
@@ -1125,7 +1125,7 @@ class GeneticOptimization(OptimizationBase):
# Usable DC energy, converted to the AC energy that can serve a load. # Usable DC energy, converted to the AC energy that can serve a load.
energy_wh = battery.current_energy_content() energy_wh = battery.current_energy_content()
if self.simulation.inverter: if self.simulation.inverter:
energy_wh *= self.simulation.inverter.dc_to_ac_efficiency energy_wh *= self.simulation.inverter.reference_dc_to_ac_efficiency
curve = getattr(self, "_terminal_value_curve", None) curve = getattr(self, "_terminal_value_curve", None)
if curve is not None and curve.energy_wh: if curve is not None and curve.energy_wh:
@@ -3129,12 +3129,15 @@ class GeneticOptimization(OptimizationBase):
inv = self.simulation.inverter inv = self.simulation.inverter
bat = self.simulation.battery bat = self.simulation.battery
# Full round-trip efficiency: 1 Wh drawn from grid → η Wh delivered to AC load # Full round-trip efficiency: 1 Wh drawn from grid → η Wh delivered to AC load.
# The later discharge has no known size here, so the DC-to-AC part
# is the efficiency at the inverter's reference load (equal to
# dc_to_ac_efficiency without an efficiency curve).
round_trip_eff = ( round_trip_eff = (
inv.ac_to_dc_efficiency inv.ac_to_dc_efficiency
* bat.charging_efficiency * bat.charging_efficiency
* bat.discharging_efficiency * bat.discharging_efficiency
* inv.dc_to_ac_efficiency * inv.reference_dc_to_ac_efficiency
) )
# Configurable penalty multiplier (default 1 = economic loss in €) # Configurable penalty multiplier (default 1 = economic loss in €)
@@ -3160,7 +3163,7 @@ class GeneticOptimization(OptimizationBase):
free_ac_wh = ( free_ac_wh = (
max(0.0, initial_soc_wh - bat.min_soc_wh) max(0.0, initial_soc_wh - bat.min_soc_wh)
* bat.discharging_efficiency * bat.discharging_efficiency
* inv.dc_to_ac_efficiency * inv.reference_dc_to_ac_efficiency
) )
# Prices/loads/free energy are constant within one optimization # Prices/loads/free energy are constant within one optimization
@@ -3186,7 +3189,8 @@ class GeneticOptimization(OptimizationBase):
# corresponding cost per useful/exported AC Wh. # corresponding cost per useful/exported AC Wh.
lcos_per_wh_dc = getattr(bat, "levelized_cost_of_storage_kwh", 0.0) / 1000.0 lcos_per_wh_dc = getattr(bat, "levelized_cost_of_storage_kwh", 0.0) / 1000.0
break_even_price = ( break_even_price = (
charge_price / round_trip_eff + lcos_per_wh_dc / inv.dc_to_ac_efficiency charge_price / round_trip_eff
+ lcos_per_wh_dc / inv.reference_dc_to_ac_efficiency
) )
best_uncovered_price = best_prices[hour] best_uncovered_price = best_prices[hour]
@@ -128,7 +128,7 @@ class TailValueCurve(TerminalValueCurve):
battery=bat, battery=bat,
slot_duration_h=context["slot_duration_h"], slot_duration_h=context["slot_duration_h"],
) )
conversion = bat.discharging_efficiency * inv.dc_to_ac_efficiency conversion = bat.discharging_efficiency * inv.reference_dc_to_ac_efficiency
state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0) state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0)
state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh)) state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh))
plan: list[TailPlanSlot] = [] plan: list[TailPlanSlot] = []
@@ -231,7 +231,9 @@ def build_tail_value_curve(
bat.charge_array = np.zeros(1, dtype=float) bat.charge_array = np.zeros(1, dtype=float)
inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h) inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h)
states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points) states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points)
usable = (states - bat.min_soc_wh) * bat.discharging_efficiency * inv.dc_to_ac_efficiency usable = (
(states - bat.min_soc_wh) * bat.discharging_efficiency * inv.reference_dc_to_ac_efficiency
)
continuation_values = np.array([continuation.value(e) for e in usable]) continuation_values = np.array([continuation.value(e) for e in usable])
operating_values = np.zeros(len(states)) operating_values = np.zeros(len(states))
values = continuation_values.copy() values = continuation_values.copy()
+1
View File
@@ -515,6 +515,7 @@ def _make_mock_simulation(
inv = SimpleNamespace( inv = SimpleNamespace(
ac_to_dc_efficiency=ac_to_dc_efficiency, ac_to_dc_efficiency=ac_to_dc_efficiency,
dc_to_ac_efficiency=dc_to_ac_efficiency, dc_to_ac_efficiency=dc_to_ac_efficiency,
reference_dc_to_ac_efficiency=dc_to_ac_efficiency,
) )
bat = SimpleNamespace( bat = SimpleNamespace(
charging_efficiency=charging_efficiency, charging_efficiency=charging_efficiency,
+666
View File
@@ -0,0 +1,666 @@
"""Tests for the load-dependent DC-to-AC efficiency curve 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.
"""
from types import SimpleNamespace
from unittest.mock import Mock, patch
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.devices.devicesabc import (
interpolate_efficiency_curve,
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.optimization.genetic.genetic import GeneticSimulation
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticEnergyManagementParameters,
)
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
CURVE = [(0.05, 0.80), (0.20, 0.90), (0.50, 0.95), (1.0, 0.93)]
def _make_inverter(
curve=None,
max_power_wh: float = 1000.0,
battery=None,
dc_to_ac_efficiency: float = 1.0,
**kwargs,
) -> Inverter:
predictor = Mock()
predictor.calculate_expected_direct_consumption.side_effect = min
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=predictor,
):
return Inverter(
InverterParameters(
device_id="inverter1",
max_power_wh=max_power_wh,
battery_id=battery.parameters.device_id if battery else None,
dc_to_ac_efficiency=dc_to_ac_efficiency,
dc_to_ac_efficiency_curve=curve,
**kwargs,
),
battery=battery,
)
def _passthrough_battery() -> Mock:
"""Battery mock that delivers exactly the requested DC energy."""
battery = Mock()
battery.parameters.device_id = "battery1"
battery.charge_energy = Mock(return_value=(0.0, 0.0))
battery.discharge_energy = Mock(side_effect=lambda dc, hour, **kw: (dc, 0.0))
return battery
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestCurveValidation:
def test_none_is_allowed(self):
assert validate_efficiency_curve(None) is None
params = InverterParameters(device_id="inverter1", max_power_wh=1000)
assert params.dc_to_ac_efficiency_curve is None
assert params.dc_to_ac_efficiency_reference_load_fraction == pytest.approx(0.06)
def test_valid_curve_is_accepted(self):
params = InverterParameters(
device_id="inverter1", max_power_wh=1000, dc_to_ac_efficiency_curve=CURVE
)
assert params.dc_to_ac_efficiency_curve == CURVE
def test_json_lists_are_accepted(self):
params = InverterParameters.model_validate(
{
"device_id": "inverter1",
"max_power_wh": 1000,
"dc_to_ac_efficiency_curve": [[0.0, 0.8], [1.0, 0.95]],
}
)
assert params.dc_to_ac_efficiency_curve == [(0.0, 0.8), (1.0, 0.95)]
@pytest.mark.parametrize(
"curve",
[
[],
[(0.5, 0.9)], # fewer than two points
[(0.5, 0.9), (0.2, 0.8)], # not sorted
[(0.2, 0.9), (0.2, 0.8)], # duplicate load fraction
[(-0.1, 0.9), (1.0, 0.9)], # load fraction below 0
[(0.0, 0.9), (1.1, 0.9)], # load fraction above 1
[(0.0, 0.0), (1.0, 0.9)], # efficiency 0
[(0.0, 0.9), (1.0, 1.2)], # efficiency above 1
[(0.0, float("nan")), (1.0, 0.9)], # not finite
],
)
def test_invalid_curve_is_rejected(self, curve):
with pytest.raises(ValidationError):
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):
with pytest.raises(ValidationError):
InverterParameters(
device_id="inverter1",
max_power_wh=1000,
dc_to_ac_efficiency_reference_load_fraction=fraction,
)
# ---------------------------------------------------------------------------
# Interpolation
# ---------------------------------------------------------------------------
class TestInterpolation:
def test_points_midpoints_and_clamping(self):
assert interpolate_efficiency_curve(CURVE, 0.05) == pytest.approx(0.80)
assert interpolate_efficiency_curve(CURVE, 0.20) == pytest.approx(0.90)
assert interpolate_efficiency_curve(CURVE, 1.0) == pytest.approx(0.93)
assert interpolate_efficiency_curve(CURVE, 0.125) == pytest.approx(0.85)
assert interpolate_efficiency_curve(CURVE, 0.75) == pytest.approx(0.94)
# Clamped to the first and the last point.
assert interpolate_efficiency_curve(CURVE, 0.0) == pytest.approx(0.80)
assert interpolate_efficiency_curve(CURVE, 5.0) == pytest.approx(0.93)
def test_inverter_evaluates_slot_load_fraction(self):
inverter = _make_inverter(CURVE, max_power_wh=1000.0)
assert inverter.dc_to_ac_efficiency_at(50.0) == pytest.approx(0.80)
assert inverter.dc_to_ac_efficiency_at(125.0) == pytest.approx(0.85)
assert inverter.dc_to_ac_efficiency_at(10.0) == pytest.approx(0.80)
assert inverter.dc_to_ac_efficiency_at(5000.0) == pytest.approx(0.93)
def test_curve_scales_with_device_size(self):
"""The same relative load gives the same efficiency on any device size."""
small = _make_inverter(CURVE, max_power_wh=5000.0)
large = _make_inverter(CURVE, max_power_wh=10000.0)
assert small.dc_to_ac_efficiency_at(1000.0) == pytest.approx(
large.dc_to_ac_efficiency_at(2000.0)
)
assert small.dc_to_ac_efficiency_at(1000.0) == pytest.approx(0.90)
def test_load_fraction_uses_slot_duration(self):
"""max_power_wh is scaled to the slot, so 250 Wh in 15 min is 1 kW."""
predictor = Mock()
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=predictor,
):
inverter = Inverter(
InverterParameters(
device_id="inverter1",
max_power_wh=5000.0,
dc_to_ac_efficiency_curve=CURVE,
),
slot_duration_h=0.25,
)
# 1 kW of 5 kW -> load fraction 0.2
assert inverter.dc_to_ac_efficiency_at(250.0) == pytest.approx(0.90)
# ---------------------------------------------------------------------------
# Without curve: unchanged behaviour
# ---------------------------------------------------------------------------
class TestWithoutCurve:
@pytest.mark.parametrize("efficiency", [1.0, 0.95, 0.8])
def test_efficiency_is_the_constant(self, efficiency):
inverter = _make_inverter(None, dc_to_ac_efficiency=efficiency)
for ac_wh in (0.0, 10.0, 500.0, 1000.0, 5000.0):
assert inverter.dc_to_ac_efficiency_at(ac_wh) == efficiency
assert inverter.reference_dc_to_ac_efficiency == efficiency
def test_reference_load_fraction_is_ignored(self):
inverter = _make_inverter(
None, dc_to_ac_efficiency=0.9, dc_to_ac_efficiency_reference_load_fraction=0.5
)
assert inverter.reference_dc_to_ac_efficiency == 0.9
def test_discharge_matches_legacy_formula(self):
battery = _passthrough_battery()
inverter = _make_inverter(None, battery=battery, dc_to_ac_efficiency=0.93)
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
0.0, 100.0, 0
)
requested_dc = battery.discharge_energy.call_args[0][0]
assert requested_dc == 100.0 / 0.93
assert self_consumption == requested_dc * 0.93
assert losses == requested_dc - requested_dc * 0.93
assert grid_import == pytest.approx(0.0, abs=1e-9)
# ---------------------------------------------------------------------------
# With curve: process_energy
# ---------------------------------------------------------------------------
class TestProcessEnergyWithCurve:
def test_partial_load_draws_more_dc(self):
"""100 Wh at 10 % load (efficiency 0.8333) needs 120 Wh DC."""
battery = _passthrough_battery()
inverter = _make_inverter(CURVE, max_power_wh=1000.0, battery=battery)
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
0.0, 100.0, 0
)
efficiency = 0.80 + (0.05 / 0.15) * 0.10
requested_dc = battery.discharge_energy.call_args[0][0]
assert requested_dc == pytest.approx(100.0 / efficiency)
assert grid_import == pytest.approx(0.0, abs=1e-9)
assert self_consumption == pytest.approx(100.0)
assert losses == pytest.approx(requested_dc - 100.0)
def test_grid_export_uses_efficiency_at_export_level(self, config_eos):
"""A full-power export runs at the efficiency of the rated load."""
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10000,
initial_soc_percentage=100,
min_soc_percentage=0,
charging_efficiency=1.0,
discharging_efficiency=1.0,
max_charge_power_w=1000,
),
prediction_hours=1,
)
battery.reset()
battery.discharge_array = np.ones(1)
inverter = _make_inverter(CURVE, max_power_wh=1000.0, battery=battery)
grid_export, _, losses, _ = inverter.process_energy(
0.0, 0.0, 0, allow_battery_grid_export=True, battery_grid_export_factor=1.0
)
# The export bound is estimated at the rated DC energy (load fraction
# 1.0, efficiency 0.93). The conversion itself uses the efficiency at
# the exported AC energy and never draws more than the rated DC energy.
assert grid_export == pytest.approx(930.0)
efficiency = inverter.dc_to_ac_efficiency_at(930.0)
assert 0.93 < efficiency < 0.95
assert losses == pytest.approx(930.0 / efficiency - 930.0)
assert grid_export + losses <= 1000.0 + 1e-9
# ---------------------------------------------------------------------------
# Reference efficiency for valuing stored energy
# ---------------------------------------------------------------------------
class TestReferenceEfficiency:
def test_default_reference_load(self):
inverter = _make_inverter(CURVE, max_power_wh=1000.0)
# 6 % load -> between 0.05:0.80 and 0.20:0.90
expected = 0.80 + (0.06 - 0.05) / (0.20 - 0.05) * (0.90 - 0.80)
assert inverter.reference_dc_to_ac_efficiency == pytest.approx(expected)
def test_configured_reference_load(self):
inverter = _make_inverter(
CURVE, max_power_wh=1000.0, dc_to_ac_efficiency_reference_load_fraction=0.5
)
assert inverter.reference_dc_to_ac_efficiency == pytest.approx(0.95)
def test_tail_value_uses_reference_efficiency(self):
"""Stored energy is valued with the efficiency at the reference load."""
def tail_curve(efficiency_curve):
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=1000,
max_charge_power_w=1000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
initial_soc_percentage=50,
charge_rates=[0, 0.5, 1],
),
prediction_hours=1,
)
inverter = Inverter(
InverterParameters(
device_id="inverter1",
battery_id="battery1",
max_power_wh=5000,
dc_to_ac_efficiency_curve=efficiency_curve,
),
battery=battery,
)
return build_tail_value_curve(
battery=battery,
inverter=inverter,
# Grid charging is never worth it at this price.
prices_euro_per_wh=np.ones(1),
feed_in_euro_per_wh=np.zeros(1),
load_wh=np.zeros(1),
pv_wh=np.zeros(1),
continuation=TerminalValueCurve(energy_wh=[0.0, 2000.0], value_euro=[0.0, 2.0]),
charge_rates=[0.5, 1],
export_rates=[1],
direct_marketing=False,
)
flat = tail_curve([(0.0, 1.0), (1.0, 1.0)])
lossy = tail_curve([(0.0, 0.8), (1.0, 0.8)])
# The same AC energy needs more stored DC energy with lossy conversion.
assert lossy.value(400.0) == pytest.approx(flat.value(400.0))
assert lossy.energy_wh[-1] == pytest.approx(0.8 * flat.energy_wh[-1])
# ---------------------------------------------------------------------------
# Whole-simulation equivalence
# ---------------------------------------------------------------------------
def _simulate(config_eos, efficiency_curve, dc_to_ac_efficiency=0.95):
hours = 24
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": hours},
"optimization": {"genetic": {"tail_horizon_hours": 0, "horizon_hours": hours}},
}
)
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10000,
initial_soc_percentage=60,
min_soc_percentage=5,
charging_efficiency=0.95,
discharging_efficiency=0.95,
max_charge_power_w=4000,
),
prediction_hours=hours,
)
battery.reset()
inverter = Inverter(
InverterParameters(
device_id="inverter1",
max_power_wh=5000,
battery_id="battery1",
dc_to_ac_efficiency=dc_to_ac_efficiency,
ac_to_dc_efficiency=0.95,
dc_to_ac_efficiency_curve=efficiency_curve,
),
battery=battery,
)
rng = np.random.default_rng(42)
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters.model_validate(
dict(
pv_prognose_wh=list(np.maximum(rng.normal(1500, 1500, hours), 0.0)),
strompreis_euro_pro_wh=list(rng.uniform(0.0001, 0.0004, hours)),
einspeiseverguetung_euro_pro_wh=list(rng.uniform(0.00005, 0.0002, hours)),
preis_euro_pro_wh_akku=0.0001,
gesamtlast=list(rng.uniform(100, 3000, hours)),
)
),
optimization_hours=hours,
prediction_hours=hours,
inverter=inverter,
direct_marketing_enabled=True,
)
assert simulation.bat_discharge_hours is not None
assert simulation.bat_grid_export_hours is not None
assert simulation.ac_charge_hours is not None
simulation.bat_discharge_hours[:] = 1
simulation.bat_grid_export_hours[18:22] = [1.0, 0.5, 0.25, 1.0]
simulation.ac_charge_hours[2:4] = 0.5
return simulation.simulate(start_hour=0)
def test_flat_curve_equals_constant_efficiency(config_eos):
"""A flat curve at the constant efficiency reproduces the result exactly."""
constant = _simulate(config_eos, None)
flat = _simulate(config_eos, [(0.0, 0.95), (1.0, 0.95)])
assert constant.keys() == flat.keys()
for key, value in constant.items():
if isinstance(value, np.ndarray):
np.testing.assert_array_equal(value, flat[key], err_msg=key)
else:
assert value == flat[key], key
def test_low_load_curve_increases_losses(config_eos):
constant = _simulate(config_eos, None, dc_to_ac_efficiency=0.95)
curve = _simulate(config_eos, [(0.02, 0.80), (0.07, 0.93), (0.3, 0.96), (1.0, 0.95)])
assert curve["Gesamt_Verluste"] > constant["Gesamt_Verluste"]
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
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
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):
settings = InverterCommonSettings(
device_id="inverter1",
max_power_w=5000,
dc_to_ac_efficiency_curve=[(0.0, 0.8), (1.0, 0.95)],
)
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)
# ---------------------------------------------------------------------------
# Rising up to half load, flat above: efficiency(x) = 0.8 + 0.3 * x for x <= 0.5.
RISING_CURVE = [(0.0, 0.80), (0.5, 0.95), (1.0, 0.95)]
def _real_battery(
*,
capacity_wh: int = 1000,
soc_percentage: int = 100,
max_power_w: float = 1000.0,
discharging_efficiency: float = 1.0,
slot_duration_h: float = 1.0,
) -> Battery:
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=capacity_wh,
initial_soc_percentage=soc_percentage,
min_soc_percentage=0,
charging_efficiency=1.0,
discharging_efficiency=discharging_efficiency,
max_charge_power_w=max_power_w,
),
prediction_hours=1,
slot_duration_h=slot_duration_h,
)
battery.reset()
battery.discharge_array = np.ones(1)
return battery
def _real_inverter(battery: Battery, curve=None, slot_duration_h: float = 1.0, **kwargs) -> Inverter:
predictor = Mock()
predictor.calculate_expected_direct_consumption.side_effect = min
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=predictor,
):
return Inverter(
InverterParameters(
device_id="inverter1",
max_power_wh=1000.0,
battery_id="battery1",
dc_to_ac_efficiency_curve=curve,
**kwargs,
),
battery=battery,
slot_duration_h=slot_duration_h,
)
class TestSolveAcForDcEnergy:
def test_rising_piece(self):
# a = 100 * (0.8 + 0.3 * a / 1000) => a = 80 / 0.97
assert solve_ac_for_dc_energy(RISING_CURVE, 100.0, 1000.0, 500.0) == pytest.approx(
80.0 / 0.97
)
def test_clamped_ends(self):
curve = [(0.2, 0.90), (0.6, 0.96)]
# Below the first point the efficiency is constant.
assert solve_ac_for_dc_energy(curve, 100.0, 1000.0, 1000.0) == pytest.approx(90.0)
# Above the last point as well.
assert solve_ac_for_dc_energy(curve, 800.0, 1000.0, 1000.0) == pytest.approx(768.0)
def test_falling_piece(self):
curve = [(0.0, 0.90), (0.5, 0.96), (1.0, 0.90)]
ac = solve_ac_for_dc_energy(curve, 900.0, 1000.0, 1000.0)
assert ac == pytest.approx(900.0 * interpolate_efficiency_curve(curve, ac / 1000.0))
assert 0.5 <= ac / 1000.0 <= 1.0
def test_never_exceeds_the_bound(self):
assert solve_ac_for_dc_energy(RISING_CURVE, 900.0, 1000.0, 300.0) <= 300.0 + 1e-6
@pytest.mark.parametrize("dc_wh", [0.0, -5.0])
def test_nothing_from_nothing(self, dc_wh):
assert solve_ac_for_dc_energy(RISING_CURVE, dc_wh, 1000.0, 500.0) == 0.0
@pytest.mark.parametrize("curve", [CURVE, RISING_CURVE, [(0.1, 0.7), (0.3, 0.97), (1.0, 0.9)]])
@pytest.mark.parametrize("dc_wh", [1.0, 37.0, 180.0, 420.0, 760.0, 1050.0])
def test_result_is_consistent_with_the_curve(self, curve, dc_wh):
ac = solve_ac_for_dc_energy(curve, dc_wh, 1000.0, 1000.0)
if ac < 1000.0 - 1e-6:
assert ac == pytest.approx(dc_wh * interpolate_efficiency_curve(curve, ac / 1000.0))
class TestBatteryLimitedDischarge:
"""The efficiency follows the AC load that is delivered, not the request."""
def test_low_soc_hourly_slot(self):
# 100 Wh DC left, 500 Wh AC requested from a 1000 W inverter.
battery = _real_battery(soc_percentage=10)
inverter = _real_inverter(battery, RISING_CURVE)
ac, losses = inverter._discharge_battery_to_ac(500.0, 0)
assert ac == pytest.approx(80.0 / 0.97) # 82.474 Wh
assert ac / 100.0 == pytest.approx(inverter.dc_to_ac_efficiency_at(ac))
assert ac + losses == pytest.approx(100.0)
assert battery.soc_wh == pytest.approx(0.0)
def test_low_soc_quarter_hour_slot(self):
# Same 100 Wh DC, but a 15-minute slot: rated AC energy is 250 Wh, so the
# same energy is a four times higher load.
battery = _real_battery(soc_percentage=10, slot_duration_h=0.25)
inverter = _real_inverter(battery, RISING_CURVE, slot_duration_h=0.25)
ac, losses = inverter._discharge_battery_to_ac(125.0, 0)
# a = 100 * (0.8 + 0.3 * a / 250) => a = 80 / 0.88
assert ac == pytest.approx(80.0 / 0.88)
assert ac / 100.0 == pytest.approx(inverter.dc_to_ac_efficiency_at(ac))
assert ac + losses == pytest.approx(100.0)
def test_power_limited_hourly_slot(self):
# Full battery, but it can only discharge with 200 W.
battery = _real_battery(capacity_wh=10000, max_power_w=200.0)
inverter = _real_inverter(battery, RISING_CURVE)
ac, losses = inverter._discharge_battery_to_ac(500.0, 0)
# a = 200 * (0.8 + 0.3 * a / 1000) => a = 160 / 0.94
assert ac == pytest.approx(160.0 / 0.94)
assert ac / 200.0 == pytest.approx(inverter.dc_to_ac_efficiency_at(ac))
assert ac + losses == pytest.approx(200.0)
def test_power_limited_quarter_hour_slot(self):
battery = _real_battery(capacity_wh=10000, max_power_w=200.0, slot_duration_h=0.25)
inverter = _real_inverter(battery, RISING_CURVE, slot_duration_h=0.25)
ac, losses = inverter._discharge_battery_to_ac(125.0, 0)
# 50 Wh DC per slot: a = 50 * (0.8 + 0.3 * a / 250) => a = 40 / 0.94
assert ac == pytest.approx(40.0 / 0.94)
assert ac / 50.0 == pytest.approx(inverter.dc_to_ac_efficiency_at(ac))
assert ac + losses == pytest.approx(50.0)
def test_battery_discharge_losses_stay_separate(self):
# Battery delivers 90 Wh DC of its 100 Wh; the curve applies to those 90 Wh.
battery = _real_battery(soc_percentage=10, discharging_efficiency=0.9)
inverter = _real_inverter(battery, RISING_CURVE)
ac, losses = inverter._discharge_battery_to_ac(500.0, 0)
assert ac == pytest.approx(90.0 * 0.8 / (1.0 - 90.0 * 0.3 / 1000.0))
assert ac / 90.0 == pytest.approx(inverter.dc_to_ac_efficiency_at(ac))
assert ac + losses == pytest.approx(100.0)
def test_load_is_covered_with_the_delivered_energy(self):
battery = _real_battery(soc_percentage=10)
inverter = _real_inverter(battery, RISING_CURVE)
grid_export, grid_import, losses, self_consumption = inverter.process_energy(0.0, 500.0, 0)
assert self_consumption == pytest.approx(80.0 / 0.97)
assert grid_import == pytest.approx(500.0 - 80.0 / 0.97)
assert grid_export == 0.0
assert self_consumption + losses == pytest.approx(100.0)
def test_unlimited_discharge_is_unchanged(self):
battery = _real_battery(capacity_wh=10000)
inverter = _real_inverter(battery, RISING_CURVE)
ac, losses = inverter._discharge_battery_to_ac(500.0, 0)
assert ac == pytest.approx(500.0)
assert losses == pytest.approx(500.0 / 0.95 - 500.0)
@pytest.mark.parametrize("slot_duration_h", [1.0, 0.25])
@pytest.mark.parametrize("efficiency", [1.0, 0.95, 0.8])
def test_constant_efficiency_is_unchanged(self, slot_duration_h, efficiency):
# Without a curve a limited discharge converts with the constant, as before.
low = _real_battery(soc_percentage=10, slot_duration_h=slot_duration_h)
inverter = _real_inverter(
low, None, slot_duration_h=slot_duration_h, dc_to_ac_efficiency=efficiency
)
ac, losses = inverter._discharge_battery_to_ac(500.0 * slot_duration_h, 0)
assert ac == pytest.approx(100.0 * efficiency)
assert losses == pytest.approx(100.0 * (1.0 - efficiency))
limited = _real_battery(
capacity_wh=10000, max_power_w=200.0, slot_duration_h=slot_duration_h
)
inverter = _real_inverter(
limited, None, slot_duration_h=slot_duration_h, dc_to_ac_efficiency=efficiency
)
ac, losses = inverter._discharge_battery_to_ac(500.0 * slot_duration_h, 0)
assert ac == pytest.approx(200.0 * slot_duration_h * efficiency)