feat(devices): port slot-aware battery export and direct-use physics

Port scoped device changes from d2e2d58237. Keep PR #1256 parameter conversion structure and separate GENETIC0 devices. Validate physical flows and reprice changed simulation results.

Co-authored-by: Andreas <drbacke@gmx.de>

Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
Andreas
2026-09-16 18:17:15 +02:00
co-authored by Christin
parent c968e4ccd1
commit 0251af0bb4
9 changed files with 745 additions and 166 deletions
+124 -9
View File
@@ -1,10 +1,11 @@
from typing import Any, Iterator, Optional
import numpy as np
from pydantic import Field
from pydantic import Field, field_validator
from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
def max_charging_power_field(description: Optional[str] = None) -> float:
@@ -80,16 +81,45 @@ class BaseBatteryParameters(DeviceParameters):
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
grid_export_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Battery-to-grid export rates as factor of maximum discharge "
"power ]0.00 ... 1.00]. Only used with direct marketing. None "
"falls back to the configured devices.batteries[0]."
"grid_export_rates."
),
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
},
)
class SolarPanelBatteryParameters(BaseBatteryParameters):
"""PV battery device simulation configuration."""
levelized_cost_of_storage_kwh: float = Field(
default=0.0,
ge=0.0,
json_schema_extra={
"description": (
"Levelized cost of storage applied once to each kWh delivered "
"by the battery [EUR/kWh]."
),
"examples": [0.12],
},
)
max_charge_power_w: Optional[float] = max_charging_power_field()
class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
"""Battery Electric Vehicle Device Simulation Configuration.
``min_soc_percentage`` is the charging target. By default it only has to be
reached by the end of the optimization horizon; a deadline
(``min_soc_deadline_datetime`` and/or ``min_soc_max_duration_h``) moves that
requirement forward, for example to the next departure.
"""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
@@ -98,14 +128,56 @@ class ElectricVehicleParameters(BaseBatteryParameters):
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
min_soc_deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute moment by which 'min_soc_percentage' has to be "
"reached (departure time). A date time without timezone is read "
"as local time. None means end of the optimization horizon."
),
"examples": [None, "2026-07-16T07:00:00+02:00"],
},
)
min_soc_max_duration_h: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Maximum time from the start of the optimization until "
"'min_soc_percentage' has to be reached [h]. Combined with "
"'min_soc_deadline_datetime' the earlier of the two applies."
),
"examples": [None, 6.0],
},
)
@field_validator("min_soc_deadline_datetime", mode="before")
@classmethod
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
class Battery:
"""Represents a battery device with methods to simulate energy charging and discharging."""
def __init__(self, parameters: BaseBatteryParameters, prediction_hours: int):
def __init__(
self,
parameters: BaseBatteryParameters,
prediction_hours: int,
slot_duration_h: float = 1.0,
):
# `prediction_hours` is the number of optimization slots, not hours. At
# the default optimization interval of 3600 s, slot_duration_h is 1.0 and
# the slot count equals the hour count, so existing callers are
# unaffected. At 900 s (15 min) slot_duration_h is 0.25 and there are 4x
# as many slots, each able to move a quarter of the hourly energy.
self.parameters = parameters
self.prediction_hours = prediction_hours
self.slot_duration_h = slot_duration_h
self._setup()
def _setup(self) -> None:
@@ -114,6 +186,11 @@ class Battery:
self.initial_soc_percentage = self.parameters.initial_soc_percentage
self.charging_efficiency = self.parameters.charging_efficiency
self.discharging_efficiency = self.parameters.discharging_efficiency
self.levelized_cost_of_storage_kwh = (
self.parameters.levelized_cost_of_storage_kwh
if isinstance(self.parameters, SolarPanelBatteryParameters)
else 0.0
)
# Charge rates, in case of None use default
self.charge_rates = np.array(BATTERY_DEFAULT_CHARGE_RATES, dtype=float)
@@ -138,6 +215,8 @@ class Battery:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
@@ -181,6 +260,30 @@ class Battery:
self.soc_wh = min(self.soc_wh, self.max_soc_wh) # Only clamp to max
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
def rated_discharge_energy_wh(self) -> float:
"""Return the DC energy one full-power discharge slot delivers.
This is the reference a grid-export rate is applied to: a rate of 0.5
exports at most half the battery's rated discharge power, independent of
how much of the slot budget self-consumption already used.
"""
return self.max_charge_power_w * self.slot_duration_h * self.discharging_efficiency
def remaining_discharge_energy_wh(self, hour: int) -> float:
"""Return DC energy still deliverable within one optimization slot."""
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
raw_power_remaining_wh = max(
raw_power_budget_wh - self._discharged_raw_wh_per_slot[hour], 0.0
)
raw_soc_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
return min(raw_power_remaining_wh, raw_soc_available_wh) * self.discharging_efficiency
def discharged_energy_wh(self, hour: int) -> float:
"""Return DC energy delivered by the battery in one optimization slot."""
return self._discharged_raw_wh_per_slot[hour] * self.discharging_efficiency
def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None:
"""Sets the discharge values for each hour."""
@@ -228,8 +331,13 @@ class Battery:
# Raw extractable energy above minimum SoC
raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
# Maximum raw discharge due to power limit
max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w
# Maximum raw discharge due to power limit, scaled to the slot duration.
# max_charge_power_w is a power [W]; energy movable in one slot is
# power x slot_duration_h.
max_raw_wh = max(
self.max_charge_power_w * self.slot_duration_h - self._discharged_raw_wh_per_slot[hour],
0.0,
) # TODO rename to max_discharge_power_w
# Actual raw withdrawal (internal)
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh)
@@ -246,6 +354,7 @@ class Battery:
# Update SoC
self.soc_wh -= raw_used_wh
self.soc_wh = max(self.soc_wh, self.min_soc_wh)
self._discharged_raw_wh_per_slot[hour] += raw_used_wh
# Losses
losses_wh = raw_used_wh - delivered_wh
@@ -320,7 +429,12 @@ class Battery:
# Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access
soc_wh_fast = self.soc_wh
max_charge_power_w_fast = self.max_charge_power_w
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours).
# At slot_duration_h=1.0 (hourly) this equals the legacy power value.
max_charge_per_slot_wh_fast = max(
self.max_charge_power_w * self.slot_duration_h - self._charged_raw_wh_per_slot[hour],
0.0,
)
charging_efficiency_fast = self.charging_efficiency
# Decide mode & determine raw_request_wh and raw_charge_wh
@@ -328,13 +442,13 @@ class Battery:
raw_request_wh = wh
raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast
elif wh is None and charge_factor > 0.0: # mode 2
raw_request_wh = max_charge_power_w_fast * charge_factor
raw_request_wh = max_charge_per_slot_wh_fast * charge_factor
raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast
if raw_request_wh > raw_charge_wh:
# Use a lower charge factor
lower_charge_factors = self._lower_charge_rates_desc(charge_factor)
for charge_factor in lower_charge_factors:
raw_request_wh = max_charge_power_w_fast * charge_factor
raw_request_wh = max_charge_per_slot_wh_fast * charge_factor
if raw_request_wh <= raw_charge_wh:
self.charge_array[hour] = charge_factor
break
@@ -349,7 +463,7 @@ class Battery:
)
# Remaining capacity
max_raw_wh = min(raw_charge_wh, max_charge_power_w_fast)
max_raw_wh = min(raw_charge_wh, max_charge_per_slot_wh_fast)
# Actual raw intake
raw_input_wh = raw_request_wh if raw_request_wh < max_raw_wh else max_raw_wh
@@ -364,6 +478,7 @@ class Battery:
)
self.soc_wh = new_soc
self._charged_raw_wh_per_slot[hour] += raw_input_wh
losses_wh = raw_input_wh - stored_wh
return stored_wh, losses_wh
+119 -92
View File
@@ -63,9 +63,11 @@ class Inverter:
self,
parameters: InverterParameters,
battery: Optional[Battery] = None,
slot_duration_h: float = 1.0,
):
self.parameters: InverterParameters = parameters
self.battery: Optional[Battery] = battery
self.slot_duration_h: float = slot_duration_h
self._setup()
def _setup(self) -> None:
@@ -74,110 +76,135 @@ class Inverter:
logger.error(error_msg)
raise ValueError(error_msg)
self.self_consumption_predictor = get_eos_load_interpolator()
self.max_power_wh = (
self.parameters.max_power_wh
) # Maximum power that the inverter can handle
# max_power_wh is supplied as power [W] but used as the maximum energy
# the inverter can move during one optimization slot.
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
# 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
def _discharge_battery_to_ac(self, requested_ac_wh: float, hour: int) -> tuple[float, float]:
"""Discharge battery energy and convert it to AC energy."""
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
battery_discharge_dc, discharge_losses = self.battery.discharge_energy(dc_request, hour)
battery_discharge_ac = battery_discharge_dc * self.dc_to_ac_efficiency
inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac
return battery_discharge_ac, discharge_losses + inverter_discharge_losses
def process_energy(
self, generation: float, consumption: float, hour: int
self,
generation: float,
consumption: float,
hour: int,
allow_battery_grid_export: bool = False,
battery_grid_export_factor: float = 1.0,
) -> tuple[float, float, float, float]:
"""Process one slot using probabilistic direct PV-to-load overlap.
``generation`` and ``consumption`` are interval energies. The load
probability table is evaluated in watts and yields the expected direct
PV-to-load power. The remaining load and PV surplus are then handled
independently, because both can occur during different sub-intervals of
the same hourly or 15-minute slot.
Args:
generation: PV energy of the slot [Wh].
consumption: Load energy of the slot [Wh].
hour: Slot index.
allow_battery_grid_export: Whether the battery may discharge into the
grid in this slot (direct marketing).
battery_grid_export_factor: Export level as a factor of the battery's
rated discharge power [0.0 ... 1.0]. 1.0 exports as much as the
battery and the inverter allow, which is the behaviour when no
export rates are configured.
"""
losses = 0.0
grid_export = 0.0
grid_import = 0.0
self_consumption = 0.0
generation = max(float(generation), 0.0)
consumption = max(float(consumption), 0.0)
# Cache inverter DC→AC efficiency for discharge path
dc_to_ac_eff = self.dc_to_ac_efficiency
if generation >= consumption:
if consumption > self.max_power_wh:
# If consumption exceeds maximum inverter power
losses += generation - self.max_power_wh
remaining_power = self.max_power_wh - consumption
grid_import = -remaining_power # Negative indicates feeding into the grid
self_consumption = self.max_power_wh
else:
# Calculate scr using cached results per energy management/optimization run
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation
# Convert interval energy [Wh] to mean power [W] for the probability
# lookup, then convert its expected direct power back to slot energy.
if generation > 0.0 and consumption > 0.0:
expected_direct_power_w = (
self.self_consumption_predictor.calculate_expected_direct_consumption(
consumption / self.slot_duration_h,
generation / self.slot_duration_h,
)
# Remaining power after consumption
remaining_power = (generation - consumption) * scr # EVQ
# Remaining load Self Consumption not perfect
remaining_load_evq = (generation - consumption) * (1.0 - scr)
if remaining_load_evq > 0:
# The battery must cover the remaining consumption
if self.battery:
# Request more DC from battery to account for DC→AC conversion loss
dc_request = remaining_load_evq / dc_to_ac_eff
from_battery_dc, discharge_losses = self.battery.discharge_energy(
dc_request, hour
)
# Convert DC output to AC
from_battery_ac = from_battery_dc * dc_to_ac_eff
inverter_discharge_losses = from_battery_dc - from_battery_ac
remaining_load_evq -= from_battery_ac
losses += discharge_losses + inverter_discharge_losses
else:
from_battery_ac = 0.0
# If the battery cannot fully cover the remaining consumption, the rest is drawn from the grid
if remaining_load_evq > 0:
grid_import += remaining_load_evq
remaining_load_evq = 0
else:
from_battery_ac = 0.0
if remaining_power > 0:
# Load battery with excess energy (DC path, no inverter conversion needed)
charge_losses = 0.0
if self.battery:
charged_energie, charge_losses = self.battery.charge_energy(
remaining_power, hour
)
remaining_surplus = remaining_power - (charged_energie + charge_losses)
else:
remaining_surplus = remaining_power
# Feed-in to the grid based on remaining capacity
if remaining_surplus > self.max_power_wh - consumption:
grid_export = self.max_power_wh - consumption
losses += remaining_surplus - grid_export
else:
grid_export = remaining_surplus
losses += charge_losses
self_consumption = (
consumption + from_battery_ac
) # Self-consumption is equal to the load
)
direct_pv_energy = expected_direct_power_w * self.slot_duration_h
else:
# Case 2: Insufficient generation, cover shortfall
shortfall = consumption - generation
available_ac_power = max(self.max_power_wh - generation, 0)
direct_pv_energy = 0.0
# Discharge battery to cover shortfall, if possible
if self.battery:
# Need shortfall in AC, request more DC from battery for DC→AC conversion
ac_needed = min(shortfall, available_ac_power)
dc_request = ac_needed / dc_to_ac_eff
battery_discharge_dc, discharge_losses = self.battery.discharge_energy(
dc_request, hour
)
# Convert DC output to AC
battery_discharge_ac = battery_discharge_dc * dc_to_ac_eff
inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac
losses += discharge_losses + inverter_discharge_losses
else:
battery_discharge_ac = 0
# Direct PV is bounded by both input energies and by the AC energy the
# inverter can move during this slot.
direct_pv_energy = min(
max(direct_pv_energy, 0.0),
generation,
consumption,
self.max_power_wh,
)
remaining_load = max(consumption - direct_pv_energy, 0.0)
pv_surplus = max(generation - direct_pv_energy, 0.0)
remaining_inverter_ac_capacity = max(self.max_power_wh - direct_pv_energy, 0.0)
# Draw remaining required power from the grid (discharge_losses are already subtracted in the battery)
grid_import = shortfall - battery_discharge_ac
self_consumption = generation + battery_discharge_ac
# Load gaps and PV surplus may both occur within the same coarse slot.
# Cover the load gap first; this preserves the existing chronological
# approximation and can create headroom for later PV charging.
battery_discharge_ac = 0.0
if remaining_load > 0.0 and self.battery and remaining_inverter_ac_capacity > 0.0:
requested_ac_wh = min(remaining_load, remaining_inverter_ac_capacity)
battery_discharge_ac, battery_discharge_losses = self._discharge_battery_to_ac(
requested_ac_wh, hour
)
remaining_load = max(remaining_load - battery_discharge_ac, 0.0)
remaining_inverter_ac_capacity = max(
remaining_inverter_ac_capacity - battery_discharge_ac, 0.0
)
losses += battery_discharge_losses
grid_import = remaining_load
# Charge from the probabilistic PV surplus on the DC path. Stored energy
# plus charge losses equals the PV energy accepted by the battery.
remaining_surplus = pv_surplus
if remaining_surplus > 0.0 and self.battery:
charged_energy, charge_losses = self.battery.charge_energy(remaining_surplus, hour)
remaining_surplus = max(remaining_surplus - charged_energy - charge_losses, 0.0)
losses += charge_losses
pv_grid_export = min(remaining_surplus, remaining_inverter_ac_capacity)
grid_export += pv_grid_export
remaining_inverter_ac_capacity = max(remaining_inverter_ac_capacity - pv_grid_export, 0.0)
# PV which can neither charge the battery nor pass through the inverter
# is curtailed and reported as a loss.
losses += max(remaining_surplus - pv_grid_export, 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)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
)
# 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
)
export_capacity = min(
remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac
)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
grid_export += battery_export_ac
losses += battery_export_losses
self_consumption = direct_pv_energy + battery_discharge_ac
return grid_export, grid_import, losses, self_consumption
@@ -27,6 +27,9 @@ if TYPE_CHECKING:
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
BATTERY_DEFAULT_GRID_EXPORT_RATES: list[float] = [0.25, 0.5, 0.75, 1.0]
class BatteriesCommonSettings(DevicesBaseSettings):
"""Battery and electric vehicle device settings.
@@ -120,6 +123,48 @@ class BatteriesCommonSettings(DevicesBaseSettings):
# GENETIC domain conversion
# ------------------------------------------------------------------
grid_export_rates: Optional[list[float]] = Field(
default=BATTERY_DEFAULT_GRID_EXPORT_RATES,
json_schema_extra={
"description": (
"Battery-to-grid export rates as factor of maximum discharge "
"power ]0.00 ... 1.00]. Only used with direct marketing "
"(feedintariff.direct_marketing_enabled). Each rate is one "
"additional optimizer state; [1.0] restores all-or-nothing "
"export. None triggers fallback to default export-rates."
),
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
},
)
@field_validator("grid_export_rates", mode="before")
def validate_and_sort_grid_export_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
"""Normalize the export rates to a sorted, duplicate-free array in ]0, 1]."""
# None means fallback to default values
if v is None:
return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy()
if isinstance(v, str):
numbers = re.split(r"[,\s]+", v.strip("[]"))
arr = np.array([float(x) for x in numbers if x])
else:
arr = np.array(v, dtype=float)
if arr.size == 0:
raise ValueError("grid_export_rates must contain at least one value.")
# A rate of 0.0 is not an export level - "no export" is expressed by the
# other battery states - so the lower bound is exclusive.
if (arr <= 0.0).any() or (arr > 1.0).any():
raise ValueError("grid_export_rates must be within ]0.0, 1.0].")
arr = np.unique(arr)
arr.sort()
return arr
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
@@ -132,6 +177,9 @@ class BatteriesCommonSettings(DevicesBaseSettings):
max_charge_power_w=self.max_charge_power_w,
min_soc_percentage=self.min_soc_percentage,
max_soc_percentage=self.max_soc_percentage,
charge_rates=self.charge_rates,
grid_export_rates=self.grid_export_rates,
levelized_cost_of_storage_kwh=self.levelized_cost_of_storage_amt_kwh,
)
def to_genetic_ev_bat_param(self) -> "ElectricVehicleParameters":
+92 -10
View File
@@ -15,31 +15,113 @@ class SelfConsumptionProbabilityInterpolator:
# Load the RegularGridInterpolator
with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
self.load_power_min_w = float(self.interpolator.grid[0][0])
self.load_power_max_w = float(self.interpolator.grid[0][-1])
self.minute_load_levels_w = np.asarray(self.interpolator.grid[1], dtype=float)
self.minute_load_max_w = float(self.interpolator.grid[1][-1])
def _load_distribution(self, mean_load_power_w: float) -> tuple[np.ndarray, np.ndarray]:
"""Return the conditional minute-load distribution for a mean load.
The table stores one probability mass for each 50 W minute-load bin.
Linear interpolation between its mean-load rows can introduce very small
numerical deviations, so negative masses are removed and the result is
normalized explicitly.
"""
bounded_mean_load_w = float(
np.clip(mean_load_power_w, self.load_power_min_w, self.load_power_max_w)
)
points = np.column_stack(
(
np.full(self.minute_load_levels_w.shape, bounded_mean_load_w),
self.minute_load_levels_w,
)
)
probabilities = np.maximum(np.asarray(self.interpolator(points), dtype=float), 0.0)
probability_sum = float(probabilities.sum())
if probability_sum <= 0.0:
return self.minute_load_levels_w, probabilities
return self.minute_load_levels_w, probabilities / probability_sum
def _generate_points(
self, load_1h_power: float, pv_power: float
self, mean_load_power_w: float, pv_power_w: float
) -> tuple[np.ndarray, np.ndarray]:
"""Generate the grid points for interpolation."""
partial_loads = np.arange(0, pv_power + 50, 50)
points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T
"""Generate in-bounds grid points for interpolation.
The bundled probability table was calibrated from a one-hour mean load
and one-minute samples. Sub-hourly optimization still passes *power* in
watts here; a native 15-minute mean is therefore a documented
approximation until a separately calibrated table is available.
"""
bounded_mean_load_w = float(
np.clip(mean_load_power_w, self.load_power_min_w, self.load_power_max_w)
)
bounded_pv_power_w = float(np.clip(pv_power_w, 0.0, self.minute_load_max_w))
partial_loads = np.arange(0.0, bounded_pv_power_w + 1.0, 50.0)
points = np.column_stack((np.full(partial_loads.shape, bounded_mean_load_w), partial_loads))
return points, partial_loads
@cache_energy_management
def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
"""Calculate the PV self-consumption rate using RegularGridInterpolator.
def calculate_self_consumption(self, mean_load_power_w: float, pv_power_w: float) -> float:
"""Return the legacy cumulative minute-load probability.
This method is retained for API compatibility. Its result is the
probability that the minute load is no greater than ``pv_power_w``;
it is not an energy self-consumption ratio. New energy-flow code must
use :meth:`calculate_expected_direct_consumption`.
The results are cached until the start of the next energy management run/ optimization.
Args:
- last_1h_power: 1h power levels (W).
- pv_power: Current PV power output (W).
- mean_load_power_w: Mean load power for the current forecast interval (W).
- pv_power_w: Current PV power output (W).
Returns:
- Self-consumption rate as a float.
"""
points, partial_loads = self._generate_points(load_1h_power, pv_power)
points, _ = self._generate_points(mean_load_power_w, pv_power_w)
probabilities = self.interpolator(points)
return probabilities.sum()
return float(np.clip(probabilities.sum(), 0.0, 1.0))
@cache_energy_management
def calculate_expected_direct_consumption(
self, mean_load_power_w: float, pv_power_w: float
) -> float:
"""Calculate expected direct PV-to-load power in watts.
For conditional minute-load probabilities ``p_i`` and load-bin powers
``L_i``, the expected direct consumption is
``sum(p_i * min(L_i, pv_power_w))``.
The tabulated load-bin powers are rescaled to preserve the supplied
forecast mean exactly. This compensates for discretization and the
finite upper table boundary while retaining the distribution shape.
Args:
mean_load_power_w: Mean load power of the forecast interval [W].
pv_power_w: Mean PV power of the forecast interval [W].
Returns:
Expected direct PV-to-load power [W].
"""
mean_load_power_w = max(float(mean_load_power_w), 0.0)
pv_power_w = max(float(pv_power_w), 0.0)
if mean_load_power_w == 0.0 or pv_power_w == 0.0:
return 0.0
load_levels_w, probabilities = self._load_distribution(mean_load_power_w)
modeled_mean_load_w = float(np.dot(probabilities, load_levels_w))
if modeled_mean_load_w <= 0.0:
return 0.0
# Preserve the requested mean load while keeping the conditional shape
# from the probability table.
normalized_load_levels_w = load_levels_w * (mean_load_power_w / modeled_mean_load_w)
expected_direct_power_w = float(
np.dot(probabilities, np.minimum(normalized_load_levels_w, pv_power_w))
)
return float(np.clip(expected_direct_power_w, 0.0, min(mean_load_power_w, pv_power_w)))
# def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
# """Calculate the PV self-consumption rate using RegularGridInterpolator.
+89
View File
@@ -1,6 +1,8 @@
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.devices.devices import BatteriesCommonSettings
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
@@ -294,3 +296,90 @@ def test_car_and_pv_battery_discharge_and_max_charge_power(setup_pv_battery, set
assert car_battery.parameters.max_charge_power_w == 7000, (
"Car battery max charge power should remain as defined"
)
def test_quarter_hour_charge_calls_share_one_power_budget():
params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10_000,
initial_soc_percentage=0,
min_soc_percentage=0,
max_soc_percentage=100,
max_charge_power_w=1_000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
)
battery = Battery(params, prediction_hours=4, slot_duration_h=0.25)
battery.set_charge_per_hour(np.ones(4))
first_stored, _ = battery.charge_energy(200.0, 0)
second_stored, _ = battery.charge_energy(200.0, 0)
assert first_stored == pytest.approx(200.0)
assert second_stored == pytest.approx(50.0)
assert battery.soc_wh == pytest.approx(250.0)
def test_quarter_hour_discharge_calls_share_one_power_budget():
params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10_000,
initial_soc_percentage=100,
min_soc_percentage=0,
max_soc_percentage=100,
max_charge_power_w=1_000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
)
battery = Battery(params, prediction_hours=4, slot_duration_h=0.25)
battery.set_discharge_per_hour(np.ones(4))
first_delivered, _ = battery.discharge_energy(200.0, 0)
second_delivered, _ = battery.discharge_energy(200.0, 0)
assert first_delivered == pytest.approx(200.0)
assert second_delivered == pytest.approx(50.0)
assert battery.discharged_energy_wh(0) == pytest.approx(250.0)
assert battery.soc_wh == pytest.approx(9_750.0)
battery.reset()
assert battery.discharged_energy_wh(0) == 0.0
def test_grid_export_rates_are_sorted_and_deduplicated():
"""Export rates are normalized like the charge rates."""
settings = BatteriesCommonSettings(
device_id="battery1", grid_export_rates=[1.0, 0.5, 0.5, 0.25]
)
assert list(settings.grid_export_rates) == [0.25, 0.5, 1.0]
def test_grid_export_rates_default_and_override():
"""None falls back to the defaults; [1.0] restores all-or-nothing export."""
assert list(BatteriesCommonSettings(device_id="battery1").grid_export_rates) == [
0.25,
0.5,
0.75,
1.0,
]
assert list(
BatteriesCommonSettings(device_id="battery1", grid_export_rates=None).grid_export_rates
) == [0.25, 0.5, 0.75, 1.0]
assert list(
BatteriesCommonSettings(device_id="battery1", grid_export_rates=[1.0]).grid_export_rates
) == [1.0]
@pytest.mark.parametrize("rates", [[0.0, 0.5], [1.5], [-0.25], []])
def test_grid_export_rates_reject_invalid_values(rates):
"""0.0 is not an export level, and rates above the rated power are rejected."""
with pytest.raises(ValidationError):
BatteriesCommonSettings(device_id="battery1", grid_export_rates=rates)
def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery):
"""The rate reference is the rated discharge energy of one slot."""
battery = setup_pv_battery
expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency
assert battery.rated_discharge_energy_wh() == pytest.approx(expected)
+7 -12
View File
@@ -334,18 +334,13 @@ def test_simulation(genetic_simulation):
"The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 1527.13."
)
# Verify the total balance
assert abs(result["Gesamtbilanz_Euro"] - 6.612835813556755) < 1e-5, (
"Total balance should be 6.612835813556755."
)
# Check total revenue and total costs
assert abs(result["Gesamteinnahmen_Euro"] - 1.964301131937134) < 1e-5, (
"Total revenue should be 1.964301131937134."
)
assert abs(result["Gesamtkosten_Euro"] - 8.577136945493889) < 1e-5, (
"Total costs should be 8.577136945493889 ."
)
# Reprice the physical grid flows independently. The new direct-use
# probability model changes the old aggregate monetary golden values.
costs = np.dot(result["Netzbezug_Wh_pro_Stunde"], simulation.elect_price_hourly[start_hour:])
revenues = np.dot(result["Netzeinspeisung_Wh_pro_Stunde"], simulation.elect_revenue_per_hour_arr[start_hour:])
assert result["Gesamtkosten_Euro"] == pytest.approx(costs)
assert result["Gesamteinnahmen_Euro"] == pytest.approx(revenues)
assert result["Gesamtbilanz_Euro"] == pytest.approx(costs - revenues)
# Check the losses
assert abs(result["Gesamt_Verluste"] - 1620.0) < 1e-5, (
+70
View File
@@ -0,0 +1,70 @@
import pytest
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
def test_quarter_hour_energy_is_converted_back_to_same_mean_power():
"""Splitting hourly energy must not change the minute-load probability lookup."""
interpolator = get_eos_load_interpolator()
hourly_load_wh = 800.0
hourly_pv_wh = 1200.0
slot_duration_h = 0.25
hourly = interpolator.calculate_expected_direct_consumption(hourly_load_wh, hourly_pv_wh)
quarter_hour = interpolator.calculate_expected_direct_consumption(
(hourly_load_wh / 4) / slot_duration_h,
(hourly_pv_wh / 4) / slot_duration_h,
)
assert quarter_hour == pytest.approx(hourly)
def test_load_above_probability_grid_uses_highest_supported_distribution():
"""Out-of-range household load must not make self-consumption jump to zero."""
interpolator = get_eos_load_interpolator()
at_boundary = interpolator.calculate_self_consumption(3450.0, 5000.0)
above_boundary = interpolator.calculate_self_consumption(4000.0, 5000.0)
assert above_boundary == pytest.approx(at_boundary)
assert above_boundary > 0.99
def test_expected_direct_consumption_accounts_for_subhourly_load_variation():
"""Expected overlap must be below the optimistic overlap of interval means."""
interpolator = get_eos_load_interpolator()
direct_power_w = interpolator.calculate_expected_direct_consumption(800.0, 1200.0)
assert direct_power_w == pytest.approx(621.0, abs=2.0)
assert 0.0 < direct_power_w < 800.0
@pytest.mark.parametrize(
("mean_load_power_w", "pv_power_w"),
[(800.0, 1200.0), (1000.0, 500.0), (1500.0, 1500.0)],
)
def test_expected_direct_consumption_produces_conservative_energy_balance(
mean_load_power_w, pv_power_w
):
"""Direct use, residual load and surplus must conserve both mean powers."""
interpolator = get_eos_load_interpolator()
direct_power_w = interpolator.calculate_expected_direct_consumption(
mean_load_power_w, pv_power_w
)
residual_load_w = mean_load_power_w - direct_power_w
pv_surplus_w = pv_power_w - direct_power_w
assert 0.0 <= direct_power_w <= min(mean_load_power_w, pv_power_w)
assert direct_power_w + residual_load_w == pytest.approx(mean_load_power_w)
assert direct_power_w + pv_surplus_w == pytest.approx(pv_power_w)
def test_expected_direct_consumption_preserves_forecast_mean_at_high_pv():
"""A PV level above every normalized load bin covers the complete mean load."""
interpolator = get_eos_load_interpolator()
direct_power_w = interpolator.calculate_expected_direct_consumption(3000.0, 10000.0)
assert direct_power_w == pytest.approx(3000.0)
+177 -28
View File
@@ -1,8 +1,13 @@
from unittest.mock import Mock, patch
from unittest.mock import Mock, call, patch
import numpy as np
import pytest
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.devices.genetic.battery import (
SolarPanelBatteryParameters,
)
@pytest.fixture
@@ -10,6 +15,9 @@ def mock_battery() -> Mock:
mock_battery = Mock()
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
# Rated discharge energy of one slot - the reference a grid-export rate is
# applied to. Large enough to never bind at the default factor of 1.0.
mock_battery.rated_discharge_energy_wh = Mock(return_value=1e9)
mock_battery.parameters.device_id = "battery1"
return mock_battery
@@ -17,7 +25,7 @@ def mock_battery() -> Mock:
@pytest.fixture
def inverter(mock_battery) -> Inverter:
mock_self_consumption_predictor = Mock()
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
mock_self_consumption_predictor.calculate_expected_direct_consumption.side_effect = min
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=mock_self_consumption_predictor,
@@ -26,11 +34,51 @@ def inverter(mock_battery) -> Inverter:
InverterParameters(
device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id
),
battery = mock_battery
battery=mock_battery,
)
return iv
def test_quarter_hour_load_and_grid_export_share_discharge_power_limit():
"""Local supply plus direct export may not exceed one slot's battery budget."""
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery",
capacity_wh=10000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
max_charge_power_w=7000,
initial_soc_percentage=100,
),
prediction_hours=1,
slot_duration_h=0.25,
)
battery.set_discharge_per_hour(np.array([1]))
quarter_hour_inverter = Inverter(
InverterParameters(
device_id="inverter",
max_power_wh=10000,
battery_id="battery",
dc_to_ac_efficiency=1.0,
ac_to_dc_efficiency=1.0,
),
battery=battery,
slot_duration_h=0.25,
)
initial_soc_wh = battery.soc_wh
grid_export, grid_import, _, _ = quarter_hour_inverter.process_energy(
generation=0.0,
consumption=1000.0,
hour=0,
allow_battery_grid_export=True,
)
assert grid_import == 0.0
assert grid_export == pytest.approx(750.0)
assert initial_soc_wh - battery.soc_wh == pytest.approx(1750.0)
def test_process_energy_excess_generation(inverter, mock_battery):
# Battery charges 100 Wh with 10 Wh loss
mock_battery.charge_energy.return_value = (100.0, 10.0)
@@ -48,7 +96,7 @@ def test_process_energy_excess_generation(inverter, mock_battery):
assert self_consumption == 200.0 # All consumption is met
mock_battery.charge_energy.assert_called_once_with(400.0, hour)
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
@@ -57,7 +105,8 @@ def test_process_energy_excess_generation_interpolator(inverter, mock_battery):
# Battery charges 100 Wh with 10 Wh loss
mock_battery.charge_energy.return_value = (100.0, 10.0)
mock_battery.discharge_energy.return_value = (20.0, 2.0)
inverter.self_consumption_predictor.calculate_self_consumption.return_value = 0.95
inverter.self_consumption_predictor.calculate_expected_direct_consumption.side_effect = None
inverter.self_consumption_predictor.calculate_expected_direct_consumption.return_value = 180.0
generation = 600.0
consumption = 200.0
@@ -67,19 +116,71 @@ def test_process_energy_excess_generation_interpolator(inverter, mock_battery):
generation, consumption, hour
)
assert grid_export == pytest.approx(
270.0, rel=1e-2
) # 290 Wh feed-in - 5% of generation-consumption self consumption after battery charges
assert grid_export == pytest.approx(300.0, rel=1e-2)
assert grid_import == pytest.approx(0.0, rel=1e-2) # No grid draw
assert losses == 12.0 # Battery charging losses
assert self_consumption == 220.0 # All consumption is met
mock_battery.charge_energy.assert_called_once_with(pytest.approx(380.0, rel=1e-2), hour)
assert losses == 22.0 # Battery/inverter losses plus curtailed PV
assert self_consumption == 200.0 # 180 Wh direct PV + 20 Wh battery
mock_battery.charge_energy.assert_called_once_with(pytest.approx(420.0, rel=1e-2), hour)
mock_battery.discharge_energy.assert_called_once_with(pytest.approx(20.0, rel=1e-2), hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_probabilistic_bypass_conserves_energy_without_battery():
predictor = Mock()
predictor.calculate_expected_direct_consumption.return_value = 150.0
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=predictor,
):
inverter_without_battery = Inverter(
InverterParameters(device_id="inverter", max_power_wh=1000.0)
)
generation = 600.0
consumption = 200.0
grid_export, grid_import, losses, self_consumption = (
inverter_without_battery.process_energy(generation, consumption, hour=0)
)
assert self_consumption == pytest.approx(150.0)
assert grid_import == pytest.approx(50.0)
assert grid_export == pytest.approx(450.0)
assert losses == 0.0
assert generation + grid_import == pytest.approx(
consumption + grid_export + losses
)
def test_probabilistic_bypass_conserves_energy_on_quarter_hour_grid():
predictor = Mock()
predictor.calculate_expected_direct_consumption.return_value = 600.0
with patch(
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
return_value=predictor,
):
inverter_without_battery = Inverter(
InverterParameters(device_id="inverter", max_power_wh=2000.0),
slot_duration_h=0.25,
)
generation = 300.0 # 1200 W over 15 minutes
consumption = 200.0 # 800 W over 15 minutes
grid_export, grid_import, losses, self_consumption = (
inverter_without_battery.process_energy(generation, consumption, hour=0)
)
predictor.calculate_expected_direct_consumption.assert_called_once_with(800.0, 1200.0)
assert self_consumption == pytest.approx(150.0)
assert grid_import == pytest.approx(50.0)
assert grid_export == pytest.approx(150.0)
assert losses == 0.0
assert generation + grid_import == pytest.approx(
consumption + grid_export + losses
)
def test_process_energy_generation_equals_consumption(inverter, mock_battery):
generation = 300.0
consumption = 300.0
@@ -96,7 +197,7 @@ def test_process_energy_generation_equals_consumption(inverter, mock_battery):
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
@@ -120,7 +221,49 @@ def test_process_energy_battery_discharges(inverter, mock_battery):
assert self_consumption == 200.0 # Generation + battery discharge
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(150.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
mock_battery.max_charge_power_w = 300.0
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (200.0, 0.0)]
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation=0.0,
consumption=100.0,
hour=12,
allow_battery_grid_export=True,
)
assert grid_export == pytest.approx(200.0, rel=1e-2)
assert grid_import == 0.0
assert losses == 0.0
assert self_consumption == 100.0
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(200.0, 12)])
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
def test_process_energy_grid_export_rate_limits_export(inverter, mock_battery):
"""An export rate caps the export at that share of the rated discharge power."""
mock_battery.max_charge_power_w = 300.0
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
mock_battery.rated_discharge_energy_wh.return_value = 300.0
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (150.0, 0.0)]
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
generation=0.0,
consumption=100.0,
hour=12,
allow_battery_grid_export=True,
battery_grid_export_factor=0.5,
)
# 0.5 * 300 Wh rated = 150 Wh, below the 200 Wh the battery could still give.
assert grid_export == pytest.approx(150.0)
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(150.0, 12)])
def test_process_energy_battery_empty(inverter, mock_battery):
@@ -140,7 +283,9 @@ def test_process_energy_battery_empty(inverter, mock_battery):
assert self_consumption == 100.0 # Only generation is consumed
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_battery_full_at_start(inverter, mock_battery):
@@ -162,7 +307,7 @@ def test_process_energy_battery_full_at_start(inverter, mock_battery):
assert self_consumption == 200.0 # Only consumption is met
mock_battery.charge_energy.assert_called_once_with(300.0, hour)
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
@@ -184,7 +329,9 @@ def test_process_energy_insufficient_generation_no_battery(inverter, mock_batter
assert self_consumption == 100.0 # Only generation is consumed
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_insufficient_generation_battery_assists(inverter, mock_battery):
@@ -209,7 +356,9 @@ def test_process_energy_insufficient_generation_battery_assists(inverter, mock_b
assert self_consumption == 250.0 # Generation + battery discharge
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(200.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_zero_generation(inverter, mock_battery):
@@ -232,7 +381,7 @@ def test_process_energy_zero_generation(inverter, mock_battery):
assert self_consumption == 100.0 # Only battery discharge is consumed
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(300.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
def test_process_energy_zero_consumption(inverter, mock_battery):
@@ -252,9 +401,7 @@ def test_process_energy_zero_consumption(inverter, mock_battery):
assert self_consumption == 0.0 # Zero consumption
mock_battery.charge_energy.assert_called_once_with(500.0, hour)
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
consumption, generation
)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
def test_process_energy_zero_generation_zero_consumption(inverter, mock_battery):
@@ -272,9 +419,7 @@ def test_process_energy_zero_generation_zero_consumption(inverter, mock_battery)
assert self_consumption == 0.0 # No consumption
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_not_called()
inverter.self_consumption_predictor.calculate_self_consumption.assert_called_once_with(
consumption, generation
)
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
def test_process_energy_partial_battery_discharge(inverter, mock_battery):
@@ -295,7 +440,9 @@ def test_process_energy_partial_battery_discharge(inverter, mock_battery):
assert self_consumption == 250.0 # Generation + battery discharge
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(200.0, 12)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_consumption_exceeds_max_no_battery(inverter, mock_battery):
@@ -315,7 +462,9 @@ def test_process_energy_consumption_exceeds_max_no_battery(inverter, mock_batter
assert self_consumption == 100.0 # Only the generation is consumed, maxing out the inverter
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(400.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_called_once_with(
consumption, generation
)
def test_process_energy_zero_generation_full_battery_high_consumption(inverter, mock_battery):
@@ -337,4 +486,4 @@ def test_process_energy_zero_generation_full_battery_high_consumption(inverter,
assert self_consumption == 500.0 # Battery fully discharges to meet consumption
mock_battery.charge_energy.assert_not_called()
mock_battery.discharge_energy.assert_called_once_with(500.0, hour)
inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called()
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
+19 -15
View File
@@ -17,14 +17,11 @@ from unittest.mock import Mock, patch
import numpy as np
import pytest
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.devices.genetic.battery import (
Battery,
SolarPanelBatteryParameters,
)
from akkudoktoreos.devices.genetic.inverter import (
Inverter,
InverterParameters,
)
# ---------------------------------------------------------------------------
# Helpers / Fixtures
@@ -40,7 +37,7 @@ def _make_inverter(
) -> Inverter:
"""Create an Inverter with custom efficiency parameters and a mock battery."""
mock_self_consumption_predictor = Mock()
mock_self_consumption_predictor.calculate_self_consumption.return_value = 1.0
mock_self_consumption_predictor.calculate_expected_direct_consumption.side_effect = min
params = InverterParameters(
device_id="inv1",
@@ -167,12 +164,14 @@ class TestDcToAcEfficiency:
assert losses == pytest.approx(10.0, rel=1e-5) # Only battery losses
def test_discharge_surplus_path_with_efficiency(self, mock_battery):
"""When generation > consumption but SCR < 1, discharge goes through inverter."""
mock_battery.discharge_energy.return_value = (50.0, 5.0)
"""A probabilistic load gap discharges through the inverter."""
mock_battery.discharge_energy.return_value = (30.0 / 0.90, 5.0)
mock_battery.charge_energy.return_value = (100.0, 10.0)
inv = _make_inverter(dc_to_ac_efficiency=0.90, mock_battery=mock_battery)
cast(Mock, inv.self_consumption_predictor).calculate_self_consumption.return_value = 0.90
predictor = cast(Mock, inv.self_consumption_predictor)
predictor.calculate_expected_direct_consumption.side_effect = None
predictor.calculate_expected_direct_consumption.return_value = 170.0
generation = 500.0
consumption = 200.0
@@ -182,18 +181,23 @@ class TestDcToAcEfficiency:
generation, consumption, hour
)
# surplus = 300, remaining_power = 300*0.9 = 270, remaining_load_evq = 300*0.1 = 30
# DC request for discharge = 30 / 0.90 = 33.333
# Expected direct PV is 170 Wh, leaving 30 Wh of load gap and
# 330 Wh of PV surplus within different sub-periods of the slot.
# DC request for discharge = 30 / 0.90 = 33.333 Wh.
expected_dc_request = 30.0 / 0.90
mock_battery.discharge_energy.assert_called_once_with(
pytest.approx(expected_dc_request, rel=1e-3), hour
)
# Battery delivers 50 Wh DC → 45 Wh AC
from_battery_ac = 50.0 * 0.90 # 45 Wh
inverter_discharge_loss = 50.0 - from_battery_ac # 5 Wh
# Battery delivers 33.333 Wh DC -> 30 Wh AC.
from_battery_dc = 30.0 / 0.90
from_battery_ac = from_battery_dc * 0.90
inverter_discharge_loss = from_battery_dc - from_battery_ac
assert self_consumption == pytest.approx(consumption + from_battery_ac, rel=1e-5)
assert self_consumption == pytest.approx(170.0 + from_battery_ac, rel=1e-5)
assert grid_import == pytest.approx(0.0)
assert grid_export == pytest.approx(220.0)
assert losses == pytest.approx(5.0 + inverter_discharge_loss + 10.0)
# ===================================================================