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>
This commit is contained in:
Christin
2026-10-11 09:19:33 +02:00
committed by GitHub
co-authored by Christin Claude Opus 5.5 Andreas
parent ecec39fcd8
commit 14d8587c9c
8 changed files with 294 additions and 9 deletions
+3
View File
@@ -38,6 +38,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta
and coverage information. A capacity estimate does not overwrite configured capacity.
- Optional local, calibratable forecasting within `PVForecastAkkudoktor`; the remote
backend remains the default.
- Optional size limit for the GENETIC fitness cache: `optimization.genetic.fitness_cache_max_entries`
(`null` = unbounded, the default; `0` = off; N = at most N keys). Bounding it lowers peak memory of
a run by up to ~150 MB on small devices without changing the plan.
- Optional `general.timezone_override`: an IANA timezone name used as `general.timezone`
instead of the lookup from latitude/longitude. Unset (default), the lookup is unchanged.
- New PV forecast providers giving operators more cloud forecast sources to choose from in
+1
View File
@@ -258,6 +258,7 @@
"individuals": 400,
"generations": 400,
"seed": null,
"fitness_cache_max_entries": null,
"measurement_max_age_seconds": 300,
"tail_horizon_hours": 48,
"terminal_value_mode": "AUTO",
+5
View File
@@ -30,6 +30,7 @@
"individuals": 400,
"generations": 400,
"seed": null,
"fitness_cache_max_entries": null,
"measurement_max_age_seconds": 300,
"tail_horizon_hours": 48,
"terminal_value_mode": "AUTO",
@@ -68,6 +69,7 @@
"individuals": 400,
"generations": 400,
"seed": null,
"fitness_cache_max_entries": null,
"measurement_max_age_seconds": 300,
"tail_horizon_hours": 48,
"terminal_value_mode": "AUTO",
@@ -173,6 +175,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| fitness_cache_max_entries | `Optional[int]` | `rw` | `None` | 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. |
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
| horizon | `int` | `ro` | `N/A` | Number of optimization steps. |
| horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
@@ -202,6 +205,7 @@
"individuals": 300,
"generations": 400,
"seed": null,
"fitness_cache_max_entries": null,
"measurement_max_age_seconds": 300,
"tail_horizon_hours": 48,
"terminal_value_mode": "AUTO",
@@ -230,6 +234,7 @@
"individuals": 300,
"generations": 400,
"seed": null,
"fitness_cache_max_entries": null,
"measurement_max_age_seconds": 300,
"tail_horizon_hours": 48,
"terminal_value_mode": "AUTO",
+15
View File
@@ -175,6 +175,21 @@ The behavior of the genetic algorithm can be customized using the following conf
A fixed seed to ensure reproducibility. Runs with the same seed and configuration will
produce the same results.
- **fitness_cache_max_entries** (`int` or `null`, default: `null`):
Limits the fitness cache of one optimization run. The cache remembers the fitness of every
evaluated plan so that a repeated plan is not simulated again. It is cleared after each run,
but during a run it grows with individuals × generations (roughly 100 MB at 300 × 400) while
typically only 5–15 % of the evaluations hit it.
- If `null`, the cache is unbounded (previous behaviour).
- If `0`, the cache is disabled; every plan is simulated.
- If a positive integer N, the cache holds at most N keys and drops the oldest first. Each
evaluated plan takes one or two keys.
The value never changes the result: with a fixed seed, all settings give the same plan. Small
devices (e.g. 512 MB RAM) should use `0` or a few thousand; this costs a few percent of run
time.
- **penalties** (`dict`):
Defines how penalties are applied to solutions that violate constraints (e.g., undercharged
batteries). Penalty function parameter values influence the fitness score, discouraging
+36
View File
@@ -8801,6 +8801,24 @@
42
]
},
"fitness_cache_max_entries": {
"anyOf": [
{
"type": "integer",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Fitness Cache Max Entries",
"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": [
null,
0,
5000
]
},
"measurement_max_age_seconds": {
"type": "integer",
"exclusiveMinimum": 0.0,
@@ -8949,6 +8967,24 @@
42
]
},
"fitness_cache_max_entries": {
"anyOf": [
{
"type": "integer",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Fitness Cache Max Entries",
"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": [
null,
0,
5000
]
},
"measurement_max_age_seconds": {
"type": "integer",
"exclusiveMinimum": 0.0,
@@ -8,7 +8,7 @@ import random
import sys
import time
from array import array
from collections import defaultdict
from collections import OrderedDict, defaultdict
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Optional
@@ -816,6 +816,9 @@ class GeneticOptimization(OptimizationBase):
# have changed even when the genome is identical.
self._fitness_cache_enabled = False
self._fitness_cache: dict[PackedGenes, FitnessCacheEntry] = {}
# Maximum number of cache keys. None keeps the cache unbounded; a bounded
# cache is an OrderedDict and evicts the oldest key first (FIFO).
self._fitness_cache_max_entries: Optional[int] = None
self._fitness_cache_hits = 0
self._fitness_cache_misses = 0
@@ -2922,8 +2925,37 @@ class GeneticOptimization(OptimizationBase):
)
self._fitness_cache[original_key] = entry
self._fitness_cache[canonical_key] = entry
max_entries = getattr(self, "_fitness_cache_max_entries", None)
if max_entries is not None:
self._evict_fitness_cache(max_entries)
return fitness
def _evict_fitness_cache(self, max_entries: int) -> None:
"""Drop the oldest cache keys until at most ``max_entries`` remain.
FIFO instead of LRU: the hit rate of the cache is low (typically below
15 %), so tracking recency would cost more than it saves. Evicting a key
only means that genome is evaluated again; the evaluation is
deterministic, so the optimization result does not change.
"""
cache = self._fitness_cache
if isinstance(cache, OrderedDict):
while len(cache) > max_entries:
cache.popitem(last=False)
else:
while len(cache) > max_entries:
del cache[next(iter(cache))]
def _configured_fitness_cache_max_entries(self) -> Optional[int]:
"""Return the configured fitness cache limit (None = unbounded)."""
try:
max_entries = self.config.optimization.genetic.fitness_cache_max_entries
except Exception:
return None
if max_entries is None:
return None
return max(int(max_entries), 0)
def _fitness_key(self, individual: list[int]) -> PackedGenes:
"""Return the fitness-relevant genome, excluding elapsed control slots."""
start_slot = self._control_start_slot()
@@ -3307,10 +3339,20 @@ class GeneticOptimization(OptimizationBase):
# The memoization scope is exactly one optimizer invocation. Always turn
# it off again, including when DEAP raises, so no later caller can reuse
# results under changed forecasts or device state.
# fitness_cache_max_entries: None = unbounded, 0 = no cache, N = at most
# N keys (oldest evicted first). Disabling or bounding the cache only
# re-evaluates genomes; it does not change the result.
max_entries = self._configured_fitness_cache_max_entries()
self._fitness_cache_max_entries = max_entries
self._fitness_cache.clear()
if max_entries is not None:
# Only a bounded cache pays for the ordered container (O(1) eviction).
self._fitness_cache = OrderedDict()
elif isinstance(self._fitness_cache, OrderedDict):
self._fitness_cache = {}
self._fitness_cache_hits = 0
self._fitness_cache_misses = 0
self._fitness_cache_enabled = True
self._fitness_cache_enabled = max_entries != 0
local_evaluations = 0
local_improvements = 0
local_initial_fitness = float("nan")
@@ -3359,13 +3401,25 @@ class GeneticOptimization(OptimizationBase):
cache_lookups = self._fitness_cache_hits + self._fitness_cache_misses
cache_hit_rate = self._fitness_cache_hits / cache_lookups if cache_lookups > 0 else 0.0
cache_keys = len(self._fitness_cache)
logger.info(
"Fitness cache: {} hits, {} misses, {:.1%} hit rate, {} keys.",
self._fitness_cache_hits,
self._fitness_cache_misses,
cache_hit_rate,
cache_keys,
)
if max_entries == 0:
logger.info("Fitness cache: disabled (fitness_cache_max_entries = 0).")
elif max_entries is None:
logger.info(
"Fitness cache: {} hits, {} misses, {:.1%} hit rate, {} keys.",
self._fitness_cache_hits,
self._fitness_cache_misses,
cache_hit_rate,
cache_keys,
)
else:
logger.info(
"Fitness cache: {} hits, {} misses, {:.1%} hit rate, {} keys (limit {}).",
self._fitness_cache_hits,
self._fitness_cache_misses,
cache_hit_rate,
cache_keys,
max_entries,
)
# Store fitness history
self.fitness_history = {
@@ -108,6 +108,21 @@ class GeneticCommonSettings(SettingsBaseModel):
},
)
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,
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the configurable size limit of the GENETIC fitness cache."""
import json
from collections import OrderedDict
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import patch
import numpy as np
import pytest
from deap import creator
from pydantic import ValidationError
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsettings import GeneticCommonSettings
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).parent / "testdata" / "genetic"
def _configure_hourly_grid(config_eos: ConfigEOS) -> None:
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"genetic": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval_sec": 3600}
},
}
)
get_ems(init=True).set_start_datetime(to_datetime().set(hour=0, minute=0))
def test_fitness_cache_max_entries_setting_defaults_to_unbounded():
assert GeneticCommonSettings().fitness_cache_max_entries is None
assert GeneticCommonSettings(fitness_cache_max_entries=0).fitness_cache_max_entries == 0
assert GeneticCommonSettings(fitness_cache_max_entries=5000).fitness_cache_max_entries == 5000
with pytest.raises(ValidationError):
GeneticCommonSettings(fitness_cache_max_entries=-1)
@pytest.mark.parametrize("max_entries", [1, 2, 3, 10])
def test_bounded_fitness_cache_evicts_oldest_keys(config_eos: ConfigEOS, max_entries: int):
_configure_hourly_grid(config_eos)
opt = GeneticOptimization(fixed_seed=42)
opt.optimize_ev = False
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
parameters = SimpleNamespace(ems=SimpleNamespace(price_per_wh_battery=0.0), ev=None)
result = {
"Gesamtbilanz_Euro": 1.0,
"Gesamt_Verluste": 0.0,
"EAuto_SoC_pro_Stunde": np.zeros(opt.control_slots),
}
opt._fitness_cache = OrderedDict()
opt._fitness_cache_max_entries = max_entries
opt._fitness_cache_enabled = True
individuals = [
creator.Individual([value % 2] + [value // 2] + [0] * (opt.control_slots - 2))
for value in range(20)
]
with patch.object(opt, "evaluate_inner", return_value=result):
for individual in individuals:
opt.evaluate(individual, parameters, 0, False) # type: ignore[arg-type]
assert len(opt._fitness_cache) <= max_entries
# The most recent evaluation is still cached; the first one has been evicted.
newest_key = opt._fitness_key(individuals[-1])
oldest_key = opt._fitness_key(individuals[0])
assert newest_key in opt._fitness_cache
assert oldest_key not in opt._fitness_cache
assert opt._fitness_cache_misses == len(individuals)
assert opt._fitness_cache_hits == 0
def test_evict_fitness_cache_also_bounds_a_plain_dict(config_eos: ConfigEOS):
opt = GeneticOptimization(fixed_seed=42)
opt._fitness_cache = {(bytes([value]),): None for value in range(10)} # type: ignore[misc]
opt._evict_fitness_cache(3)
assert list(opt._fitness_cache) == [(bytes([value]),) for value in (7, 8, 9)]
def _run_optimization(
config_eos: ConfigEOS, max_entries: Optional[int]
) -> tuple[dict[str, Any], dict[str, Any], int, int]:
"""Run a short, seeded GENETIC optimization with EV and return its result."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {
"algorithm": "GENETIC",
"genetic": {
"horizon_hours": 38,
"tail_horizon_hours": 0,
"terminal_value_mode": "FIXED",
"individuals": 40,
"generations": 10,
"fitness_cache_max_entries": max_entries,
"penalties": {"ev_soc_miss": 10, "ac_charge_break_even": 1},
},
},
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": {
"ev1": {"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]}
},
},
}
)
assert config_eos.optimization.genetic.fitness_cache_max_entries == max_entries
with (DIR_TESTDATA / "optimize_input_2.json").open("r") as f_in:
parameters = GeneticOptimizationParameters(**json.load(f_in))
get_ems(init=True).set_start_datetime(
to_datetime("2026-09-16T10:00:00+02:00", in_timezone="Europe/Berlin")
)
CacheEnergyManagementStore().clear()
opt = GeneticOptimization(fixed_seed=42)
max_cache_size = 0
uncached = opt._evaluate_uncached
def spy(*args: Any, **kwargs: Any) -> tuple[float]:
nonlocal max_cache_size
max_cache_size = max(max_cache_size, len(opt._fitness_cache))
return uncached(*args, **kwargs)
with patch.object(opt, "_evaluate_uncached", side_effect=spy):
solution = opt.optimize_ems(parameters=parameters, start_hour=10, ngen=12)
dump = solution.model_dump(mode="json")
history = dump.pop("fitness_history")
cache_stats = history.pop("fitness_cache")
max_cache_size = max(max_cache_size, cache_stats["keys"])
return dump, history, cache_stats["hits"], max_cache_size
def test_fitness_cache_limit_does_not_change_the_result(config_eos: ConfigEOS):
reference, reference_history, reference_hits, unbounded_size = _run_optimization(
config_eos, None
)
assert reference_hits > 0
assert unbounded_size > 20
for max_entries in (0, 1, 7, 20):
result, history, hits, max_size = _run_optimization(config_eos, max_entries)
assert result == reference, f"fitness_cache_max_entries={max_entries}"
assert history == reference_history, f"fitness_cache_max_entries={max_entries}"
assert max_size <= max_entries
if max_entries == 0:
assert hits == 0