"""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