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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -194,9 +194,17 @@ class GeneticOptimizationParameters(
|
||||
if cls.config.optimization.interval is None:
|
||||
logger.info("Optimization interval unknown - defaulting to 3600 seconds.")
|
||||
cls.config.optimization.interval = 3600
|
||||
if cls.config.optimization.interval != 3600:
|
||||
logger.info(
|
||||
"Optimization interval '{}' seconds not supported - forced to 3600 seconds."
|
||||
# The genetic optimizer runs on a fixed slot grid whose length is
|
||||
# prediction.hours * (3600 / interval). 900 s (15 min) enables a
|
||||
# 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
|
||||
# Check genetic algorithm definitions
|
||||
@@ -306,12 +314,18 @@ class GeneticOptimizationParameters(
|
||||
# Retry
|
||||
continue
|
||||
try:
|
||||
loadforecast_power_w = cls.prediction.key_to_array(
|
||||
key="loadforecast_power_w",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
# 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",
|
||||
start_datetime=parameter_start_datetime,
|
||||
end_datetime=parameter_end_datetime,
|
||||
interval=interval,
|
||||
fill_method="ffill",
|
||||
)
|
||||
* power_to_energy_per_interval_factor
|
||||
).tolist()
|
||||
except:
|
||||
logger.info(
|
||||
|
||||
@@ -391,20 +391,28 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
|
||||
"""
|
||||
start_datetime = get_ems().start_datetime
|
||||
start_day_hour = start_datetime.in_timezone(self.config.general.timezone).hour
|
||||
interval_hours = 1
|
||||
power_to_energy_per_interval_factor = 1.0
|
||||
# The genetic core emits total_slots = prediction.hours * slots_per_hour
|
||||
# entries indexed by slot (slot 0 == 00:00 local). Index this serializer
|
||||
# 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 ---
|
||||
# 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(
|
||||
start=start_datetime,
|
||||
periods=periods,
|
||||
freq=f"{interval_hours}h",
|
||||
freq=f"{interval_s}s",
|
||||
)
|
||||
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
|
||||
# - load_energy_wh: Load of all energy consumers in wh"
|
||||
@@ -420,7 +428,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
solution = pd.DataFrame(
|
||||
{
|
||||
"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],
|
||||
"grid_feedin_energy_wh": self.result.Netzeinspeisung_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()
|
||||
solution[f"{battery_device_id}_soc_factor"] = [
|
||||
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]] = {
|
||||
"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
|
||||
for hour_idx, rate in enumerate(self.ac_charge):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
if hour_idx >= start_day_hour + n_points:
|
||||
if hour_idx >= start_day_slot + n_points:
|
||||
break
|
||||
ac_charge_hour = self.ac_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
|
||||
# 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 = (
|
||||
self.result.akku_soc_pro_stunde[result_idx]
|
||||
if result_idx < len(self.result.akku_soc_pro_stunde)
|
||||
@@ -533,9 +541,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
"genetic_ev_charge_factor": [],
|
||||
}
|
||||
for hour_idx, rate in enumerate(self.eautocharge_hours_float):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
if hour_idx >= start_day_hour + n_points:
|
||||
if hour_idx >= start_day_slot + n_points:
|
||||
break
|
||||
operation["genetic_ev_charge_factor"].append(rate)
|
||||
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)
|
||||
# even if configured to be started.
|
||||
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"] = (
|
||||
self.result.Home_appliance_wh_per_hour[:n_points]
|
||||
)
|
||||
@@ -663,7 +671,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
key=pred_key,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=to_duration(f"{interval_hours} hours"),
|
||||
interval=to_duration(f"{interval_s} seconds"),
|
||||
fill_method=pred_fill_method,
|
||||
)
|
||||
# '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:
|
||||
"""Provide the genetic solution as an energy management plan."""
|
||||
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(
|
||||
id=f"plan-genetic@{to_datetime(as_string=True)}",
|
||||
generated_at=to_datetime(),
|
||||
@@ -704,15 +718,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
last_operation_mode_factor: Optional[float] = None
|
||||
resource_id = self._battery_device_id()
|
||||
# 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):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
# Derive SOC-clamped effective factors so that FRBCInstruction
|
||||
# operation_mode_factor reflects what can physically be executed,
|
||||
# while the raw genetic gene values are preserved in the solution
|
||||
# dataframe (genetic_*_factor columns).
|
||||
result_idx = hour_idx - start_day_hour
|
||||
result_idx = hour_idx - start_day_slot
|
||||
soc_h_pct = (
|
||||
self.result.akku_soc_pro_stunde[result_idx]
|
||||
if result_idx < len(self.result.akku_soc_pro_stunde)
|
||||
@@ -741,7 +755,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
continue
|
||||
last_operation_mode = operation_mode
|
||||
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(
|
||||
FRBCInstruction(
|
||||
resource_id=resource_id,
|
||||
@@ -772,10 +786,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
last_operation_mode = None
|
||||
last_operation_mode_factor = None
|
||||
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):
|
||||
if hour_idx < start_day_hour:
|
||||
if hour_idx < start_day_slot:
|
||||
continue
|
||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||
rate, 0.0, False
|
||||
@@ -788,7 +802,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
continue
|
||||
last_operation_mode = operation_mode
|
||||
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(
|
||||
FRBCInstruction(
|
||||
resource_id=resource_id,
|
||||
@@ -817,7 +833,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
||||
else:
|
||||
operation_mode = ApplianceOperationMode.OFF # type: ignore[assignment]
|
||||
operation_mode_factor = 1.0
|
||||
execution_time = start_datetime.add(hours=hours)
|
||||
execution_time = start_datetime.add(seconds=interval_s * hours)
|
||||
plan.add_instruction(
|
||||
DDBCInstruction(
|
||||
resource_id=resource_id,
|
||||
|
||||
@@ -72,7 +72,11 @@ class OptimizationCommonSettings(SettingsBaseModel):
|
||||
ge=15 * 60,
|
||||
le=60 * 60,
|
||||
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],
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user