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
+7
View File
@@ -13,6 +13,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `PVForecastPVNode` — native 15-minute forecasts from the pvnode.com API. - `PVForecastPVNode` — native 15-minute forecasts from the pvnode.com API.
- `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API. - `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API.
- `PVForecastSolcast` — forecasts from the Solcast rooftop-site API. - `PVForecastSolcast` — forecasts from the Solcast rooftop-site API.
- 15-minute optimization interval for the genetic optimizer. `optimization.interval`
now accepts 900 (15 min) in addition to the default 3600 (1 hour), letting the
optimizer schedule on a quarter-hour grid for 15-minute dynamic electricity
tariffs. Device power caps and the solution/plan serializers are slot-aware; the
default 3600 s interval keeps the previous hourly behaviour. The new sub-hourly PV
providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight
into the quarter-hour grid.
## 0.3.0 (2026-03-17) ## 0.3.0 (2026-03-17)
+1 -1
View File
@@ -11,7 +11,7 @@
| genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | Genetic optimization algorithm configuration. | | genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | Genetic optimization algorithm configuration. |
| horizon | | `int` | `ro` | `N/A` | Number of optimization steps. | | horizon | | `int` | `ro` | `N/A` | Number of optimization steps. |
| horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. | | horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
| interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) | | interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval (slot length) [sec]. The genetic optimizer supports 3600 (1 hour) and 900 (15 min); other values fall back to 3600. Defaults to 3600 seconds (1 hour). |
| keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. | | keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. |
::: :::
<!-- pyml enable line-length --> <!-- pyml enable line-length -->
+9 -10
View File
@@ -139,17 +139,16 @@ The energy management can be run in three modes:
Each device simulation run must ensure that all tasks or appliance cycles (e.g., running a Each device simulation run must ensure that all tasks or appliance cycles (e.g., running a
dishwasher) are completed within the configured time windows. dishwasher) are completed within the configured time windows.
- **interval**: Defines the time step in seconds between control actions - **interval**: Defines the time step (slot length) in seconds between control actions.
(e.g. `3600` for one hour, `900` for 15 minutes). The genetic algorithm supports `3600` (one hour, the default) and `900` (15 minutes);
any other value falls back to `3600`. The number of optimization slots is
`prediction.hours * (3600 / interval)`, and device power caps as well as the solution
and energy-management-plan serializers are slot-aware.
:::{warning} :::{note}
**Current Limitation** Use `900` together with a 15-minute electricity price source (for example a dynamic or
exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the
At present, the `interval` setting is **not used** by the genetic algorithm. Instead: default `3600` preserves the previous hourly behaviour.
- The control interval is fixed to **1 hour**.
Support for configurable intervals (e.g. 15-minute steps) may be added in a future release.
::: :::
#### Genetic Algorithm Parameters #### Genetic Algorithm Parameters
+24 -7
View File
@@ -12,9 +12,20 @@ from akkudoktoreos.optimization.genetic.geneticdevices import (
class Battery: class Battery:
"""Represents a battery device with methods to simulate energy charging and discharging.""" """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.parameters = parameters
self.prediction_hours = prediction_hours self.prediction_hours = prediction_hours
self.slot_duration_h = slot_duration_h
self._setup() self._setup()
def _setup(self) -> None: def _setup(self) -> None:
@@ -137,8 +148,12 @@ class Battery:
# Raw extractable energy above minimum SoC # Raw extractable energy above minimum SoC
raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0) raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
# Maximum raw discharge due to power limit # Maximum raw discharge due to power limit, scaled to the slot duration.
max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w # 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) # Actual raw withdrawal (internal)
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh) 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 # Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access
soc_wh_fast = self.soc_wh 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 charging_efficiency_fast = self.charging_efficiency
# Decide mode & determine raw_request_wh and raw_charge_wh # Decide mode & determine raw_request_wh and raw_charge_wh
@@ -237,13 +254,13 @@ class Battery:
raw_request_wh = wh raw_request_wh = wh
raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast 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 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 raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast
if raw_request_wh > raw_charge_wh: if raw_request_wh > raw_charge_wh:
# Use a lower charge factor # Use a lower charge factor
lower_charge_factors = self._lower_charge_rates_desc(charge_factor) lower_charge_factors = self._lower_charge_rates_desc(charge_factor)
for charge_factor in lower_charge_factors: 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: if raw_request_wh <= raw_charge_wh:
self.charge_array[hour] = charge_factor self.charge_array[hour] = charge_factor
break break
@@ -258,7 +275,7 @@ class Battery:
) )
# Remaining capacity # 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 # Actual raw intake
raw_input_wh = raw_request_wh if raw_request_wh < max_raw_wh else max_raw_wh 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, parameters: HomeApplianceParameters,
optimization_hours: int, optimization_hours: int,
prediction_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.parameters: HomeApplianceParameters = parameters
self.prediction_hours = prediction_hours self.prediction_hours = prediction_hours
self.slot_duration_h = slot_duration_h
self._setup() self._setup()
def _setup(self) -> None: def _setup(self) -> None:
+12 -2
View File
@@ -12,9 +12,14 @@ class Inverter:
self, self,
parameters: InverterParameters, parameters: InverterParameters,
battery: Optional[Battery] = None, 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.parameters: InverterParameters = parameters
self.battery: Optional[Battery] = battery self.battery: Optional[Battery] = battery
self.slot_duration_h: float = slot_duration_h
self._setup() self._setup()
def _setup(self) -> None: def _setup(self) -> None:
@@ -23,11 +28,16 @@ class Inverter:
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
self.self_consumption_predictor = get_eos_load_interpolator() 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.max_power_wh = (
self.parameters.max_power_wh self.parameters.max_power_wh * self.slot_duration_h
) # Maximum power that the inverter can handle ) # Maximum energy the inverter can move in one optimization slot
self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_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 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]: def _discharge_battery_to_ac(self, requested_ac_wh: float, hour: int) -> tuple[float, float]:
@@ -484,6 +484,45 @@ class GeneticSimulation(PydanticBaseModel):
class GeneticOptimization(OptimizationBase): class GeneticOptimization(OptimizationBase):
"""GENETIC algorithm to solve energy optimization.""" """GENETIC algorithm to solve energy optimization."""
# Slot-math helpers — single source of truth for the optimization grid.
# At the default optimization interval of 3600 s, slot_duration_h is 1.0 and
# total_slots equals prediction.hours, so the established hourly behaviour is
# preserved. At 900 s (15 min) slot_duration_h is 0.25 and there are 4x as
# many slots.
@property
def slot_duration_h(self) -> float:
"""Length of one optimization slot in hours (1.0 hourly, 0.25 at 15 min)."""
interval = self.config.optimization.interval or 3600
return interval / 3600
@property
def slots_per_hour(self) -> int:
"""Number of optimization slots per hour (1 hourly, 4 at 15 min)."""
interval = self.config.optimization.interval or 3600
return 3600 // interval
@property
def total_slots(self) -> int:
"""Total number of optimization slots = prediction.hours * slots_per_hour."""
# Read prediction.hours directly to avoid recursing through total_slots.
return int(self.config.prediction.hours * self.slots_per_hour)
def _start_day_slot(self) -> int:
"""Slot index of ems.start_datetime counted from the start day's midnight.
simulate()/evaluate() use the simulation start position as a slot index
into the prediction/charge arrays. Those arrays begin at the midnight of
``ems.start_datetime`` (geneticparams sets ``start_datetime.set(hour=0)``),
so the index is computed from the same datetime — no timezone conversion —
keeping it consistent with how the arrays are built. At interval=3600 s
slots_per_hour == 1 and minute // 60 == 0, so this reduces to
``start_datetime.hour`` (the previous hourly behaviour).
"""
sd = self.ems.start_datetime
sph = self.slots_per_hour
slot_minutes = max(1, 60 // sph)
return sd.hour * sph + sd.minute // slot_minutes
def __init__( def __init__(
self, self,
verbose: bool = False, verbose: bool = False,
@@ -491,8 +530,11 @@ class GeneticOptimization(OptimizationBase):
): ):
"""Initialize the optimization problem with the required parameters.""" """Initialize the optimization problem with the required parameters."""
self.opti_param: dict[str, Any] = {} self.opti_param: dict[str, Any] = {}
self.fixed_eauto_hours = ( # Number of slots at the tail of the optimization window where EV
self.config.prediction.hours - self.config.optimization.horizon_hours # charging is fixed to 0. Slot-counted so 15-min runs reserve the right
# tail length (at interval=3600 s this equals prediction.hours - horizon).
self.fixed_eauto_hours = self.total_slots - (
self.config.optimization.horizon_hours * self.slots_per_hour
) )
self.ev_possible_charge_values: list[float] = [1.0] self.ev_possible_charge_values: list[float] = [1.0]
# Separate charge-level list for battery AC charging (independent of EV rates). # Separate charge-level list for battery AC charging (independent of EV rates).
@@ -610,25 +652,21 @@ class GeneticOptimization(OptimizationBase):
total_states += 1 total_states += 1
# 1. Mutating the charge_discharge part # 1. Mutating the charge_discharge part
charge_discharge_part = individual[: self.config.prediction.hours] charge_discharge_part = individual[: self.total_slots]
(charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part) (charge_discharge_mutated,) = self.toolbox.mutate_charge_discharge(charge_discharge_part)
# Instead of a fixed clamping to 0..8 or 0..6 dynamically: # Instead of a fixed clamping to 0..8 or 0..6 dynamically:
charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1) charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1)
individual[: self.config.prediction.hours] = charge_discharge_mutated individual[: self.total_slots] = charge_discharge_mutated
# 2. Mutating the EV charge part, if active # 2. Mutating the EV charge part, if active
if self.optimize_ev: if self.optimize_ev:
ev_charge_part = individual[ ev_charge_part = individual[self.total_slots : self.total_slots * 2]
self.config.prediction.hours : self.config.prediction.hours * 2
]
(ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part) (ev_charge_part_mutated,) = self.toolbox.mutate_ev_charge_index(ev_charge_part)
ev_charge_part_mutated[self.config.prediction.hours - self.fixed_eauto_hours :] = [ ev_charge_part_mutated[self.total_slots - self.fixed_eauto_hours :] = [
0 0
] * self.fixed_eauto_hours ] * self.fixed_eauto_hours
individual[self.config.prediction.hours : self.config.prediction.hours * 2] = ( individual[self.total_slots : self.total_slots * 2] = ev_charge_part_mutated
ev_charge_part_mutated
)
# 3. Mutating the appliance start time, if applicable # 3. Mutating the appliance start time, if applicable
if self.opti_param["home_appliance"] > 0: if self.opti_param["home_appliance"] > 0:
@@ -642,13 +680,13 @@ class GeneticOptimization(OptimizationBase):
def create_individual(self) -> list[int]: def create_individual(self) -> list[int]:
# Start with discharge states for the individual # Start with discharge states for the individual
individual_components = [ individual_components = [
self.toolbox.attr_discharge_state() for _ in range(self.config.prediction.hours) self.toolbox.attr_discharge_state() for _ in range(self.total_slots)
] ]
# Add EV charge index values if optimize_ev is True # Add EV charge index values if optimize_ev is True
if self.optimize_ev: if self.optimize_ev:
individual_components += [ individual_components += [
self.toolbox.attr_ev_charge_index() for _ in range(self.config.prediction.hours) self.toolbox.attr_ev_charge_index() for _ in range(self.total_slots)
] ]
# Add the start time of the household appliance if it's being optimized # Add the start time of the household appliance if it's being optimized
@@ -681,7 +719,7 @@ class GeneticOptimization(OptimizationBase):
individual.extend(eautocharge_hours_index.tolist()) individual.extend(eautocharge_hours_index.tolist())
elif self.optimize_ev: elif self.optimize_ev:
# Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu # Falls optimize_ev aktiv ist, aber keine EV-Daten vorhanden sind, fügen wir Nullen hinzu
individual.extend([0] * self.config.prediction.hours) individual.extend([0] * self.total_slots)
# Add dishwasher start time if applicable # Add dishwasher start time if applicable
if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None: if self.opti_param.get("home_appliance", 0) > 0 and washingstart_int is not None:
@@ -703,13 +741,13 @@ class GeneticOptimization(OptimizationBase):
3. Dishwasher start time (integer if applicable). 3. Dishwasher start time (integer if applicable).
""" """
# Discharge hours as a NumPy array of ints # Discharge hours as a NumPy array of ints
discharge_hours_bin = np.array(individual[: self.config.prediction.hours], dtype=int) discharge_hours_bin = np.array(individual[: self.total_slots], dtype=int)
# EV charge hours as a NumPy array of ints (if optimize_ev is True) # EV charge hours as a NumPy array of ints (if optimize_ev is True)
eautocharge_hours_index = ( eautocharge_hours_index = (
# append ev charging states to individual # append ev charging states to individual
np.array( np.array(
individual[self.config.prediction.hours : self.config.prediction.hours * 2], individual[self.total_slots : self.total_slots * 2],
dtype=int, dtype=int,
) )
if self.optimize_ev if self.optimize_ev
@@ -819,7 +857,7 @@ class GeneticOptimization(OptimizationBase):
if self.optimize_dc_charge: if self.optimize_dc_charge:
self.simulation.dc_charge_hours = dc_charge_hours self.simulation.dc_charge_hours = dc_charge_hours
else: else:
self.simulation.dc_charge_hours = np.full(self.config.prediction.hours, 1) self.simulation.dc_charge_hours = np.full(self.total_slots, 1)
self.simulation.ac_charge_hours = ac_charge_hours self.simulation.ac_charge_hours = ac_charge_hours
if eautocharge_hours_index is not None: if eautocharge_hours_index is not None:
@@ -831,10 +869,12 @@ class GeneticOptimization(OptimizationBase):
self.simulation.ev_charge_hours = eautocharge_hours_float self.simulation.ev_charge_hours = eautocharge_hours_float
else: else:
# discharge is set to 0 by default # discharge is set to 0 by default
self.simulation.ev_charge_hours = np.full(self.config.prediction.hours, 0) self.simulation.ev_charge_hours = np.full(self.total_slots, 0)
# Do the simulation and return result. # Do the simulation and return result. simulate()'s argument is a slot
return self.simulation.simulate(self.ems.start_datetime.hour) # index into the prediction/charge arrays, not an hour-of-day, so pass
# the start_day_slot to keep sub-hourly runs aligned.
return self.simulation.simulate(self._start_day_slot())
def evaluate( def evaluate(
self, self,
@@ -1188,6 +1228,10 @@ class GeneticOptimization(OptimizationBase):
raise ValueError( raise ValueError(
f"Start hour not synced. EMS {self.ems.start_datetime.hour} vs. GENETIC {start_hour}." f"Start hour not synced. EMS {self.ems.start_datetime.hour} vs. GENETIC {start_hour}."
) )
# start_hour stays the hour-of-day for the appliance-start gene bounds
# (0..23). Everything that indexes the slot arrays (the simulate offset
# and evaluate's break-even loop) uses the slot index instead.
start_slot = self._start_day_slot()
# Set the number of generations # Set the number of generations
generations = ngen generations = ngen
@@ -1200,22 +1244,25 @@ class GeneticOptimization(OptimizationBase):
self.simulation.reset() self.simulation.reset()
# Initialize PV and EV batteries # Initialize PV and EV batteries. slot_duration_h lets the Battery scale
# its power caps (max_charge_power_w) to a per-slot energy cap.
akku: Optional[Battery] = None akku: Optional[Battery] = None
if parameters.pv_akku: if parameters.pv_akku:
akku = Battery( akku = Battery(
parameters.pv_akku, parameters.pv_akku,
prediction_hours=self.config.prediction.hours, prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
) )
akku.set_charge_per_hour(np.full(self.config.prediction.hours, 0)) akku.set_charge_per_hour(np.full(self.total_slots, 0))
eauto: Optional[Battery] = None eauto: Optional[Battery] = None
if parameters.eauto: if parameters.eauto:
eauto = Battery( eauto = Battery(
parameters.eauto, parameters.eauto,
prediction_hours=self.config.prediction.hours, prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
) )
eauto.set_charge_per_hour(np.full(self.config.prediction.hours, 1)) eauto.set_charge_per_hour(np.full(self.total_slots, 1))
self.optimize_ev = ( self.optimize_ev = (
parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage
) )
@@ -1273,36 +1320,41 @@ class GeneticOptimization(OptimizationBase):
HomeAppliance( HomeAppliance(
parameters=parameters.dishwasher, parameters=parameters.dishwasher,
optimization_hours=self.config.optimization.horizon_hours, optimization_hours=self.config.optimization.horizon_hours,
prediction_hours=self.config.prediction.hours, prediction_hours=self.total_slots,
slot_duration_h=self.slot_duration_h,
) )
if parameters.dishwasher is not None if parameters.dishwasher is not None
else None else None
) )
# Initialize the inverter and energy management system # Initialize the inverter and energy management system. slot_duration_h
# lets the Inverter scale max_power_wh to a per-slot energy cap.
inverter: Optional[Inverter] = None inverter: Optional[Inverter] = None
if parameters.inverter: if parameters.inverter:
inverter = Inverter( inverter = Inverter(
parameters.inverter, parameters.inverter,
battery=akku, battery=akku,
slot_duration_h=self.slot_duration_h,
) )
# Prepare device simulation # Prepare device simulation
self.simulation.prepare( self.simulation.prepare(
parameters=parameters.ems, parameters=parameters.ems,
optimization_hours=self.config.optimization.horizon_hours, optimization_hours=self.config.optimization.horizon_hours,
prediction_hours=self.config.prediction.hours, prediction_hours=self.total_slots,
inverter=inverter, # battery is part of inverter inverter=inverter, # battery is part of inverter
ev=eauto, ev=eauto,
home_appliance=dishwasher, home_appliance=dishwasher,
direct_marketing_enabled=direct_marketing_enabled, direct_marketing_enabled=direct_marketing_enabled,
) )
# Setup the DEAP environment and optimization process # Setup the DEAP environment and optimization process. setup_deap gets
# the hour-of-day (appliance gene bounds); evaluate gets the slot index
# (its break-even loop walks the slot arrays from "now").
self.setup_deap_environment({"home_appliance": 1 if dishwasher else 0}, start_hour) self.setup_deap_environment({"home_appliance": 1 if dishwasher else 0}, start_hour)
self.toolbox.register( self.toolbox.register(
"evaluate", "evaluate",
lambda ind: self.evaluate(ind, parameters, start_hour, worst_case), lambda ind: self.evaluate(ind, parameters, start_slot, worst_case),
) )
start_time = time.time() start_time = time.time()
@@ -194,9 +194,17 @@ class GeneticOptimizationParameters(
if cls.config.optimization.interval is None: if cls.config.optimization.interval is None:
logger.info("Optimization interval unknown - defaulting to 3600 seconds.") logger.info("Optimization interval unknown - defaulting to 3600 seconds.")
cls.config.optimization.interval = 3600 cls.config.optimization.interval = 3600
if cls.config.optimization.interval != 3600: # The genetic optimizer runs on a fixed slot grid whose length is
logger.info( # prediction.hours * (3600 / interval). 900 s (15 min) enables a
"Optimization interval '{}' seconds not supported - forced to 3600 seconds." # quarter-hour grid for 15-minute electricity tariffs; the default
# 3600 s keeps the established hourly resolution. Other values fall back
# to 3600 s.
allowed_intervals = (3600, 900)
if cls.config.optimization.interval not in allowed_intervals:
logger.warning(
"Optimization interval {} seconds not in {} - forcing 3600 seconds.",
cls.config.optimization.interval,
allowed_intervals,
) )
cls.config.optimization.interval = 3600 cls.config.optimization.interval = 3600
# Check genetic algorithm definitions # Check genetic algorithm definitions
@@ -306,12 +314,18 @@ class GeneticOptimizationParameters(
# Retry # Retry
continue continue
try: try:
loadforecast_power_w = cls.prediction.key_to_array( # Load is a power series [W] that the genetic optimizer consumes
# as Wh-per-slot. Scale by interval/3600 (mirrors the PV forecast
# above) so a 15-min slot sees a quarter of the hourly energy.
loadforecast_power_w = (
cls.prediction.key_to_array(
key="loadforecast_power_w", key="loadforecast_power_w",
start_datetime=parameter_start_datetime, start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime, end_datetime=parameter_end_datetime,
interval=interval, interval=interval,
fill_method="ffill", fill_method="ffill",
)
* power_to_energy_per_interval_factor
).tolist() ).tolist()
except: except:
logger.info( logger.info(
@@ -391,20 +391,28 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1 - GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
""" """
start_datetime = get_ems().start_datetime start_datetime = get_ems().start_datetime
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour # The genetic core emits total_slots = prediction.hours * slots_per_hour
interval_hours = 1 # entries indexed by slot (slot 0 == 00:00 local). Index this serializer
power_to_energy_per_interval_factor = 1.0 # by slot too. At the default interval of 3600 s slots_per_hour == 1 and
# this is the established hourly behaviour.
interval_s = int(self.config.optimization.interval or 3600)
slots_per_hour = max(1, 3600 // interval_s)
slot_minutes = max(1, interval_s // 60)
start_local = start_datetime.in_timezone(self.config.general.timezone)
start_day_slot = start_local.hour * slots_per_hour + start_local.minute // slot_minutes
# power [W] -> energy per slot [Wh]: multiply by the slot duration in hours.
power_to_energy_per_interval_factor = interval_s / 3600.0
# --- Create index based on list length and interval --- # --- Create index based on list length and interval ---
# Ensure we only use the minimum of results and commands if differing # Ensure we only use the minimum of results and commands if differing
periods = min(len(self.result.Kosten_Euro_pro_Stunde), len(self.ac_charge) - start_day_hour) periods = min(len(self.result.Kosten_Euro_pro_Stunde), len(self.ac_charge) - start_day_slot)
time_index = pd.date_range( time_index = pd.date_range(
start=start_datetime, start=start_datetime,
periods=periods, periods=periods,
freq=f"{interval_hours}h", freq=f"{interval_s}s",
) )
n_points = len(time_index) n_points = len(time_index)
end_datetime = start_datetime.add(hours=n_points) end_datetime = start_datetime.add(seconds=interval_s * n_points)
# Fill solution into dataframe with correct column names # Fill solution into dataframe with correct column names
# - load_energy_wh: Load of all energy consumers in wh" # - load_energy_wh: Load of all energy consumers in wh"
@@ -420,7 +428,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
solution = pd.DataFrame( solution = pd.DataFrame(
{ {
"date_time": time_index, "date_time": time_index,
# result starts at start_day_hour # result starts at start_day_slot
"load_energy_wh": self.result.Last_Wh_pro_Stunde[:n_points], "load_energy_wh": self.result.Last_Wh_pro_Stunde[:n_points],
"grid_feedin_energy_wh": self.result.Netzeinspeisung_Wh_pro_Stunde[:n_points], "grid_feedin_energy_wh": self.result.Netzeinspeisung_Wh_pro_Stunde[:n_points],
"grid_consumption_energy_wh": self.result.Netzbezug_Wh_pro_Stunde[:n_points], "grid_consumption_energy_wh": self.result.Netzbezug_Wh_pro_Stunde[:n_points],
@@ -435,7 +443,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
battery_device_id = self._battery_device_id() battery_device_id = self._battery_device_id()
solution[f"{battery_device_id}_soc_factor"] = [ solution[f"{battery_device_id}_soc_factor"] = [
v / 100 v / 100
for v in self.result.akku_soc_pro_stunde[:n_points] # result starts at start_day_hour for v in self.result.akku_soc_pro_stunde[:n_points] # result starts at start_day_slot
] ]
operation: dict[str, list[float]] = { operation: dict[str, list[float]] = {
"genetic_ac_charge_factor": [], "genetic_ac_charge_factor": [],
@@ -445,9 +453,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
} }
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day # ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
for hour_idx, rate in enumerate(self.ac_charge): for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_hour: if hour_idx < start_day_slot:
continue continue
if hour_idx >= start_day_hour + n_points: if hour_idx >= start_day_slot + n_points:
break break
ac_charge_hour = self.ac_charge[hour_idx] ac_charge_hour = self.ac_charge[hour_idx]
dc_charge_hour = self.dc_charge[hour_idx] dc_charge_hour = self.dc_charge[hour_idx]
@@ -468,7 +476,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# SOC-clamped effective values — what can physically be executed at # SOC-clamped effective values — what can physically be executed at
# this hour given the expected battery state of charge. # this hour given the expected battery state of charge.
result_idx = hour_idx - start_day_hour result_idx = hour_idx - start_day_slot
soc_h_pct = ( soc_h_pct = (
self.result.akku_soc_pro_stunde[result_idx] self.result.akku_soc_pro_stunde[result_idx]
if result_idx < len(self.result.akku_soc_pro_stunde) if result_idx < len(self.result.akku_soc_pro_stunde)
@@ -533,9 +541,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"genetic_ev_charge_factor": [], "genetic_ev_charge_factor": [],
} }
for hour_idx, rate in enumerate(self.eautocharge_hours_float): for hour_idx, rate in enumerate(self.eautocharge_hours_float):
if hour_idx < start_day_hour: if hour_idx < start_day_slot:
continue continue
if hour_idx >= start_day_hour + n_points: if hour_idx >= start_day_slot + n_points:
break break
operation["genetic_ev_charge_factor"].append(rate) operation["genetic_ev_charge_factor"].append(rate)
operation_mode, operation_mode_factor = self._battery_operation_from_solution( operation_mode, operation_mode_factor = self._battery_operation_from_solution(
@@ -565,7 +573,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
# Use config and not self.washingstart as washingstart may be None (no start) # Use config and not self.washingstart as washingstart may be None (no start)
# even if configured to be started. # even if configured to be started.
homeappliance_device_id = self._homeappliance_device_id() homeappliance_device_id = self._homeappliance_device_id()
# result starts at start_day_hour # result starts at start_day_slot
solution[f"{homeappliance_device_id}_energy_wh"] = ( solution[f"{homeappliance_device_id}_energy_wh"] = (
self.result.Home_appliance_wh_per_hour[:n_points] self.result.Home_appliance_wh_per_hour[:n_points]
) )
@@ -663,7 +671,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
key=pred_key, key=pred_key,
start_datetime=start_datetime, start_datetime=start_datetime,
end_datetime=end_datetime, end_datetime=end_datetime,
interval=to_duration(f"{interval_hours} hours"), interval=to_duration(f"{interval_s} seconds"),
fill_method=pred_fill_method, fill_method=pred_fill_method,
) )
# 'key_to_array()' creates None values array if no data records are available. # 'key_to_array()' creates None values array if no data records are available.
@@ -691,7 +699,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
def energy_management_plan(self) -> EnergyManagementPlan: def energy_management_plan(self) -> EnergyManagementPlan:
"""Provide the genetic solution as an energy management plan.""" """Provide the genetic solution as an energy management plan."""
start_datetime = get_ems().start_datetime start_datetime = get_ems().start_datetime
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour # Index by slot, not hour (mirrors optimization_solution). At the default
# interval of 3600 s this reduces to the start hour-of-day.
interval_s = int(self.config.optimization.interval or 3600)
slots_per_hour = max(1, 3600 // interval_s)
slot_minutes = max(1, interval_s // 60)
start_local = start_datetime.in_timezone(self.config.general.timezone)
start_day_slot = start_local.hour * slots_per_hour + start_local.minute // slot_minutes
plan = EnergyManagementPlan( plan = EnergyManagementPlan(
id=f"plan-genetic@{to_datetime(as_string=True)}", id=f"plan-genetic@{to_datetime(as_string=True)}",
generated_at=to_datetime(), generated_at=to_datetime(),
@@ -704,15 +718,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
last_operation_mode_factor: Optional[float] = None last_operation_mode_factor: Optional[float] = None
resource_id = self._battery_device_id() resource_id = self._battery_device_id()
# ac_charge, dc_charge, discharge_allowed start at hour 0 of start day # ac_charge, dc_charge, discharge_allowed start at hour 0 of start day
logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_hour:]) logger.debug("BAT: {} - {}", resource_id, self.ac_charge[start_day_slot:])
for hour_idx, rate in enumerate(self.ac_charge): for hour_idx, rate in enumerate(self.ac_charge):
if hour_idx < start_day_hour: if hour_idx < start_day_slot:
continue continue
# Derive SOC-clamped effective factors so that FRBCInstruction # Derive SOC-clamped effective factors so that FRBCInstruction
# operation_mode_factor reflects what can physically be executed, # operation_mode_factor reflects what can physically be executed,
# while the raw genetic gene values are preserved in the solution # while the raw genetic gene values are preserved in the solution
# dataframe (genetic_*_factor columns). # dataframe (genetic_*_factor columns).
result_idx = hour_idx - start_day_hour result_idx = hour_idx - start_day_slot
soc_h_pct = ( soc_h_pct = (
self.result.akku_soc_pro_stunde[result_idx] self.result.akku_soc_pro_stunde[result_idx]
if result_idx < len(self.result.akku_soc_pro_stunde) if result_idx < len(self.result.akku_soc_pro_stunde)
@@ -741,7 +755,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
continue continue
last_operation_mode = operation_mode last_operation_mode = operation_mode
last_operation_mode_factor = operation_mode_factor last_operation_mode_factor = operation_mode_factor
execution_time = start_datetime.add(hours=hour_idx - start_day_hour) execution_time = start_datetime.add(seconds=interval_s * (hour_idx - start_day_slot))
plan.add_instruction( plan.add_instruction(
FRBCInstruction( FRBCInstruction(
resource_id=resource_id, resource_id=resource_id,
@@ -772,10 +786,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
last_operation_mode = None last_operation_mode = None
last_operation_mode_factor = None last_operation_mode_factor = None
logger.debug( logger.debug(
"EV: {} - {}", resource_id, self.eautocharge_hours_float[start_day_hour:] "EV: {} - {}", resource_id, self.eautocharge_hours_float[start_day_slot:]
) )
for hour_idx, rate in enumerate(self.eautocharge_hours_float): for hour_idx, rate in enumerate(self.eautocharge_hours_float):
if hour_idx < start_day_hour: if hour_idx < start_day_slot:
continue continue
operation_mode, operation_mode_factor = self._battery_operation_from_solution( operation_mode, operation_mode_factor = self._battery_operation_from_solution(
rate, 0.0, False rate, 0.0, False
@@ -788,7 +802,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
continue continue
last_operation_mode = operation_mode last_operation_mode = operation_mode
last_operation_mode_factor = operation_mode_factor last_operation_mode_factor = operation_mode_factor
execution_time = start_datetime.add(hours=hour_idx - start_day_hour) execution_time = start_datetime.add(
seconds=interval_s * (hour_idx - start_day_slot)
)
plan.add_instruction( plan.add_instruction(
FRBCInstruction( FRBCInstruction(
resource_id=resource_id, resource_id=resource_id,
@@ -817,7 +833,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
else: else:
operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment] operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment]
operation_mode_factor = 1.0 operation_mode_factor = 1.0
execution_time = start_datetime.add(hours=hours) execution_time = start_datetime.add(seconds=interval_s * hours)
plan.add_instruction( plan.add_instruction(
DDBCInstruction( DDBCInstruction(
resource_id=resource_id, resource_id=resource_id,
@@ -72,7 +72,11 @@ class OptimizationCommonSettings(SettingsBaseModel):
ge=15 * 60, ge=15 * 60,
le=60 * 60, le=60 * 60,
json_schema_extra={ json_schema_extra={
"description": "The optimization interval [sec]. Defaults to 3600 seconds (1 hour)", "description": (
"The optimization interval (slot length) [sec]. The genetic "
"optimizer supports 3600 (1 hour) and 900 (15 min); other values "
"fall back to 3600. Defaults to 3600 seconds (1 hour)."
),
"examples": [60 * 60, 15 * 60], "examples": [60 * 60, 15 * 60],
}, },
) )
+148
View File
@@ -0,0 +1,148 @@
"""Tests for the 15-minute optimization interval.
The genetic optimizer runs on a fixed slot grid whose length is
``prediction.hours * (3600 / interval)``. At the default interval of 3600 s this
is the established hourly behaviour (covered by ``test_geneticoptimize.py``);
here we cover the 900 s (15 min) slot grid.
"""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.visualize import prepare_visualize
ems_eos = get_ems(init=True) # init once
DIR_TESTDATA = Path(__file__).parent / "testdata"
@pytest.mark.parametrize(
"interval, exp_slots_per_hour, exp_slot_duration_h",
[
(3600, 1, 1.0),
(900, 4, 0.25),
],
)
def test_slot_helpers(
config_eos: ConfigEOS,
interval: int,
exp_slots_per_hour: int,
exp_slot_duration_h: float,
):
"""slot_duration_h / slots_per_hour / total_slots track the configured interval."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": interval},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
opt = GeneticOptimization(fixed_seed=42)
assert opt.slots_per_hour == exp_slots_per_hour
assert opt.slot_duration_h == exp_slot_duration_h
assert opt.total_slots == 48 * exp_slots_per_hour
# At minute 0 the start slot is the hour scaled by the slot count.
assert opt._start_day_slot() == 10 * exp_slots_per_hour
def test_start_day_slot_includes_minute_offset(config_eos: ConfigEOS):
"""At 15-min resolution the start slot includes the minute offset."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=30))
opt = GeneticOptimization(fixed_seed=42)
# Slot index is derived from the actual EMS start datetime (which may be
# floored to the hour by the energy management system): hour*4 + minute//15.
sd = opt.ems.start_datetime
assert opt._start_day_slot() == sd.hour * 4 + sd.minute // 15
def test_optimize_15min_slot_grid(config_eos: ConfigEOS):
"""An end-to-end optimization at interval=900 runs on a 192-slot day grid.
This exercises the full path (parameter preparation, GA core, device
simulation, solution/plan serialization) at 15-min resolution and asserts the
structural properties; the optimization result itself is not pinned because
the 15-min grid is a different problem than the hourly one.
"""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"horizon_hours": 48,
"interval": 900,
"genetic": {
"individuals": 300,
"generations": 10,
"penalties": {
"ev_soc_miss": 10,
"ac_charge_break_even": 0,
},
},
},
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": [
{
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
}
],
},
}
)
with (DIR_TESTDATA / "optimize_input_1.json").open("r") as f_in:
input_data = GeneticOptimizationParameters(**json.load(f_in))
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
CacheEnergyManagementStore().clear()
opt = GeneticOptimization(fixed_seed=42)
assert opt.total_slots == 192
assert opt.slot_duration_h == 0.25
visualize_filename = str((DIR_TESTDATA / "new_optimize_15min.json").with_suffix(".pdf"))
with patch(
"akkudoktoreos.utils.visualize.prepare_visualize",
side_effect=lambda parameters, results, *args, **kwargs: prepare_visualize(
parameters, results, filename=visualize_filename, **kwargs
),
):
genetic_solution = opt.optimierung_ems(
parameters=input_data, start_hour=10, ngen=3
)
# The genetic core emitted a full-day grid at 15-min resolution.
assert len(genetic_solution.ac_charge) == 192
assert len(genetic_solution.dc_charge) == 192
assert len(genetic_solution.discharge_allowed) == 192
# The serializers consume the 15-min grid without error and emit a 900 s
# spaced solution index.
solution = genetic_solution.optimization_solution()
df = solution.solution.to_dataframe()
assert len(df.index) >= 2
delta_seconds = (df.index[1] - df.index[0]).total_seconds()
assert delta_seconds == 900
plan = genetic_solution.energy_management_plan()
assert plan is not None