File size: 4,821 Bytes
0febf0f
 
3c49095
0febf0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c49095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfa8acb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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