mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
418 lines
16 KiB
Python
418 lines
16 KiB
Python
"""Real small optimizer runs covering device contracts across the public result."""
|
|||
|
|
|
||
|
|
from collections.abc import AsyncGenerator, Callable
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
|
||
|
|
from akkudoktoreos.config.config import ConfigEOS
|
||
|
|
from akkudoktoreos.core.coreabc import get_ems, get_measurement
|
||
|
|
from akkudoktoreos.core.emplan import DDBCInstruction
|
||
|
|
from akkudoktoreos.measurement.measurement import Measurement
|
||
|
|
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||
|
|
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 DateTime, to_datetime
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True, params=["UTC", "Europe/Berlin"])
|
||
|
|
def local_clock(request: pytest.FixtureRequest, set_other_timezone: Callable[[str], str]) -> None:
|
||
|
|
"""Run the same local-wall-clock schedules in UTC and a DST-observing zone.
|
||
|
|
|
||
|
|
Scenario dates intentionally have no fixed offset: each names local midnight,
|
||
|
|
a local time window or a local departure in the selected timezone.
|
||
|
|
"""
|
||
|
|
set_other_timezone(request.param)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def isolated_measurement(config_eos: ConfigEOS) -> AsyncGenerator[Measurement, None]:
|
||
|
|
"""Keep synthetic records out of the process-wide measurement singleton."""
|
||
|
|
measurement = get_measurement()
|
||
|
|
await measurement.delete_by_datetime(None, None)
|
||
|
|
try:
|
||
|
|
yield measurement
|
||
|
|
finally:
|
||
|
|
await measurement.delete_by_datetime(None, None)
|
||
|
|
|
||
|
|
|
||
|
|
def configure(
|
||
|
|
config: ConfigEOS,
|
||
|
|
*,
|
||
|
|
hours: int = 4,
|
||
|
|
start: str = "2026-09-16T00:00:00",
|
||
|
|
marketing: bool = False,
|
||
|
|
) -> None:
|
||
|
|
config.merge_settings_from_dict(
|
||
|
|
{
|
||
|
|
"prediction": {"hours": max(48, hours)},
|
||
|
|
"optimization": {
|
||
|
|
"algorithm": "GENETIC",
|
||
|
|
"genetic": {
|
||
|
|
"horizon_hours": hours,
|
||
|
|
"tail_horizon_hours": 0,
|
||
|
|
"interval_sec": 900,
|
||
|
|
"individuals": 12,
|
||
|
|
"generations": 10,
|
||
|
|
"terminal_value_mode": "FIXED",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
"feedintariff": {"direct_marketing_enabled": marketing},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
get_ems(init=True).set_start_datetime(to_datetime(start))
|
||
|
|
|
||
|
|
|
||
|
|
def parameters(
|
||
|
|
*, hours: int = 4, consumers: list[dict[str, Any]] | None = None, **kwargs: Any
|
||
|
|
) -> GeneticOptimizationParameters:
|
||
|
|
slots = hours * 4 + get_ems().start_datetime.hour * 4 + get_ems().start_datetime.minute // 15
|
||
|
|
return GeneticOptimizationParameters.model_validate(
|
||
|
|
{
|
||
|
|
"ems": {
|
||
|
|
"pv_prognose_wh": [0.0] * slots,
|
||
|
|
"gesamtlast": [0.0] * slots,
|
||
|
|
"strompreis_euro_pro_wh": [0.0003] * slots,
|
||
|
|
"einspeiseverguetung_euro_pro_wh": [0.0001] * slots,
|
||
|
|
"preis_euro_pro_wh_akku": 0.0,
|
||
|
|
},
|
||
|
|
"inverter": {"device_id": "inv", "max_power_wh": 10000},
|
||
|
|
"forecast_interval_seconds": 900,
|
||
|
|
"home_appliances": consumers,
|
||
|
|
"pv_battery": None,
|
||
|
|
"ev": None,
|
||
|
|
**kwargs,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def run(params: GeneticOptimizationParameters) -> tuple[GeneticOptimization, GeneticSolution]:
|
||
|
|
optimizer = GeneticOptimization(fixed_seed=42)
|
||
|
|
solution = optimizer.optimize_ems(params, ngen=1, individuals=12)
|
||
|
|
return optimizer, solution
|
||
|
|
|
||
|
|
|
||
|
|
def run_local_starts(solution: GeneticSolution) -> list[DateTime]:
|
||
|
|
"""Compare scheduled instants in the run zone, independently of output zone."""
|
||
|
|
timezone = get_ems().start_datetime.timezone_name
|
||
|
|
assert timezone is not None
|
||
|
|
return [moment.in_timezone(timezone) for moment in solution.appliance_starts["washer"]]
|
||
|
|
|
||
|
|
|
||
|
|
def profile(**kwargs: Any) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"device_id": "washer",
|
||
|
|
"load_profile_power_w": [1200.0, 600.0, 300.0],
|
||
|
|
"load_profile_interval_seconds": 600,
|
||
|
|
**kwargs,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_optimizer_keeps_reverse_cycle_window_identity(config_eos: ConfigEOS) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumer = profile(
|
||
|
|
num_cycles=2,
|
||
|
|
time_windows={
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "02:00", "duration": "30 minutes", "value": 0},
|
||
|
|
{"start_time": "01:00", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
_, solution = run(parameters(consumers=[consumer]))
|
||
|
|
assert [value.hour for value in run_local_starts(solution)] == [1, 2]
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||
|
|
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(700.0)
|
||
|
|
assert solution.result.total_costs == pytest.approx(0.21)
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_daily_optimizer_skips_completed_cycles_only_on_first_day(
|
||
|
|
config_eos: ConfigEOS,
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos, hours=28)
|
||
|
|
consumer = profile(
|
||
|
|
num_cycles=2,
|
||
|
|
completed_cycles=1,
|
||
|
|
schedule_mode="DAILY",
|
||
|
|
time_windows={
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||
|
|
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
_, solution = run(parameters(hours=28, consumers=[consumer]))
|
||
|
|
assert [(value.day, value.hour) for value in run_local_starts(solution)] == [
|
||
|
|
(16, 2),
|
||
|
|
(17, 1),
|
||
|
|
(17, 2),
|
||
|
|
]
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(1050.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_best_effort_multicycle_prioritizes_delay_over_cheaper_prices(
|
||
|
|
config_eos: ConfigEOS,
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumer = profile(num_cycles=2, min_cycle_gap_h=1, deadline_datetime="2026-09-15T23:00:00")
|
||
|
|
params = parameters(consumers=[consumer])
|
||
|
|
params.ems.electricity_price_per_wh = [0.001] * 8 + [-0.001] * 8
|
||
|
|
_, solution = run(params)
|
||
|
|
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||
|
|
(0, 0),
|
||
|
|
(1, 30),
|
||
|
|
]
|
||
|
|
assert solution.appliance_deadline_missed["washer"]
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(700.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_mixed_best_effort_cycle_status_survives_later_strict_cycle(
|
||
|
|
config_eos: ConfigEOS,
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumer = profile(
|
||
|
|
num_cycles=2,
|
||
|
|
deadline_datetime="2026-09-16T01:00:00",
|
||
|
|
time_windows={
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "02:00", "duration": "2 hours", "value": 0},
|
||
|
|
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
params = parameters(consumers=[consumer])
|
||
|
|
params.ems.electricity_price_per_wh = [0.001] * 12 + [-0.001] * 4
|
||
|
|
_, solution = run(params)
|
||
|
|
assert [(value.hour, value.minute) for value in run_local_starts(solution)] == [
|
||
|
|
(0, 0),
|
||
|
|
(2, 0),
|
||
|
|
]
|
||
|
|
assert solution.appliance_deadline_missed["washer"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_warm_start_handles_changed_completed_cycle_layout(config_eos: ConfigEOS) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumer = profile(
|
||
|
|
num_cycles=2,
|
||
|
|
time_windows={
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||
|
|
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
optimizer, first = run(parameters(consumers=[consumer]))
|
||
|
|
consumer["completed_cycles"] = 1
|
||
|
|
followup = parameters(
|
||
|
|
consumers=[consumer],
|
||
|
|
start_solution=first.start_solution,
|
||
|
|
start_solution_datetime=first.start_solution_datetime,
|
||
|
|
)
|
||
|
|
second = optimizer.optimize_ems(followup, ngen=1, individuals=12)
|
||
|
|
assert len(second.appliance_starts["washer"]) == 1
|
||
|
|
assert run_local_starts(second)[0].hour == 2
|
||
|
|
assert sum(second.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||
|
|
assert first.start_solution is not None and second.start_solution is not None
|
||
|
|
assert len(second.start_solution) == len(first.start_solution) - 1
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("deadline", ["2026-09-15T23:00:00", "2026-09-16T00:01:00"])
|
||
|
|
def test_real_ev_does_not_credit_energy_delivered_after_departure(
|
||
|
|
config_eos: ConfigEOS, deadline: str
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
params = parameters(
|
||
|
|
ev={
|
||
|
|
"device_id": "car",
|
||
|
|
"capacity_wh": 4000,
|
||
|
|
"initial_soc_percentage": 0,
|
||
|
|
"min_soc_percentage": 25,
|
||
|
|
"charging_efficiency": 1.0,
|
||
|
|
"max_charge_power_w": 4000,
|
||
|
|
"charge_rates": [0.0, 1.0],
|
||
|
|
"min_soc_deadline_datetime": deadline,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
optimizer, solution = run(params)
|
||
|
|
assert optimizer._ev_soc_deadline_slot == 0
|
||
|
|
assert (
|
||
|
|
optimizer._ev_soc_at_deadline({"EAuto_SoC_pro_Stunde": solution.result.ev_soc_per_hour}, 0)
|
||
|
|
== 0.0
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_ev_target_across_midnight_uses_elapsed_slots(config_eos: ConfigEOS) -> None:
|
||
|
|
configure(config_eos, start="2026-09-16T23:30:00")
|
||
|
|
params = parameters(
|
||
|
|
ev={
|
||
|
|
"device_id": "car",
|
||
|
|
"capacity_wh": 4000,
|
||
|
|
"initial_soc_percentage": 0,
|
||
|
|
"min_soc_percentage": 50,
|
||
|
|
"charging_efficiency": 1.0,
|
||
|
|
"max_charge_power_w": 4000,
|
||
|
|
"charge_rates": [0.0, 1.0],
|
||
|
|
"min_soc_deadline_datetime": "2026-09-17T00:00:00",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
params.ems.electricity_price_per_wh[-16:] = [0.001] * 2 + [0.00001] * 14
|
||
|
|
optimizer, solution = run(params)
|
||
|
|
assert optimizer._ev_soc_deadline_slot == 2
|
||
|
|
assert solution.result.ev_soc_per_hour[2] >= 50
|
||
|
|
assert solution.ev_charge_hours_float is not None
|
||
|
|
assert solution.ev_charge_hours_float[:2] == [1.0, 1.0]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"marketing,lcos,export_expected",
|
||
|
|
[(False, 0.0, False), (True, 0.0, True), (True, 0.2, True), (True, 2.0, False)],
|
||
|
|
)
|
||
|
|
def test_real_export_respects_marketing_gate_and_storage_cost(
|
||
|
|
config_eos: ConfigEOS, marketing: bool, lcos: float, export_expected: bool
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos, marketing=marketing)
|
||
|
|
params = parameters(
|
||
|
|
pv_battery={
|
||
|
|
"device_id": "battery",
|
||
|
|
"capacity_wh": 1000,
|
||
|
|
"initial_soc_percentage": 100,
|
||
|
|
"charging_efficiency": 1.0,
|
||
|
|
"discharging_efficiency": 1.0,
|
||
|
|
"min_soc_percentage": 0,
|
||
|
|
"max_charge_power_w": 4000,
|
||
|
|
"grid_export_rates": [0.5, 1.0],
|
||
|
|
"levelized_cost_of_storage_kwh": lcos,
|
||
|
|
},
|
||
|
|
inverter={"device_id": "inv", "max_power_wh": 10000, "battery_id": "battery"},
|
||
|
|
)
|
||
|
|
params.ems.feed_in_tariff_per_wh = [0.00001] + [0.001] * 4 + [0.00001] * 11
|
||
|
|
_, solution = run(params)
|
||
|
|
exported = sum(solution.result.grid_feed_in_wh_per_hour)
|
||
|
|
if export_expected:
|
||
|
|
assert exported == pytest.approx(1000.0)
|
||
|
|
assert solution.result.total_revenue == pytest.approx(1.0)
|
||
|
|
assert solution.result.total_costs == pytest.approx(lcos)
|
||
|
|
assert any(solution.battery_grid_export_allowed)
|
||
|
|
else:
|
||
|
|
assert exported == pytest.approx(0.0)
|
||
|
|
assert not any(solution.battery_grid_export_allowed)
|
||
|
|
assert np.isfinite(solution.result.total_balance)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@pytest.mark.parametrize("custom_key", [None, "washer.completed_today"])
|
||
|
|
async def test_real_measurement_completed_cycles_reach_request_and_optimizer(
|
||
|
|
config_eos: ConfigEOS, custom_key: str | None, isolated_measurement: Measurement
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
config_eos.merge_settings_from_dict(
|
||
|
|
{
|
||
|
|
"devices": {
|
||
|
|
"max_batteries": 0,
|
||
|
|
"batteries": {},
|
||
|
|
"max_electric_vehicles": 0,
|
||
|
|
"electric_vehicles": {},
|
||
|
|
"max_inverters": 1,
|
||
|
|
"inverters": {"inv": {"max_power_w": 10000}},
|
||
|
|
"max_home_appliances": 1,
|
||
|
|
"home_appliances": {
|
||
|
|
"washer": profile(
|
||
|
|
num_cycles=2,
|
||
|
|
cycles_completed_measurement_key=custom_key,
|
||
|
|
cycle_time_windows={
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||
|
|
{"start_time": "02:00", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
key = custom_key or "washer.cycles_completed"
|
||
|
|
assert key in config_eos.devices.measurement_keys
|
||
|
|
measurement = isolated_measurement
|
||
|
|
zero = get_ems().start_datetime
|
||
|
|
await measurement.update_value(zero.subtract(days=1), key, 2.0)
|
||
|
|
await measurement.update_value(zero, key, 1.0)
|
||
|
|
await measurement.update_value(zero.add(seconds=1), key, 2.0)
|
||
|
|
dates, counts = await measurement.key_to_lists(
|
||
|
|
key=key, start_datetime=zero, end_datetime=zero.add(seconds=1)
|
||
|
|
)
|
||
|
|
assert len(dates) == 1 and counts == [1.0]
|
||
|
|
prepared = await ConfigOptimizationRequest.model_validate(
|
||
|
|
{
|
||
|
|
"forecasts": {
|
||
|
|
"pv_forecast_wh": [0.0] * 16,
|
||
|
|
"total_load": [0.0] * 16,
|
||
|
|
"electricity_price_per_wh": [0.0003] * 16,
|
||
|
|
"feed_in_tariff_per_wh": [0.0001] * 16,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
).resolve()
|
||
|
|
assert prepared.home_appliances is not None
|
||
|
|
assert prepared.home_appliances[0].completed_cycles == 1
|
||
|
|
_, solution = run(prepared)
|
||
|
|
assert [start.hour for start in run_local_starts(solution)] == [2]
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_real_multiple_consumers_keep_separate_solution_channels(
|
||
|
|
config_eos: ConfigEOS,
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumers = [
|
||
|
|
profile(
|
||
|
|
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "30 minutes"}]}
|
||
|
|
),
|
||
|
|
profile(
|
||
|
|
device_id="dryer",
|
||
|
|
load_profile_power_w=[2000.0],
|
||
|
|
load_profile_interval_seconds=900,
|
||
|
|
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "15 minutes"}]},
|
||
|
|
),
|
||
|
|
]
|
||
|
|
_, solution = run(parameters(consumers=consumers))
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == pytest.approx(350.0)
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["dryer"]) == pytest.approx(500.0)
|
||
|
|
assert sum(solution.result.grid_consumption_wh_per_hour) == pytest.approx(850.0)
|
||
|
|
exported = await solution.optimization_solution()
|
||
|
|
table = exported.solution.to_dataframe()
|
||
|
|
assert table["washer_energy_wh"].sum() == pytest.approx(350.0)
|
||
|
|
assert table["dryer_energy_wh"].sum() == pytest.approx(500.0)
|
||
|
|
assert table.index[4].hour == 1
|
||
|
|
assert table["washer_run_op_mode"].tolist()[4:6] == [1.0, 1.0]
|
||
|
|
assert table["dryer_run_op_mode"].tolist()[4:6] == [1.0, 0.0]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_profile_zero_power_phase_keeps_device_running_until_complete(
|
||
|
|
config_eos: ConfigEOS,
|
||
|
|
) -> None:
|
||
|
|
configure(config_eos)
|
||
|
|
consumer = profile(
|
||
|
|
load_profile_power_w=[1200.0, 0.0, 1200.0],
|
||
|
|
load_profile_interval_seconds=900,
|
||
|
|
shared_time_windows={"windows": [{"start_time": "01:00", "duration": "45 minutes"}]},
|
||
|
|
)
|
||
|
|
_, solution = run(parameters(consumers=[consumer]))
|
||
|
|
exported = await solution.optimization_solution()
|
||
|
|
table = exported.solution.to_dataframe()
|
||
|
|
assert table["washer_energy_wh"].tolist()[4:7] == [300.0, 0.0, 300.0]
|
||
|
|
assert table["washer_run_op_mode"].tolist()[4:7] == [1.0, 1.0, 1.0]
|
||
|
|
plan = solution.energy_management_plan()
|
||
|
|
assert {item.resource_id for item in plan.instructions} == {"washer"}
|
||
|
|
commands = [
|
||
|
|
(item.execution_time.hour, item.execution_time.minute, str(item.operation_mode_id))
|
||
|
|
for item in plan.instructions
|
||
|
|
if isinstance(item, DDBCInstruction)
|
||
|
|
]
|
||
|
|
assert commands == [(0, 0, "OFF"), (1, 0, "RUN"), (1, 45, "OFF")]
|