Files
EOS/src/akkudoktoreos/optimization/genetic/geneticsettings.py
T
14d8587c9c feat(genetic): make the fitness cache size configurable (#1376)
The per-run fitness cache of the GENETIC optimizer is unbounded. It
grows with individuals x generations (~110k keys at 300 x 400, about
1.1 kB each, 1.6 kB with an EV) and adds 120-170 MB to the peak memory
of a run, while only 6-15 % of the lookups hit. On 512 MB boards this
is the largest part of the optimizer's memory.

Add optimization.genetic.fitness_cache_max_entries:

- None (default): unbounded, the previous behaviour.
- 0: no cache; every genome is evaluated.
- N > 0: at most N keys. The cache is then an OrderedDict and evicts
  the oldest key first (FIFO, O(1)); LRU would cost bookkeeping on
  every hit for a cache that rarely hits.

A cache hit returns exactly what the deterministic evaluation would
return, so the plan for a fixed seed is identical with any setting.

Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Andreas <drbacke@gmx.de>
2026-10-11 09:19:33 +02:00

200 lines
6.7 KiB
Python

"""Settings for the GENETIC optimization algorithm.
Kept in an extra module to avoid cyclic dependencies on package import.
"""
from enum import StrEnum
from typing import Any, Literal, Optional, Union
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
def normalize_genetic_settings(value: Any) -> Any:
"""Preserve old feature settings in the current algorithm-specific namespace.
Explicit nested values win. The classic flat configuration without GENETIC
markers remains available to the existing GENETIC0 migration.
"""
if not isinstance(value, dict):
return value
feature_keys = (
"tail_horizon_hours",
"terminal_value_mode",
"terminal_value_euro_per_kwh",
"terminal_value_window_hours",
"measurement_max_age_seconds",
)
is_genetic = value.get("algorithm") == "GENETIC" or any(key in value for key in feature_keys)
if not is_genetic or not any(
key in value for key in (*feature_keys, "interval", "horizon_hours")
):
return value
result = dict(value)
nested = dict(result.get("genetic") or {})
for key in (*feature_keys, "interval", "horizon_hours"):
if key in result:
nested.setdefault("interval_sec" if key == "interval" else key, result.pop(key))
result["genetic"] = nested
return result
class TerminalValueMode(StrEnum):
"""How the energy left in the battery at the end of the horizon is valued.
Modes
-----
- AUTO:
Solve the deterministic forecast tail and apply a conservative
continuation proxy at its end. Tail values may decrease with SOC when
empty capacity is valuable. With a zero tail, use the proxy directly.
- FIXED:
Credit every stored kWh with the configured
``terminal_value_euro_per_kwh`` (or ``preis_euro_pro_wh_akku`` of the
request). The historical behaviour; a value of 0 makes the optimizer
empty the battery towards the end of the horizon.
"""
AUTO = "AUTO"
FIXED = "FIXED"
class GeneticCommonSettings(SettingsBaseModel):
"""GENETIC Optimization Algorithm Configuration."""
interval_sec: Literal[900, 3600] = Field(
default=3600,
json_schema_extra={
"description": "The optimization interval [sec]. Defaults to 3600 seconds (1 hour)",
"examples": [60 * 60, 15 * 60],
},
)
horizon_hours: int = Field(
default=24,
ge=0,
json_schema_extra={
"description": "The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours.",
"examples": [24],
},
)
individuals: Optional[int] = Field(
default=300,
ge=10,
json_schema_extra={
"description": "Number of individuals (solutions) in the population [>= 10]. Defaults to 300.",
"examples": [300],
},
)
generations: Optional[int] = Field(
default=400,
ge=10,
json_schema_extra={
"description": "Number of generations to evolve [>= 10]. Defaults to 400.",
"examples": [400],
},
)
seed: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Random seed for reproducibility. None = random.",
"examples": [None, 42],
},
)
fitness_cache_max_entries: Optional[int] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum number of keys in the per-run fitness cache. None = unbounded "
"(default), 0 = cache disabled, N > 0 = keep at most N keys and evict the "
"oldest first. Every cached evaluation takes one or two keys. The cache "
"only saves repeated evaluations; the result for a fixed seed is the same "
"with any value. Limit it to cap memory on small devices."
),
"examples": [None, 0, 5000],
},
)
measurement_max_age_seconds: int = Field(
default=300,
gt=0,
description="Maximum age of SoC measurements for configuration-based optimization [s].",
)
tail_horizon_hours: int = Field(
default=48,
ge=0,
json_schema_extra={
"description": "Forecast lookahead after the control horizon [h]. No tail commands are issued. Set 0 to disable."
},
)
terminal_value_mode: TerminalValueMode = Field(
default=TerminalValueMode.AUTO,
json_schema_extra={
"description": (
"How to value the energy left in the battery at the end of the "
"control horizon. AUTO solves the forecast tail with an AUTO "
"continuation proxy at its end (or only the proxy if tail is zero); FIXED "
"uses 'terminal_value_euro_per_kwh'. Defaults to AUTO."
),
"examples": ["AUTO", "FIXED"],
},
)
terminal_value_euro_per_kwh: float = Field(
default=0.0,
json_schema_extra={
"description": (
"Value assigned to usable battery energy remaining at the end of the "
"optimization horizon [EUR/kWh]. This terminal value is independent "
"of the battery LCOS. Only used with terminal_value_mode = FIXED. "
"Defaults to 0 EUR/kWh."
),
"examples": [0.0, 0.20],
},
)
terminal_value_window_hours: int = Field(
default=24,
ge=1,
json_schema_extra={
"description": (
"Length of the trailing window at the effective tail end the AUTO continuation "
"curve is derived from [h]. One day covers a full load and PV "
"cycle. Defaults to 24 hours."
),
"examples": [24],
},
)
# --- Penalties (existing) -------------------------------------------------
penalties: dict[str, Union[float, int, str]] = Field(
default_factory=lambda: dict[str, float | int | str](
ev_soc_miss=10,
ac_charge_break_even=1.0,
),
json_schema_extra={
"description": "Penalty parameters used in fitness evaluation.",
"examples": [{"ev_soc_miss": 10}],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def horizon(self) -> int:
"""Number of optimization steps."""
if self.interval_sec is None or self.interval_sec == 0 or self.horizon_hours is None:
return 0
num_steps = int(float(self.horizon_hours * 3600) / self.interval_sec)
return num_steps