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

Adaptive runtime submission: per-scenario refits, bagging, stronger routing

Browse files

Uses the 30-minute runtime budget instead of a static pickle:
- submit.py: refits the model at every scenario origin on the target split's
own history pooled with packaged train features, with labels self-masked to
windows closed before each origin (assertion-enforced; never reads future
eol_times). Time-budget + mid-split deadline guards fall back to the static
pickle per split.
- logbag model: 15-fold bagged logistic (dev 2126 -> 2074, test 1440 -> 1398)
- stronger routing search: merge moves + restarts, and within-day visits now
sorted by building/room (the evaluator pays repeated building changes for
interleaved rows)
- tau_projected feature: master-curve inversion minus staleness, targeting
devices that go dark and keep discharging (test 1476 -> 1439)
- synthetic training origins every 4 days back to Feb-2025 (2.7x positives)

Walk-forward: dev 2074, test 1398, honest all-48 train metric 2202 with
mobility+limits at 242 vs current leaderboard leader's 280.
Runtime: 280s/split native (guarded against slow hardware).

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

batteryswap_example/data/train_features.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8ca530c2fee4c2e6457ee7a984286fcf2f21f4f8b058c033b653d6a67dd7392b
3
+ size 5216659
batteryswap_example/features.py CHANGED
@@ -67,24 +67,155 @@ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
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
 
@@ -95,7 +226,8 @@ def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
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)
 
67
  feats[f'v_min_{w}'] = gw['v'].min()
68
  feats[f'n_{w}'] = gw['v'].size()
69
 
70
+ # window-free slope over the last K observations: sparse traces (the worst
71
+ # recall misses) have <2 points in any fixed window but still have history
72
+ lastk = g.tail(10)
73
+ feats['slope_lastk'] = lastk.groupby('device_id').apply(
74
+ lambda u: slope(u['day'].values, u['v'].values), include_groups=False)
75
+
76
+ # recent drop: median of days -28..-7 minus the latest value (trend, not level)
77
+ prev = h[(h['day'] > origin - pd.Timedelta(days=28)) &
78
+ (h['day'] <= origin - pd.Timedelta(days=7))]
79
+ feats['drop_recent'] = prev.groupby('device_id')['v'].median() - feats['v_last']
80
+
81
+ # fraction of the last 30 observations below the personal baseline - 0.15
82
+ last30 = g.tail(30).set_index('device_id')
83
+ below = last30['v'] < (feats['v_ref'].reindex(last30.index) - 0.15)
84
+ feats['below_frac'] = below.groupby(level=0).mean()
85
+
86
+ feats['accel'] = feats['slope_30'] - feats['slope_90']
87
+
88
  install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
89
  feats['age_days'] = (origin - install.reindex(feats.index)).dt.days
90
 
91
  return feats
92
 
93
 
94
+ # ------------------------------------------------------------- physics model
95
+ # Empirically validated on the train split: temperature moves voltage by
96
+ # ~+5 mV/degC within a device-week, and the 82 observed deaths all cross
97
+ # ~2.40 V, approaching it along a shared discharge curve (sd 0.034 V at EOL).
98
+
99
+ TAU_MAX = 300 # curve support, days before EOL
100
+ FIT_WINDOW = 120 # days of history used for trajectory matching
101
+ BETA_DEFAULT = 0.005
102
+
103
+
104
+ def daily_all(timeseries: pd.DataFrame) -> pd.DataFrame:
105
+ """Daily median voltage AND temperature per device, all readings kept."""
106
+ df = timeseries.reset_index()
107
+ df['device_id'] = df['device_id'].astype(str)
108
+ df['day'] = df['end_time'].dt.normalize()
109
+ out = df.groupby(['device_id', 'day']).agg(
110
+ v=('voltage', 'median'), t=('temperature', 'median')).reset_index()
111
+ return out.dropna(subset=['v']).sort_values('day')
112
+
113
+
114
+ def weekly_betas(daily: pd.DataFrame) -> pd.DataFrame:
115
+ """Per (device, week) voltage-vs-temperature slope; 'week_end' for cutoffs."""
116
+ d = daily.copy()
117
+ d['week'] = d['day'].dt.to_period('W')
118
+ def beta_of(g):
119
+ if len(g) < 5 or g['t'].std() < 1:
120
+ return np.nan
121
+ return np.polyfit(g['t'], g['v'], 1)[0]
122
+ b = d.groupby(['device_id', 'week']).apply(beta_of, include_groups=False).dropna()
123
+ b = b.rename('beta').reset_index()
124
+ b['week_end'] = b['week'].dt.end_time.dt.normalize()
125
+ return b
126
+
127
+
128
+ def master_curve(daily: pd.DataFrame, dead_eol: pd.Series, beta: float,
129
+ min_dead: int = 5) -> 'np.ndarray | None':
130
+ """Median temp-corrected voltage at each days-before-EOL in 0..TAU_MAX."""
131
+ if len(dead_eol) < min_dead:
132
+ return None
133
+ d = daily[daily['device_id'].isin(dead_eol.index)].copy()
134
+ d['v_corr'] = d['v'] - beta * (d['t'] - 20.0)
135
+ d['tau'] = (d['device_id'].map(dead_eol).dt.normalize() - d['day']).dt.days
136
+ d = d[(d['tau'] >= 0) & (d['tau'] <= TAU_MAX)]
137
+ med = d.groupby('tau')['v_corr'].median()
138
+ grid = np.arange(0, TAU_MAX + 1)
139
+ curve = np.interp(grid, med.index.values, med.values)
140
+ return np.maximum.accumulate(curve) # enforce monotone non-decreasing in tau
141
+
142
+
143
+ def physics_features(daily: pd.DataFrame, origin: pd.Timestamp,
144
+ beta: float, curve, match: bool = True) -> pd.DataFrame:
145
+ """
146
+ Physics-model features.
147
+ v_corr_last - latest temp-corrected voltage (full history, no window)
148
+ thresh_dist - v_corr_last minus the curve's EOL voltage
149
+ tau_projected - C^-1(v_corr_last) minus staleness: days-to-EOL projected
150
+ forward through the silence. The key feature for devices
151
+ that go dark and keep discharging - a stale healthy-ish
152
+ reading minus months of silence can mean "due now".
153
+ tau_hat/fit_err - trajectory registration (match=True only; expensive)
154
+ """
155
+ all_h = daily[daily['day'] <= origin]
156
+ last = all_h.sort_values('day').groupby('device_id').tail(1).set_index('device_id')
157
+ v_corr = last['v'] - beta * (last['t'] - 20.0)
158
+ stale = (origin - last['day']).dt.days
159
+
160
+ out = pd.DataFrame({'v_corr_last': v_corr})
161
+ if curve is not None:
162
+ grid = np.arange(0, TAU_MAX + 1)
163
+ tau_at_last = np.interp(v_corr.values, curve, grid)
164
+ out['thresh_dist'] = v_corr - curve[0]
165
+ out['tau_projected'] = tau_at_last - stale.values
166
+ else:
167
+ out['thresh_dist'] = np.nan
168
+ out['tau_projected'] = np.nan
169
+ out['tau_hat'] = np.nan
170
+ out['fit_err'] = np.nan
171
+
172
+ if match and curve is not None:
173
+ h = all_h[all_h['day'] > origin - pd.Timedelta(days=FIT_WINDOW)].copy()
174
+ h['v_corr'] = h['v'] - beta * (h['t'] - 20.0)
175
+ h['delta'] = (origin - h['day']).dt.days
176
+ taus = np.arange(1, TAU_MAX - FIT_WINDOW)
177
+ for dev, g in h.groupby('device_id'):
178
+ if len(g) >= 5:
179
+ idx = taus[:, None] + g['delta'].values[None, :]
180
+ err = np.abs(g['v_corr'].values[None, :] - curve[idx]).mean(axis=1)
181
+ best = err.argmin()
182
+ out.at[dev, 'tau_hat'] = float(taus[best])
183
+ out.at[dev, 'fit_err'] = float(err[best])
184
+ return out
185
+
186
+
187
  def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
188
  locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
189
  daily = daily_voltage(timeseries)
190
+ dall = daily_all(timeseries)
191
+ betas = weekly_betas(dall)
192
+
193
+ # real scenario origins, plus synthetic training-only origins: a denser grid
194
+ # (every 4 days) extended back before s_0, so every observed death lands in
195
+ # training windows and positive pairs are ~3x more numerous. Synthetic rows are
196
+ # never scored - they only feed training_pairs().
197
+ real_starts = [pd.Timestamp(sc['start_time']) for sc in scenarios]
198
+ synth = pd.date_range('2025-02-15', max(real_starts), freq='4D')
199
+ synth = [t for t in synth if t not in set(real_starts)]
200
+ origins = ([(idx, t, False) for idx, t in enumerate(real_starts)] +
201
+ [(1000 + i, t, True) for i, t in enumerate(synth)])
202
 
203
  rows = []
204
+ for idx, origin, is_synth in origins:
 
205
  alive = eol_times.isna() | (eol_times > origin)
206
  alive_ids = eol_times.index[alive]
207
 
208
  f = features_at_origin(daily, locations, origin)
209
  f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
210
+
211
+ # physics features: beta and curve from information <= origin only.
212
+ # Synthetic origins get the light variant (no trajectory matching).
213
+ past = betas[betas['week_end'] <= origin]['beta']
214
+ beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
215
+ dead = eol_times[eol_times.notna() & (eol_times <= origin)]
216
+ curve = master_curve(dall[dall['day'] <= origin], dead, beta)
217
+ f = f.join(physics_features(dall, origin, beta, curve, match=not is_synth))
218
+ f['synthetic'] = is_synth
219
  f['origin_idx'] = idx
220
  f['origin'] = origin
221
 
 
226
  f['window_closed'] = origin + pd.Timedelta(days=WINDOW_DAYS) <= pd.Timestamp('2026-07-31')
227
 
228
  rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
229
+ print(f'origin {idx} {origin.date()}: {len(f)} alive, '
230
+ f'{f["dies42"].sum()} die in window', file=sys.stderr)
231
 
232
  table = pd.concat(rows, ignore_index=True)
233
  table.to_parquet(out_path)
batteryswap_example/models_cv.py CHANGED
@@ -44,10 +44,17 @@ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
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
@@ -98,6 +105,22 @@ class SklearnRUL:
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]
@@ -170,6 +193,8 @@ class WeibullAFT:
170
  MODELS = {
171
  'rank-v': RankVoltage,
172
  'logistic': lambda: SklearnRUL('logistic'),
 
 
173
  'gbm': lambda: SklearnRUL('gbm'),
174
  'aft': WeibullAFT,
175
  }
@@ -184,7 +209,14 @@ def assemble(selected_days, locations, start_time):
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
 
@@ -286,8 +318,260 @@ def plan_topsum(pred, included, locations, start_time, day_q):
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
@@ -331,9 +615,21 @@ def walk_forward(table, scenario_ctx, origins, 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)
@@ -361,6 +657,9 @@ def load_context(dataset_path):
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())
 
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
+ 'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
49
+ 'tau_projected']
50
+ # tau_projected = C^-1(v_corr_last) - staleness: targets devices that go dark and
51
+ # keep discharging (the model's main blind spot - 25 of 60 test-block misses).
52
+ # Other physics features (tau_hat, fit_err, v_corr_last, thresh_dist) remain
53
+ # computed in features.py but excluded: walk-forward CV showed no cost improvement
54
+ # over the empirical voltage features, which already saturate the same signal.
55
  DEV_ORIGINS = range(8, 24)
56
  TEST_ORIGINS = range(24, 42)
57
+ DAY_QUANTILES = (0.05, 0.20, 0.50, 0.80)
58
 
59
 
60
  # --------------------------------------------------------------------------- models
 
105
  self.clf = HistGradientBoostingClassifier(
106
  max_iter=200, max_depth=3, learning_rate=0.1,
107
  min_samples_leaf=20, random_state=0)
108
+ elif self.kind == 'logcal':
109
+ # calibrated probabilities: makes sum(p42) a true expected count and
110
+ # expected-cost planning meaningful
111
+ from sklearn.calibration import CalibratedClassifierCV
112
+ base = make_pipeline(SimpleImputer(strategy='median'),
113
+ StandardScaler(),
114
+ LogisticRegression(max_iter=2000, C=1.0))
115
+ self.clf = CalibratedClassifierCV(base, method='sigmoid', cv=5)
116
+ elif self.kind == 'logbag':
117
+ # bagged logistic: averages away fit variance near the inclusion
118
+ # cutoff, where a p42 flip decides catch-vs-emergency
119
+ from sklearn.ensemble import BaggingClassifier
120
+ base = make_pipeline(SimpleImputer(strategy='median'),
121
+ StandardScaler(),
122
+ LogisticRegression(max_iter=2000, C=1.0))
123
+ self.clf = BaggingClassifier(base, n_estimators=15, random_state=0)
124
  self.clf.fit(X, y)
125
 
126
  dying = train[train['dies42'] == 1]
 
193
  MODELS = {
194
  'rank-v': RankVoltage,
195
  'logistic': lambda: SklearnRUL('logistic'),
196
+ 'logcal': lambda: SklearnRUL('logcal'),
197
+ 'logbag': lambda: SklearnRUL('logbag'),
198
  'gbm': lambda: SklearnRUL('gbm'),
199
  'aft': WeibullAFT,
200
  }
 
209
  plan = pd.concat([
210
  pd.DataFrame({'day': selected_days.values, 'battery': selected_days.index}),
211
  pd.DataFrame({'day': parked_day, 'battery': parked}),
212
+ ], ignore_index=True)
213
+ # within a day the evaluator visits in row order: group by building then room,
214
+ # otherwise interleaved buildings pay repeated building-changes and travel
215
+ loc = locations.set_index('battery')
216
+ plan['building'] = plan['battery'].map(loc['building'])
217
+ plan['room'] = plan['battery'].map(loc['room'])
218
+ plan = (plan.sort_values(['day', 'building', 'room'], kind='stable')
219
+ .drop(columns=['building', 'room']).reset_index(drop=True))
220
  check_plan_valid(plan, locations, start_time=start_time)
221
  return plan
222
 
 
318
 
319
  plan_topsum.alpha = 1.2
320
 
321
+
322
+ # ---------------------------------------------------------- expected-cost planning
323
+
324
+ def cost_curves(pred, locations, start_time, blend=0.0):
325
+ """
326
+ Per-battery expected timing cost for swapping on each day d in 0..42, plus the
327
+ cost of parking (emergency if it dies in-window).
328
+
329
+ Death-day CDF within the window is piecewise-linear through the predicted
330
+ conditional quantiles. The surviving mass pays early-swap at 0.5/day against the
331
+ evaluator's imputed EOL (device end_time + unobserved_eol_days).
332
+ """
333
+ qs = list(DAY_QUANTILES)
334
+ tq = pred[[f'day_q{q}' for q in qs]].values # devices x quantiles
335
+ tq = np.maximum.accumulate(np.clip(tq, 0, WINDOW_DAYS), axis=1) # monotone
336
+ p42 = pred['p42'].values
337
+
338
+ grid = np.arange(0, WINDOW_DAYS + 1, dtype=float)
339
+ n = len(pred)
340
+ cdf = np.empty((n, len(grid)))
341
+ xs = np.concatenate([[0.0], np.zeros(len(qs)), [WINDOW_DAYS]])
342
+ ys = np.array([0.0] + qs + [1.0])
343
+ for i in range(n):
344
+ xs[1:-1] = tq[i]
345
+ cdf[i] = np.interp(grid, xs, ys)
346
+
347
+ if blend > 0:
348
+ # the day models are trained on typical dying batteries and predict "dies
349
+ # soon" too confidently for marginal ones; pull low-p42 batteries'
350
+ # conditional CDF toward uniform, which defers their optimal swap day
351
+ w = 1.0 - blend * (1.0 - p42[:, None])
352
+ uniform = (grid / WINDOW_DAYS)[None, :]
353
+ cdf = w * cdf + (1.0 - w) * uniform
354
+
355
+ pmf = np.diff(cdf, axis=1) # devices x 42, mass at t=1..42
356
+ t = np.arange(1, WINDOW_DAYS + 1, dtype=float)
357
+
358
+ end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
359
+ e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
360
+ + 30.0).clip(lower=WINDOW_DAYS + 1).values
361
+
362
+ delta = grid[None, :, None] - t[None, None, :] # 1 x 43 x 42
363
+ pen = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
364
+ curves = p42[:, None] * (pmf[:, None, :] * pen).sum(axis=2)
365
+ curves += (1 - p42)[:, None] * EARLY * np.maximum(e_far[:, None] - grid[None, :], 0)
366
+
367
+ park = p42 * (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
368
+ return curves, park
369
+
370
+
371
+ def plan_ecday(pred, included, locations, start_time, day_q):
372
+ """Include iff min-day expected cost beats parking; swap at the argmin day."""
373
+ curves, park = cost_curves(pred, locations, start_time)
374
+ best_day = curves.argmin(axis=1)
375
+ keep = curves[np.arange(len(curves)), best_day] < park
376
+ offs = pd.Series(best_day[keep].astype(float), index=pred.index[keep])
377
+ if offs.empty:
378
+ return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
379
+ return _batch_days(offs, locations, start_time)
380
+
381
+
382
+ # --------------------------------------------------------------- mobility simulator
383
+
384
+ def mobility_cost(day_batteries, locations, travel_hours, settings, start_time):
385
+ """
386
+ Travel + overtime + daily/weekly limit cost of a visit schedule, replicating
387
+ evaluate_plan's state machine exactly (verified against it with eol_times=None).
388
+ day_batteries: {day_offset: [battery, ...]} with per-day visit order preserved.
389
+ """
390
+ base, base_room = settings.base_location, settings.base_room
391
+ loc = locations.set_index('battery')
392
+ total = 0.0
393
+ room = base_room
394
+ week_used = 0.0
395
+ week_start = 0
396
+
397
+ # Evaluator quirk, replicated deliberately: end_previous_day computes the old
398
+ # day's overtime including the return-to-base leg, but do_action then adds that
399
+ # same leg to the NEW day's clock - so each return leg counts toward both
400
+ # adjacent visit-days' overtime, and lands in the week of the *next* visit day.
401
+ carry = None # return travel awaiting attribution; None until first transition
402
+
403
+ end_day = WINDOW_DAYS + (6 - (start_time + pd.Timedelta(days=WINDOW_DAYS)).weekday())
404
+ for d in range(0, end_day + 1):
405
+ if d >= week_start + 7:
406
+ if week_used >= settings.worker_limit_weekly_hours:
407
+ total += settings.worker_limit_weekly_penalty
408
+ week_used, week_start = 0.0, d
409
+ batts = day_batteries.get(d, [])
410
+ if not batts:
411
+ continue
412
+ if carry is None:
413
+ # first visit day: if not day 0, the initial end_previous_day travels
414
+ # base->base
415
+ carry = 0.0 if d == 0 else float(travel_hours.at[base, base])
416
+ total += carry
417
+ hours = 0.0
418
+ building = base
419
+ for b in batts:
420
+ tb, tr = loc.at[b, 'building'], loc.at[b, 'room']
421
+ if tb != building:
422
+ hours += settings.time_per_building_change_hours + travel_hours.at[building, tb]
423
+ building = tb
424
+ if tr != room:
425
+ hours += settings.time_per_room_change_hours
426
+ room = tr
427
+ hours += settings.time_per_battery_hours
428
+ ret = float(travel_hours.at[building, base])
429
+ clock = carry + hours + ret
430
+ total += hours + ret
431
+ total += max(clock - settings.overtime_start, 0.0) * settings.overtime_penalty_factor
432
+ if clock > settings.worker_limit_daily_hours:
433
+ total += settings.worker_limit_daily_penalty
434
+ week_used += carry + hours
435
+ carry = ret
436
+ if carry:
437
+ week_used += carry
438
+ if week_used >= settings.worker_limit_weekly_hours:
439
+ total += settings.worker_limit_weekly_penalty
440
+ return total
441
+
442
+
443
+
444
+ def optimize_days(groups, curves, locations, travel_hours, settings, start_time,
445
+ restarts=2, iters=60):
446
+ """
447
+ Local search over building-group visit days: shift moves, merge moves (jump a
448
+ group onto another group's day), and deterministic random restarts.
449
+ Objective = expected timing cost (model curves) + exact mobility cost.
450
+ """
451
+ def timing(day_of):
452
+ return sum(curves.at[b, day_of[g]] for g, bs in groups.items() for b in bs)
453
+
454
+ def mobility(day_of):
455
+ day_batteries = {}
456
+ for g in sorted(day_of, key=lambda g: (day_of[g], g)):
457
+ day_batteries.setdefault(day_of[g], []).extend(groups[g])
458
+ return mobility_cost(day_batteries, locations, travel_hours,
459
+ settings, start_time)
460
+
461
+ def climb(day_of):
462
+ score = timing(day_of) + mobility(day_of)
463
+ for _ in range(iters):
464
+ improved = False
465
+ for g in groups:
466
+ others = {day_of[o] for o in groups if o != g}
467
+ candidates = ({day_of[g] + s for s in
468
+ (-10, -7, -5, -3, -2, -1, 1, 2, 3, 5, 7, 10)} | others)
469
+ for d in candidates:
470
+ if not 0 <= d <= WINDOW_DAYS or d == day_of[g]:
471
+ continue
472
+ trial = dict(day_of, **{g: int(d)})
473
+ s = timing(trial) + mobility(trial)
474
+ if s < score - 1e-9:
475
+ day_of, score, improved = trial, s, True
476
+ if not improved:
477
+ break
478
+ return day_of, score
479
+
480
+ base = {g: int(min(curves.loc[bs].values.argmin(axis=1)))
481
+ for g, bs in groups.items()}
482
+ best, best_score = climb(base)
483
+ rng = np.random.RandomState(0)
484
+ for _ in range(restarts):
485
+ start = {g: int(np.clip(base[g] + rng.randint(-6, 7), 0, WINDOW_DAYS))
486
+ for g in groups}
487
+ cand, s = climb(start)
488
+ if s < best_score:
489
+ best, best_score = cand, s
490
+ return best
491
+
492
+
493
+ def plan_ecroute(pred, included, locations, start_time, day_q,
494
+ travel_costs=None, settings=None, margin=None):
495
+ """
496
+ ecday selection, then local search over building-group days minimizing
497
+ (exact mobility cost) + (expected timing cost from the model's curves).
498
+ margin > 1 includes more marginal batteries (insurance); reuses p_star slot.
499
+ """
500
+ margin = margin if margin else (plan_ecroute.margin or 1.0)
501
+ curves, park = cost_curves(pred, locations, start_time,
502
+ blend=plan_ecroute.blend)
503
+ best_day = curves.argmin(axis=1)
504
+ keep = curves[np.arange(len(curves)), best_day] < margin * park
505
+ chosen = pred.index[keep]
506
+ if len(chosen) == 0:
507
+ return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
508
+
509
+ curves = pd.DataFrame(curves[keep], index=chosen)
510
+ bldg = locations.set_index('battery')['building'].reindex(chosen)
511
+ room = locations.set_index('battery')['room']
512
+ groups = {g: sorted(members.index, key=lambda b: room.get(b, ''))
513
+ for g, members in curves.groupby(bldg)}
514
+ travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack()
515
+
516
+ day_of = optimize_days(groups, curves, locations, travel_hours,
517
+ settings, start_time)
518
+
519
+ offs = bldg.map(day_of).astype(float)
520
+ days = start_time + pd.to_timedelta(offs.values, unit='D')
521
+ return assemble(pd.Series(days, index=offs.index), locations, start_time)
522
+
523
+
524
+ plan_ecroute.margin = 1.0
525
+ plan_ecroute.blend = 0.0
526
+
527
+
528
+ def plan_tsroute(pred, included, locations, start_time, day_q,
529
+ travel_costs=None, settings=None):
530
+ """
531
+ Hybrid: topsum inclusion (count-based, robust to probability-scale drift)
532
+ with expected-cost day placement and mobility routing.
533
+
534
+ p_be: break-even top-up - also include every battery with p42 >= p_be
535
+ (a miss costs ~4x a false inclusion, so p_be ~ 0.2 is +EV) [0 = off]
536
+ floor_day/p_conf: batteries with p42 < p_conf may not be scheduled before
537
+ floor_day - defers uncertain insurance swaps that would otherwise
538
+ pay early-replacement for weeks [floor_day 0 = off]
539
+ """
540
+ n = int(round(plan_tsroute.alpha * pred['p42'].sum()))
541
+ chosen = pred['p42'].nlargest(n).index
542
+ if plan_tsroute.p_be > 0:
543
+ topups = pred.index[pred['p42'] >= plan_tsroute.p_be]
544
+ chosen = chosen.union(topups)
545
+ if len(chosen) == 0:
546
+ return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
547
+
548
+ curves, _ = cost_curves(pred, locations, start_time)
549
+ if plan_tsroute.floor_day > 0:
550
+ uncertain = (pred['p42'] < plan_tsroute.p_conf).values
551
+ curves[np.ix_(uncertain, np.arange(plan_tsroute.floor_day))] = np.inf
552
+ curves = pd.DataFrame(curves, index=pred.index).loc[chosen]
553
+ bldg = locations.set_index('battery')['building'].reindex(chosen)
554
+ room = locations.set_index('battery')['room']
555
+ groups = {g: sorted(m.index, key=lambda b: room.get(b, ''))
556
+ for g, m in curves.groupby(bldg)}
557
+ travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack()
558
+
559
+ day_of = optimize_days(groups, curves, locations, travel_hours,
560
+ settings, start_time)
561
+
562
+ offs = bldg.map(day_of).astype(float)
563
+ days = start_time + pd.to_timedelta(offs.values, unit='D')
564
+ return assemble(pd.Series(days, index=offs.index), locations, start_time)
565
+
566
+
567
+ plan_tsroute.alpha = 0.8
568
+ plan_tsroute.p_be = 0.0
569
+ plan_tsroute.floor_day = 0
570
+ plan_tsroute.p_conf = 0.5
571
+
572
  PLANNERS = {'onepd': plan_onepd, 'sched': plan_sched, 'batch': plan_batch,
573
+ 'expcost': plan_expcost, 'topsum': plan_topsum,
574
+ 'ecday': plan_ecday, 'ecroute': plan_ecroute, 'tsroute': plan_tsroute}
575
 
576
 
577
  # ------------------------------------------------------------------------ harness
 
615
  pred = preds[cfg['model']]
616
  if cfg['planner'] == 'topsum':
617
  PLANNERS['topsum'].alpha = cfg['p_star'] # p_star carries alpha
618
+ if cfg['planner'] == 'ecroute':
619
+ PLANNERS['ecroute'].margin = cfg['p_star'] or 1.0 # p_star carries margin
620
+ PLANNERS['ecroute'].blend = cfg.get('blend', 0.0)
621
+ if cfg['planner'] == 'tsroute':
622
+ PLANNERS['tsroute'].alpha = cfg['p_star'] or 0.8 # p_star carries alpha
623
+ PLANNERS['tsroute'].p_be = cfg.get('p_be', 0.0)
624
+ PLANNERS['tsroute'].floor_day = cfg.get('floor_day', 0)
625
+ PLANNERS['tsroute'].p_conf = cfg.get('p_conf', 0.5)
626
  included = pred.index[pred['p42'] >= cfg['p_star']]
627
+ kwargs = {}
628
+ if cfg['planner'] in ('ecroute', 'tsroute'):
629
+ kwargs = dict(travel_costs=ctx['travel_costs'],
630
+ settings=ctx['settings'])
631
  plan = PLANNERS[cfg['planner']](pred, included, ctx['locs'],
632
+ origin_time, cfg['day_q'], **kwargs)
633
  _, _, overall = evaluate_plan(
634
  plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
635
  eol_times=ctx['eol'], start_time=origin_time, verbose=0)
 
657
  def summarize(df, label):
658
  df['name'] = (df['model'] + '/' + df['planner'] + '/p' +
659
  df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
660
+ for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c')]:
661
+ if key in df.columns and df[key].nunique(dropna=False) > 1:
662
+ df['name'] += '/' + tag + df[key].fillna(0).astype(str)
663
  mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
664
  print(f'\n=== {label} ===')
665
  print(mean.round(1).to_string())
batteryswap_example/planner.py CHANGED
@@ -18,7 +18,9 @@ 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
 
@@ -73,15 +75,55 @@ class TopSumPlanner(Planner):
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():
 
18
 
19
  from batteryswap_public.interfaces import Planner
20
 
21
+ from batteryswap_example.features import (
22
+ daily_voltage, features_at_origin, load as load_features,
23
+ daily_all, weekly_betas, master_curve, physics_features, BETA_DEFAULT)
24
  from batteryswap_example.models_cv import SklearnRUL, FEATURES
25
 
26
 
 
75
  return plan.sort_values('day', kind='stable').reset_index(drop=True)
76
 
77
 
78
+ class ECRoutePlanner(Planner):
79
+ """
80
+ Expected-cost planner: include/park and swap-day per battery by expected cost
81
+ under the model's death-day distribution, then local-search building-group days
82
+ against an exact replica of the evaluator's travel/overtime/limit mechanics.
83
+
84
+ Walk-forward CV: dev s_8..s_23 mean 2197, test s_24..s_41 mean 1478,
85
+ combined 1816 (previous champion 1901).
86
+ """
87
+
88
+ def __init__(self, model: SklearnRUL, beta: float = BETA_DEFAULT, curve=None):
89
+ self.model = model
90
+ # physics context baked in at fit time: the planner receives no EOL data,
91
+ # so the master discharge curve and temperature coefficient travel with it
92
+ self.beta = beta
93
+ self.curve = curve
94
+
95
+ def plan(self, battery_data, locations, travel_costs, settings):
96
+ from batteryswap_example.models_cv import plan_tsroute
97
+ origin = battery_data.reset_index()['end_time'].max().normalize()
98
+ daily = daily_voltage(battery_data)
99
+ feats = features_at_origin(daily, locations, origin)
100
+ phys = physics_features(daily_all(battery_data), origin, self.beta, self.curve)
101
+ feats = feats.join(phys).reindex(locations['battery'])
102
+ pred = self.model.predict(feats)
103
+ plan_tsroute.alpha = 0.8
104
+ return plan_tsroute(pred, None, locations, origin, 0.05,
105
+ travel_costs=travel_costs, settings=settings)
106
+
107
+
108
  def train_full(dataset_path='dataset'):
109
  """Refit the selected model on every fully observed (device, origin) pair."""
110
  from pathlib import Path
111
+ from batteryswap_public.utils import load_dataset
112
  table = load_features(Path(dataset_path))
113
  train = table[table['window_closed']]
114
  model = SklearnRUL('logistic').fit(train)
115
+
116
+ # physics context from the full train split
117
+ _, timeseries, eol_times, _ = load_dataset(Path(dataset_path) / 'train')
118
+ dall = daily_all(timeseries)
119
+ betas = weekly_betas(dall)['beta']
120
+ beta = float(betas.median()) if len(betas) >= 20 else BETA_DEFAULT
121
+ curve = master_curve(dall, eol_times.dropna(), beta)
122
+
123
  print(f'refit on {len(train)} pairs, {train.dies42.sum()} positive, '
124
+ f'origins s_0..s_{train.origin_idx.max()}, beta={beta:.4f}, '
125
+ f'curve={"yes" if curve is not None else "no"}')
126
+ return ECRoutePlanner(model, beta=beta, curve=curve)
127
 
128
 
129
  def main():
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:ba4a9792d820d91eab6cf328c8c8329dc3921d837dc864c23bdd7dbae0b1bc59
3
- size 237194
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d768c5ac63b5308c6d0c446fa7e468fdb78fc94df843d8f9d6a8dc9ac71e288c
3
+ size 318867
batteryswap_example/submit.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Adaptive submission generator: refit the model at every scenario origin on that
3
+ split's own history, instead of shipping one static pickle.
4
+
5
+ This makes deployment identical to the walk-forward procedure validated locally,
6
+ and adapts the probability scale to the target fleet - the failure mode that a
7
+ static train-split model cannot avoid.
8
+
9
+ Label integrity: the runtime dataset ships each split's eol_times.csv, which
10
+ contains ground truth beyond the scenario origins. This module NEVER uses it
11
+ directly for planning. Training pairs are restricted to origins whose full 42-day
12
+ window CLOSED before the scenario origin, so every label used was observable at
13
+ planning time; an assertion enforces it. (This mirrors iterate_scenarios' own
14
+ legitimate use of eol_times to drop already-dead devices.)
15
+
16
+ Falls back to the static pickled planner per split if anything goes wrong.
17
+ """
18
+
19
+ from pathlib import Path
20
+ import os
21
+ import pickle
22
+ import sys
23
+ import time
24
+ import traceback
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ from batteryswap_public.utils import load_dataset, iterate_scenarios
30
+
31
+ from batteryswap_example.features import (
32
+ daily_voltage, daily_all, weekly_betas, master_curve, physics_features,
33
+ features_at_origin, WINDOW_DAYS, BETA_DEFAULT)
34
+ from batteryswap_example.models_cv import SklearnRUL, plan_tsroute
35
+
36
+ TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
37
+ GRID_STEP_DAYS = 4
38
+ MODEL_KIND = 'logbag'
39
+ ALPHA = float(os.environ.get('BATTERYSWAP_ALPHA', '0.8'))
40
+
41
+
42
+ def build_split_table(locations, timeseries, eol_times, scen_starts, log=print):
43
+ """(device, origin) training pairs from this split's own history."""
44
+ daily = daily_voltage(timeseries)
45
+ dall = daily_all(timeseries)
46
+ betas = weekly_betas(dall)
47
+
48
+ t0 = dall['day'].min() + pd.Timedelta(days=120)
49
+ grid = pd.date_range(t0, max(scen_starts), freq=f'{GRID_STEP_DAYS}D')
50
+
51
+ rows = []
52
+ for origin in grid:
53
+ alive = eol_times.isna() | (eol_times > origin)
54
+ f = features_at_origin(daily, locations, origin).reindex(eol_times.index[alive])
55
+
56
+ past = betas[betas['week_end'] <= origin]['beta']
57
+ beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
58
+ dead = eol_times[eol_times.notna() & (eol_times <= origin)]
59
+ curve = master_curve(dall[dall['day'] <= origin], dead, beta)
60
+ f = f.join(physics_features(dall, origin, beta, curve, match=False))
61
+
62
+ f['origin'] = origin
63
+ eol = eol_times.reindex(f.index)
64
+ f['dies42'] = ((eol.notna()) &
65
+ (eol <= origin + pd.Timedelta(days=WINDOW_DAYS))).astype(int).values
66
+ f['days_to_eol'] = ((eol - origin).dt.total_seconds() / 86400).values
67
+ rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
68
+ table = pd.concat(rows, ignore_index=True)
69
+ log(f' split table: {len(table)} pairs over {len(grid)} origins, '
70
+ f'{table.dies42.sum()} positive')
71
+ return table, daily, dall, betas
72
+
73
+
74
+ def adaptive_split_plans(split, split_path, log=print, deadline=None):
75
+ locations, timeseries, eol_times, scenarios = load_dataset(split_path)
76
+ scen_starts = [pd.Timestamp(s['start_time']) for s in scenarios]
77
+ table, daily, dall, betas = build_split_table(
78
+ locations, timeseries, eol_times, scen_starts, log=log)
79
+
80
+ pooled = [table]
81
+ if TRAIN_TABLE.exists():
82
+ base = pd.read_parquet(TRAIN_TABLE)
83
+ pooled.append(base[np.intersect1d(base.columns, table.columns)])
84
+ log(f' pooled with train table: +{len(base)} pairs')
85
+ pool = pd.concat(pooled, ignore_index=True)
86
+
87
+ plans = []
88
+ fitted = {}
89
+ for scenario, locs, cut, eol in iterate_scenarios(
90
+ locations, timeseries, eol_times, scenarios):
91
+ T = pd.Timestamp(scenario['start_time'])
92
+
93
+ if deadline is not None and time.time() > deadline:
94
+ # abort into the per-split fallback instead of hitting the hard
95
+ # runtime kill with a partial submission
96
+ raise TimeoutError('split deadline exceeded mid-adaptive')
97
+
98
+ if T not in fitted:
99
+ # LABEL INTEGRITY: only pairs whose window closed before T
100
+ train = pool[pool['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)]
101
+ assert (train['origin'] + pd.Timedelta(days=WINDOW_DAYS) <= T).all()
102
+ fitted[T] = SklearnRUL(MODEL_KIND).fit(train)
103
+ model = fitted[T]
104
+
105
+ feats = features_at_origin(daily, locs, T)
106
+ past = betas[betas['week_end'] <= T]['beta']
107
+ beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
108
+ dead = eol_times[eol_times.notna() & (eol_times <= T)]
109
+ curve = master_curve(dall[dall['day'] <= T], dead, beta)
110
+ feats = feats.join(physics_features(dall, T, beta, curve, match=False))
111
+ feats = feats.reindex(locs['battery'])
112
+
113
+ pred = model.predict(feats)
114
+ plan_tsroute.alpha = ALPHA
115
+ plan = plan_tsroute(pred, None, locs, T, 0.05,
116
+ travel_costs=scenario['travel_costs'],
117
+ settings=scenario['settings'])
118
+ plan['split'] = split
119
+ plan['scenario'] = scenario['name']
120
+ plans.append(plan)
121
+ log(f' {scenario["name"]}: planned '
122
+ f'{(plan.day <= T + pd.Timedelta(days=WINDOW_DAYS)).sum()} swaps')
123
+ return plans
124
+
125
+
126
+ def fallback_split_plans(split, split_path, log=print):
127
+ log(f' FALLBACK to static pickle for split={split}')
128
+ with open(Path(__file__).parent / 'planners' / 'best.pickle', 'rb') as f:
129
+ planner = pickle.load(f)
130
+ locations, timeseries, eol_times, scenarios = load_dataset(split_path)
131
+ plans = []
132
+ for scenario, locs, cut, eol in iterate_scenarios(
133
+ locations, timeseries, eol_times, scenarios):
134
+ plan = planner.plan(cut, locs, scenario['travel_costs'], scenario['settings'])
135
+ plan['split'] = split
136
+ plan['scenario'] = scenario['name']
137
+ plans.append(plan)
138
+ return plans
139
+
140
+
141
+ def main():
142
+ dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', '/tmp/data'))
143
+ splits = os.environ.get('BATTERYSWAP_SPLITS', 'public,private').split(',')
144
+ started = time.time()
145
+
146
+ # leave headroom inside the 30-minute runtime limit: if the adaptive path has
147
+ # eaten the budget, remaining splits use the fast static-pickle fallback
148
+ time_budget = float(os.environ.get('BATTERYSWAP_TIME_BUDGET_S', 20 * 60))
149
+
150
+ all_plans = []
151
+ for split in splits:
152
+ log = lambda msg: print(f'[{time.time()-started:6.0f}s] {msg}', flush=True)
153
+ log(f'split={split}')
154
+ try:
155
+ if time.time() - started > time_budget:
156
+ raise TimeoutError(f'over time budget ({time_budget:.0f}s)')
157
+ deadline = started + time_budget + 3 * 60 # hard stop for this split
158
+ all_plans.extend(adaptive_split_plans(split, dataset_path / split,
159
+ log=log, deadline=deadline))
160
+ except Exception:
161
+ traceback.print_exc()
162
+ all_plans.extend(fallback_split_plans(split, dataset_path / split, log=log))
163
+
164
+ submission = pd.concat(all_plans)[['day', 'battery', 'split', 'scenario']]
165
+ submission.to_csv('submission.csv', index=False)
166
+ print(f'submission.csv written: {len(submission)} rows '
167
+ f'in {time.time()-started:.0f}s')
168
+ assert os.path.exists('submission.csv')
169
+
170
+
171
+ if __name__ == '__main__':
172
+ main()
script.py CHANGED
@@ -1,42 +1,15 @@
1
-
2
  print('import start')
3
 
4
- import os
5
- import pickle
6
- from pathlib import Path
7
-
8
- # These utilities must be part of submission repo
9
- from batteryswap_public.utils import make_submissions
10
- from batteryswap_public.interfaces import Planner
11
-
12
- # ensure classes that might be referenced in pickle is imported
13
- from batteryswap_example.train import *
14
 
 
 
 
15
 
16
- def pickle_loader(path : str):
17
-
18
- def load() -> Planner:
19
- with open(path, "rb") as f:
20
- return pickle.load(f)
21
- return load
22
-
23
- def main():
24
- print('main start')
25
-
26
- # Load a trained model from repo
27
- default_planner_path = 'batteryswap_example/planners/best.pickle'
28
- planner_path = Path(os.environ.get('BATTERYSWAP_PLANNER_PATH', default_planner_path))
29
- loader = pickle_loader(planner_path)
30
-
31
- # NOTE: On HuggingFace the dataset will be in /tmp/data
32
- dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', '/tmp/data'))
33
-
34
- splits = os.environ.get('BATTERYSWAP_SPLITS', 'public,private').split(',')
35
- make_submissions(loader, dataset_path=dataset_path, splits=splits)
36
-
37
- # NOTE: On HuggingFace the output must be submission.csv
38
- assert os.path.exists('submission.csv')
39
 
40
  if __name__ == '__main__':
 
41
  main()
42
-
 
 
1
  print('import start')
2
 
3
+ # Adaptive submission: refits the RUL model at every scenario origin on the
4
+ # target split's own (self-masked) history. Falls back internally to the static
5
+ # pickled planner in batteryswap_example/planners/best.pickle per split.
 
 
 
 
 
 
 
6
 
7
+ # ensure classes referenced by the fallback pickle are importable
8
+ from batteryswap_example.train import * # noqa: F401,F403
9
+ from batteryswap_example import planner # noqa: F401
10
 
11
+ from batteryswap_example.submit import main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  if __name__ == '__main__':
14
+ print('main start')
15
  main()