mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-18 03:51:56 +00:00
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:
co-authored by
Christin
Claude Opus 5.5
Andreas
parent
560370281f
commit
7f26ea3f3f
+6
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user