mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-13 09:31:56 +00:00
206 lines
7.3 KiB
Python
206 lines
7.3 KiB
Python
"""Parallel fitness evaluation gives the same result as evaluating in one process."""
|
|||
|
|
|
||
|
|
import multiprocessing
|
||
|
|
|
||
|
|
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
|