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
@@ -484,6 +484,45 @@ class GeneticSimulation(PydanticBaseModel):
class GeneticOptimization(OptimizationBase):
"""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__(
self,
verbose: bool = False,
@@ -491,8 +530,11 @@ class GeneticOptimization(OptimizationBase):
):
"""Initialize the optimization problem with the required parameters."""
self.opti_param: dict[str, Any] = {}
self.fixed_eauto_hours = (
self.config.prediction.hours - self.config.optimization.horizon_hours
# Number of slots at the tail of the optimization window where EV
# 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]
# Separate charge-level list for battery AC charging (independent of EV rates).
@@ -610,25 +652,21 @@ class GeneticOptimization(OptimizationBase):
total_states += 1
# 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)
# Instead of a fixed clamping to 0..8 or 0..6 dynamically:
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
if self.optimize_ev:
ev_charge_part = individual[
self.config.prediction.hours : self.config.prediction.hours * 2
]
ev_charge_part = individual[self.total_slots : self.total_slots * 2]
(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
] * self.fixed_eauto_hours
individual[self.config.prediction.hours : self.config.prediction.hours * 2] = (
ev_charge_part_mutated
)
individual[self.total_slots : self.total_slots * 2] = ev_charge_part_mutated
# 3. Mutating the appliance start time, if applicable
if self.opti_param["home_appliance"] > 0:
@@ -642,13 +680,13 @@ class GeneticOptimization(OptimizationBase):
def create_individual(self) -> list[int]:
# Start with discharge states for the individual
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
if self.optimize_ev:
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
@@ -681,7 +719,7 @@ class GeneticOptimization(OptimizationBase):
individual.extend(eautocharge_hours_index.tolist())
elif self.optimize_ev:
# 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
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).
"""
# 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)
eautocharge_hours_index = (
# append ev charging states to individual
np.array(
individual[self.config.prediction.hours : self.config.prediction.hours * 2],
individual[self.total_slots : self.total_slots * 2],
dtype=int,
)
if self.optimize_ev
@@ -819,7 +857,7 @@ class GeneticOptimization(OptimizationBase):
if self.optimize_dc_charge:
self.simulation.dc_charge_hours = dc_charge_hours
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
if eautocharge_hours_index is not None:
@@ -831,10 +869,12 @@ class GeneticOptimization(OptimizationBase):
self.simulation.ev_charge_hours = eautocharge_hours_float
else:
# 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.
return self.simulation.simulate(self.ems.start_datetime.hour)
# Do the simulation and return result. simulate()'s argument is a slot
# 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(
self,
@@ -1188,6 +1228,10 @@ class GeneticOptimization(OptimizationBase):
raise ValueError(
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
generations = ngen
@@ -1200,22 +1244,25 @@ class GeneticOptimization(OptimizationBase):
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
if parameters.pv_akku:
akku = Battery(
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
if parameters.eauto:
eauto = Battery(
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 = (
parameters.eauto.min_soc_percentage > parameters.eauto.initial_soc_percentage
)
@@ -1273,36 +1320,41 @@ class GeneticOptimization(OptimizationBase):
HomeAppliance(
parameters=parameters.dishwasher,
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
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
if parameters.inverter:
inverter = Inverter(
parameters.inverter,
battery=akku,
slot_duration_h=self.slot_duration_h,
)
# Prepare device simulation
self.simulation.prepare(
parameters=parameters.ems,
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
ev=eauto,
home_appliance=dishwasher,
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.toolbox.register(
"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()