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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user