diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index fe7009c1..a257a672 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -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 diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index 977aa3df..ea71c9c5 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -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 diff --git a/src/akkudoktoreos/devices/settings/batterysettings.py b/src/akkudoktoreos/devices/settings/batterysettings.py index 6eb60055..8438c884 100644 --- a/src/akkudoktoreos/devices/settings/batterysettings.py +++ b/src/akkudoktoreos/devices/settings/batterysettings.py @@ -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": diff --git a/src/akkudoktoreos/prediction/interpolator.py b/src/akkudoktoreos/prediction/interpolator.py index ae7818d6..5e70485c 100644 --- a/src/akkudoktoreos/prediction/interpolator.py +++ b/src/akkudoktoreos/prediction/interpolator.py @@ -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. diff --git a/tests/test_battery.py b/tests/test_battery.py index 0021a620..b849b30b 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -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) diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index e348790b..2b3eba84 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -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, ( diff --git a/tests/test_interpolator.py b/tests/test_interpolator.py new file mode 100644 index 00000000..8e746153 --- /dev/null +++ b/tests/test_interpolator.py @@ -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) diff --git a/tests/test_inverter.py b/tests/test_inverter.py index ff82d435..2aac1dcd 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -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() diff --git a/tests/test_inverter_efficiency.py b/tests/test_inverter_efficiency.py index f3f24f8b..aeb438b1 100644 --- a/tests/test_inverter_efficiency.py +++ b/tests/test_inverter_efficiency.py @@ -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) # ===================================================================