fix(genetic): same plan on every CPU (fixed-order dot products, stable sort of equal prices) (#1518)

* 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>
This commit is contained in:
Christin
2026-11-12 19:05:43 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 43cb0f04b2
commit b4d2d7b2aa
4 changed files with 93 additions and 5 deletions
+38 -1
View File
@@ -2,7 +2,10 @@ import numpy as np
import pytest
from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
from akkudoktoreos.prediction.interpolator import (
fixed_order_dot,
get_eos_load_interpolator,
)
def test_quarter_hour_energy_is_converted_back_to_same_mean_power():
@@ -133,3 +136,37 @@ def test_expected_direct_consumption_cache_is_bounded(monkeypatch):
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)