mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-13 17:41:56 +00:00
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>
157 lines
6.2 KiB
Python
157 lines
6.2 KiB
Python
"""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
|