mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-12 09:11:56 +00:00
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:
co-authored by
Christin
Claude Opus 5.5
Andreas
parent
ecec39fcd8
commit
14d8587c9c
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user