from __future__ import annotations import pytest import numpy as np from batteryswapai.competition_model import FrozenLogisticCalibrator def test_frozen_logistic_calibrator_is_stable_and_normalized() -> None: calibrator = FrozenLogisticCalibrator( coefficients=np.array([1.5, -0.5]), intercept=-0.25, ) values = np.array([[0.0, 0.0], [1000.0, -1000.0], [-1000.0, 1000.0]]) probabilities = calibrator.predict_proba(values) assert probabilities.shape == (3, 2) assert np.isfinite(probabilities).all() np.testing.assert_allclose(probabilities.sum(axis=1), 1.0) assert probabilities[1, 1] == 1.0 assert probabilities[2, 1] == 0.0 def test_blended_risk_keeps_the_calibrated_scale_and_ranks_within_scenario(): import numpy as np import pandas as pd from batteryswapai.competition_model import _map_to_scale, _scenario_ranks groups = np.array(["s_0", "s_0", "s_0", "s_1", "s_1", "s_1"]) reference = np.array([0.10, 0.20, 0.30, 0.01, 0.02, 0.03]) blended = np.array([0.9, 0.1, 0.5, 0.2, 0.9, 0.5]) mapped = _map_to_scale(blended, reference, groups) # the calibrated values are reused exactly, only the ordering changes assert sorted(mapped[:3]) == sorted(reference[:3]) assert sorted(mapped[3:]) == sorted(reference[3:]) assert mapped[0] == 0.30 and mapped[1] == 0.10 assert mapped[4] == 0.03 and mapped[3] == 0.01 ranks = _scenario_ranks(blended, groups) assert ranks[0] == pytest.approx(1.0) and ranks[1] == pytest.approx(1 / 3) def test_context_columns_describe_one_scenario_and_are_not_model_features(): import pandas as pd from batteryswapai.competition_features import ( CONTEXT_COLUMNS, add_context_columns, numeric_feature_columns, ) from batteryswapai.competition_model import _context_features snapshot = add_context_columns( pd.DataFrame( { "battery": ["d_a", "d_b", "d_c"], "building": ["b_1", "b_1", "b_2"], "room": ["r_1", "r_1", "r_2"], "stable_voltage_median": [2.40, 2.60, 2.90], } ) ) assert snapshot["room_voltage_mean"].tolist() == [2.5, 2.5, 2.9] assert snapshot["room_low_count"].tolist() == [1.0, 1.0, 0.0] assert snapshot["scenario_low_fraction"].tolist() == [pytest.approx(1 / 3)] * 3 # the base ensemble must not train on scenario-level context assert not set(CONTEXT_COLUMNS) & set(numeric_feature_columns(snapshot)) # the auxiliary model reads the precomputed columns rather than regrouping assert list(_context_features(snapshot).columns) == list(CONTEXT_COLUMNS) def test_trajectory_blend_leaves_short_history_batteries_where_they_were(): import numpy as np from batteryswapai.competition_model import blend_trajectory groups = np.array(["s_0"] * 4) primary = np.array([0.40, 0.30, 0.20, 0.10]) residual = np.array([0.10, 0.20, 0.30, 0.40]) # exactly reversed unchanged = blend_trajectory(primary, residual, np.zeros(4), groups) assert np.allclose(unchanged, primary) nudged = blend_trajectory(primary, residual, np.full(4, 0.25), groups) assert sorted(nudged) == sorted(primary) # calibrated scale is preserved # a quarter weight is a nudge, not a takeover; a dominant weight does reorder strong = blend_trajectory(primary, residual, np.full(4, 0.9), groups) assert sorted(strong) == sorted(primary) assert not np.allclose(strong, primary) # a battery with no usable history keeps its own position while the others move partial = blend_trajectory(primary, residual, np.array([0.0, 0.25, 0.25, 0.0]), groups) assert sorted(partial) == sorted(primary) def test_trajectory_blend_is_deterministic_and_scenario_local(): import numpy as np from batteryswapai.competition_model import blend_trajectory groups = np.array(["s_0", "s_0", "s_1", "s_1"]) primary = np.array([0.9, 0.1, 0.02, 0.01]) residual = np.array([0.1, 0.9, 0.01, 0.02]) first = blend_trajectory(primary, residual, np.full(4, 0.25), groups) second = blend_trajectory(primary, residual, np.full(4, 0.25), groups) assert np.array_equal(first, second) # scenario 0 values never leak into scenario 1 assert sorted(first[:2]) == sorted(primary[:2]) assert sorted(first[2:]) == sorted(primary[2:]) def test_model_without_a_trajectory_stack_returns_the_blend_unchanged(): import numpy as np import pandas as pd from batteryswapai.competition_model import EventTimeModel model = EventTimeModel.__new__(EventTimeModel) model.residual_model = None model.forecasters = None risk, weight = model._residual_risk(pd.DataFrame({"battery": ["d_a"]})) assert risk is None and weight is None