diff --git a/CHANGELOG.md b/CHANGELOG.md index 5263f0c2..222a9490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - Optional local, calibratable forecasting within `PVForecastAkkudoktor`; the remote 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` (`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. @@ -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. - Runtime configuration changes now take priority over environment and file values for 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`). 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 @@ -283,7 +289,6 @@ In addition, bugs were fixed and new features were added: - API to list backups - API to revert to a specific backup - EOSdash Admin tab integration - - Pendulum date types via `pydantic_extra_types.pendulum_dt` - `Time`, `TimeWindow`, `TimeWindowSequence`, and `to_time` helpers in `datetimeutil` - Extended `DataRecord` with configurable field-like semantics @@ -294,7 +299,6 @@ In addition, bugs were fixed and new features were added: - Predictions tab - Cache management in Admin tab - About tab - - Pydantic merge model tests - Developer profiling entry in Makefile - Changelog & docs updated for commitizen release flow @@ -307,11 +311,9 @@ In addition, bugs were fixed and new features were added: - Performance improvements - Charge + start times now reflect realistic simulation - - Appliance simulation: - Time windows may roll over to next day - - Revised load prediction by splitting original `LoadAkkudoktor` into: - `LoadAkkudoktor` @@ -326,7 +328,6 @@ In addition, bugs were fixed and new features were added: - Interval execution + locking + new endpoints - Properly loads required data - EV charge-rate migration for proper availability - - Genetic common settings consistently available - Config markdown generation - Recognize environment variables on EOS server startup diff --git a/docs/akkudoktoreos/optimauto.md b/docs/akkudoktoreos/optimauto.md index ff1511dd..6d278e95 100644 --- a/docs/akkudoktoreos/optimauto.md +++ b/docs/akkudoktoreos/optimauto.md @@ -299,6 +299,39 @@ The inverter supports separate AC↔DC conversion efficiencies: Must be > 0. Default `1.0`. - `max_ac_charge_power_w`: Maximum AC charging power in watts. `null` = no additional limit. Set to `0` to disable AC charging. Default `null`. +- `dc_to_ac_efficiency_curve`: Optional load-dependent DC→AC efficiency as a list of + `[load_fraction, efficiency]` points, with the load fraction relative to `max_power_w`. + Real inverters are much less efficient at low load, e.g. when the battery serves a small + night-time base load. The points must be strictly increasing in load fraction (0-1) with + efficiencies in (0, 1]; values in between are interpolated linearly, values outside are + clamped to the first/last point. When set, the curve replaces `dc_to_ac_efficiency` for + battery discharge. The efficiency is always the one at the AC power that is delivered: if + the battery limits a discharge (low state of charge or its power limit), the conversion is + evaluated at that lower load, not at the requested one. Default `null` (constant + `dc_to_ac_efficiency`, unchanged behaviour). +- `dc_to_ac_efficiency_reference_load_fraction`: Load fraction at which the curve is evaluated + where stored energy is valued without a specific discharge, e.g. the energy left in the + battery at the end of the horizon and the AC charge break-even. Choose the typical load + the battery serves. Only used with `dc_to_ac_efficiency_curve`. Default `0.06`. + +**Example with an efficiency curve:** + +```json +{ + "devices": { + "inverters": { + "inv1": { + "max_power_w": 10000, + "battery_id": "bat1", + "dc_to_ac_efficiency_curve": [ + [0.02, 0.80], [0.07, 0.93], [0.30, 0.96], [1.00, 0.95] + ], + "dc_to_ac_efficiency_reference_load_fraction": 0.06 + } + } + } +} +``` #### Electric vehicle simulation configuration diff --git a/openapi.json b/openapi.json index 7388787d..b7358420 100644 --- a/openapi.json +++ b/openapi.json @@ -11304,6 +11304,70 @@ "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": { "anyOf": [ { @@ -11715,6 +11779,70 @@ "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": { "anyOf": [ { @@ -12147,6 +12275,64 @@ 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": { "anyOf": [ { diff --git a/src/akkudoktoreos/devices/devicesabc.py b/src/akkudoktoreos/devices/devicesabc.py index 144cf9a5..d74b5be3 100644 --- a/src/akkudoktoreos/devices/devicesabc.py +++ b/src/akkudoktoreos/devices/devicesabc.py @@ -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). diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index 9458d382..09453bae 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -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 ) diff --git a/src/akkudoktoreos/devices/settings/invertersettings.py b/src/akkudoktoreos/devices/settings/invertersettings.py index d3221ad0..704982d5 100644 --- a/src/akkudoktoreos/devices/settings/invertersettings.py +++ b/src/akkudoktoreos/devices/settings/invertersettings.py @@ -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, ) diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 9e9603d8..5a95bf0e 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -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] diff --git a/src/akkudoktoreos/optimization/genetic/tailvalue.py b/src/akkudoktoreos/optimization/genetic/tailvalue.py index 1a952410..316bcb32 100644 --- a/src/akkudoktoreos/optimization/genetic/tailvalue.py +++ b/src/akkudoktoreos/optimization/genetic/tailvalue.py @@ -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() diff --git a/tests/test_inverter_efficiency.py b/tests/test_inverter_efficiency.py index d720236b..4258225f 100644 --- a/tests/test_inverter_efficiency.py +++ b/tests/test_inverter_efficiency.py @@ -515,6 +515,7 @@ def _make_mock_simulation( inv = SimpleNamespace( ac_to_dc_efficiency=ac_to_dc_efficiency, dc_to_ac_efficiency=dc_to_ac_efficiency, + reference_dc_to_ac_efficiency=dc_to_ac_efficiency, ) bat = SimpleNamespace( charging_efficiency=charging_efficiency, diff --git a/tests/test_inverter_efficiency_curve.py b/tests/test_inverter_efficiency_curve.py new file mode 100644 index 00000000..14036de5 --- /dev/null +++ b/tests/test_inverter_efficiency_curve.py @@ -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)