diff --git a/CHANGELOG.md b/CHANGELOG.md index 801cf111..95f588f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 22aa2c0d..a93e1f30 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -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", diff --git a/docs/_generated/configoptimization.md b/docs/_generated/configoptimization.md index 7ddf1101..6a16c0f2 100644 --- a/docs/_generated/configoptimization.md +++ b/docs/_generated/configoptimization.md @@ -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", diff --git a/docs/akkudoktoreos/optimauto.md b/docs/akkudoktoreos/optimauto.md index b4156401..ff1511dd 100644 --- a/docs/akkudoktoreos/optimauto.md +++ b/docs/akkudoktoreos/optimauto.md @@ -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 diff --git a/openapi.json b/openapi.json index b5a1b6b7..38086abc 100644 --- a/openapi.json +++ b/openapi.json @@ -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, diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 037e6cb3..9e9603d8 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -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 = { diff --git a/src/akkudoktoreos/optimization/genetic/geneticsettings.py b/src/akkudoktoreos/optimization/genetic/geneticsettings.py index 9e9e600e..f692e768 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsettings.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsettings.py @@ -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, diff --git a/tests/test_genetic_fitness_cache_limit.py b/tests/test_genetic_fitness_cache_limit.py new file mode 100644 index 00000000..eca51a05 --- /dev/null +++ b/tests/test_genetic_fitness_cache_limit.py @@ -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