2026-10-11 10:47:49 +02:00
|
|
|
"""Parallel fitness evaluation gives the same result as evaluating in one process."""
|
|
|
|
|
|
|
|
|
|
import multiprocessing
|
2026-10-11 11:37:43 +02:00
|
|
|
from types import SimpleNamespace
|
2026-10-11 10:47:49 +02:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from akkudoktoreos.config.config import ConfigEOS
|
|
|
|
|
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
|
|
|
|
from akkudoktoreos.core.coreabc import get_ems
|
|
|
|
|
from akkudoktoreos.optimization.genetic import genetic as genetic_module
|
|
|
|
|
from akkudoktoreos.optimization.genetic.genetic import (
|
|
|
|
|
GeneticOptimization,
|
|
|
|
|
auto_evaluation_workers,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.optimization.genetic.geneticparams import (
|
|
|
|
|
GeneticOptimizationParameters,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
|
|
|
|
|
|
|
|
|
ems_eos = get_ems(init=True) # init once
|
|
|
|
|
|
|
|
|
|
HOURS = 24
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"cores,cpu_limit,expected",
|
|
|
|
|
[
|
|
|
|
|
(1, None, 1),
|
|
|
|
|
(2, None, 1), # one core stays free
|
|
|
|
|
(3, None, 2),
|
|
|
|
|
(4, None, 2),
|
|
|
|
|
(16, None, 2), # at most two
|
|
|
|
|
(4, 1, 1), # container CPU limit
|
|
|
|
|
(4, 2, 1), # a quarter core stays free for the server process
|
|
|
|
|
(4, 2.5, 2),
|
|
|
|
|
(4, 3, 2),
|
|
|
|
|
(2, 4, 1),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_auto_workers_keep_one_core_free(cores, cpu_limit, expected):
|
|
|
|
|
assert auto_evaluation_workers(cores=cores, cpu_limit=cpu_limit) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parameters(
|
|
|
|
|
config_eos: ConfigEOS, workers: int, cache_entries
|
|
|
|
|
) -> GeneticOptimizationParameters:
|
|
|
|
|
config_eos.merge_settings_from_dict(
|
|
|
|
|
{
|
|
|
|
|
"prediction": {"hours": HOURS},
|
|
|
|
|
"optimization": {
|
|
|
|
|
"genetic": {
|
|
|
|
|
"individuals": 30,
|
|
|
|
|
"generations": 10,
|
|
|
|
|
"tail_horizon_hours": 0,
|
|
|
|
|
"horizon_hours": HOURS,
|
|
|
|
|
"interval_sec": 3600,
|
|
|
|
|
"workers": workers,
|
|
|
|
|
"fitness_cache_max_entries": cache_entries,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"feedintariff": {"direct_marketing_enabled": True},
|
|
|
|
|
"devices": {
|
|
|
|
|
"max_batteries": 1,
|
|
|
|
|
"batteries": {
|
|
|
|
|
"battery1": {"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
|
|
|
|
CacheEnergyManagementStore().clear()
|
|
|
|
|
return GeneticOptimizationParameters.model_validate(
|
|
|
|
|
dict(
|
|
|
|
|
ems={
|
|
|
|
|
"pv_prognose_wh": [0.0] * 8 + [3000.0] * 8 + [0.0] * 8,
|
|
|
|
|
"strompreis_euro_pro_wh": [0.0003] * 6 + [0.0001] * 6 + [0.0004] * 12,
|
|
|
|
|
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
|
|
|
|
|
"preis_euro_pro_wh_akku": 0.0,
|
|
|
|
|
"gesamtlast": [400.0] * HOURS,
|
|
|
|
|
},
|
|
|
|
|
pv_battery={
|
|
|
|
|
"device_id": "battery1",
|
|
|
|
|
"capacity_wh": 10000,
|
|
|
|
|
"initial_soc_percentage": 40,
|
|
|
|
|
"min_soc_percentage": 5,
|
|
|
|
|
"max_charge_power_w": 5000,
|
|
|
|
|
},
|
|
|
|
|
inverter={
|
|
|
|
|
"device_id": "inverter1",
|
|
|
|
|
"max_power_wh": 10000,
|
|
|
|
|
"battery_id": "battery1",
|
|
|
|
|
},
|
|
|
|
|
ev=None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run(config_eos: ConfigEOS, workers: int, cache_entries):
|
|
|
|
|
parameters = _parameters(config_eos, workers, cache_entries)
|
|
|
|
|
optimization = GeneticOptimization(fixed_seed=7)
|
|
|
|
|
used: list[int] = []
|
|
|
|
|
original_start = optimization._start_evaluation_pool
|
|
|
|
|
|
|
|
|
|
def start_and_record():
|
|
|
|
|
original_start()
|
|
|
|
|
used.append(
|
|
|
|
|
0 if optimization._evaluation_pool is None else optimization._evaluation_workers
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
optimization._start_evaluation_pool = start_and_record # type: ignore[method-assign]
|
|
|
|
|
solution = optimization.optimize_ems(parameters=parameters, start_hour=0, ngen=6)
|
|
|
|
|
assert optimization._evaluation_pool is None # stopped after the run
|
|
|
|
|
return solution, used
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("cache_entries", [None, 0])
|
|
|
|
|
@pytest.mark.skipif("fork" not in multiprocessing.get_all_start_methods(), reason="requires fork")
|
|
|
|
|
def test_parallel_evaluation_matches_serial(config_eos: ConfigEOS, cache_entries):
|
|
|
|
|
serial, serial_used = _run(config_eos, 1, cache_entries)
|
|
|
|
|
parallel, parallel_used = _run(config_eos, 2, cache_entries)
|
|
|
|
|
|
|
|
|
|
assert serial_used == [0]
|
|
|
|
|
assert parallel_used == [2]
|
|
|
|
|
assert parallel.start_solution == serial.start_solution
|
|
|
|
|
assert parallel.result.total_balance == serial.result.total_balance
|
|
|
|
|
assert parallel.fitness_history == serial.fitness_history
|
|
|
|
|
assert parallel.ac_charge == serial.ac_charge
|
|
|
|
|
assert parallel.dc_charge == serial.dc_charge
|
|
|
|
|
assert parallel.discharge_allowed == serial.discharge_allowed
|
|
|
|
|
assert parallel.battery_grid_export_factor == serial.battery_grid_export_factor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif("fork" not in multiprocessing.get_all_start_methods(), reason="requires fork")
|
|
|
|
|
def test_failed_batch_falls_back_to_serial(config_eos: ConfigEOS, monkeypatch):
|
|
|
|
|
serial, _ = _run(config_eos, 1, None)
|
|
|
|
|
|
|
|
|
|
def broken_worker(genome):
|
|
|
|
|
raise RuntimeError("worker lost")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(genetic_module, "_evaluation_worker", broken_worker)
|
|
|
|
|
fallback, used = _run(config_eos, 2, None)
|
|
|
|
|
|
|
|
|
|
assert used == [2]
|
|
|
|
|
assert fallback.start_solution == serial.start_solution
|
|
|
|
|
assert fallback.ac_charge == serial.ac_charge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unsupported_fork_falls_back_to_serial(config_eos: ConfigEOS, monkeypatch):
|
|
|
|
|
serial, _ = _run(config_eos, 1, None)
|
|
|
|
|
|
|
|
|
|
def unsupported_context(method):
|
|
|
|
|
assert method == "fork"
|
|
|
|
|
raise ValueError("fork is unavailable")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(genetic_module.multiprocessing, "get_context", unsupported_context)
|
|
|
|
|
fallback, used = _run(config_eos, 2, None)
|
|
|
|
|
|
|
|
|
|
assert used == [0]
|
|
|
|
|
assert fallback.start_solution == serial.start_solution
|
|
|
|
|
assert fallback.result.total_balance == serial.result.total_balance
|
|
|
|
|
assert fallback.ac_charge == serial.ac_charge
|
|
|
|
|
assert fallback.dc_charge == serial.dc_charge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cgroup_cpu_limit_reads_own_and_parent_cgroups(tmp_path, monkeypatch):
|
|
|
|
|
from akkudoktoreos.optimization.genetic.genetic import _cgroup_cpu_limit
|
|
|
|
|
|
|
|
|
|
service = tmp_path / "system.slice" / "eos.service"
|
|
|
|
|
service.mkdir(parents=True)
|
|
|
|
|
(tmp_path / "cpu.max").write_text("max 100000\n")
|
|
|
|
|
(tmp_path / "system.slice" / "cpu.max").write_text("max 100000\n")
|
|
|
|
|
(service / "cpu.max").write_text("150000 100000\n")
|
|
|
|
|
|
|
|
|
|
real_open = open
|
|
|
|
|
|
|
|
|
|
def fake_open(path, *args, **kwargs):
|
|
|
|
|
if path == "/proc/self/cgroup":
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
return io.StringIO("0::/system.slice/eos.service\n")
|
|
|
|
|
return real_open(path, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("builtins.open", fake_open)
|
|
|
|
|
assert _cgroup_cpu_limit(str(tmp_path)) == 1.5
|
|
|
|
|
(service / "cpu.max").write_text("max 100000\n")
|
|
|
|
|
assert _cgroup_cpu_limit(str(tmp_path)) is None
|
|
|
|
|
(tmp_path / "cpu.max").write_text("100000 100000\n")
|
|
|
|
|
assert _cgroup_cpu_limit(str(tmp_path)) == 1 # a parent limit counts too
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"workers,allowed,expected",
|
|
|
|
|
[
|
|
|
|
|
(2, {0, 1, 2, 3}, {2, 3}), # core 0/1 stay free for the server and the system
|
|
|
|
|
(3, {0, 1, 2, 3}, {1, 2, 3}),
|
|
|
|
|
(1, {0, 1}, {1}),
|
|
|
|
|
(2, {0, 1}, None), # no spare core -> no pinning
|
|
|
|
|
(2, {4, 5, 6, 7}, {6, 7}), # restricted container cpuset
|
|
|
|
|
(0, {0, 1, 2, 3}, None),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_worker_cpu_set(workers, allowed, expected):
|
|
|
|
|
from akkudoktoreos.optimization.genetic.genetic import worker_cpu_set
|
|
|
|
|
|
|
|
|
|
assert worker_cpu_set(workers, allowed) == expected
|
2026-10-11 11:37:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Fitness:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.values: tuple[float, ...] = ()
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def valid(self):
|
|
|
|
|
return bool(self.values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Individual(list):
|
|
|
|
|
extra_data: tuple[float, float, float]
|
|
|
|
|
|
|
|
|
|
def __init__(self, genes):
|
|
|
|
|
super().__init__(genes)
|
|
|
|
|
self.fitness = _Fitness()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_identical_genomes_of_a_batch_are_evaluated_once_without_cache():
|
|
|
|
|
"""Repeats get the repaired genome and results of the one evaluation."""
|
|
|
|
|
optimization = GeneticOptimization.__new__(GeneticOptimization)
|
|
|
|
|
evaluated: list[list[int]] = []
|
|
|
|
|
|
|
|
|
|
def evaluate(individual):
|
|
|
|
|
evaluated.append(list(individual))
|
|
|
|
|
individual[0] = 9 # an evaluation may repair the genome in place
|
|
|
|
|
individual.extra_data = (float(sum(individual)), 0.0, 0.0)
|
|
|
|
|
return (float(sum(individual)),)
|
|
|
|
|
|
|
|
|
|
optimization.toolbox = SimpleNamespace(map=map, evaluate=evaluate)
|
|
|
|
|
population = [_Individual(genes) for genes in ([1, 2, 3], [4, 5, 6], [1, 2, 3], [7, 8, 9])]
|
|
|
|
|
done = _Individual([0, 0, 0])
|
|
|
|
|
done.fitness.values = (1.0,)
|
|
|
|
|
|
|
|
|
|
assert optimization._evaluate_invalid(population + [done]) == 4
|
|
|
|
|
|
|
|
|
|
assert evaluated == [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
|
|
|
|
|
assert optimization._duplicate_evaluations_skipped == 1
|
|
|
|
|
assert population[2] == population[0] == [9, 2, 3]
|
|
|
|
|
assert population[2].fitness.values == population[0].fitness.values == (14.0,)
|
|
|
|
|
assert population[2].extra_data == population[0].extra_data
|
|
|
|
|
assert population[1].fitness.values == (20.0,)
|
|
|
|
|
assert done == [0, 0, 0] and done.fitness.values == (1.0,)
|