mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
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:
@@ -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,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]:
|
||||
|
||||
Reference in New Issue
Block a user