diff --git a/CHANGELOG.md b/CHANGELOG.md index d812d1fa..e912019c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - `PVForecastForecastSolar` — forecasts from the free Forecast.Solar 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) diff --git a/docs/_generated/configoptimization.md b/docs/_generated/configoptimization.md index 74651c8a..6a002a4f 100644 --- a/docs/_generated/configoptimization.md +++ b/docs/_generated/configoptimization.md @@ -11,7 +11,7 @@ | genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | Genetic optimization algorithm configuration. | | 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. | -| 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. | ::: diff --git a/docs/akkudoktoreos/optimauto.md b/docs/akkudoktoreos/optimauto.md index b4156401..659e1964 100644 --- a/docs/akkudoktoreos/optimauto.md +++ b/docs/akkudoktoreos/optimauto.md @@ -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 dishwasher) are completed within the configured time windows. -- **interval**: Defines the time step in seconds between control actions - (e.g. `3600` for one hour, `900` for 15 minutes). +- **interval**: Defines the time step (slot length) in seconds between control actions. + 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} -**Current Limitation** - -At present, the `interval` setting is **not used** by the genetic algorithm. Instead: - -- The control interval is fixed to **1 hour**. - -Support for configurable intervals (e.g. 15-minute steps) may be added in a future release. +:::{note} +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 +default `3600` preserves the previous hourly behaviour. ::: #### Genetic Algorithm Parameters diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index 88123c08..24b914f9 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -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 diff --git a/src/akkudoktoreos/devices/genetic/homeappliance.py b/src/akkudoktoreos/devices/genetic/homeappliance.py index 087d89e8..06cf5f94 100644 --- a/src/akkudoktoreos/devices/genetic/homeappliance.py +++ b/src/akkudoktoreos/devices/genetic/homeappliance.py @@ -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: diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index c2a1ce06..fe8f56c1 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -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]: diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 031699ce..c8fc46e1 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -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() diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index 77ee1721..f6d3cd8e 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -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( diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index ed3b2d55..ff3d4b21 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -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, diff --git a/src/akkudoktoreos/optimization/optimization.py b/src/akkudoktoreos/optimization/optimization.py index a6c52530..57b840e4 100644 --- a/src/akkudoktoreos/optimization/optimization.py +++ b/src/akkudoktoreos/optimization/optimization.py @@ -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], }, ) diff --git a/tests/test_optimization_interval.py b/tests/test_optimization_interval.py new file mode 100644 index 00000000..17a69ab8 --- /dev/null +++ b/tests/test_optimization_interval.py @@ -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