mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-14 01:51:56 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user