2026-09-17 19:14:01 +02:00
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-10-11 11:37:43 +02:00
|
|
|
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
2026-09-17 19:14:01 +02:00
|
|
|
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_quarter_hour_energy_is_converted_back_to_same_mean_power():
|
|
|
|
|
"""Splitting hourly energy must not change the minute-load probability lookup."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
hourly_load_wh = 800.0
|
|
|
|
|
hourly_pv_wh = 1200.0
|
|
|
|
|
slot_duration_h = 0.25
|
|
|
|
|
|
|
|
|
|
hourly = interpolator.calculate_expected_direct_consumption(hourly_load_wh, hourly_pv_wh)
|
|
|
|
|
quarter_hour = interpolator.calculate_expected_direct_consumption(
|
|
|
|
|
(hourly_load_wh / 4) / slot_duration_h,
|
|
|
|
|
(hourly_pv_wh / 4) / slot_duration_h,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert quarter_hour == pytest.approx(hourly)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_above_probability_grid_uses_highest_supported_distribution():
|
|
|
|
|
"""Out-of-range household load must not make self-consumption jump to zero."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
|
|
|
|
|
at_boundary = interpolator.calculate_self_consumption(3450.0, 5000.0)
|
|
|
|
|
above_boundary = interpolator.calculate_self_consumption(4000.0, 5000.0)
|
|
|
|
|
|
|
|
|
|
assert above_boundary == pytest.approx(at_boundary)
|
|
|
|
|
assert above_boundary > 0.99
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expected_direct_consumption_accounts_for_subhourly_load_variation():
|
|
|
|
|
"""Expected overlap must be below the optimistic overlap of interval means."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
|
|
|
|
|
direct_power_w = interpolator.calculate_expected_direct_consumption(800.0, 1200.0)
|
|
|
|
|
|
|
|
|
|
assert direct_power_w == pytest.approx(621.0, abs=2.0)
|
|
|
|
|
assert 0.0 < direct_power_w < 800.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("mean_load_power_w", "pv_power_w"),
|
|
|
|
|
[(800.0, 1200.0), (1000.0, 500.0), (1500.0, 1500.0)],
|
|
|
|
|
)
|
|
|
|
|
def test_expected_direct_consumption_produces_conservative_energy_balance(
|
|
|
|
|
mean_load_power_w, pv_power_w
|
|
|
|
|
):
|
|
|
|
|
"""Direct use, residual load and surplus must conserve both mean powers."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
|
|
|
|
|
direct_power_w = interpolator.calculate_expected_direct_consumption(
|
|
|
|
|
mean_load_power_w, pv_power_w
|
|
|
|
|
)
|
|
|
|
|
residual_load_w = mean_load_power_w - direct_power_w
|
|
|
|
|
pv_surplus_w = pv_power_w - direct_power_w
|
|
|
|
|
|
|
|
|
|
assert 0.0 <= direct_power_w <= min(mean_load_power_w, pv_power_w)
|
|
|
|
|
assert direct_power_w + residual_load_w == pytest.approx(mean_load_power_w)
|
|
|
|
|
assert direct_power_w + pv_surplus_w == pytest.approx(pv_power_w)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expected_direct_consumption_preserves_forecast_mean_at_high_pv():
|
|
|
|
|
"""A PV level above every normalized load bin covers the complete mean load."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
|
|
|
|
|
direct_power_w = interpolator.calculate_expected_direct_consumption(3000.0, 10000.0)
|
|
|
|
|
|
|
|
|
|
assert direct_power_w == pytest.approx(3000.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("load,pv", [(4000.0, 5000.0), (10000.0, 20000.0), (0.0, 0.0)])
|
|
|
|
|
def test_genetic_interpolator_boundaries_are_finite_and_physical(load, pv):
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
fraction = interpolator.calculate_self_consumption(load, pv)
|
|
|
|
|
direct = interpolator.calculate_expected_direct_consumption(load, pv)
|
|
|
|
|
assert np.isfinite(fraction)
|
|
|
|
|
assert 0.0 <= fraction <= 1.0
|
|
|
|
|
assert np.isfinite(direct)
|
|
|
|
|
assert 0.0 <= direct <= min(load, pv)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_genetic0_inverter_keeps_its_independent_interpolator():
|
|
|
|
|
from akkudoktoreos.devices.genetic0.genetic0inverter import (
|
|
|
|
|
Genetic0Inverter,
|
|
|
|
|
Genetic0InverterParameters,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.optimization.genetic0.genetic0loadinterpolator import (
|
|
|
|
|
get_genetic0_load_interpolator,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
inverter = Genetic0Inverter(Genetic0InverterParameters(device_id="legacy", max_power_wh=10000))
|
|
|
|
|
assert inverter.self_consumption_predictor is get_genetic0_load_interpolator()
|
|
|
|
|
assert inverter.self_consumption_predictor is not get_eos_load_interpolator()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("load,pv", [(4000.0, 5000.0), (10000.0, 20000.0)])
|
|
|
|
|
def test_genetic_inverter_boundary_flows_remain_nonnegative(load, pv):
|
|
|
|
|
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
|
|
|
|
|
|
|
|
|
inverter = Inverter(InverterParameters(device_id="boundary", max_power_wh=25000))
|
|
|
|
|
flows = inverter.process_energy(generation=pv, consumption=load, hour=0)
|
|
|
|
|
assert all(np.isfinite(value) and value >= 0.0 for value in flows)
|
2026-10-11 11:37:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expected_direct_consumption_cache_matches_uncached_and_follows_the_run_cache():
|
|
|
|
|
"""The per-run cache returns the computed value and is dropped with the EMS cache."""
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
CacheEnergyManagementStore().clear()
|
|
|
|
|
|
|
|
|
|
first = interpolator.calculate_expected_direct_consumption(800.0, 1200.0)
|
|
|
|
|
assert first == interpolator._expected_direct_consumption(800.0, 1200.0)
|
|
|
|
|
assert interpolator._direct_consumption[(800.0, 1200.0)] == first
|
|
|
|
|
assert interpolator.calculate_expected_direct_consumption(800.0, 1200.0) == first
|
|
|
|
|
|
|
|
|
|
CacheEnergyManagementStore().clear()
|
|
|
|
|
assert interpolator.calculate_expected_direct_consumption(900.0, 1200.0) > 0.0
|
|
|
|
|
assert (800.0, 1200.0) not in interpolator._direct_consumption
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expected_direct_consumption_cache_is_bounded(monkeypatch):
|
|
|
|
|
interpolator = get_eos_load_interpolator()
|
|
|
|
|
CacheEnergyManagementStore().clear()
|
|
|
|
|
monkeypatch.setattr(interpolator, "DIRECT_CONSUMPTION_CACHE_SIZE", 3)
|
|
|
|
|
|
|
|
|
|
for pv_power_w in (500.0, 600.0, 700.0, 800.0, 900.0):
|
|
|
|
|
interpolator.calculate_expected_direct_consumption(800.0, pv_power_w)
|
|
|
|
|
|
|
|
|
|
assert len(interpolator._direct_consumption) <= 3
|
|
|
|
|
assert interpolator.calculate_expected_direct_consumption(
|
|
|
|
|
800.0, 500.0
|
|
|
|
|
) == interpolator._expected_direct_consumption(800.0, 500.0)
|