mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
* fix(genetic): sum the self-consumption dot products in a fixed order GENETIC gave different plans for the same input and seed on different CPUs. SelfConsumptionProbabilityInterpolator computed its two dot products with np.dot, which calls BLAS; OpenBLAS picks a ddot kernel per CPU at run time (and macOS uses Accelerate), and the kernels sum in different orders, so the result differs in the last bit. The optimizer compares fitness values exactly, so one bit is enough for a different plan. fixed_order_dot() adds the rounded products in index order with Python floats, without BLAS: the same result on every CPU. The vectors have one entry per 50 W load bin and the results are cached per (load, PV) pair, so the run time does not change noticeably. Plans change once in the last bits compared with the previous np.dot result of a given CPU. * fix(genetic): sort equal prices of the terminal value window in slot order np.argsort with the default kind (quicksort) leaves the order of equal values to the platform: NumPy sorts with SIMD kernels on x86-64 (x86-simd-sort) and with introsort elsewhere. Slots with the same price then fill the terminal value curve in another order, the battery capacity cuts it elsewhere, and the same input and seed gave another plan on x86-64 than on aarch64 (seen as a native-vs-Python compare difference on x86-64 in test_plan_keeps_reserve_for_a_peak_beyond_the_control_horizon). The window is now sorted with kind="stable": slots with the same price keep their slot order on every platform. Same goal as the dot product change in this PR: the same input gives the same plan on every CPU. Assisted-by: Claude Code (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.1 KiB
Python
173 lines
7.1 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
|
from akkudoktoreos.prediction.interpolator import (
|
|
fixed_order_dot,
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_fixed_order_dot_sums_in_index_order():
|
|
"""The sum runs from the first product to the last, whatever BLAS would do."""
|
|
ones = np.ones(3)
|
|
# 1e16 + 1 rounds back to 1e16, so the order decides whether the 1 survives.
|
|
assert fixed_order_dot(np.array([1e16, 1.0, -1e16]), ones) == 0.0
|
|
assert fixed_order_dot(np.array([1e16, -1e16, 1.0]), ones) == 1.0
|
|
|
|
rng = np.random.default_rng(7)
|
|
a, b = rng.random(97), rng.random(97) * 5000.0
|
|
expected = 0.0
|
|
for x, y in zip(a.tolist(), b.tolist()):
|
|
expected += x * y
|
|
assert fixed_order_dot(a, b) == expected
|
|
assert fixed_order_dot(a, b) == pytest.approx(float(np.dot(a, b)))
|
|
|
|
|
|
def test_expected_direct_consumption_does_not_use_blas(monkeypatch):
|
|
"""The expected direct consumption must not depend on the CPU's BLAS kernel."""
|
|
interpolator = get_eos_load_interpolator()
|
|
load_levels_w, probabilities = interpolator._load_distribution(800.0)
|
|
modeled_mean_load_w = 0.0
|
|
for p, level in zip(probabilities.tolist(), load_levels_w.tolist()):
|
|
modeled_mean_load_w += p * level
|
|
expected = 0.0
|
|
for p, level in zip(probabilities.tolist(), load_levels_w.tolist()):
|
|
expected += p * min(level * (800.0 / modeled_mean_load_w), 1200.0)
|
|
|
|
def no_blas(*args, **kwargs):
|
|
raise AssertionError("np.dot must not be used: its rounding depends on the CPU")
|
|
|
|
monkeypatch.setattr(np, "dot", no_blas)
|
|
assert interpolator._expected_direct_consumption(800.0, 1200.0) == min(expected, 800.0)
|