Files
EOS/tests/test_geneticoptimize.py
T

489 lines
18 KiB
Python
Raw Normal View History

2025-10-28 02:50:31 +01:00
import json
2026-07-16 09:56:50 +02:00
from datetime import datetime
2025-10-28 02:50:31 +01:00
from pathlib import Path
from typing import Any, Optional
2025-10-28 02:50:31 +01:00
from unittest.mock import patch
import pytest
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems
2025-10-28 02:50:31 +01:00
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.visualize import (
prepare_visualize, # Import the new prepare_visualize
)
ems_eos = get_ems(init=True) # init once
2025-10-28 02:50:31 +01:00
DIR_TESTDATA = Path(__file__).parent / "testdata"
def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
assert set(actual) == set(expected)
for key, value in expected.items():
if isinstance(value, dict):
assert isinstance(actual[key], dict)
compare_dict(actual[key], value)
elif isinstance(value, list):
assert isinstance(actual[key], list)
2026-07-16 09:56:50 +02:00
if value and isinstance(value[0], datetime):
assert actual[key] == value
else:
assert actual[key] == pytest.approx(value)
2025-10-28 02:50:31 +01:00
else:
assert actual[key] == pytest.approx(value)
def test_direct_marketing_uses_market_price_as_feed_in_tariff(config_eos: ConfigEOS):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0, 0.0],
"strompreis_euro_pro_wh": [0.0002, -0.0001],
"einspeiseverguetung_euro_pro_wh": [0.00007, 0.00007],
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [0.0, 0.0],
},
pv_akku=None,
# Without an inverter the simulation books no grid energy at all, so the
# price signal would never reach the fitness.
inverter={"device_id": "inverter1", "max_power_wh": 20000},
eauto=None,
)
adjusted = GeneticOptimization()._parameters_for_config(parameters)
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0002, -0.0001]
assert parameters.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007]
def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
config_eos.merge_settings_from_dict(
{"feedintariff": {"direct_marketing_enabled": True}}
)
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0, 0.0],
"strompreis_euro_pro_wh": [0.0002, 0.0003],
"einspeiseverguetung_euro_pro_wh": [0.0001, -0.00005],
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [0.0, 0.0],
},
pv_akku=None,
inverter=None,
eauto=None,
)
adjusted = GeneticOptimization()._parameters_for_config(parameters)
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS):
"""Configured export rates end up as per-slot export levels in the solution."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 24},
"optimization": {"tail_horizon_hours": 0,
"horizon_hours": 24,
"interval": 3600,
"genetic": {"individuals": 40, "generations": 10},
},
"feedintariff": {"direct_marketing_enabled": True},
"devices": {
"max_batteries": 1,
"batteries": [{"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}],
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
hours = 24
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
"strompreis_euro_pro_wh": [0.0003] * hours,
# A pronounced tariff peak makes exporting worthwhile at all.
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [200.0] * hours,
},
pv_akku={
"device_id": "battery1",
"capacity_wh": 10000,
"initial_soc_percentage": 100,
"min_soc_percentage": 0,
"max_charge_power_w": 5000,
},
inverter={
"device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1",
},
eauto=None,
)
optimization = GeneticOptimization(fixed_seed=42)
solution = optimization.optimierung_ems(parameters=parameters, start_hour=0, ngen=3)
# Full power first, so the full-power state keeps the lowest export index.
assert optimization.bat_possible_grid_export_values == [1.0, 0.5]
assert len(solution.battery_grid_export_factor) == len(solution.battery_grid_export_allowed)
assert set(solution.battery_grid_export_factor) <= {0.0, 0.5, 1.0}
assert [
1 if factor > 0.0 else 0 for factor in solution.battery_grid_export_factor
] == solution.battery_grid_export_allowed
2025-10-28 02:50:31 +01:00
@pytest.mark.parametrize(
"fn_in, fn_out, ngen, break_even",
2025-10-28 02:50:31 +01:00
[
("optimize_input_1.json", "optimize_result_1.json", 3, 0),
("optimize_input_2.json", "optimize_result_2.json", 3, 0),
("optimize_input_2.json", "optimize_result_2_full.json", 400, 0),
("optimize_input_1.json", "optimize_result_1_be.json", 3, 1),
("optimize_input_2.json", "optimize_result_2_be.json", 3, 1),
2025-10-28 02:50:31 +01:00
],
)
def test_optimize(
fn_in: str,
fn_out: str,
ngen: int,
break_even: int,
2025-10-28 02:50:31 +01:00
config_eos: ConfigEOS,
is_finalize: bool,
2025-10-28 02:50:31 +01:00
):
"""Test optimierung_ems."""
# Test parameters
fixed_start_hour = 10
fixed_seed = 42
# Assure configuration holds the correct values
config_eos.merge_settings_from_dict(
{
"prediction": {
"hours": 48
},
"optimization": {"tail_horizon_hours": 0,
"horizon_hours": 38,
2025-10-28 02:50:31 +01:00
"genetic": {
"individuals": 300,
"generations": 10,
"penalties": {
"ev_soc_miss": 10,
"ac_charge_break_even": break_even,
2025-10-28 02:50:31 +01:00
}
}
},
"devices": {
"max_electric_vehicles": 1,
"electric_vehicles": [
{
"charge_rates": [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0],
}
],
}
}
)
# Load input and output data
file = DIR_TESTDATA / fn_in
with file.open("r") as f_in:
input_data = GeneticOptimizationParameters(**json.load(f_in))
file = DIR_TESTDATA / fn_out
# In case a new test case is added, we don't want to fail here, so the new output is written
# to disk before
try:
with file.open("r") as f_out:
expected_data = json.load(f_out)
expected_result = GeneticSolution(**expected_data)
except FileNotFoundError:
pass
# Fake energy management run start datetime
2026-07-16 09:56:50 +02:00
ems_eos.set_start_datetime(to_datetime("2025-01-15T10:00:00+01:00"))
2025-10-28 02:50:31 +01:00
# Throw away any cached results of the last energy management run.
CacheEnergyManagementStore().clear()
genetic_optimization = GeneticOptimization(fixed_seed=fixed_seed)
# Activate with pytest --finalize
if ngen > 10 and not is_finalize:
2025-10-28 02:50:31 +01:00
pytest.skip()
visualize_filename = str((DIR_TESTDATA / f"new_{fn_out}").with_suffix(".pdf"))
with patch(
"akkudoktoreos.utils.visualize.prepare_visualize",
side_effect=lambda parameters, results, *args, **kwargs: prepare_visualize(
parameters, results, filename=visualize_filename, **kwargs
),
) as prepare_visualize_patch:
# Call the optimization function
genetic_solution = genetic_optimization.optimierung_ems(
parameters=input_data, start_hour=fixed_start_hour, ngen=ngen
)
# The function creates a visualization result PDF as a side-effect.
prepare_visualize_patch.assert_called_once()
assert Path(visualize_filename).exists()
# Write test output to file, so we can take it as new data on intended change
TESTDATA_FILE = DIR_TESTDATA / f"new_{fn_out}"
with TESTDATA_FILE.open("w", encoding="utf-8", newline="\n") as f_out:
f_out.write(genetic_solution.model_dump_json(indent=4, exclude_unset=True))
# The old snapshot included midnight-prefix genes and a prediction-sized
# genome. Check the new run-relative contract and accounting instead.
assert len(genetic_solution.ac_charge) == 38
assert len(genetic_solution.result.Kosten_Euro_pro_Stunde) == 38
2025-10-28 02:50:31 +01:00
assert genetic_solution.result.Gesamtbilanz_Euro == pytest.approx(
genetic_solution.result.Gesamtkosten_Euro - genetic_solution.result.Gesamteinnahmen_Euro
2025-10-28 02:50:31 +01:00
)
# Check the correct generic optimization solution is created
optimization_solution = genetic_solution.optimization_solution()
# @TODO
# Check the correct generic energy management plan is created
plan = genetic_solution.energy_management_plan()
# @TODO
def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParameters:
"""Optimization parameters with an EV that has to be charged."""
return GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
# Expensive for the first six hours, dirt cheap afterwards: without a
# deadline the optimizer would always wait for the cheap slots.
"strompreis_euro_pro_wh": [0.0009] * 6 + [0.00001] * (hours - 6),
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [300.0] * hours,
},
pv_akku=None,
inverter=None,
eauto={
"device_id": "ev1",
"capacity_wh": 60000,
"charging_efficiency": 0.95,
"max_charge_power_w": 11040,
"initial_soc_percentage": 20,
"min_soc_percentage": 60,
**ev_extra,
},
)
def test_ev_deadline_slot_resolution(config_eos: ConfigEOS):
"""Datetime and maximum duration resolve to a slot; the earlier one wins."""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600}}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
optimization = GeneticOptimization(fixed_seed=1)
optimization._slot0_datetime = optimization.ems.start_datetime
slot0 = optimization._slot0_datetime
# Duration only: 6 h after the start hour 10.
parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6)
assert optimization._ev_deadline_slot(parameters) == 6
# Datetime only.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14))
assert optimization._ev_deadline_slot(parameters) == 14
# Both: the earlier one wins.
parameters = _ev_deadline_parameters(
48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6
)
assert optimization._ev_deadline_slot(parameters) == 6
# Beyond the horizon: no deadline, the end-of-horizon target already covers it.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100))
assert optimization._ev_deadline_slot(parameters) is None
# In the past: due right now.
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.subtract(hours=2))
assert optimization._ev_deadline_slot(parameters) == 0
# No deadline at all.
assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None
def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS):
"""With a deadline the penalty checks the SoC at that slot, not at the end."""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"tail_horizon_hours": 0, "horizon_hours": 48, "interval": 3600}}
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
optimization = GeneticOptimization(fixed_seed=1)
simulation_result = {"EAuto_SoC_pro_Stunde": [20.0, 35.0, 50.0, 80.0]}
class _Ev:
def current_soc_percentage(self):
return 80.0
optimization.simulation.ev = _Ev()
# Without a deadline the final SoC counts.
optimization._ev_soc_deadline_slot = None
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
# With one, the SoC at the beginning of the deadline slot counts.
optimization._ev_soc_deadline_slot = 12
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 50.0
# A deadline beyond the reported slots falls back to the final SoC.
optimization._ev_soc_deadline_slot = 99
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
"""The EV reaches its target before the deadline even when energy is cheaper later."""
hours = 24
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": hours},
"optimization": {"tail_horizon_hours": 0,
"horizon_hours": hours,
"interval": 3600,
"genetic": {"individuals": 100, "generations": 40},
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
parameters = _ev_deadline_parameters(hours, min_soc_max_duration_h=6)
solution = GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=0, ngen=40
)
soc_per_hour = solution.result.EAuto_SoC_pro_Stunde
# Slot 6 is the first slot at or after the deadline, so its start-of-slot SoC
# is what the target is checked against.
assert soc_per_hour[6] >= 60.0
def _terminal_value_run(
config_eos: ConfigEOS, mode: str, prices: Optional[list[float]] = None
) -> GeneticSolution:
"""48 h with expensive energy and two dirt-cheap slots at the very end.
Charging in those last slots only pays off when the stored energy keeps a
value beyond the horizon.
"""
hours = 48
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": hours},
"optimization": {"tail_horizon_hours": 0,
"horizon_hours": hours,
"interval": 3600,
"terminal_value_mode": mode,
"terminal_value_euro_per_kwh": 0.0,
"genetic": {"individuals": 80, "generations": 20},
},
}
)
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
CacheEnergyManagementStore().clear()
if prices is None:
prices = [0.0004] * (hours - 2) + [0.00002] * 2
parameters = GeneticOptimizationParameters(
ems={
"pv_prognose_wh": [0.0] * hours,
"strompreis_euro_pro_wh": prices,
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
"preis_euro_pro_wh_akku": 0.0,
"gesamtlast": [200.0] * hours,
},
pv_akku={
"device_id": "battery1",
"capacity_wh": 10000,
"initial_soc_percentage": 20,
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"charging_efficiency": 1.0,
"discharging_efficiency": 1.0,
"max_charge_power_w": 5000,
},
inverter={
"device_id": "inverter1",
"max_power_wh": 10000,
"battery_id": "battery1",
"ac_to_dc_efficiency": 1.0,
"dc_to_ac_efficiency": 1.0,
"max_ac_charge_power_w": 5000,
},
eauto=None,
)
return GeneticOptimization(fixed_seed=7).optimierung_ems(
parameters=parameters, start_hour=0, ngen=20
)
def test_terminal_value_auto_keeps_energy_that_fixed_zero_throws_away(config_eos: ConfigEOS):
"""AUTO values the energy left in the battery, a fixed zero does not."""
auto = _terminal_value_run(config_eos, "AUTO")
fixed = _terminal_value_run(config_eos, "FIXED")
assert auto.terminal_value is not None
assert auto.terminal_value.mode == "AUTO"
assert auto.terminal_value.curve is not None
assert auto.terminal_value.credited_euro > 0.0
assert fixed.terminal_value is not None
assert fixed.terminal_value.mode == "FIXED"
assert fixed.terminal_value.credited_euro == 0.0
# The cheap slots at the end are only worth using with a terminal value.
assert auto.result.akku_soc_pro_stunde[-1] > fixed.result.akku_soc_pro_stunde[-1]
def test_terminal_value_curve_is_concave_and_reported(config_eos: ConfigEOS):
"""The reported curve is what the credit was read from."""
solution = _terminal_value_run(config_eos, "AUTO")
curve = solution.terminal_value.curve
assert curve.window_slots == 24
assert len(curve.energy_wh) == len(curve.value_euro)
assert len(curve.marginal_euro_per_kwh) == len(curve.energy_wh) - 1
marginals = curve.marginal_euro_per_kwh
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
# The credit is the curve evaluated at the energy left in the battery.
expected = curve.value(solution.terminal_value.battery_energy_wh)
assert solution.terminal_value.credited_euro == pytest.approx(expected)
def test_terminal_value_reports_why_it_fell_back_to_fixed(config_eos: ConfigEOS):
"""AUTO without any prices cannot build a curve - and has to say so.
A request whose price forecast is all zeros used to be indistinguishable
from a run configured for FIXED.
"""
hours = 48
solution = _terminal_value_run(config_eos, "AUTO", prices=[0.0] * hours)
assert solution.terminal_value.mode == "FIXED"
assert solution.terminal_value.curve is None
assert "no priced residual load" in solution.terminal_value.reason
configured = _terminal_value_run(config_eos, "FIXED")
assert configured.terminal_value.reason == "terminal_value_mode is FIXED"