Skip to content

Commit 4093f6c

Browse files
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.
1 parent 441a463 commit 4093f6c

11 files changed

Lines changed: 374 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to the akkudoktoreos project will be documented in this file
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

7+
## Unreleased
8+
9+
### Added
10+
11+
- 15-minute optimization interval for the genetic optimizer. `optimization.interval`
12+
now accepts 900 (15 min) in addition to the default 3600 (1 hour), letting the
13+
optimizer schedule on a quarter-hour grid for 15-minute dynamic electricity
14+
tariffs. Device power caps and the solution/plan serializers are slot-aware; the
15+
default 3600 s interval keeps the previous hourly behaviour.
16+
717
## 0.3.0 (2026-03-17)
818

919
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.

docs/_generated/configoptimization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
| genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | Genetic optimization algorithm configuration. |
1212
| horizon | | `int` | `ro` | `N/A` | Number of optimization steps. |
1313
| 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. |
14-
| interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
14+
| 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). |
1515
| keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. |
1616
:::
1717
<!-- pyml enable line-length -->

docs/akkudoktoreos/optimauto.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,16 @@ The energy management can be run in three modes:
139139
Each device simulation run must ensure that all tasks or appliance cycles (e.g., running a
140140
dishwasher) are completed within the configured time windows.
141141

142-
- **interval**: Defines the time step in seconds between control actions
143-
(e.g. `3600` for one hour, `900` for 15 minutes).
142+
- **interval**: Defines the time step (slot length) in seconds between control actions.
143+
The genetic algorithm supports `3600` (one hour, the default) and `900` (15 minutes);
144+
any other value falls back to `3600`. The number of optimization slots is
145+
`prediction.hours * (3600 / interval)`, and device power caps as well as the solution
146+
and energy-management-plan serializers are slot-aware.
144147

145-
:::{warning}
146-
**Current Limitation**
147-
148-
At present, the `interval` setting is **not used** by the genetic algorithm. Instead:
149-
150-
- The control interval is fixed to **1 hour**.
151-
152-
Support for configurable intervals (e.g. 15-minute steps) may be added in a future release.
148+
:::{note}
149+
Use `900` together with a 15-minute electricity price source (for example a dynamic or
150+
exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the
151+
default `3600` preserves the previous hourly behaviour.
153152
:::
154153

155154
#### Genetic Algorithm Parameters

src/akkudoktoreos/devices/genetic/battery.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,20 @@
1212
class Battery:
1313
"""Represents a battery device with methods to simulate energy charging and discharging."""
1414

15-
def __init__(self, parameters: BaseBatteryParameters, prediction_hours: int):
15+
def __init__(
16+
self,
17+
parameters: BaseBatteryParameters,
18+
prediction_hours: int,
19+
slot_duration_h: float = 1.0,
20+
):
21+
# `prediction_hours` is the number of optimization slots, not hours. At
22+
# the default optimization interval of 3600 s, slot_duration_h is 1.0 and
23+
# the slot count equals the hour count, so existing callers are
24+
# unaffected. At 900 s (15 min) slot_duration_h is 0.25 and there are 4x
25+
# as many slots, each able to move a quarter of the hourly energy.
1626
self.parameters = parameters
1727
self.prediction_hours = prediction_hours
28+
self.slot_duration_h = slot_duration_h
1829
self._setup()
1930

2031
def _setup(self) -> None:
@@ -137,8 +148,12 @@ def discharge_energy(self, wh: float, hour: int) -> tuple[float, float]:
137148
# Raw extractable energy above minimum SoC
138149
raw_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
139150

140-
# Maximum raw discharge due to power limit
141-
max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w
151+
# Maximum raw discharge due to power limit, scaled to the slot duration.
152+
# max_charge_power_w is a power [W]; energy movable in one slot is
153+
# power x slot_duration_h.
154+
max_raw_wh = (
155+
self.max_charge_power_w * self.slot_duration_h
156+
) # TODO rename to max_discharge_power_w
142157

143158
# Actual raw withdrawal (internal)
144159
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh)
@@ -229,21 +244,23 @@ def charge_energy(
229244

230245
# Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access
231246
soc_wh_fast = self.soc_wh
232-
max_charge_power_w_fast = self.max_charge_power_w
247+
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours).
248+
# At slot_duration_h=1.0 (hourly) this equals the legacy power value.
249+
max_charge_per_slot_wh_fast = self.max_charge_power_w * self.slot_duration_h
233250
charging_efficiency_fast = self.charging_efficiency
234251

235252
# Decide mode & determine raw_request_wh and raw_charge_wh
236253
if wh is not None and charge_factor == 0.0: # mode 1
237254
raw_request_wh = wh
238255
raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast
239256
elif wh is None and charge_factor > 0.0: # mode 2
240-
raw_request_wh = max_charge_power_w_fast * charge_factor
257+
raw_request_wh = max_charge_per_slot_wh_fast * charge_factor
241258
raw_charge_wh = max(self.max_soc_wh - soc_wh_fast, 0.0) / charging_efficiency_fast
242259
if raw_request_wh > raw_charge_wh:
243260
# Use a lower charge factor
244261
lower_charge_factors = self._lower_charge_rates_desc(charge_factor)
245262
for charge_factor in lower_charge_factors:
246-
raw_request_wh = max_charge_power_w_fast * charge_factor
263+
raw_request_wh = max_charge_per_slot_wh_fast * charge_factor
247264
if raw_request_wh <= raw_charge_wh:
248265
self.charge_array[hour] = charge_factor
249266
break
@@ -258,7 +275,7 @@ def charge_energy(
258275
)
259276

260277
# Remaining capacity
261-
max_raw_wh = min(raw_charge_wh, max_charge_power_w_fast)
278+
max_raw_wh = min(raw_charge_wh, max_charge_per_slot_wh_fast)
262279

263280
# Actual raw intake
264281
raw_input_wh = raw_request_wh if raw_request_wh < max_raw_wh else max_raw_wh

src/akkudoktoreos/devices/genetic/homeappliance.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ def __init__(
1111
parameters: HomeApplianceParameters,
1212
optimization_hours: int,
1313
prediction_hours: int,
14+
slot_duration_h: float = 1.0,
1415
):
16+
# slot_duration_h is a forward-compatibility hook. Full sub-hourly home
17+
# appliance scheduling additionally requires converting the start hour to
18+
# a slot index and the duration to a slot count; the default of 1.0 keeps
19+
# the hourly behaviour for the default optimization interval of 3600 s.
1520
self.parameters: HomeApplianceParameters = parameters
1621
self.prediction_hours = prediction_hours
22+
self.slot_duration_h = slot_duration_h
1723
self._setup()
1824

1925
def _setup(self) -> None:

src/akkudoktoreos/devices/genetic/inverter.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ def __init__(
1212
self,
1313
parameters: InverterParameters,
1414
battery: Optional[Battery] = None,
15+
slot_duration_h: float = 1.0,
1516
):
17+
# slot_duration_h scales the per-slot energy cap (max_power_wh). It
18+
# defaults to 1.0, which keeps the hourly behaviour for the default
19+
# optimization interval of 3600 s.
1620
self.parameters: InverterParameters = parameters
1721
self.battery: Optional[Battery] = battery
22+
self.slot_duration_h: float = slot_duration_h
1823
self._setup()
1924

2025
def _setup(self) -> None:
@@ -23,11 +28,16 @@ def _setup(self) -> None:
2328
logger.error(error_msg)
2429
raise ValueError(error_msg)
2530
self.self_consumption_predictor = get_eos_load_interpolator()
31+
# max_power_wh is supplied as a power [W] that the legacy hourly code
32+
# treats as Wh-per-hour. Scale it to the actual slot length so a 15-min
33+
# slot can move at most a quarter of that energy.
2634
self.max_power_wh = (
27-
self.parameters.max_power_wh
28-
) # Maximum power that the inverter can handle
35+
self.parameters.max_power_wh * self.slot_duration_h
36+
) # Maximum energy the inverter can move in one optimization slot
2937
self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency
3038
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency
39+
# max_ac_charge_power_w stays in Watts. It feeds a dimensionless,
40+
# slot-agnostic power-ratio cap in genetic.py simulate().
3141
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w
3242

3343
def process_energy(

0 commit comments

Comments
 (0)