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
+131
View File
@@ -134,6 +134,137 @@ def validate_home_appliance_load_definition(
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):
"""Schedule mode of a flexible consumer (home appliance).
+93 -8
View File
@@ -1,8 +1,13 @@
from typing import Optional
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.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
@@ -44,6 +49,37 @@ class InverterParameters(DeviceParameters):
"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(
default=None,
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:
def __init__(
@@ -93,11 +136,32 @@ class Inverter:
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.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
# slot-independent charge-factor limit.
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
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:
"""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:
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_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
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:
export_factor = min(max(float(battery_grid_export_factor), 0.0), 1.0)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
# Upper bounds of the export. With an efficiency curve, the
# 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
# power, so it stays a plain power setpoint ("export at 50 %") that
# does not silently grow when self-consumption used less of the slot.
# At factor 1.0 this bound never binds; behaviour is unchanged.
rated_export_ac = (
self.battery.rated_discharge_energy_wh() * export_factor * self.dc_to_ac_efficiency
)
rated_export_dc = self.battery.rated_discharge_energy_wh() * export_factor
rated_export_ac = rated_export_dc * self.dc_to_ac_efficiency_at(rated_export_dc)
export_capacity = min(
remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac
)
@@ -2,9 +2,10 @@
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.devices.devicesabc import validate_efficiency_curve
from akkudoktoreos.devices.settings.devicebasesettings import (
DevicesBaseSettings,
)
@@ -62,6 +63,38 @@ class InverterCommonSettings(DevicesBaseSettings):
"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(
default=None,
ge=0,
@@ -351,6 +384,13 @@ class InverterCommonSettings(DevicesBaseSettings):
# Validators
# ------------------------------------------------------------------
@field_validator("dc_to_ac_efficiency_curve")
@classmethod
def _validate_dc_to_ac_efficiency_curve(
cls, value: Optional[list[tuple[float, float]]]
) -> Optional[list[tuple[float, float]]]:
return validate_efficiency_curve(value)
@model_validator(mode="after")
def _validate_soc_factors(self) -> "InverterCommonSettings":
if self.battery_min_soc_factor >= self.battery_max_soc_factor:
@@ -375,6 +415,10 @@ class InverterCommonSettings(DevicesBaseSettings):
battery_id=self.battery_id,
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
dc_to_ac_efficiency_curve=self.dc_to_ac_efficiency_curve,
dc_to_ac_efficiency_reference_load_fraction=(
self.dc_to_ac_efficiency_reference_load_fraction
),
max_ac_charge_power_w=self.max_ac_charge_power_w,
ac_charge_limits_total_charge=self.ac_charge_limits_total_charge,
)
@@ -1002,7 +1002,7 @@ class GeneticOptimization(OptimizationBase):
self._terminal_value_reason = "terminal_value_mode is FIXED"
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
# that can actually leave the house.
max_energy_wh = (
@@ -1125,7 +1125,7 @@ class GeneticOptimization(OptimizationBase):
# Usable DC energy, converted to the AC energy that can serve a load.
energy_wh = battery.current_energy_content()
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)
if curve is not None and curve.energy_wh:
@@ -3129,12 +3129,15 @@ class GeneticOptimization(OptimizationBase):
inv = self.simulation.inverter
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 = (
inv.ac_to_dc_efficiency
* bat.charging_efficiency
* bat.discharging_efficiency
* inv.dc_to_ac_efficiency
* inv.reference_dc_to_ac_efficiency
)
# Configurable penalty multiplier (default 1 = economic loss in €)
@@ -3160,7 +3163,7 @@ class GeneticOptimization(OptimizationBase):
free_ac_wh = (
max(0.0, initial_soc_wh - bat.min_soc_wh)
* bat.discharging_efficiency
* inv.dc_to_ac_efficiency
* inv.reference_dc_to_ac_efficiency
)
# Prices/loads/free energy are constant within one optimization
@@ -3186,7 +3189,8 @@ class GeneticOptimization(OptimizationBase):
# corresponding cost per useful/exported AC Wh.
lcos_per_wh_dc = getattr(bat, "levelized_cost_of_storage_kwh", 0.0) / 1000.0
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]
@@ -128,7 +128,7 @@ class TailValueCurve(TerminalValueCurve):
battery=bat,
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 = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh))
plan: list[TailPlanSlot] = []
@@ -231,7 +231,9 @@ def build_tail_value_curve(
bat.charge_array = np.zeros(1, dtype=float)
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)
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])
operating_values = np.zeros(len(states))
values = continuation_values.copy()