mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-18 03:51:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user