feat(optimization): deadlines for consumers and EV, graded grid export

Three related scheduling improvements, all opt-in and behaviour-preserving
when the new fields are not set.

Flexible consumers get absolute time bounds next to the recurring
time_windows: earliest_start_datetime and deadline_datetime, where the
deadline requires the complete run to have *finished* before that moment
("clean dishes by 03:00 tonight"). When no start can meet it,
deadline_policy decides between BEST_EFFORT (run as early as possible, so
the delay rather than the cost is minimized) and STRICT (keep the
deadline; a ONCE consumer then fails the optimization). The solution
reports appliance_deadline_missed per device.

The EV charging target can be given the same kind of deadline, as an
absolute min_soc_deadline_datetime and/or a relative min_soc_max_duration_h
("full in 6 hours"), the earlier of the two winning. The ev_soc_miss
penalty is then evaluated at that slot instead of at the end of the
horizon, and the seeding heuristic only proposes charge slots before it.

Battery-to-grid export under direct marketing is no longer all-or-nothing:
grid_export_rates configures the selectable export levels as a factor of
the rated discharge power (default [0.25, 0.5, 0.75, 1.0]). Each rate is
its own optimizer state, with the full-power state keeping its previous
index so existing seeds and heuristics are unaffected. The chosen level
per slot is reported in battery_grid_export_factor and as the
GRID_SUPPORT_EXPORT operation factor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Andreas
2026-09-03 17:53:33 +02:00
co-authored by Claude Opus 5
parent 8926cc7ae0
commit f24d9ea0eb
20 changed files with 1469 additions and 39 deletions
+46
View File
@@ -24,6 +24,12 @@ from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# Default charge rates for battery
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
# Default grid export rates for battery (direct marketing). Coarser than the
# charge rates on purpose: every rate is one more state the genetic optimizer
# has to explore, and export levels below a quarter of the rated power rarely
# pay for the extra search effort.
BATTERY_DEFAULT_GRID_EXPORT_RATES: list[float] = [0.25, 0.5, 0.75, 1.0]
class BatteriesCommonSettings(DevicesBaseSettings):
"""Battery devices base settings."""
@@ -87,6 +93,20 @@ class BatteriesCommonSettings(DevicesBaseSettings):
},
)
grid_export_rates: Optional[list[float]] = Field(
default=BATTERY_DEFAULT_GRID_EXPORT_RATES,
json_schema_extra={
"description": (
"Battery-to-grid export rates as factor of maximum discharge "
"power ]0.00 ... 1.00]. Only used with direct marketing "
"(feedintariff.direct_marketing_enabled). Each rate is one "
"additional optimizer state; [1.0] restores all-or-nothing "
"export. None triggers fallback to default export-rates."
),
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
},
)
min_soc_percentage: int = Field(
default=0,
ge=0,
@@ -140,6 +160,32 @@ class BatteriesCommonSettings(DevicesBaseSettings):
return arr
@field_validator("grid_export_rates", mode="before")
def validate_and_sort_grid_export_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
"""Normalize the export rates to a sorted, duplicate-free array in ]0, 1]."""
# None means fallback to default values
if v is None:
return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy()
if isinstance(v, str):
numbers = re.split(r"[,\s]+", v.strip("[]"))
arr = np.array([float(x) for x in numbers if x])
else:
arr = np.array(v, dtype=float)
if arr.size == 0:
raise ValueError("grid_export_rates must contain at least one value.")
# A rate of 0.0 is not an export level - "no export" is expressed by the
# other battery states - so the lower bound is exclusive.
if (arr <= 0.0).any() or (arr > 1.0).any():
raise ValueError("grid_export_rates must be within ]0.0, 1.0].")
arr = np.unique(arr)
arr.sort()
return arr
@computed_field # type: ignore[prop-decorator]
@property
def measurement_key_soc_factor(self) -> str:
+26
View File
@@ -173,6 +173,32 @@ class ConsumerScheduleMode(StrEnum):
DAILY = "DAILY"
class ConsumerDeadlinePolicy(StrEnum):
"""Behaviour when a flexible consumer's deadline cannot be met.
A deadline (``deadline_datetime``) demands that a complete run has *finished*
before that moment. Depending on "now", the run duration, the optimization
horizon and the allowed time windows, no such start may exist.
Policies
--------
- BEST_EFFORT:
Run as early as the remaining constraints allow, i.e. minimize the
delay instead of the cost ("it should have been done by 03:00, so
start now"). A warning is logged. This keeps an optimization request
answerable instead of failing it - the usual choice for home
automation.
- STRICT:
Keep the deadline. A ONCE consumer without a feasible start makes the
optimization fail; a DAILY consumer is simply not scheduled on days
without a feasible start.
"""
BEST_EFFORT = "BEST_EFFORT"
STRICT = "STRICT"
class ApplianceOperationMode(StrEnum):
"""Appliance operation modes.
@@ -111,6 +111,15 @@ class Battery:
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
def rated_discharge_energy_wh(self) -> float:
"""Return the DC energy one full-power discharge slot delivers.
This is the reference a grid-export rate is applied to: a rate of 0.5
exports at most half the battery's rated discharge power, independent of
how much of the slot budget self-consumption already used.
"""
return self.max_charge_power_w * self.slot_duration_h * self.discharging_efficiency
def remaining_discharge_energy_wh(self, hour: int) -> float:
"""Return DC energy still deliverable within one optimization slot."""
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
@@ -7,12 +7,14 @@ energy at the chosen start(s). Several runs (DAILY mode) and several devices may
overlap; their energies simply add up.
"""
import math
from typing import Optional
import numpy as np
from loguru import logger
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.devices.devicesabc import ConsumerScheduleMode
from akkudoktoreos.devices.devicesabc import ConsumerDeadlinePolicy, ConsumerScheduleMode
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
@@ -95,6 +97,12 @@ class HomeAppliance:
self.device_id: str = parameters.device_id
self.schedule_mode: ConsumerScheduleMode = parameters.schedule_mode
self.time_windows: Optional[TimeWindowSequence] = parameters.time_windows
self.earliest_start_datetime: Optional[DateTime] = parameters.earliest_start_datetime
self.deadline_datetime: Optional[DateTime] = parameters.deadline_datetime
self.deadline_policy: ConsumerDeadlinePolicy = parameters.deadline_policy
# Set when a BEST_EFFORT deadline had to be dropped in the last
# allowed_start_slots() call, so callers can report the miss.
self.deadline_relaxed: bool = False
self._build_run_profile()
self.reset_load_curve()
@@ -119,6 +127,25 @@ class HomeAppliance:
)
self.run_slots: int = int(len(self.run_energy_wh))
def _slot_offset(self, moment: DateTime, slot0_datetime: DateTime, *, round_up: bool) -> int:
"""Convert an absolute moment into a slot index relative to slot 0.
Args:
moment: Absolute moment; converted into ``slot0_datetime``'s timezone.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
round_up: ``True`` returns the first slot boundary at or after
``moment`` (lower bounds), ``False`` the last one at or before
it (upper bounds).
Returns:
Slot index (may be negative or beyond the grid; callers clamp).
"""
seconds = (moment.in_timezone(slot0_datetime.timezone) - slot0_datetime).total_seconds()
exact = seconds / self.slot_interval_seconds
# Tolerance absorbs float noise so a moment that sits exactly on a slot
# boundary is not pushed to the neighbouring slot.
return math.ceil(exact - 1e-9) if round_up else math.floor(exact + 1e-9)
def allowed_start_slots(
self,
*,
@@ -128,12 +155,21 @@ class HomeAppliance:
) -> list[int]:
"""Return the sorted absolute start slots at which a full run is allowed.
A start slot ``s`` is allowed when the complete run fits both the
optimization horizon and (if configured) a single allowed time window:
A start slot ``s`` is allowed when the complete run fits the optimization
horizon, both absolute bounds and (if configured) a single allowed time
window:
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
- with ``earliest_start_datetime`` set, the run starts at or after it
- with ``deadline_datetime`` set, the run *ends* at or before it
- with ``time_windows`` set, the run's whole occupied span starting at
``s`` is contained in one window (respecting weekday/date constraints).
``s`` is contained in one window (respecting weekday/date constraints)
When a deadline leaves no start at all and the policy is
``BEST_EFFORT``, the deadline is dropped and only the earliest still
possible start is offered (a warning is logged and ``deadline_relaxed``
is set): the run happens too late anyway, so it is scheduled with the
smallest possible delay instead of at the cheapest slot.
No snapping is performed: every returned slot is a genuinely valid start.
@@ -142,14 +178,83 @@ class HomeAppliance:
earliest_slot: First slot the optimizer may schedule at ("now").
horizon_end_slot: Exclusive upper bound; a run must end at or before.
Returns:
Sorted list of allowed absolute start slots (may be empty).
"""
self.deadline_relaxed = False
allowed = self._allowed_start_slots(
slot0_datetime=slot0_datetime,
earliest_slot=earliest_slot,
horizon_end_slot=horizon_end_slot,
apply_deadline=True,
)
if (
allowed
or self.deadline_datetime is None
or self.deadline_policy == ConsumerDeadlinePolicy.STRICT
):
return allowed
relaxed = self._allowed_start_slots(
slot0_datetime=slot0_datetime,
earliest_slot=earliest_slot,
horizon_end_slot=horizon_end_slot,
apply_deadline=False,
)
if not relaxed:
return relaxed
self.deadline_relaxed = True
# Keep only the earliest possible start: the deadline is already missed,
# so the run is scheduled as soon as possible rather than as cheap as
# possible.
earliest = relaxed[:1]
logger.warning(
"Home appliance '{}': deadline {} can not be met - running as early as "
"possible instead (BEST_EFFORT). Run ends {}.",
self.device_id,
self.deadline_datetime,
self.run_end_datetime(earliest[0], slot0_datetime),
)
return earliest
def _allowed_start_slots(
self,
*,
slot0_datetime: DateTime,
earliest_slot: int,
horizon_end_slot: int,
apply_deadline: bool,
) -> list[int]:
"""Compute the allowed start slots for one set of constraints.
Args:
slot0_datetime: Local, timezone-aware datetime of slot index 0.
earliest_slot: First slot the optimizer may schedule at ("now").
horizon_end_slot: Exclusive upper bound; a run must end at or before.
apply_deadline: Whether ``deadline_datetime`` restricts the run end.
Returns:
Sorted list of allowed absolute start slots (may be empty).
"""
run_slots = self.run_slots
if run_slots <= 0:
return []
last_start = min(horizon_end_slot, self.total_slots) - run_slots
first_start = max(earliest_slot, 0)
if self.earliest_start_datetime is not None:
first_start = max(
first_start,
self._slot_offset(self.earliest_start_datetime, slot0_datetime, round_up=True),
)
end_bound = min(horizon_end_slot, self.total_slots)
if apply_deadline and self.deadline_datetime is not None:
end_bound = min(
end_bound,
self._slot_offset(self.deadline_datetime, slot0_datetime, round_up=False),
)
last_start = end_bound - run_slots
if last_start < first_start:
return []
@@ -164,6 +269,41 @@ class HomeAppliance:
allowed.append(slot)
return allowed
def run_end_datetime(self, start_slot: int, slot0_datetime: DateTime) -> DateTime:
"""Absolute local moment at which a run started at ``start_slot`` finishes.
Args:
start_slot: Absolute start slot of the run.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
Returns:
End datetime of the run (exclusive, i.e. the first free moment).
"""
return slot0_datetime.add(
seconds=(start_slot + self.run_slots) * self.slot_interval_seconds
)
def deadline_missed(self, starts: list[int], slot0_datetime: DateTime) -> bool:
"""Whether the scheduled runs violate the configured deadline.
Without a deadline nothing can be missed. With one, a consumer that was
not scheduled at all, or whose run ends after the deadline (a relaxed
BEST_EFFORT deadline), counts as missed.
Args:
starts: Absolute start slots of the scheduled runs.
slot0_datetime: Local, timezone-aware datetime of slot index 0.
Returns:
True if the deadline is set and not met.
"""
if self.deadline_datetime is None:
return False
if not starts:
return True
deadline = self.deadline_datetime.in_timezone(slot0_datetime.timezone)
return any(self.run_end_datetime(start, slot0_datetime) > deadline for start in starts)
def build_load_curve(self, starts: list[int]) -> None:
"""Place the resampled run energy at each decoded start slot.
+23 -1
View File
@@ -51,6 +51,7 @@ class Inverter:
consumption: float,
hour: int,
allow_battery_grid_export: bool = False,
battery_grid_export_factor: float = 1.0,
) -> tuple[float, float, float, float]:
"""Process one slot using probabilistic direct PV-to-load overlap.
@@ -59,6 +60,17 @@ class Inverter:
PV-to-load power. The remaining load and PV surplus are then handled
independently, because both can occur during different sub-intervals of
the same hourly or 15-minute slot.
Args:
generation: PV energy of the slot [Wh].
consumption: Load energy of the slot [Wh].
hour: Slot index.
allow_battery_grid_export: Whether the battery may discharge into the
grid in this slot (direct marketing).
battery_grid_export_factor: Export level as a factor of the battery's
rated discharge power [0.0 ... 1.0]. 1.0 exports as much as the
battery and the inverter allow, which is the behaviour when no
export rates are configured.
"""
losses = 0.0
grid_export = 0.0
@@ -123,10 +135,20 @@ class Inverter:
losses += max(remaining_surplus - pv_grid_export, 0.0)
if allow_battery_grid_export and self.battery and remaining_inverter_ac_capacity > 0.0:
export_factor = min(max(float(battery_grid_export_factor), 0.0), 1.0)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
)
export_capacity = min(remaining_inverter_ac_capacity, remaining_battery_ac)
# The rate caps the export against the battery's *rated* discharge
# power, so it stays a plain power setpoint ("export at 50 %") that
# does not silently grow when self-consumption used less of the slot.
# At factor 1.0 this bound never binds; behaviour is unchanged.
rated_export_ac = (
self.battery.rated_discharge_energy_wh() * export_factor * self.dc_to_ac_efficiency
)
export_capacity = min(
remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac
)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
+175 -18
View File
@@ -1,5 +1,6 @@
"""Genetic algorithm."""
import math
import random
import time
from collections import OrderedDict, defaultdict
@@ -87,13 +88,21 @@ class FitnessCacheEntry:
@dataclass(frozen=True)
class BatteryStateLayout:
"""Indices of optional battery states appended to the legacy state ranges."""
"""Indices of optional battery states appended to the legacy state ranges.
With graded direct-marketing export there is one state per configured export
rate. ``grid_export_states`` holds them in the order of
``bat_possible_grid_export_values`` (full power first), and
``grid_export_state`` is that full-power state - the one every seeding
heuristic uses when it wants "export in this slot".
"""
total_states: int
dc_not_allowed_state: Optional[int] = None
dc_allowed_state: Optional[int] = None
grid_export_state: Optional[int] = None
self_consumption_state: Optional[int] = None
grid_export_states: tuple[int, ...] = ()
class GeneticSimulation(PydanticBaseModel):
@@ -446,10 +455,14 @@ class GeneticSimulation(PydanticBaseModel):
if inverter_fast:
energy_produced = pv_prediction_wh_fast[hour]
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
# bat_grid_export_hours carries the export level per slot:
# 0.0 = no export, otherwise the factor of the rated discharge
# power the optimizer selected.
battery_grid_export_factor = float(bat_grid_export_hours_fast[hour])
battery_grid_export_allowed = (
direct_marketing_enabled_fast
and hourly_feed_in_tariff > 0.0
and bat_grid_export_hours_fast[hour] > 0
and battery_grid_export_factor > 0.0
)
(
energy_feedin_grid_actual,
@@ -461,6 +474,7 @@ class GeneticSimulation(PydanticBaseModel):
consumption,
hour,
allow_battery_grid_export=battery_grid_export_allowed,
battery_grid_export_factor=battery_grid_export_factor,
)
else:
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
@@ -648,6 +662,13 @@ class GeneticOptimization(OptimizationBase):
# Separate charge-level list for battery AC charging (independent of EV rates).
# Populated from parameters.pv_akku.charge_rates in optimierung_ems.
self.bat_possible_charge_values: list[float] = [1.0]
# Battery-to-grid export levels (direct marketing), full power first.
# Populated from parameters.pv_akku.grid_export_rates in optimierung_ems;
# the single full-power default keeps the all-or-nothing export.
self.bat_possible_grid_export_values: list[float] = [1.0]
# Slot by which the EV has to reach its target SoC. None means the SoC is
# only required at the end of the horizon (the behaviour without a deadline).
self._ev_soc_deadline_slot: Optional[int] = None
self.verbose = verbose
self.fix_seed = fixed_seed
self.optimize_ev = True
@@ -709,9 +730,14 @@ class GeneticOptimization(OptimizationBase):
dc_allowed_state = next_state + 1
next_state += 2
grid_export_states: tuple[int, ...] = ()
if self.optimize_battery_grid_export:
grid_export_state = next_state
next_state += 1
export_count = max(len(self.bat_possible_grid_export_values), 1)
grid_export_states = tuple(range(next_state, next_state + export_count))
# The first export state stays the full-power one, so its index does
# not move when further rates are configured.
grid_export_state = grid_export_states[0]
next_state += export_count
if self.optimize_dc_charge:
self_consumption_state = next_state
@@ -723,6 +749,7 @@ class GeneticOptimization(OptimizationBase):
dc_allowed_state=dc_allowed_state,
grid_export_state=grid_export_state,
self_consumption_state=self_consumption_state,
grid_export_states=grid_export_states,
)
def _appliance_horizon_end_slot(self) -> int:
@@ -736,6 +763,50 @@ class GeneticOptimization(OptimizationBase):
horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour
return min(self.total_slots, start_slot + horizon_slots)
def _ev_deadline_slot(self, parameters: GeneticOptimizationParameters) -> Optional[int]:
"""Slot index by which the EV has to reach ``min_soc_percentage``.
The deadline may be given as an absolute datetime, as a maximum duration
from the start of the optimization, or both - then the earlier one wins.
The returned slot is the first one that starts at or after the deadline,
so all charging that completes before the deadline still counts.
Args:
parameters: Optimization parameters of this run.
Returns:
Absolute slot index, or None when the target is only required at the
end of the horizon (no deadline, or one beyond the horizon).
"""
ev_parameters = parameters.eauto
if ev_parameters is None:
return None
start_slot = self._start_day_slot()
slot_seconds = self.slot_duration_h * 3600
candidates: list[int] = []
deadline = ev_parameters.min_soc_deadline_datetime
if deadline is not None:
seconds = (
deadline.in_timezone(self._slot0_datetime.timezone) - self._slot0_datetime
).total_seconds()
candidates.append(math.ceil(seconds / slot_seconds - 1e-9))
duration_h = ev_parameters.min_soc_max_duration_h
if duration_h is not None:
candidates.append(start_slot + math.ceil(duration_h * 3600 / slot_seconds - 1e-9))
if not candidates:
return None
deadline_slot = min(candidates)
if deadline_slot >= self.total_slots:
# Beyond the horizon: the end-of-horizon requirement already covers it.
return None
# A deadline in the past means the target is due right now.
return max(deadline_slot, start_slot)
def _build_appliance_layout(
self, appliances: list[HomeAppliance], slot0_datetime: Any
) -> ApplianceGeneLayout:
@@ -762,7 +833,8 @@ class GeneticOptimization(OptimizationBase):
if not allowed:
raise ValueError(
f"Home appliance '{appliance.device_id}' (ONCE) has no valid "
f"start slot within the optimization horizon and its time windows."
f"start slot within the optimization horizon, its time windows "
f"and its deadline."
)
genes.append(
ApplianceGeneSlot(
@@ -1056,10 +1128,17 @@ class GeneticOptimization(OptimizationBase):
ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float)
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices]
battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=int)
if state_layout.grid_export_state is not None:
# Export rate per slot: 0.0 = no export, otherwise the factor of the
# rated discharge power the optimizer picked for that slot.
battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=float)
for index, export_state in enumerate(state_layout.grid_export_states):
rate = (
self.bat_possible_grid_export_values[index]
if index < len(self.bat_possible_grid_export_values)
else 1.0
)
battery_grid_export = np.where(
discharge_hours_bin_np == state_layout.grid_export_state, 1, 0
discharge_hours_bin_np == export_state, rate, battery_grid_export
)
# Idle is just 0, already default.
@@ -1079,8 +1158,8 @@ class GeneticOptimization(OptimizationBase):
policy_states.append(state_layout.self_consumption_state)
if state_layout.dc_allowed_state is not None:
policy_states.append(state_layout.dc_allowed_state)
if state_layout.grid_export_state is not None:
policy_states.append(state_layout.grid_export_state)
# Every export level is a coherent policy for a whole block.
policy_states.extend(state_layout.grid_export_states)
block_start = random.randint(start_slot, self.total_slots - 1) # noqa: S311
max_length = min(12, self.total_slots - block_start)
@@ -1122,15 +1201,15 @@ class GeneticOptimization(OptimizationBase):
def _mutate_energy_shift(self, individual: list[int]) -> bool:
"""Move battery energy from a weak export into later expensive self-consumption."""
state_layout = self._battery_state_layout()
export_state = state_layout.grid_export_state
export_states = set(state_layout.grid_export_states)
self_state = state_layout.self_consumption_state
if export_state is None or self_state is None:
if not export_states or self_state is None:
return False
start_slot = self._start_day_slot()
viable: list[tuple[int, list[int]]] = []
for source_slot in range(start_slot, self.total_slots):
if int(individual[source_slot]) != export_state:
if int(individual[source_slot]) not in export_states:
continue
targets = self._energy_shift_target_slots(individual, source_slot)
if targets:
@@ -1420,6 +1499,9 @@ class GeneticOptimization(OptimizationBase):
start_slot = self._start_day_slot()
end_slot = max(start_slot, self.total_slots - self.fixed_eauto_hours)
if getattr(self, "_ev_soc_deadline_slot", None) is not None:
# Charging after the deadline does not help to reach the target.
end_slot = max(start_slot, min(end_slot, self._ev_soc_deadline_slot))
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
@@ -1692,9 +1774,9 @@ class GeneticOptimization(OptimizationBase):
) -> list[list[int]]:
"""Build deterministic export-to-self-consumption neighbourhood candidates."""
state_layout = self._battery_state_layout()
export_state = state_layout.grid_export_state
export_states = set(state_layout.grid_export_states)
self_state = state_layout.self_consumption_state
if export_state is None or self_state is None:
if not export_states or self_state is None:
return []
start_slot = self._start_day_slot()
@@ -1709,7 +1791,7 @@ class GeneticOptimization(OptimizationBase):
sources = [
slot
for slot in range(start_slot, self.total_slots)
if int(individual[slot]) == export_state
if int(individual[slot]) in export_states
]
# Search weak and late export decisions first. They are the most likely
# to compete with later, more valuable avoided grid imports.
@@ -2218,6 +2300,35 @@ class GeneticOptimization(OptimizationBase):
relevant.extend(individual[-n_appliance_genes:])
return tuple(int(value) for value in relevant)
def _ev_soc_at_deadline(self, simulation_result: dict[str, Any], start_slot: int) -> float:
"""EV state of charge the target is checked against [%].
Without a deadline this is the SoC after the last slot, which is what the
penalty always used. With a deadline it is the SoC at the beginning of
the deadline slot, i.e. after every charge that completes in time.
Args:
simulation_result: Result of the simulation run for this individual.
start_slot: Slot index the result arrays start at.
Returns:
State of charge in percent.
"""
ev = self.simulation.ev
if ev is None:
return 0.0
# Lightweight callers construct the optimizer without __init__ (see
# evaluate()); a missing deadline must behave like no deadline.
deadline_slot = getattr(self, "_ev_soc_deadline_slot", None)
if deadline_slot is None:
return ev.current_soc_percentage()
soc_per_slot = simulation_result.get("EAuto_SoC_pro_Stunde")
index = deadline_slot - start_slot
if soc_per_slot is None or index >= len(soc_per_slot):
return ev.current_soc_percentage()
return float(soc_per_slot[max(index, 0)])
def _evaluate_uncached(
self,
individual: list[int],
@@ -2432,7 +2543,7 @@ class GeneticOptimization(OptimizationBase):
logger.error(
"Penalty function parameter `ev_soc_miss` not configured, using {}.", penalty
)
ev_soc_percentage = self.simulation.ev.current_soc_percentage()
ev_soc_percentage = self._ev_soc_at_deadline(simulation_result, start_hour)
if ev_soc_percentage < parameters.eauto.min_soc_percentage:
gesamtbilanz += (
abs(parameters.eauto.min_soc_percentage - ev_soc_percentage) * penalty
@@ -2773,6 +2884,26 @@ class GeneticOptimization(OptimizationBase):
self.bat_possible_charge_values = [1.0]
logger.debug("Battery AC charge levels: {}", self.bat_possible_charge_values)
# Battery-to-grid export levels (direct marketing only). Same resolution
# order as the charge rates: request parameters win over the configured
# battery, and the fallback is the previous all-or-nothing export.
export_rates: Optional[list[float]] = None
if parameters.pv_akku and parameters.pv_akku.grid_export_rates:
export_rates = list(parameters.pv_akku.grid_export_rates)
elif (
self.config.devices.batteries
and self.config.devices.batteries[0]
and self.config.devices.batteries[0].grid_export_rates is not None
):
export_rates = list(self.config.devices.batteries[0].grid_export_rates)
# Highest rate first so the full-power state keeps the lowest index and
# every heuristic that seeds "export here" keeps seeding full power.
self.bat_possible_grid_export_values = sorted(
(rate for rate in (export_rates or [1.0]) if rate > 0.0), reverse=True
) or [1.0]
if self.optimize_battery_grid_export:
logger.debug("Battery grid export levels: {}", self.bat_possible_grid_export_values)
# Initialize the flexible consumers (home appliances) and their genome
# layout. slot0_datetime (midnight of the start day) turns decoded start
# slots into absolute local timestamps and drives DAILY day grouping.
@@ -2790,6 +2921,18 @@ class GeneticOptimization(OptimizationBase):
]
self.appliance_layout = self._build_appliance_layout(home_appliances, self._slot0_datetime)
# EV charging deadline (departure). Resolved once per run; the seeding
# heuristic and the SoC penalty both read it.
self._ev_soc_deadline_slot = self._ev_deadline_slot(parameters)
if self._ev_soc_deadline_slot is not None:
logger.debug(
"EV target SoC required by slot {} ({}).",
self._ev_soc_deadline_slot,
self._slot0_datetime.add(
seconds=self._ev_soc_deadline_slot * self.slot_duration_h * 3600
),
)
# 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
@@ -2844,6 +2987,7 @@ class GeneticOptimization(OptimizationBase):
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
home_appliance_energy_wh: dict[str, list[float]] = {}
appliance_starts: dict[str, list[Any]] = {}
appliance_deadline_missed: dict[str, bool] = {}
timezone = self.config.general.timezone
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
device_id = appliance.device_id
@@ -2855,6 +2999,13 @@ class GeneticOptimization(OptimizationBase):
).in_timezone(timezone)
for start in starts
]
# Report a deadline that could not be kept (no run scheduled at all,
# or a run that ends late because a BEST_EFFORT deadline was dropped)
# so the caller can warn instead of silently trusting the schedule.
if appliance.deadline_datetime is not None:
appliance_deadline_missed[device_id] = appliance.deadline_missed(
starts, self._slot0_datetime
)
simulation_result["home_appliance_energy_wh"] = home_appliance_energy_wh
# Deprecated single-device hourly start (kept for backward compatibility).
@@ -2888,9 +3039,13 @@ class GeneticOptimization(OptimizationBase):
discharge = discharge.tolist()
battery_grid_export = self.simulation.bat_grid_export_hours
if not direct_marketing_enabled or battery_grid_export is None:
battery_grid_export_factor = []
battery_grid_export = []
else:
battery_grid_export = battery_grid_export.tolist()
# The simulation array holds the export level; the legacy signal is
# its boolean projection.
battery_grid_export_factor = [float(value) for value in battery_grid_export]
battery_grid_export = [1 if value > 0.0 else 0 for value in battery_grid_export_factor]
# Visualize the results in PDF. Skippable via config — matplotlib PDF
# generation costs several seconds per run, which headless setups
@@ -2926,11 +3081,13 @@ class GeneticOptimization(OptimizationBase):
"dc_charge": dc_charge_hours,
"discharge_allowed": discharge,
"battery_grid_export_allowed": battery_grid_export,
"battery_grid_export_factor": battery_grid_export_factor,
"eautocharge_hours_float": eautocharge_hours_float,
"result": GeneticSimulationResult(**simulation_result),
"eauto_obj": self.simulation.ev,
"start_solution": start_solution,
"washingstart": washingstart_int,
"appliance_starts": appliance_starts,
"appliance_deadline_missed": appliance_deadline_missed,
}
)
@@ -1,16 +1,18 @@
"""Genetic optimization algorithm device interfaces/ parameters."""
from typing import Optional
from typing import Any, Optional
from pydantic import Field, model_validator
from pydantic import Field, field_validator, model_validator
from typing_extensions import Self
from akkudoktoreos.config.configabc import TimeWindowSequence
from akkudoktoreos.devices.devicesabc import (
ConsumerDeadlinePolicy,
ConsumerScheduleMode,
validate_home_appliance_load_definition,
)
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
class DeviceParameters(GeneticParametersBaseModel):
@@ -98,6 +100,18 @@ class BaseBatteryParameters(DeviceParameters):
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
},
)
grid_export_rates: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Battery-to-grid export rates as factor of maximum discharge "
"power ]0.00 ... 1.00]. Only used with direct marketing. None "
"falls back to the configured devices.batteries[0]."
"grid_export_rates."
),
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
},
)
class SolarPanelBatteryParameters(BaseBatteryParameters):
@@ -118,7 +132,13 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
class ElectricVehicleParameters(BaseBatteryParameters):
"""Battery Electric Vehicle Device Simulation Configuration."""
"""Battery Electric Vehicle Device Simulation Configuration.
``min_soc_percentage`` is the charging target. By default it only has to be
reached by the end of the optimization horizon; a deadline
(``min_soc_deadline_datetime`` and/or ``min_soc_max_duration_h``) moves that
requirement forward, for example to the next departure.
"""
device_id: str = Field(
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
@@ -127,6 +147,37 @@ class ElectricVehicleParameters(BaseBatteryParameters):
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the current state of charge (SOC) of the battery in percentage."
)
min_soc_deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute moment by which 'min_soc_percentage' has to be "
"reached (departure time). A date time without timezone is read "
"as local time. None means end of the optimization horizon."
),
"examples": [None, "2026-07-16T07:00:00+02:00"],
},
)
min_soc_max_duration_h: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Maximum time from the start of the optimization until "
"'min_soc_percentage' has to be reached [h]. Combined with "
"'min_soc_deadline_datetime' the earlier of the two applies."
),
"examples": [None, 6.0],
},
)
@field_validator("min_soc_deadline_datetime", mode="before")
@classmethod
def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
class HomeApplianceParameters(DeviceParameters):
@@ -136,6 +187,14 @@ class HomeApplianceParameters(DeviceParameters):
(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)
**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one
of the two must be provided.
*When* the run may happen is constrained by three independent mechanisms that
all have to hold at once:
- ``time_windows``: recurring wall-clock windows ("only between 10:00 and 13:00").
- ``earliest_start_datetime``: absolute lower bound ("not before I get home").
- ``deadline_datetime``: absolute upper bound; the run must be *finished*
before that moment ("clean dishes by 03:00 tonight").
"""
device_id: str = Field(
@@ -207,6 +266,49 @@ class HomeApplianceParameters(DeviceParameters):
],
},
)
earliest_start_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute earliest moment the run may start. Starts before it are "
"dropped, in addition to 'time_windows' and the horizon. A date "
"time without timezone is read as local time. This bound is never "
"relaxed."
),
"examples": [None, "2026-07-15T20:00:00+02:00"],
},
)
deadline_datetime: Optional[DateTime] = Field(
default=None,
json_schema_extra={
"description": (
"Absolute deadline: the complete run must have *finished* at or "
"before this moment (e.g. end of the day, or 03:00 tonight). A "
"date time without timezone is read as local time. See "
"'deadline_policy' for what happens when no start can meet it."
),
"examples": [None, "2026-07-16T03:00:00+02:00"],
},
)
deadline_policy: ConsumerDeadlinePolicy = Field(
default=ConsumerDeadlinePolicy.BEST_EFFORT,
json_schema_extra={
"description": (
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
"runs as early as possible instead (warning logged), STRICT keeps "
"the deadline (a ONCE consumer then fails the optimization)."
),
"examples": ["BEST_EFFORT", "STRICT"],
},
)
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
@classmethod
def transform_to_datetime(cls, value: Any) -> Optional[DateTime]:
"""Accept the usual date time representations, naive input is local time."""
if value is None:
return None
return to_datetime(value)
@model_validator(mode="after")
def validate_load_definition(self) -> Self:
@@ -219,6 +321,17 @@ class HomeApplianceParameters(DeviceParameters):
)
return self
@model_validator(mode="after")
def validate_schedule_bounds(self) -> Self:
"""Reject an empty scheduling interval."""
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
raise ValueError(
f"deadline_datetime {self.deadline_datetime} must be after "
f"earliest_start_datetime {self.earliest_start_datetime}."
)
return self
class InverterParameters(DeviceParameters):
"""Inverter Device Simulation Configuration."""
@@ -201,6 +201,18 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
},
)
battery_grid_export_factor: list[float] = Field(
default_factory=list,
json_schema_extra={
"description": (
"Array with the battery-to-grid export level per slot as factor "
"of the rated discharge power (0.0 for no export). Empty when "
"direct marketing is disabled; a solution without this array "
"exports at full power wherever "
"'battery_grid_export_allowed' is 1."
)
},
)
eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
result: GeneticSimulationResult
eauto_obj: Optional[ElectricVehicleResult]
@@ -229,6 +241,16 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
},
)
appliance_deadline_missed: dict[str, bool] = Field(
default_factory=dict,
json_schema_extra={
"description": (
"Per appliance device_id with a 'deadline_datetime': whether the "
"scheduled run misses that deadline (or was not scheduled at "
"all). Appliances without a deadline are not listed."
)
},
)
@field_validator(
"ac_charge",
@@ -276,6 +298,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
dc_charge: float,
discharge_allowed: bool,
battery_grid_export_allowed: bool = False,
battery_grid_export_factor: float = 1.0,
) -> tuple[BatteryOperationMode, float]:
"""Maps low-level solution to a representative operation mode and factor.
@@ -284,6 +307,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
dc_charge (float): Allowed DC-side charging power (relative units).
discharge_allowed (bool): Whether discharging to local load is permitted.
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
battery_grid_export_factor (float): Export level as factor of the rated
discharge power ]0.0 ... 1.0]. Becomes the operation factor of
GRID_SUPPORT_EXPORT.
Returns:
tuple[BatteryOperationMode, float]: A tuple containing
@@ -310,7 +336,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
raise ValueError(
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
)
return BatteryOperationMode.GRID_SUPPORT_EXPORT, 1.0
return BatteryOperationMode.GRID_SUPPORT_EXPORT, min(
max(float(battery_grid_export_factor), 0.0), 1.0
)
# (0,0,1) -> Discharge for local load only
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
@@ -505,13 +533,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if hour_idx < len(self.battery_grid_export_allowed)
else False
)
# Solutions written before graded export carry no factor array; they
# exported at full power wherever the signal was set.
battery_grid_export_factor_hour = (
float(self.battery_grid_export_factor[hour_idx])
if hour_idx < len(self.battery_grid_export_factor)
else (1.0 if battery_grid_export_allowed_hour else 0.0)
)
# Raw genetic gene values — optimizer intent, stored verbatim
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
operation["genetic_battery_grid_export_allowed_factor"].append(
float(battery_grid_export_allowed_hour)
battery_grid_export_factor_hour
)
# SOC-clamped effective values — what can physically be executed at
@@ -530,7 +565,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
battery_grid_export_allowed_hour,
)
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
eff_ac, eff_dc, eff_dis, eff_grid_export
eff_ac, eff_dc, eff_dis, eff_grid_export, battery_grid_export_factor_hour
)
for mode in BatteryOperationMode:
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"