feat(optimization): model battery LCOS and probabilistic bypass

This commit is contained in:
Andreas
2026-07-15 09:23:29 +02:00
parent 8ddb7ce754
commit bed1f0f275
27 changed files with 1362 additions and 890 deletions
+5 -1
View File
@@ -50,8 +50,12 @@ class BatteriesCommonSettings(DevicesBaseSettings):
levelized_cost_of_storage_kwh: float = Field(
default=0.0,
ge=0.0,
json_schema_extra={
"description": "Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [€/kWh].",
"description": (
"Levelized cost of storage (LCOS), applied once to each kWh delivered "
"by the battery [€/kWh]."
),
"examples": [0.12],
},
)
@@ -34,6 +34,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)
@@ -115,6 +120,10 @@ class Battery:
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."""
if len(discharge_array) != self.prediction_hours:
+78 -126
View File
@@ -14,9 +14,6 @@ class Inverter:
battery: Optional[Battery] = None,
slot_duration_h: float = 1.0,
):
# slot_duration_h scales the per-slot energy cap (max_power_wh). It
# defaults to 1.0, which keeps the hourly behaviour for the default
# optimization interval of 3600 s.
self.parameters: InverterParameters = parameters
self.battery: Optional[Battery] = battery
self.slot_duration_h: float = slot_duration_h
@@ -28,16 +25,13 @@ class Inverter:
logger.error(error_msg)
raise ValueError(error_msg)
self.self_consumption_predictor = get_eos_load_interpolator()
# max_power_wh is supplied as a power [W] that the legacy hourly code
# treats as Wh-per-hour. Scale it to the actual slot length so a 15-min
# slot can move at most a quarter of that energy.
self.max_power_wh = (
self.parameters.max_power_wh * self.slot_duration_h
) # Maximum energy the inverter can move in one optimization slot
# 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
# max_ac_charge_power_w stays in Watts. It feeds a dimensionless,
# slot-agnostic power-ratio cap in genetic.py simulate().
# 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]:
@@ -58,128 +52,86 @@ class Inverter:
hour: int,
allow_battery_grid_export: bool = False,
) -> 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.
"""
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.
# The interpolator expects power levels [W]; consumption/generation are
# energy per slot [Wh], so convert via the slot duration (identical at
# the hourly default, ×4 on the 15-minute grid).
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption / self.slot_duration_h, generation / self.slot_duration_h
# 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)
from_battery_dc = 0.0
if remaining_load_evq > 0:
# Akku muss den Restverbrauch decken
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
# Wenn der Akku den Restverbrauch nicht vollständig decken kann, wird der Rest ins Netz gezogen
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
if allow_battery_grid_export and self.battery:
export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
grid_export += battery_export_ac
losses += battery_export_losses
)
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
if allow_battery_grid_export and self.battery and grid_import <= 0.0:
export_capacity = max(self.max_power_wh - consumption, 0.0)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
grid_export += battery_export_ac
losses += battery_export_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:
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
)
export_capacity = min(remaining_inverter_ac_capacity, remaining_battery_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