File size: 6,946 Bytes
b89e779
7d93fb8
 
b89e779
7d93fb8
 
 
 
 
 
b89e779
 
 
 
 
 
 
 
 
 
 
 
fc9c286
 
 
7d93fb8
b89e779
 
 
7d93fb8
 
 
 
 
 
b89e779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc9c286
 
7d93fb8
 
 
fc9c286
 
7d93fb8
 
 
fc9c286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fb1dc8
 
2ba75d6
fc9c286
 
 
 
b89e779
 
 
fc9c286
b89e779
 
 
fc9c286
 
 
 
 
 
 
 
b89e779
fc9c286
 
 
b89e779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"""
Static pickled planner: logistic RUL model + tsroute planning (topsum inclusion,
expected-cost day placement, building-group day search).

train_full() fits it on every closed-window (device, origin) pair and main()
writes planners/best.pickle - the per-split fallback submit.py uses when the
adaptive path fails. Planner knobs mirror the phase-11/14 champion settings
(alpha=0.8, ec_margin=0.8, ec_pmax=25, split_days=7; see models_cv.py and
EXPERIMENTS.md). TopSumPlanner below is the retired v1 champion, kept for
reference and so older pickles that recorded it stay loadable.

Everything the planner needs at plan() time is computed from the arguments the
competition passes it: the truncated timeseries, the locations table (which carries
each device's install date), and the scenario settings.
"""

import pickle

import pandas as pd

from batteryswap_public.interfaces import Planner

from batteryswap_example.features import (
    daily_voltage, features_at_origin, load as load_features,
    daily_all, weekly_betas, master_curve, physics_features, BETA_DEFAULT)
from batteryswap_example.models_cv import SklearnRUL


class TopSumPlanner(Planner):
    """
    Retired v1 champion (phase 1): adaptive top-sum inclusion + building
    batching. Walk-forward CV at selection time: test block s_24..s_41 mean
    total_cost 1368 vs voltage-15 2034, defer-all 2512, oracle 186.
    Superseded by ECRoutePlanner (tsroute); see EXPERIMENTS.md.
    """

    def __init__(self, model: SklearnRUL, alpha: float = 0.8, day_q: float = 0.05,
                 max_groups_per_day: int = 2):
        self.model = model
        self.alpha = alpha
        self.day_q = day_q
        self.max_groups_per_day = max_groups_per_day

    def plan(self, battery_data, locations, travel_costs, settings):
        window = int(settings.planning_window_days)
        origin = battery_data.reset_index()['end_time'].max().normalize()

        daily = daily_voltage(battery_data)
        feats = features_at_origin(daily, locations, origin)
        feats = feats.reindex(locations['battery'])

        pred = self.model.predict(feats)

        # adaptive inclusion: expected death count tracks prevalence even when the
        # probability scale drifts between refits, unlike a fixed threshold
        n = int(round(self.alpha * pred['p42'].sum()))
        chosen = pred['p42'].nlargest(n).index

        offs = pred.loc[chosen, f'day_q{self.day_q}'].round().clip(0, window)

        # batching: one visit per building at the group's earliest predicted day,
        # capped groups per day (travel is expensive, early days are cheap)
        if len(offs):
            bldg = locations.set_index('battery')['building'].reindex(offs.index)
            group_day = offs.groupby(bldg).min().sort_values()
            used, final = {}, {}
            for building, day in group_day.items():
                d = int(day)
                while used.get(d, 0) >= self.max_groups_per_day and d < window:
                    d += 1
                used[d] = used.get(d, 0) + 1
                final[building] = d
            offs = bldg.map(final).astype(float)

        parked_day = origin + pd.Timedelta(days=window + 1)
        scheduled = set(offs.index)
        parked = [b for b in locations['battery'] if b not in scheduled]
        plan = pd.concat([
            pd.DataFrame({'day': origin + pd.to_timedelta(offs.values, unit='D'),
                          'battery': offs.index}),
            pd.DataFrame({'day': parked_day, 'battery': parked}),
        ], ignore_index=True)
        return plan.sort_values('day', kind='stable').reset_index(drop=True)


class ECRoutePlanner(Planner):
    """
    The pickled fallback planner. Despite the legacy name it calls plan_tsroute:
    topsum inclusion (N = alpha * sum(p42)) plus the phase-11 break-even top-up,
    expected-cost day placement, and local search over building-group days
    against an exact replica of the evaluator's travel/overtime/limit mechanics.

    The name dates from the pure expected-cost include/park selection
    (plan_ecroute, combined dev+test mean 1816 vs previous champion 1901),
    superseded by tsroute in phases 6-14; kept so existing pickles load.
    """

    def __init__(self, model: SklearnRUL, beta: float = BETA_DEFAULT, curve=None):
        self.model = model
        # physics context baked in at fit time: the planner receives no EOL data,
        # so the master discharge curve and temperature coefficient travel with it
        self.beta = beta
        self.curve = curve

    def plan(self, battery_data, locations, travel_costs, settings):
        from batteryswap_example.models_cv import plan_tsroute
        origin = battery_data.reset_index()['end_time'].max().normalize()
        daily = daily_voltage(battery_data)
        feats = features_at_origin(daily, locations, origin)
        phys = physics_features(daily_all(battery_data), origin, self.beta, self.curve)
        feats = feats.join(phys).reindex(locations['battery'])
        pred = self.model.predict(feats)
        plan_tsroute.alpha = 0.8
        plan_tsroute.ec_margin = 0.8   # phase-11 break-even top-up
        plan_tsroute.ec_pmax = 25.0
        plan_tsroute.split_days = 7    # phase-14 building day-splitting
        return plan_tsroute(pred, None, locations, origin, 0.05,
                            travel_costs=travel_costs, settings=settings)


def train_full(dataset_path='dataset'):
    """Refit the selected model on every fully observed (device, origin) pair."""
    from pathlib import Path
    from batteryswap_public.utils import load_dataset
    table = load_features(Path(dataset_path))
    train = table[table['window_closed']]
    model = SklearnRUL('logistic').fit(train)

    # physics context from the full train split
    _, timeseries, eol_times, _ = load_dataset(Path(dataset_path) / 'train')
    dall = daily_all(timeseries)
    betas = weekly_betas(dall)['beta']
    beta = float(betas.median()) if len(betas) >= 20 else BETA_DEFAULT
    curve = master_curve(dall, eol_times.dropna(), beta)

    print(f'refit on {len(train)} pairs, {train.dies42.sum()} positive, '
          f'origins s_0..s_{train.origin_idx.max()}, beta={beta:.4f}, '
          f'curve={"yes" if curve is not None else "no"}')
    return ECRoutePlanner(model, beta=beta, curve=curve)


def main():
    # When run as a script/-m, classes defined here would pickle under __main__ and
    # be unloadable by script.py. Rebuild the instance from the imported module so
    # the pickle records the importable path.
    import os
    import batteryswap_example.planner as this_module
    dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')
    planner = this_module.train_full(dataset_path)
    path = 'batteryswap_example/planners/best.pickle'
    with open(path, 'wb') as f:
        pickle.dump(planner, f)
    print('planner-save', path)


if __name__ == '__main__':
    main()