Files
EOS/tests/test_gridinterpolator.py
T
560370281f perf(prediction): optional SciPy-free evaluation of the self-consumption tables (#1378)
* perf(prediction): evaluate self-consumption tables without SciPy

The GENETIC inverter (and the GENETIC0 one) evaluates a pickled
scipy.interpolate.RegularGridInterpolator for the self-consumption
probabilities. SciPy is used nowhere else in EOS, yet loading it costs
about 35 MB RSS (21 MB anonymous) on Linux arm64, which every
optimization run pays.

Add utils/gridinterpolator.py:

- LinearGridInterpolator: 2-D linear interpolation with the same steps
  as SciPy's linear 2-D path (interval search, normalised distances,
  corner weights summed in the same order, fill value outside the grid,
  NaN for NaN input).
- load_grid_interpolator(): reads the shipped pickles with a restricted
  unpickler that maps the SciPy class to a stand-in and accepts only
  the NumPy globals needed for the arrays. SciPy is not imported, and
  the data files no longer depend on SciPy's private module paths.

Results are bit-identical to SciPy on Linux (aarch64, checked on 3.6M
points per table including the query pattern of the inverter). The
macOS arm64 SciPy wheel fuses multiply-add, so there SciPy itself
differs by <= 1.1e-16; GENETIC plans for fixed seeds are unchanged on
both platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(prediction): make the SciPy-free table evaluation opt-in

Add optimization.self_consumption_interpolator (scipy | numpy, default scipy).
The default keeps evaluating the self-consumption tables with SciPy's
RegularGridInterpolator, built from the same grid, values and fill value the
pickle holds, so results are unchanged. With numpy the tables are evaluated by
LinearGridInterpolator and SciPy is never imported. The backend is read on
every evaluation, so a config change applies to the next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Christin <christin@BigBrother.bikini.bottom.zone>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Andreas <drbacke@gmx.de>
2026-10-11 09:38:14 +02:00

207 lines
8.6 KiB
Python

"""Tests for the SciPy-free evaluation of the self-consumption probability tables."""
import io
import os
import pickle
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
from scipy.interpolate import RegularGridInterpolator
from akkudoktoreos.utils.gridinterpolator import (
GridInterpolatorBackend,
LinearGridInterpolator,
SwitchableGridInterpolator,
load_grid_interpolator,
)
DATA_DIR = Path(__file__).parent.parent / "src" / "akkudoktoreos" / "data"
TABLES = [
DATA_DIR / "regular_grid_interpolator.pkl",
DATA_DIR / "genetic0_load_interpolator.pkl",
]
def _reference(grid, values, point, fill_value):
"""SciPy's linear 2-D evaluation, written out in plain (unfused) float arithmetic."""
x0, x1 = (float(c) for c in point)
if np.isnan(x0) or np.isnan(x1):
return float("nan")
if not (grid[0][0] <= x0 <= grid[0][-1] and grid[1][0] <= x1 <= grid[1][-1]):
return float(fill_value)
corners = []
for axis, x in zip(grid, (x0, x1)):
i = int(np.searchsorted(axis, x, side="right")) - 1
i = min(max(i, 0), len(axis) - 2)
corners.append((i, (x - float(axis[i])) / (float(axis[i + 1]) - float(axis[i]))))
(i0, y0), (i1, y1) = corners
v = values
result = 0.0
result = result + float(v[i0, i1]) * (1 - y0) * (1 - y1)
result = result + float(v[i0, i1 + 1]) * (1 - y0) * y1
result = result + float(v[i0 + 1, i1]) * y0 * (1 - y1)
result = result + float(v[i0 + 1, i1 + 1]) * y0 * y1
return result
def _points(grid, n=20000, seed=0):
rng = np.random.default_rng(seed)
g0, g1 = grid
random = np.column_stack(
[
rng.uniform(g0[0] - 100, g0[-1] + 100, n),
rng.uniform(g1[0] - 100, g1[-1] + 100, n),
]
)
nodes = np.array(np.meshgrid(g0, g1, indexing="ij")).reshape(2, -1).T
# Query pattern of the EOS interpolator: one mean load, PV steps of 50 W.
pattern = np.vstack(
[
np.column_stack(
[np.full(int(g1[-1] // 50) + 1, load), np.arange(0.0, g1[-1] + 1.0, 50.0)]
)
for load in rng.uniform(g0[0], g0[-1], 50)
]
)
edges = np.array([[g0[0], g1[0]], [g0[-1], g1[-1]], [g0[-1], g1[0]], [np.nan, g1[0]]])
return np.vstack([random, nodes, pattern, edges])
@pytest.mark.parametrize("table", TABLES, ids=lambda path: path.name)
def test_loaded_table_matches_scipy(table: Path):
with table.open("rb") as file:
scipy_interpolator = pickle.load(file) # noqa: S301 - shipped data file
interpolator = load_grid_interpolator(table)
for ours, theirs in zip(interpolator.grid, scipy_interpolator.grid):
np.testing.assert_array_equal(ours, theirs)
np.testing.assert_array_equal(interpolator.values, scipy_interpolator.values)
assert interpolator.fill_value == scipy_interpolator.fill_value
points = _points(interpolator.grid)
ours = interpolator(points)
theirs = scipy_interpolator(points)
# Bit-identical to SciPy builds without fused multiply-add (Linux wheels);
# builds that fuse it (macOS arm64) differ only by rounding (|diff| <= ~1e-16).
np.testing.assert_array_equal(np.isnan(ours), np.isnan(theirs))
finite = ~np.isnan(ours)
np.testing.assert_allclose(ours[finite], theirs[finite], rtol=0, atol=1e-15)
# Exactly SciPy's arithmetic, evaluated without any fusion.
sample = points[:: max(1, len(points) // 3000)]
expected = np.array(
[
_reference(interpolator.grid, interpolator.values, point, interpolator.fill_value)
for point in sample
]
)
np.testing.assert_array_equal(interpolator(sample), expected)
def test_interpolator_matches_scipy_semantics_on_small_grid():
grid = (np.array([0.0, 1.0, 3.0]), np.array([10.0, 20.0]))
values = np.array([[1.0, 2.0], [3.0, 5.0], [-1.0, 0.5]])
ours = LinearGridInterpolator(grid, values, fill_value=0)
theirs = RegularGridInterpolator(grid, values, bounds_error=False, fill_value=0)
points = np.array(
[[0.0, 10.0], [3.0, 20.0], [2.5, 12.5], [-0.1, 15.0], [1.0, 20.1], [np.nan, 10.0]]
)
np.testing.assert_allclose(ours(points)[:-1], theirs(points)[:-1], rtol=0, atol=1e-15)
assert np.isnan(ours(points)[-1])
assert ours(points)[3] == 0.0
assert ours(np.array([[2.5, 12.5]])).shape == (1,)
assert ours(np.zeros((3, 4, 2))).shape == (3, 4)
def test_interpolator_rejects_invalid_grids():
with pytest.raises(ValueError):
LinearGridInterpolator((np.array([0.0]), np.array([0.0, 1.0])), np.zeros((1, 2)))
with pytest.raises(ValueError):
LinearGridInterpolator((np.array([1.0, 0.0]), np.array([0.0, 1.0])), np.zeros((2, 2)))
with pytest.raises(ValueError):
LinearGridInterpolator((np.array([0.0, 1.0]), np.array([0.0, 1.0])), np.zeros((3, 2)))
def test_loader_rejects_foreign_pickles():
with pytest.raises(pickle.UnpicklingError):
load_grid_interpolator(io.BytesIO(pickle.dumps(os.system)))
with pytest.raises(pickle.UnpicklingError):
load_grid_interpolator(io.BytesIO(pickle.dumps({"a": 1})))
method_nearest = RegularGridInterpolator(
(np.array([0.0, 1.0]), np.array([0.0, 1.0])), np.zeros((2, 2)), method="nearest"
)
with pytest.raises(pickle.UnpicklingError):
load_grid_interpolator(io.BytesIO(pickle.dumps(method_nearest)))
def test_loading_the_tables_does_not_import_scipy():
code = (
"import sys\n"
"from akkudoktoreos.utils.gridinterpolator import load_grid_interpolator\n"
+ "".join(f"load_grid_interpolator({str(table)!r})([[500.0, 100.0]])\n" for table in TABLES)
+ "print('scipy' in sys.modules)\n"
)
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code], capture_output=True, text=True, check=True, timeout=120
)
assert result.stdout.strip().splitlines()[-1] == "False"
@pytest.mark.parametrize("table", TABLES, ids=lambda path: path.name)
def test_switchable_interpolator_defaults_to_scipy(table: Path):
with table.open("rb") as file:
scipy_interpolator = pickle.load(file) # noqa: S301 - shipped data file
backend: dict[str, str] = {"value": GridInterpolatorBackend.SCIPY}
switchable = SwitchableGridInterpolator(load_grid_interpolator(table), lambda: backend["value"])
points = _points(switchable.grid, n=5000)
# SciPy backend: the same RegularGridInterpolator as the pickle, so exactly its results.
np.testing.assert_array_equal(switchable(points), scipy_interpolator(points))
# NumPy backend: LinearGridInterpolator; the switch applies to the next call.
backend["value"] = GridInterpolatorBackend.NUMPY
np.testing.assert_array_equal(switchable(points), load_grid_interpolator(table)(points))
backend["value"] = "scipy"
np.testing.assert_array_equal(switchable(points), scipy_interpolator(points))
def test_setting_selects_the_backend(config_eos):
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
assert config_eos.optimization.self_consumption_interpolator == GridInterpolatorBackend.SCIPY
interpolator = get_eos_load_interpolator().interpolator
points = _points(interpolator.grid, n=2000)
with (DATA_DIR / "regular_grid_interpolator.pkl").open("rb") as file:
scipy_values = pickle.load(file)(points) # noqa: S301 - shipped data file
np.testing.assert_array_equal(interpolator(points), scipy_values)
config_eos.merge_settings_from_dict({"optimization": {"self_consumption_interpolator": "numpy"}})
assert config_eos.optimization.self_consumption_interpolator == GridInterpolatorBackend.NUMPY
linear = load_grid_interpolator(DATA_DIR / "regular_grid_interpolator.pkl")
np.testing.assert_array_equal(interpolator(points), linear(points))
with pytest.raises(ValueError):
config_eos.merge_settings_from_dict({"optimization": {"self_consumption_interpolator": "cubic"}})
@pytest.mark.parametrize(
("backend", "imports_scipy"), [("numpy", "False"), ("scipy", "True")]
)
def test_numpy_backend_does_not_import_scipy(backend: str, imports_scipy: str):
code = (
"import sys\n"
"from akkudoktoreos.utils.gridinterpolator import SwitchableGridInterpolator, load_grid_interpolator\n"
+ "".join(
f"SwitchableGridInterpolator(load_grid_interpolator({str(table)!r}), lambda: {backend!r})([[500.0, 100.0]])\n"
for table in TABLES
)
+ "print('scipy' in sys.modules)\n"
)
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code], capture_output=True, text=True, check=True, timeout=120
)
assert result.stdout.strip().splitlines()[-1] == imports_scipy