Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
b89e779
·
1 Parent(s): 6d45918

Logistic RUL model with adaptive top-sum planner (walk-forward CV selected)

Browse files

- features.py: cached (device, origin) feature table; leak-free per-origin slicing
- models_cv.py: walk-forward CV harness; logistic/GBM/AFT/rank models,
threshold/topsum/batching planners; dev s_8-s_23, test s_24-s_41
- planner.py: final TopSumPlanner (logistic p42, N = 0.8 * sum(p42),
q05 day placement, building batching); refit on all closed windows
- harness.py: baseline comparisons (defer-all, lowest-voltage-k, oracle)
- best.pickle trained inside the competition Docker image (sklearn 1.7.2)
to avoid version skew; verified via script.py + metric in the image

Test-block walk-forward mean total_cost 1368 (voltage-15 2034, defer 2512,
oracle 186); train split via official metric: 2150 vs 8673 for the example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

batteryswap_example/features.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Precompute the (device, origin) feature/label table used by every model.
3
+
4
+ One row per (alive device, scenario origin). Features use only data at or before
5
+ the origin; no fleet-wide statistics are computed here, so slicing at an origin
6
+ cannot leak the future. Labels use the full eol_times and are for
7
+ training/evaluation only - the planner never reads them.
8
+
9
+ Cached to dataset/train/features.parquet (rebuild with --force).
10
+ """
11
+
12
+ from pathlib import Path
13
+ import os
14
+ import sys
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from batteryswap_public.utils import load_dataset
20
+
21
+ MIN_TEMP = 10
22
+ MAX_TEMP = 30
23
+ WINDOW_DAYS = 42
24
+
25
+
26
+ def daily_voltage(timeseries: pd.DataFrame) -> pd.DataFrame:
27
+ """In-band daily median voltage per device: (device_id, day, v)."""
28
+ df = timeseries.reset_index()
29
+ # device_id is categorical in the parquet; observed=False groupbys would emit
30
+ # the full device x day cartesian product with NaN voltages
31
+ df['device_id'] = df['device_id'].astype(str)
32
+ df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
33
+ df['day'] = df['end_time'].dt.normalize()
34
+ out = df.groupby(['device_id', 'day'])['voltage'].median().rename('v').reset_index()
35
+ return out.dropna(subset=['v']).sort_values('day')
36
+
37
+
38
+ def slope(days: np.ndarray, values: np.ndarray) -> float:
39
+ """Per-day slope; NaN when under 2 points."""
40
+ if len(values) < 2:
41
+ return np.nan
42
+ x = (days - days[-1]) / np.timedelta64(1, 'D')
43
+ return float(np.polyfit(x.astype(float), values, 1)[0])
44
+
45
+
46
+ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
47
+ origin: pd.Timestamp) -> pd.DataFrame:
48
+ """Feature rows for every device with any reading at or before origin."""
49
+ h = daily[daily['day'] <= origin]
50
+
51
+ g = h.groupby('device_id')
52
+ last = g.tail(1).set_index('device_id')
53
+ feats = pd.DataFrame({'v_last': last['v']})
54
+ feats['staleness'] = (origin - last['day']).dt.days
55
+
56
+ # per-device historical reference: median over first 90 observed days
57
+ first_day = g['day'].min()
58
+ early = h[h['day'] <= h['device_id'].map(first_day) + pd.Timedelta(days=90)]
59
+ feats['v_ref'] = early.groupby('device_id')['v'].median()
60
+ feats['v_drop'] = feats['v_ref'] - feats['v_last']
61
+
62
+ for w in (30, 90):
63
+ hw = h[h['day'] > origin - pd.Timedelta(days=w)]
64
+ gw = hw.groupby('device_id')
65
+ feats[f'slope_{w}'] = gw.apply(
66
+ lambda u: slope(u['day'].values, u['v'].values), include_groups=False)
67
+ feats[f'v_min_{w}'] = gw['v'].min()
68
+ feats[f'n_{w}'] = gw['v'].size()
69
+
70
+ install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
71
+ feats['age_days'] = (origin - install.reindex(feats.index)).dt.days
72
+
73
+ return feats
74
+
75
+
76
+ def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
77
+ locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
78
+ daily = daily_voltage(timeseries)
79
+
80
+ rows = []
81
+ for idx, sc in enumerate(scenarios):
82
+ origin = pd.Timestamp(sc['start_time'])
83
+ alive = eol_times.isna() | (eol_times > origin)
84
+ alive_ids = eol_times.index[alive]
85
+
86
+ f = features_at_origin(daily, locations, origin)
87
+ f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
88
+ f['origin_idx'] = idx
89
+ f['origin'] = origin
90
+
91
+ eol = eol_times.reindex(alive_ids)
92
+ f['eol'] = eol.values
93
+ f['dies42'] = ((eol.notna()) & (eol <= origin + pd.Timedelta(days=WINDOW_DAYS))).astype(int).values
94
+ f['days_to_eol'] = ((eol - origin).dt.total_seconds() / 86400).values
95
+ f['window_closed'] = origin + pd.Timedelta(days=WINDOW_DAYS) <= pd.Timestamp('2026-07-31')
96
+
97
+ rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
98
+ print(f'{sc["name"]}: {len(f)} alive, {f["dies42"].sum()} die in window', file=sys.stderr)
99
+
100
+ table = pd.concat(rows, ignore_index=True)
101
+ table.to_parquet(out_path)
102
+ return table
103
+
104
+
105
+ def load(dataset_path: Path = Path('dataset'), force: bool = False) -> pd.DataFrame:
106
+ out_path = dataset_path / 'train' / 'features.parquet'
107
+ if out_path.exists() and not force:
108
+ return pd.read_parquet(out_path)
109
+ return build(dataset_path, out_path)
110
+
111
+
112
+ if __name__ == '__main__':
113
+ force = '--force' in sys.argv
114
+ dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
115
+ t = load(dataset_path, force=force)
116
+ print(t.groupby('origin_idx')['dies42'].agg(['size', 'sum']).T)
117
+ print(t.describe().round(3).to_string())
batteryswap_example/harness.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline planners and a forward-split evaluation harness.
3
+
4
+ The harness fits nothing (these baselines have no parameters), but it establishes
5
+ the reference points any real model has to beat:
6
+
7
+ current - the shipped OrderedPlanner
8
+ defer - park every battery past the horizon (costs nothing but emergencies)
9
+ voltage-k - swap the k lowest-voltage batteries
10
+ oracle - knows the true EOL, swaps on the exact day
11
+
12
+ Scored on origins s_24..s_41, which sit after the training block (origins whose
13
+ 42-day window closes before s_24) and before the origins whose windows run past
14
+ the end of the data.
15
+ """
16
+
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import numpy
22
+ import pandas
23
+ import structlog
24
+ from pydantic_settings import BaseSettings, SettingsConfigDict
25
+
26
+ from batteryswap_public.interfaces import Planner
27
+ from batteryswap_public.utils import load_dataset, iterate_scenarios
28
+ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
29
+
30
+ log = structlog.get_logger()
31
+
32
+ # Origins whose 42-day window closes before s_24 are the training block.
33
+ TEST_ORIGINS = range(24, 42)
34
+
35
+ # Temperature band for trusting a voltage reading, as in utils.smooth_series
36
+ MIN_TEMP = 10
37
+ MAX_TEMP = 30
38
+
39
+
40
+ def infer_start_time(battery_data: pandas.DataFrame) -> pandas.Timestamp:
41
+ """
42
+ Recover the planning start from the truncated timeseries.
43
+
44
+ The Planner interface does not receive start_time - iterate_scenarios cuts the
45
+ series at it, so the last observation is the best available estimate.
46
+ """
47
+ return battery_data.reset_index()['end_time'].max().normalize()
48
+
49
+
50
+ def voltage_feature(battery_data: pandas.DataFrame) -> pandas.Series:
51
+ """
52
+ Last daily-median voltage per device, using only in-band readings.
53
+
54
+ Daily median rejects within-day noise; the temperature band rejects the part of
55
+ the voltage swing that reflects ambient rather than state-of-charge. No recency
56
+ limit - a stale low reading still predicts EOL far better than no reading, and
57
+ the 95th percentile of staleness is ~148 days.
58
+
59
+ Deliberately uses no fleet-wide statistic, so slicing history at an origin can
60
+ never leak information from after it.
61
+ """
62
+ df = battery_data.reset_index()
63
+ df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
64
+ if df.empty:
65
+ return pandas.Series(dtype=float, name='voltage')
66
+
67
+ df['day'] = df['end_time'].dt.normalize()
68
+ last_day = df.groupby('device_id')['day'].transform('max')
69
+ latest = df[df['day'] == last_day]
70
+ return latest.groupby('device_id')['voltage'].median().rename('voltage')
71
+
72
+
73
+ def _assemble(selected_days: pandas.Series, locations, start_time, window_days):
74
+ """
75
+ Build a valid plan: scheduled batteries on their days, everything else parked
76
+ one day past the horizon where evaluate_plan discards it for free.
77
+ """
78
+ parked_day = start_time + pandas.Timedelta(days=window_days + 1)
79
+ scheduled = set(selected_days.index)
80
+ parked = [b for b in locations['battery'] if b not in scheduled]
81
+
82
+ plan = pandas.concat([
83
+ pandas.DataFrame({'day': selected_days.values, 'battery': selected_days.index}),
84
+ pandas.DataFrame({'day': parked_day, 'battery': parked}),
85
+ ], ignore_index=True)
86
+ plan = plan.sort_values('day', kind='stable').reset_index(drop=True)
87
+
88
+ check_plan_valid(plan, locations, start_time=start_time)
89
+ return plan
90
+
91
+
92
+ class DeferAllPlanner(Planner):
93
+ """Park everything. Pays only forced emergency visits."""
94
+
95
+ def plan(self, battery_data, locations, travel_costs, settings):
96
+ start_time = infer_start_time(battery_data)
97
+ empty = pandas.Series(dtype='datetime64[ns]')
98
+ return _assemble(empty, locations, start_time, settings.planning_window_days)
99
+
100
+
101
+ class LowestVoltagePlanner(Planner):
102
+ """
103
+ Swap the k batteries with the lowest last in-band voltage, worst first, one per
104
+ day from the start of the window.
105
+
106
+ k is deliberately allowed to exceed the expected number of deaths: missing a
107
+ battery that dies costs an emergency visit, while including a healthy one costs
108
+ only early_replacement_penalty_daily per day.
109
+ """
110
+
111
+ def __init__(self, k: int = 10, feature_cache: Optional[dict] = None):
112
+ self.k = k
113
+ self.feature_cache = feature_cache if feature_cache is not None else {}
114
+
115
+ def _voltage(self, battery_data, start_time):
116
+ key = pandas.Timestamp(start_time)
117
+ if key not in self.feature_cache:
118
+ self.feature_cache[key] = voltage_feature(battery_data)
119
+ return self.feature_cache[key]
120
+
121
+ def plan(self, battery_data, locations, travel_costs, settings):
122
+ start_time = infer_start_time(battery_data)
123
+ voltage = self._voltage(battery_data, start_time)
124
+
125
+ # Restrict to batteries still in the fleet; unmeasured ones sort last.
126
+ v = voltage.reindex(locations['battery']).fillna(numpy.inf)
127
+ worst = v.nsmallest(self.k, keep='first')
128
+
129
+ days = start_time + pandas.to_timedelta(numpy.arange(len(worst)), unit='D')
130
+ selected = pandas.Series(days, index=worst.index)
131
+ return _assemble(selected, locations, start_time, settings.planning_window_days)
132
+
133
+
134
+ class OraclePlanner(Planner):
135
+ """Upper bound: knows the true EOL and swaps on exactly that day."""
136
+
137
+ def __init__(self, eol_times: pandas.Series):
138
+ self.eol_times = eol_times
139
+
140
+ def plan(self, battery_data, locations, travel_costs, settings):
141
+ start_time = infer_start_time(battery_data)
142
+ window = pandas.Timedelta(days=settings.planning_window_days)
143
+ eol = self.eol_times.reindex(locations['battery']).dropna()
144
+ doomed = eol[(eol > start_time) & (eol <= start_time + window)].sort_values()
145
+ return _assemble(doomed, locations, start_time, settings.planning_window_days)
146
+
147
+
148
+ def check_feature_parity(battery_data, cache_key, feature_cache):
149
+ """
150
+ Guard against train/serve skew: the harness prefills the same cache the planner
151
+ reads, so assert the cached value matches a fresh online computation.
152
+ """
153
+ cached = feature_cache[cache_key]
154
+ online = voltage_feature(battery_data)
155
+ pandas.testing.assert_series_equal(cached.sort_index(), online.sort_index())
156
+
157
+
158
+ def load_shipped_planner(path='batteryswap_example/planners/best.pickle'):
159
+ """
160
+ best.pickle was written by running train.py as a script, so its classes are
161
+ recorded under __main__. Register them there before unpickling.
162
+ """
163
+ import pickle
164
+ import __main__
165
+ from batteryswap_example import train as train_module
166
+ for name in ('OrderedPlanner', 'DummyRULModel'):
167
+ setattr(__main__, name, getattr(train_module, name))
168
+ with open(path, 'rb') as f:
169
+ return pickle.load(f)
170
+
171
+
172
+ def run(dataset_path: Path, origins=TEST_ORIGINS, k_values=(5, 10, 15, 20, 30)):
173
+ locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
174
+ log.info('harness-loaded', devices=len(locations), origins=list(origins))
175
+
176
+ shipped = load_shipped_planner()
177
+
178
+ feature_cache: dict = {}
179
+ policies = {'current': shipped, 'defer': DeferAllPlanner(),
180
+ 'oracle': OraclePlanner(eol_times)}
181
+ for k in k_values:
182
+ policies[f'voltage-{k}'] = LowestVoltagePlanner(k, feature_cache=feature_cache)
183
+
184
+ records = []
185
+ parity_checked = False
186
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
187
+ for index, (scenario, locs, cut, eol) in enumerate(gen):
188
+ if index not in origins:
189
+ continue
190
+ start_time = pandas.Timestamp(scenario['start_time'])
191
+ inferred = infer_start_time(cut)
192
+ assert inferred == start_time, (scenario['name'], inferred, start_time)
193
+
194
+ for name, planner in policies.items():
195
+ plan = planner.plan(cut, locs, scenario['travel_costs'], scenario['settings'])
196
+ _, _, overall = evaluate_plan(
197
+ plan, locs, scenario['travel_costs'], scenario['settings'],
198
+ eol_times=eol, start_time=start_time, verbose=0)
199
+ records.append(dict(overall, policy=name, origin=scenario['name']))
200
+
201
+ if not parity_checked:
202
+ check_feature_parity(cut, start_time, feature_cache)
203
+ parity_checked = True
204
+ log.info('feature-parity-ok', origin=scenario['name'])
205
+
206
+ log.info('origin-done', origin=scenario['name'])
207
+
208
+ return pandas.DataFrame(records)
209
+
210
+
211
+ def report(df: pandas.DataFrame):
212
+ order = ['total_cost', 'late_swap', 'early_swap', 'daily_limit', 'weekly_limit',
213
+ 'overtime', 'travel', 'battery_swap', 'building_change', 'room_change']
214
+ mean = df.groupby('policy')[order].mean().sort_values('total_cost')
215
+
216
+ defer = mean.loc['defer', 'total_cost']
217
+ per_origin = df.pivot(index='origin', columns='policy', values='total_cost')
218
+ beats = (per_origin.lt(per_origin['defer'], axis=0)).sum()
219
+ spread = per_origin.std()
220
+
221
+ summary = mean.copy()
222
+ summary['vs_defer_%'] = (100 * (mean['total_cost'] / defer - 1)).round(1)
223
+ summary['origins_beating_defer'] = beats.reindex(summary.index).astype(str) + f'/{len(per_origin)}'
224
+ summary['std_across_origins'] = spread.reindex(summary.index).round(1)
225
+
226
+ print(f'\nForward split: scored on {len(per_origin)} origins '
227
+ f'({per_origin.index[0]}..{per_origin.index[-1]})\n')
228
+ print(summary.round(2).to_string())
229
+ return summary
230
+
231
+
232
+ class Config(BaseSettings):
233
+ model_config = SettingsConfigDict(env_prefix='', cli_parse_args=True,
234
+ cli_ignore_unknown_args=True)
235
+ dataset_path: Optional[Path] = None
236
+
237
+
238
+ def main():
239
+ cfg = Config()
240
+ dataset_path = cfg.dataset_path or Path(
241
+ os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
242
+ df = run(Path(dataset_path))
243
+ report(df)
244
+
245
+
246
+ if __name__ == '__main__':
247
+ main()
batteryswap_example/models_cv.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model comparison under walk-forward CV.
3
+
4
+ Protocol
5
+ --------
6
+ - Training pairs at origin T: all (device, origin') rows whose 42-day window closed
7
+ before T (origin' <= T - 42d). Window closed => label fully observed, and there is
8
+ a built-in embargo: no training window overlaps the scored window.
9
+ - Walk-forward: the model is refit at every scored origin on exactly the pairs that
10
+ would have been observable then.
11
+ - DEV block s_8..s_23: hyperparameters (p_star, day quantile, planner) are tuned here.
12
+ - TEST block s_24..s_41: scored once with dev-chosen settings.
13
+
14
+ Models predict, per alive device:
15
+ p42 - probability of EOL within the 42-day window
16
+ day_q - quantile of days-to-EOL conditional on dying in the window
17
+
18
+ Planners turn (p42, day_q) into a plan:
19
+ onepd - worst-first, one per day (control, shape of the shipped planner)
20
+ sched - swap each included battery on its predicted day
21
+ batch - sched + move each building's swaps to the group's earliest day and
22
+ cap building-groups per day (travel is expensive, early days are cheap)
23
+ """
24
+
25
+ from pathlib import Path
26
+ import itertools
27
+ import os
28
+ import sys
29
+ import warnings
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ from sklearn.impute import SimpleImputer
35
+ from sklearn.preprocessing import StandardScaler
36
+ from sklearn.pipeline import make_pipeline
37
+ from sklearn.linear_model import LogisticRegression
38
+ from sklearn.ensemble import HistGradientBoostingClassifier, GradientBoostingRegressor
39
+ from sklearn.metrics import roc_auc_score
40
+
41
+ from batteryswap_public.utils import load_dataset, iterate_scenarios
42
+ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
43
+
44
+ from batteryswap_example.features import load as load_features, WINDOW_DAYS
45
+
46
+ FEATURES = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30',
47
+ 'staleness', 'age_days']
48
+ DEV_ORIGINS = range(8, 24)
49
+ TEST_ORIGINS = range(24, 42)
50
+ DAY_QUANTILES = (0.05, 0.20, 0.50)
51
+
52
+
53
+ # --------------------------------------------------------------------------- models
54
+
55
+ class DayQuantiles:
56
+ """Quantiles of days-to-EOL, fit on training pairs that died in-window."""
57
+
58
+ def __init__(self):
59
+ self.models = {}
60
+ self.fallback = {}
61
+
62
+ def fit(self, X, days):
63
+ for q in DAY_QUANTILES:
64
+ self.fallback[q] = float(np.quantile(days, q))
65
+ if len(days) >= 40:
66
+ m = make_pipeline(
67
+ SimpleImputer(strategy='median'),
68
+ GradientBoostingRegressor(loss='quantile', alpha=q,
69
+ n_estimators=100, max_depth=2,
70
+ random_state=0))
71
+ m.fit(X, days)
72
+ self.models[q] = m
73
+ return self
74
+
75
+ def predict(self, X):
76
+ out = {}
77
+ for q in DAY_QUANTILES:
78
+ if q in self.models:
79
+ out[f'day_q{q}'] = np.clip(self.models[q].predict(X), 0, WINDOW_DAYS)
80
+ else:
81
+ out[f'day_q{q}'] = np.full(len(X), self.fallback[q])
82
+ return pd.DataFrame(out, index=X.index)
83
+
84
+
85
+ class SklearnRUL:
86
+ """p42 from a sklearn classifier + DayQuantiles for timing."""
87
+
88
+ def __init__(self, kind):
89
+ self.kind = kind
90
+
91
+ def fit(self, train):
92
+ X, y = train[FEATURES], train['dies42']
93
+ if self.kind == 'logistic':
94
+ self.clf = make_pipeline(SimpleImputer(strategy='median'),
95
+ StandardScaler(),
96
+ LogisticRegression(max_iter=2000, C=1.0))
97
+ elif self.kind == 'gbm':
98
+ self.clf = HistGradientBoostingClassifier(
99
+ max_iter=200, max_depth=3, learning_rate=0.1,
100
+ min_samples_leaf=20, random_state=0)
101
+ self.clf.fit(X, y)
102
+
103
+ dying = train[train['dies42'] == 1]
104
+ self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
105
+ return self
106
+
107
+ def predict(self, rows):
108
+ X = rows[FEATURES]
109
+ out = self.days.predict(X)
110
+ out['p42'] = self.clf.predict_proba(X)[:, 1]
111
+ return out
112
+
113
+
114
+ class RankVoltage:
115
+ """Control: score by v_last only; p42 is a rank in [0,1], day from DayQuantiles."""
116
+
117
+ def fit(self, train):
118
+ dying = train[train['dies42'] == 1]
119
+ self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
120
+ return self
121
+
122
+ def predict(self, rows):
123
+ out = self.days.predict(rows[FEATURES])
124
+ v = rows['v_last'].fillna(np.inf)
125
+ out['p42'] = 1.0 - v.rank(pct=True).values # lowest voltage -> highest score
126
+ return out
127
+
128
+
129
+ class WeibullAFT:
130
+ """lifelines Weibull AFT on censored pairs; survival curve gives p42 and timing."""
131
+
132
+ def fit(self, train_censored):
133
+ from lifelines import WeibullAFTFitter
134
+ df = train_censored[FEATURES + ['duration', 'event']].copy()
135
+ self.imputer = SimpleImputer(strategy='median').fit(df[FEATURES])
136
+ df[FEATURES] = self.imputer.transform(df[FEATURES])
137
+ self.aft = WeibullAFTFitter(penalizer=0.01)
138
+ with warnings.catch_warnings():
139
+ warnings.simplefilter('ignore')
140
+ self.aft.fit(df, duration_col='duration', event_col='event')
141
+ return self
142
+
143
+ def predict(self, rows):
144
+ X = rows[FEATURES].copy()
145
+ X[FEATURES] = self.imputer.transform(X[FEATURES])
146
+ times = np.arange(1, WINDOW_DAYS + 1)
147
+ surv = self.aft.predict_survival_function(X, times=times) # times x devices
148
+ cdf = 1.0 - surv.T # devices x times
149
+ p42 = cdf.iloc[:, -1]
150
+ out = pd.DataFrame({'p42': p42.values}, index=rows.index)
151
+
152
+ # per-day death mass and expected residual life given surviving the window,
153
+ # for the expected-cost planner
154
+ pmf = np.diff(np.column_stack([np.zeros(len(cdf)), cdf.values]), axis=1)
155
+ for t in times:
156
+ out[f'pd_{t}'] = pmf[:, t - 1]
157
+ far = np.arange(WINDOW_DAYS, 400)
158
+ surv_far = self.aft.predict_survival_function(X, times=far).T.values
159
+ s42 = np.maximum(surv_far[:, 0], 1e-12)
160
+ out['e_far'] = WINDOW_DAYS + np.trapezoid(surv_far, far, axis=1) / s42
161
+ # quantiles of death time conditional on dying within the window
162
+ cond = cdf.div(p42.replace(0, np.nan), axis=0).clip(upper=1.0)
163
+ for q in DAY_QUANTILES:
164
+ day = (cond.values >= q).argmax(axis=1) + 1.0
165
+ day[~(cond.values[:, -1] >= q)] = WINDOW_DAYS
166
+ out[f'day_q{q}'] = np.where(p42.values > 0, day, WINDOW_DAYS)
167
+ return out
168
+
169
+
170
+ MODELS = {
171
+ 'rank-v': RankVoltage,
172
+ 'logistic': lambda: SklearnRUL('logistic'),
173
+ 'gbm': lambda: SklearnRUL('gbm'),
174
+ 'aft': WeibullAFT,
175
+ }
176
+
177
+
178
+ # ------------------------------------------------------------------------- planners
179
+
180
+ def assemble(selected_days, locations, start_time):
181
+ parked_day = start_time + pd.Timedelta(days=WINDOW_DAYS + 1)
182
+ scheduled = set(selected_days.index)
183
+ parked = [b for b in locations['battery'] if b not in scheduled]
184
+ plan = pd.concat([
185
+ pd.DataFrame({'day': selected_days.values, 'battery': selected_days.index}),
186
+ pd.DataFrame({'day': parked_day, 'battery': parked}),
187
+ ], ignore_index=True).sort_values('day', kind='stable').reset_index(drop=True)
188
+ check_plan_valid(plan, locations, start_time=start_time)
189
+ return plan
190
+
191
+
192
+ def plan_onepd(pred, included, locations, start_time, day_q):
193
+ order = pred.loc[included, 'p42'].sort_values(ascending=False)
194
+ days = start_time + pd.to_timedelta(np.arange(len(order)), unit='D')
195
+ return assemble(pd.Series(days, index=order.index), locations, start_time)
196
+
197
+
198
+ def plan_sched(pred, included, locations, start_time, day_q):
199
+ offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
200
+ days = start_time + pd.to_timedelta(offs.values, unit='D')
201
+ return assemble(pd.Series(days, index=offs.index), locations, start_time)
202
+
203
+
204
+ def plan_batch(pred, included, locations, start_time, day_q, max_groups_per_day=2):
205
+ offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
206
+ bldg = locations.set_index('battery')['building'].reindex(offs.index)
207
+
208
+ # one visit per building at that group's earliest predicted day
209
+ group_day = offs.groupby(bldg).min().sort_values()
210
+
211
+ # cap building-groups per day; push overflow to the next free day
212
+ used = {}
213
+ final = {}
214
+ for building, day in group_day.items():
215
+ d = int(day)
216
+ while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS:
217
+ d += 1
218
+ used[d] = used.get(d, 0) + 1
219
+ final[building] = d
220
+
221
+ offs = bldg.map(final).astype(float)
222
+ days = start_time + pd.to_timedelta(offs.values, unit='D')
223
+ return assemble(pd.Series(days, index=offs.index), locations, start_time)
224
+
225
+
226
+ def _batch_days(offs, locations, start_time, max_groups_per_day=2):
227
+ """Shared batching pass: one visit per building at the group's earliest day."""
228
+ bldg = locations.set_index('battery')['building'].reindex(offs.index)
229
+ group_day = offs.groupby(bldg).min().sort_values()
230
+ used, final = {}, {}
231
+ for building, day in group_day.items():
232
+ d = int(day)
233
+ while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS:
234
+ d += 1
235
+ used[d] = used.get(d, 0) + 1
236
+ final[building] = d
237
+ offs = bldg.map(final).astype(float)
238
+ days = start_time + pd.to_timedelta(offs.values, unit='D')
239
+ return assemble(pd.Series(days, index=offs.index), locations, start_time)
240
+
241
+
242
+ EARLY, LATE = 0.5, 10.0
243
+ EMERGENCY_DAY = WINDOW_DAYS + 3 # emergencies run just after the plan ends
244
+
245
+
246
+ def plan_expcost(pred, included, locations, start_time, day_q):
247
+ """
248
+ Include/park and swap-day both chosen by expected cost under the model's
249
+ death distribution. `included` and `day_q` are ignored.
250
+ """
251
+ t = np.arange(1, WINDOW_DAYS + 1)
252
+ pmf = pred[[f'pd_{i}' for i in t]].values # devices x 42
253
+ p_surv = np.clip(1.0 - pmf.sum(axis=1), 0.0, 1.0)
254
+ e_far = pred['e_far'].values
255
+
256
+ d = np.arange(0, WINDOW_DAYS + 1)
257
+ delta = d[None, :, None] - t[None, None, :] # 1 x 43 x 42
258
+ swap_penalty = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
259
+ cost = (pmf[:, None, :] * swap_penalty).sum(axis=2) # devices x 43
260
+ cost += p_surv[:, None] * EARLY * np.maximum(e_far[:, None] - d[None, :], 0)
261
+
262
+ cost_park = (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
263
+
264
+ best_day = cost.argmin(axis=1)
265
+ include = cost[np.arange(len(cost)), best_day] < cost_park
266
+ offs = pd.Series(best_day[include].astype(float), index=pred.index[include])
267
+ if offs.empty:
268
+ return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
269
+ return _batch_days(offs, locations, start_time)
270
+
271
+
272
+ def plan_topsum(pred, included, locations, start_time, day_q):
273
+ """
274
+ Adaptive inclusion: take the top-N batteries by p42 with N = alpha * sum(p42).
275
+ sum(p42) tracks the expected death count even when the probability scale
276
+ drifts between refits, unlike a fixed threshold. alpha arrives via p_star.
277
+ """
278
+ alpha = plan_topsum.alpha
279
+ n = int(round(alpha * pred['p42'].sum()))
280
+ chosen = pred['p42'].nlargest(n).index
281
+ offs = pred.loc[chosen, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
282
+ if offs.empty:
283
+ return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
284
+ return _batch_days(offs, locations, start_time)
285
+
286
+
287
+ plan_topsum.alpha = 1.2
288
+
289
+ PLANNERS = {'onepd': plan_onepd, 'sched': plan_sched, 'batch': plan_batch,
290
+ 'expcost': plan_expcost, 'topsum': plan_topsum}
291
+
292
+
293
+ # ------------------------------------------------------------------------ harness
294
+
295
+ def training_pairs(table, origin_time):
296
+ closed = table['origin'] <= origin_time - pd.Timedelta(days=WINDOW_DAYS)
297
+ return table[closed]
298
+
299
+
300
+ def censored_pairs(table, origin_time):
301
+ """All prior pairs with duration censored at origin_time (for AFT)."""
302
+ prior = table[table['origin'] < origin_time].copy()
303
+ eol = prior['eol']
304
+ end = eol.where(eol.notna() & (eol <= origin_time), origin_time)
305
+ prior['duration'] = ((end - prior['origin']).dt.total_seconds() / 86400).clip(lower=0.5)
306
+ prior['event'] = (eol.notna() & (eol <= origin_time)).astype(int)
307
+ return prior
308
+
309
+
310
+ def walk_forward(table, scenario_ctx, origins, configs):
311
+ """configs: list of dicts(model, planner, p_star, day_q, name)."""
312
+ fitted = {}
313
+ records = []
314
+ for idx in origins:
315
+ ctx = scenario_ctx[idx]
316
+ origin_time = ctx['start_time']
317
+ rows = table[table['origin_idx'] == idx].set_index('device_id')
318
+
319
+ preds = {}
320
+ for model_name in {c['model'] for c in configs}:
321
+ if (model_name, idx) not in fitted:
322
+ factory = MODELS[model_name]
323
+ if model_name == 'aft':
324
+ train = censored_pairs(table, origin_time)
325
+ else:
326
+ train = training_pairs(table, origin_time)
327
+ fitted[(model_name, idx)] = factory().fit(train)
328
+ preds[model_name] = fitted[(model_name, idx)].predict(rows)
329
+
330
+ for cfg in configs:
331
+ pred = preds[cfg['model']]
332
+ if cfg['planner'] == 'topsum':
333
+ PLANNERS['topsum'].alpha = cfg['p_star'] # p_star carries alpha
334
+ included = pred.index[pred['p42'] >= cfg['p_star']]
335
+ plan = PLANNERS[cfg['planner']](pred, included, ctx['locs'],
336
+ origin_time, cfg['day_q'])
337
+ _, _, overall = evaluate_plan(
338
+ plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
339
+ eol_times=ctx['eol'], start_time=origin_time, verbose=0)
340
+ records.append(dict(overall, origin=idx, **cfg))
341
+
342
+ auc = {m: roc_auc_score(rows['dies42'], p['p42'])
343
+ for m, p in preds.items() if rows['dies42'].sum() > 0}
344
+ print(f"s_{idx}: " + ' '.join(f'{m}={a:.3f}' for m, a in auc.items()),
345
+ file=sys.stderr)
346
+ return pd.DataFrame(records)
347
+
348
+
349
+ def load_context(dataset_path):
350
+ locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
351
+ ctx = {}
352
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
353
+ for idx, (scenario, locs, cut, eol) in enumerate(gen):
354
+ ctx[idx] = dict(start_time=pd.Timestamp(scenario['start_time']),
355
+ locs=locs, eol=eol,
356
+ travel_costs=scenario['travel_costs'],
357
+ settings=scenario['settings'])
358
+ return ctx
359
+
360
+
361
+ def summarize(df, label):
362
+ df['name'] = (df['model'] + '/' + df['planner'] + '/p' +
363
+ df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
364
+ mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
365
+ print(f'\n=== {label} ===')
366
+ print(mean.round(1).to_string())
367
+ return mean
368
+
369
+
370
+ def main():
371
+ dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
372
+ table = load_features(dataset_path)
373
+ ctx = load_context(dataset_path)
374
+
375
+ phase = sys.argv[1] if len(sys.argv) > 1 else 'dev'
376
+
377
+ if phase == 'dev':
378
+ configs = []
379
+ for model in MODELS:
380
+ for planner in PLANNERS:
381
+ for p_star in (0.1, 0.2, 0.35):
382
+ for day_q in (0.05, 0.20):
383
+ configs.append(dict(model=model, planner=planner,
384
+ p_star=p_star, day_q=day_q))
385
+ df = walk_forward(table, ctx, DEV_ORIGINS, configs)
386
+ df.to_csv('/tmp/dev_results.csv', index=False)
387
+ summarize(df, f'DEV s_8..s_23 ({len(configs)} configs)')
388
+ else:
389
+ # phase 'dev2' or 'test': configs from /tmp/<phase>_configs.json
390
+ import json
391
+ configs = json.loads(Path(f'/tmp/{phase}_configs.json').read_text())
392
+ origins = DEV_ORIGINS if phase.startswith('dev') else TEST_ORIGINS
393
+ df = walk_forward(table, ctx, origins, configs)
394
+ df.to_csv(f'/tmp/{phase}_results.csv', index=False)
395
+ summarize(df, f'{phase.upper()} origins {list(origins)[0]}..{list(origins)[-1]}')
396
+
397
+
398
+ if __name__ == '__main__':
399
+ main()
batteryswap_example/planner.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Final planner: logistic RUL model + adaptive top-sum inclusion + building batching.
3
+
4
+ Selected by walk-forward CV (see models_cv.py):
5
+ dev block s_8..s_23 - hyperparameters chosen (alpha=0.8, day_q=0.05, batching)
6
+ test block s_24..s_41 - mean total_cost 1368 vs voltage-15 2034, defer-all 2512,
7
+ oracle 186
8
+
9
+ Everything the planner needs at plan() time is computed from the arguments the
10
+ competition passes it: the truncated timeseries, the locations table (which carries
11
+ each device's install date), and the scenario settings.
12
+ """
13
+
14
+ import pickle
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from batteryswap_public.interfaces import Planner
20
+
21
+ from batteryswap_example.features import daily_voltage, features_at_origin, load as load_features
22
+ from batteryswap_example.models_cv import SklearnRUL, FEATURES
23
+
24
+
25
+ class TopSumPlanner(Planner):
26
+
27
+ def __init__(self, model: SklearnRUL, alpha: float = 0.8, day_q: float = 0.05,
28
+ max_groups_per_day: int = 2):
29
+ self.model = model
30
+ self.alpha = alpha
31
+ self.day_q = day_q
32
+ self.max_groups_per_day = max_groups_per_day
33
+
34
+ def plan(self, battery_data, locations, travel_costs, settings):
35
+ window = int(settings.planning_window_days)
36
+ origin = battery_data.reset_index()['end_time'].max().normalize()
37
+
38
+ daily = daily_voltage(battery_data)
39
+ feats = features_at_origin(daily, locations, origin)
40
+ feats = feats.reindex(locations['battery'])
41
+
42
+ pred = self.model.predict(feats)
43
+
44
+ # adaptive inclusion: expected death count tracks prevalence even when the
45
+ # probability scale drifts between refits, unlike a fixed threshold
46
+ n = int(round(self.alpha * pred['p42'].sum()))
47
+ chosen = pred['p42'].nlargest(n).index
48
+
49
+ offs = pred.loc[chosen, f'day_q{self.day_q}'].round().clip(0, window)
50
+
51
+ # batching: one visit per building at the group's earliest predicted day,
52
+ # capped groups per day (travel is expensive, early days are cheap)
53
+ if len(offs):
54
+ bldg = locations.set_index('battery')['building'].reindex(offs.index)
55
+ group_day = offs.groupby(bldg).min().sort_values()
56
+ used, final = {}, {}
57
+ for building, day in group_day.items():
58
+ d = int(day)
59
+ while used.get(d, 0) >= self.max_groups_per_day and d < window:
60
+ d += 1
61
+ used[d] = used.get(d, 0) + 1
62
+ final[building] = d
63
+ offs = bldg.map(final).astype(float)
64
+
65
+ parked_day = origin + pd.Timedelta(days=window + 1)
66
+ scheduled = set(offs.index)
67
+ parked = [b for b in locations['battery'] if b not in scheduled]
68
+ plan = pd.concat([
69
+ pd.DataFrame({'day': origin + pd.to_timedelta(offs.values, unit='D'),
70
+ 'battery': offs.index}),
71
+ pd.DataFrame({'day': parked_day, 'battery': parked}),
72
+ ], ignore_index=True)
73
+ return plan.sort_values('day', kind='stable').reset_index(drop=True)
74
+
75
+
76
+ def train_full(dataset_path='dataset'):
77
+ """Refit the selected model on every fully observed (device, origin) pair."""
78
+ from pathlib import Path
79
+ table = load_features(Path(dataset_path))
80
+ train = table[table['window_closed']]
81
+ model = SklearnRUL('logistic').fit(train)
82
+ print(f'refit on {len(train)} pairs, {train.dies42.sum()} positive, '
83
+ f'origins s_0..s_{train.origin_idx.max()}')
84
+ return TopSumPlanner(model)
85
+
86
+
87
+ def main():
88
+ # When run as a script/-m, classes defined here would pickle under __main__ and
89
+ # be unloadable by script.py. Rebuild the instance from the imported module so
90
+ # the pickle records the importable path.
91
+ import os
92
+ import batteryswap_example.planner as this_module
93
+ dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')
94
+ planner = this_module.train_full(dataset_path)
95
+ path = 'batteryswap_example/planners/best.pickle'
96
+ with open(path, 'wb') as f:
97
+ pickle.dump(planner, f)
98
+ print('planner-save', path)
99
+
100
+
101
+ if __name__ == '__main__':
102
+ main()
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:66b857b72fcb7f476bbfc14077480f894ea15dfcd0043ca90bf1056cbc677de8
3
- size 589
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ba4a9792d820d91eab6cf328c8c8329dc3921d837dc864c23bdd7dbae0b1bc59
3
+ size 237194