feat(optimization): support a 15-minute optimization interval

The genetic optimizer was hard-wired to an hourly grid and forced
optimization.interval to 3600 s. Generalize it to a configurable slot grid
of length prediction.hours * (3600 / interval), accepting 900 (15 min) in
addition to the default 3600 (1 hour) so the optimizer can schedule on a
quarter-hour grid for 15-minute dynamic electricity tariffs.

- genetic.py: slot_duration_h / slots_per_hour / total_slots helpers; all GA
  vectors sized by total_slots; simulate()/evaluate() indexed by start slot.
- geneticparams.py: allow {900, 3600}; scale the load power series to per-slot
  energy, mirroring the PV series.
- battery.py / inverter.py: scale power caps to per-slot energy caps via
  slot_duration_h; homeappliance.py carries the hook.
- geneticsolution.py: serialize solution and plan on the slot grid (interval
  freq, start-slot offset, second-based instruction instants).

The default 3600 s interval keeps the previous hourly behaviour; the genetic
regression suite is unchanged. Adds tests for the 15-minute slot grid.
This commit is contained in:
Christin
2026-07-12 09:08:39 +02:00
committed by Andreas
parent 7f2ac9098c
commit 3098605b0f
11 changed files with 357 additions and 84 deletions
+24 -7
View File
@@ -12,9 +12,20 @@ from akkudoktoreos.optimization.genetic.geneticdevices import (
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:
@@ -137,8 +148,12 @@ 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 = (
self.max_charge_power_w * self.slot_duration_h
) # TODO rename to max_discharge_power_w
# Actual raw withdrawal (internal)
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh)
@@ -229,7 +244,9 @@ 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 = self.max_charge_power_w * self.slot_duration_h
charging_efficiency_fast = self.charging_efficiency
# Decide mode & determine raw_request_wh and raw_charge_wh
@@ -237,13 +254,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
@@ -258,7 +275,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
@@ -11,9 +11,15 @@ class HomeAppliance:
parameters: HomeApplianceParameters,
optimization_hours: int,
prediction_hours: int,
slot_duration_h: float = 1.0,
):
# slot_duration_h is a forward-compatibility hook. Full sub-hourly home
# appliance scheduling additionally requires converting the start hour to
# a slot index and the duration to a slot count; the default of 1.0 keeps
# the hourly behaviour for the default optimization interval of 3600 s.
self.parameters: HomeApplianceParameters = parameters
self.prediction_hours = prediction_hours
self.slot_duration_h = slot_duration_h
self._setup()
def _setup(self) -> None:
+12 -2
View File
@@ -12,9 +12,14 @@ class Inverter:
self,
parameters: InverterParameters,
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
self._setup()
def _setup(self) -> None:
@@ -23,11 +28,16 @@ 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
) # Maximum power that the inverter can handle
self.parameters.max_power_wh * self.slot_duration_h
) # Maximum energy the inverter can move in one optimization slot
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().
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]: