Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
4fb1dc8
·
1 Parent(s): cedc5cb

Phase 11: price-adaptive break-even top-up (fresh-only, m0.8/pm25) + two-sided cost-curve fix

Browse files

Panel 1702.0 vs 1715.8, 63% random-split selection, never beaten in a
decided blocked split. All-48 Docker metric 2175.4 (was 2202). Includes
user's phase-10 CohortGate + merged-block protocol write-up.

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

EXPERIMENTS.md CHANGED
@@ -278,9 +278,129 @@ block), deep sequence RUL (82 deaths), RL scheduling (sample complexity),
278
  MNAR state-space voltage models (gap features were already dev-neutral;
279
  highest-effort/lowest-evidence of the batch).
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  ## Test-look ledger
282
 
283
- 8 looks total (threshold stack, topsum, ecroute+ablations, tsroute, aug-tsroute,
 
284
  +tau_projected, logbag+α-challenger, aux-features). Dev/test disagreed
285
  persistently by ~0.7σ (dev prefers richer stacks, test the simpler v1);
286
  selection followed dev + the combined mean, with the public leaderboard as
 
278
  MNAR state-space voltage models (gap features were already dev-neutral;
279
  highest-effort/lowest-evidence of the batch).
280
 
281
+ ## Phase 10 - late_swap gap: cohort-gated rescue (rejected; bucket is empty)
282
+
283
+ Motivation: public-LB late/early = 1296/156 vs leader 660/658; misses→emergency
284
+ is ~718/origin of test cost. Hypothesis: the phase-6 cohort clock (AUC 0.906 on
285
+ silent pairs) was unfairly rejected because dev-era coverage was 35%, vs 72%
286
+ test / 84% deployment-era - so deploy it *gated to covered devices only* and
287
+ validate on the late-dev block s_18-23 where coverage is representative.
288
+
289
+ Quantifying first where the cohort signal lives (covered pairs, s_18-41):
290
+
291
+ | subgroup | n | deaths | cohort_overdue_m AUC |
292
+ |---|---|---|---|
293
+ | all covered | 6134 | 131 | 0.584 |
294
+ | dark >14d / >30d (the actual miss bucket) | 1282/1144 | 26/23 | **0.426 / 0.410 - worse than chance** |
295
+ | looks healthy (v_corr > 2.55) | 4734 | **4** | 0.902 |
296
+
297
+ The 0.906 was real but rests on 4 positives: fast plungers that were still
298
+ healthy at origin. Even perfect recall there is ~4 catches per 24 origins, and
299
+ reaching them means including high-overdue healthy devices at hopeless
300
+ precision (~60:1) x ~100 early-cost each.
301
+
302
+ CV confirmation (`CohortGate`: logbag + per-device lift to a cohort-hazard
303
+ logistic on covered devices; 'silent' mode lifts only p42 < 0.2), late-dev
304
+ s_18-23: logbag 1943 · cg_all 2421 · cg_silent 2421 (identical plans - all
305
+ lifts land in the p<0.2 bucket) · cg_silent α0.9 2588. **Rejected.**
306
+
307
+ Conclusion on the late_swap gap: the remaining misses' plunge onset is *after*
308
+ the planning origin. No origin-time feature in this dataset sees them: voltage
309
+ is healthy, usage stress is flat (phase 8), FHT drift is flat (phase 9), and
310
+ the batch clock is uninformative exactly where the misses live. The late/early
311
+ imbalance vs the leader is not recoverable by rebalancing (phase 6 α-sweep) or
312
+ by any tested signal; parity would require either mid-window replanning (not
313
+ allowed by the submission format) or a signal source we do not have.
314
+
315
+ ## Protocol change (2026-08-18): dev and test merged into one 34-origin block
316
+
317
+ After 8 counted looks the test block no longer provides an unbiased holdout,
318
+ and dev s_8-23 is structurally unrepresentative of deployment (smaller refits,
319
+ 35% cohort coverage vs 84% at deployment-era origins). Before merging, every
320
+ near-miss candidate without a test number was scored on s_24-41 (look 9):
321
+
322
+ | model | dev16 | test18 | combined34 |
323
+ |---|---|---|---|
324
+ | **logbag (incumbent)** | 2073.9 | 1398.0 | **1716.1** |
325
+ | logbag_rank | 2055.3 | 1446.0 | 1732.7 |
326
+ | logbag_aux | 2035.5 | 1510.2 | 1757.4 |
327
+ | dthaz | 2089.3 | 1502.6 | 1778.7 |
328
+ | aux_proj | 2036.5 | 1596.7 | 1803.7 |
329
+
330
+ The incumbent wins the combined block against every dev-winner - merging the
331
+ blocks retroactively changes no decision ever made, which is the strongest
332
+ available evidence the dev-only selection was sound. Going forward: tune on all
333
+ 34 origins (s_8-41) with a pre-registered acceptance rule (mean improvement
334
+ AND per-origin win count AND no era-block regression); the public leaderboard
335
+ is the only remaining holdout and is spent deliberately.
336
+
337
+ **Repeated-split selection (adopted with the merge)**: training stays
338
+ walk-forward (time-locked), but champion selection is resampled - choose the
339
+ best config on a random half of the 34 origins, measure it on the other half,
340
+ repeat (2000 iid splits + all 20 blocked 3-of-6-chunk splits, which respect
341
+ window-overlap correlation). On the 5-finalist panel: logbag is the modal
342
+ champion (63% iid / 50% blocked selection frequency, and just as often the
343
+ true best on the held-out half); logbag_rank is the only live challenger
344
+ (27%/35%); aux/dthaz variants essentially never win. Mean selection regret
345
+ ~38-64 cost units. The historical dev->test split turns out to have been an
346
+ unlucky draw: on the current feature set, dev-16 alone would select
347
+ logbag_rank while the test half's best is logbag. Acceptance rule for future
348
+ candidates: selected in >=60% of random splits AND modal under blocked splits.
349
+
350
+ ## Phase 11 - price-adaptive inclusion (ADOPTED: break-even top-up, fresh-only, m0.8/pm25)
351
+
352
+ Trigger: new public leader (sackriti 1477.48, late/early 804/343 + 179 in
353
+ worker-limit penalties). Their +187 early for -492 late vs us is a 2.6:1
354
+ winning trade; our own dev frontier re-measured that same trade LOSING at
355
+ 0.5:1 (α0.8→1.2: -213 late, +406 early). Not a modelling gap - a *price* gap.
356
+
357
+ **The price structure**: a false swap costs 0.5/day against the imputed EOL
358
+ (device end_time + 30d), so it depends on distance to the split's data end:
359
+ dev origins 81-133 per false swap (day-42 placement), test 18-77 falling
360
+ monotonically, s_41 17.7 - and ~0.5 for scenarios adjacent to a split's data
361
+ end, which the runtime splits contain. α=0.8 is the CV-optimal response to
362
+ dev's prices, not a universal constant. Crucially the runtime planner and the
363
+ evaluator read the same /tmp/data metadata, so a price-based rule cannot
364
+ disagree with the scorer about prices.
365
+
366
+ **Counterfactual harness** (`/tmp/counterfactual.py`): same dev origins, same
367
+ real deaths, same evaluator - end_time/EOL observability truncated at
368
+ origin+50d to reproduce cheap pricing (~19/false swap) from local data only.
369
+ There, break-even inclusion (ecroute m1.0) wins 1678 vs champion 1754; α
370
+ widening still fails (+10 at best) - a fixed count cannot express "include
371
+ everything break-even at today's prices".
372
+
373
+ **Iterations under the merged-block protocol** (panel = all 34 origins +
374
+ repeated-split selection):
375
+
376
+ | candidate | panel mean | random-split sel% | verdict |
377
+ |---|---|---|---|
378
+ | champion (fixed α0.8) | 1715.8 | - | baseline |
379
+ | ungated break-even top-up m1.0 | +24 vs champ on real dev, worst +461 | - | rejected: EV errors are punished where wrong costs ~100 |
380
+ | + price gate pm25 (v1 e_far clip) | 1760.5 | 5.5% | rejected: clip made long-stale devices (imputed EOL in the past) look FREE; evaluator charges those swaps LATE 10/day (s_34 +411, s_36 +850, s_37 +668) |
381
+ | + two-sided unclipped pricing | 1706.6 | 42.5% | closer; price-0 "stale, imputed EOL in-window" fiction still costs mobility (s_23/s_41 flips) |
382
+ | + fresh-only (e_far >= 42), m1.0 | 1703.8 | 25.6% | s_39 -78 / s_40 -406 / s_41 +76 |
383
+ | **+ fresh-only, m0.8** | **1702.0** | **63.0%** | **ADOPTED**: s_40 -438.7, s_41 -102.2, s_39 +72.5; 31/34 exact ties |
384
+
385
+ Blocked splits: 50/50 with champion, but every champion "win" is an
386
+ alphabetical tie-break on byte-identical costs (splits excluding the last
387
+ chunk are exact ties); the hybrid wins every decided split. Mean selection
388
+ regret drops 36 -> 8. The cost_curves fix (two-sided, unclipped survivor
389
+ pricing) is byte-inert for the old champion on all 34 origins and is adopted
390
+ as a pure correction.
391
+
392
+ New champion: **logbag / tsroute α=0.8 + break-even top-up (ec_margin=0.8,
393
+ ec_pmax=25, fresh devices only) + fixed cost curves**. The top-up is
394
+ structurally empty at dev-like prices (all 16 dev origins byte-identical) and
395
+ widens exactly where a wrong inclusion is provably cheap - which is where the
396
+ runtime splits live. Expected effect is largest on public/private scenarios
397
+ near their data end; the panel contains only ~3 such origins, so the local
398
+ delta (-14) understates the deployable one.
399
+
400
  ## Test-look ledger
401
 
402
+ 8 looks total (now historical - see protocol change above); look 9 = the
403
+ near-miss backfill table. Original ledger: 8 looks (threshold stack, topsum, ecroute+ablations, tsroute, aug-tsroute,
404
  +tau_projected, logbag+α-challenger, aux-features). Dev/test disagreed
405
  persistently by ~0.7σ (dev prefers richer stacks, test the simpler v1);
406
  selection followed dev + the combined mean, with the public leaderboard as
batteryswap_example/models_cv.py CHANGED
@@ -736,6 +736,49 @@ class ConformalCut:
736
  return out
737
 
738
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  MODELS = {
740
  'rank-v': RankVoltage,
741
  'logistic': lambda: SklearnRUL('logistic'),
@@ -787,6 +830,8 @@ MODELS = {
787
  'crt_in50': lambda: ConformalCut(0.50, 'in'),
788
  'logbag_fht': lambda: LogBagFeats(['fht_days', 'wnr_m', 'wnr_s2']),
789
  'logbag_fht2': lambda: LogBagFeats(['fht_days']),
 
 
790
  }
791
 
792
 
@@ -953,13 +998,19 @@ def cost_curves(pred, locations, start_time, blend=0.0):
953
  t = np.arange(1, WINDOW_DAYS + 1, dtype=float)
954
 
955
  end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
 
 
 
956
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
957
- + 30.0).clip(lower=WINDOW_DAYS + 1).values
958
 
959
  delta = grid[None, :, None] - t[None, None, :] # 1 x 43 x 42
960
  pen = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
961
  curves = p42[:, None] * (pmf[:, None, :] * pen).sum(axis=2)
962
- curves += (1 - p42)[:, None] * EARLY * np.maximum(e_far[:, None] - grid[None, :], 0)
 
 
 
963
 
964
  park = p42 * (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
965
  return curves, park
@@ -1147,10 +1198,34 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
1147
  if plan_tsroute.p_be > 0:
1148
  topups = pred.index[pred['p42'] >= plan_tsroute.p_be]
1149
  chosen = chosen.union(topups)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  if len(chosen) == 0:
1151
  return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
1152
 
1153
- curves, _ = cost_curves(pred, locations, start_time)
1154
  if plan_tsroute.floor_day > 0:
1155
  uncertain = (pred['p42'] < plan_tsroute.p_conf).values
1156
  curves[np.ix_(uncertain, np.arange(plan_tsroute.floor_day))] = np.inf
@@ -1173,6 +1248,8 @@ plan_tsroute.alpha = 0.8
1173
  plan_tsroute.p_be = 0.0
1174
  plan_tsroute.floor_day = 0
1175
  plan_tsroute.p_conf = 0.5
 
 
1176
 
1177
  def plan_tspig(pred, included, locations, start_time, day_q,
1178
  travel_costs=None, settings=None):
@@ -1407,6 +1484,8 @@ def walk_forward(table, scenario_ctx, origins, configs):
1407
  PLANNERS['tsroute'].p_be = cfg.get('p_be', 0.0)
1408
  PLANNERS['tsroute'].floor_day = cfg.get('floor_day', 0)
1409
  PLANNERS['tsroute'].p_conf = cfg.get('p_conf', 0.5)
 
 
1410
  global EMERGENCY_DAY
1411
  EMERGENCY_DAY = cfg.get('emergency_day', WINDOW_DAYS + 3)
1412
  optimize_days.restarts = int(cfg.get('restarts', 2))
@@ -1447,7 +1526,7 @@ def summarize(df, label):
1447
  df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
1448
  for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'),
1449
  ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'),
1450
- ('iters', 'i')]:
1451
  if key in df.columns and df[key].nunique(dropna=False) > 1:
1452
  df['name'] += '/' + tag + df[key].fillna(0).astype(str)
1453
  mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
@@ -1456,6 +1535,67 @@ def summarize(df, label):
1456
  return mean
1457
 
1458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1459
  def main():
1460
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
1461
  table = load_features(dataset_path)
@@ -1498,6 +1638,15 @@ def main():
1498
  df = walk_forward(table, ctx, DEV_ORIGINS, configs)
1499
  df.to_csv('/tmp/dev_results.csv', index=False)
1500
  summarize(df, f'DEV s_8..s_23 ({len(configs)} configs)')
 
 
 
 
 
 
 
 
 
1501
  else:
1502
  # phase 'dev2' or 'test': configs from /tmp/<phase>_configs.json
1503
  import json
 
736
  return out
737
 
738
 
739
+
740
+ class CohortGate:
741
+ """
742
+ logbag everywhere; on devices whose install batch has a calibrated monthly
743
+ clock (cohort_rel_age_m present), lift p42 to a cohort-hazard logistic's
744
+ probability when it is higher. mode 'silent' lifts only devices the voltage
745
+ model scores low (<0.2) - the miss bucket - leaving confident scores alone.
746
+ """
747
+
748
+ CFEATS = ['cohort_rel_age_m', 'cohort_overdue_m', 'age_days']
749
+
750
+ def __init__(self, mode='all'):
751
+ self.mode = mode
752
+
753
+ def fit(self, train):
754
+ self.base = SklearnRUL('logbag').fit(train)
755
+ cov = train[train['cohort_rel_age_m'].notna()]
756
+ self.cmodel = None
757
+ if len(cov) >= 300 and cov['dies42'].sum() >= 10:
758
+ self.cmodel = make_pipeline(SimpleImputer(strategy='median'),
759
+ StandardScaler(),
760
+ LogisticRegression(max_iter=2000, C=1.0))
761
+ self.cmodel.fit(cov[self.CFEATS], cov['dies42'])
762
+ return self
763
+
764
+ def predict(self, rows):
765
+ out = self.base.predict(rows)
766
+ if self.cmodel is None:
767
+ return out
768
+ covered = rows['cohort_rel_age_m'].notna().values
769
+ if not covered.any():
770
+ return out
771
+ pc = np.zeros(len(rows))
772
+ pc[covered] = self.cmodel.predict_proba(
773
+ rows.loc[covered, self.CFEATS])[:, 1]
774
+ p = out['p42'].values
775
+ lift = covered & (pc > p)
776
+ if self.mode == 'silent':
777
+ lift &= p < 0.2
778
+ out['p42'] = np.where(lift, pc, p)
779
+ return out
780
+
781
+
782
  MODELS = {
783
  'rank-v': RankVoltage,
784
  'logistic': lambda: SklearnRUL('logistic'),
 
830
  'crt_in50': lambda: ConformalCut(0.50, 'in'),
831
  'logbag_fht': lambda: LogBagFeats(['fht_days', 'wnr_m', 'wnr_s2']),
832
  'logbag_fht2': lambda: LogBagFeats(['fht_days']),
833
+ 'cg_all': lambda: CohortGate('all'),
834
+ 'cg_silent': lambda: CohortGate('silent'),
835
  }
836
 
837
 
 
998
  t = np.arange(1, WINDOW_DAYS + 1, dtype=float)
999
 
1000
  end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
1001
+ # unclipped: for long-stale devices the imputed EOL (last data + 30d) can
1002
+ # precede the window, and the evaluator then charges a swap as LATE at
1003
+ # 10/day - the old clip(lower=43) made exactly those devices look free
1004
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1005
+ + 30.0).values
1006
 
1007
  delta = grid[None, :, None] - t[None, None, :] # 1 x 43 x 42
1008
  pen = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
1009
  curves = p42[:, None] * (pmf[:, None, :] * pen).sum(axis=2)
1010
+ surv_delta = e_far[:, None] - grid[None, :]
1011
+ curves += (1 - p42)[:, None] * np.where(surv_delta >= 0,
1012
+ EARLY * surv_delta,
1013
+ LATE * -surv_delta)
1014
 
1015
  park = p42 * (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
1016
  return curves, park
 
1198
  if plan_tsroute.p_be > 0:
1199
  topups = pred.index[pred['p42'] >= plan_tsroute.p_be]
1200
  chosen = chosen.union(topups)
1201
+ curves, park = cost_curves(pred, locations, start_time)
1202
+ if plan_tsroute.ec_margin > 0:
1203
+ # break-even top-up: include every battery whose best swap day is
1204
+ # cheaper than parking it. The false-swap price is 0.5/day against the
1205
+ # imputed EOL (end_time+30), so this set is tiny when data extends far
1206
+ # past the window (train dev block) and widens automatically when the
1207
+ # scenario sits near the split's data end (runtime), where false swaps
1208
+ # cost almost nothing but misses still cost 10/day.
1209
+ # ec_pmax guards the p42/curve estimation error: the top-up only fires
1210
+ # for batteries whose worst-case cost of a wrong inclusion (false swap
1211
+ # at day 42) is at most ec_pmax - EV says include, price caps the risk.
1212
+ # Real-dev CV rejected the ungated rule (+24, worst origin +461); the
1213
+ # counterfactual cheap-price CV shows -76 where wrong costs ~19.
1214
+ end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
1215
+ e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1216
+ + 30.0).values
1217
+ # top-up only fresh devices (imputed EOL beyond the window): for stale
1218
+ # ones the survivor branch is a bookkeeping fiction - swapping "on the
1219
+ # imputed day" scores ~0 but buys nothing and costs real mobility
1220
+ # (panel: admitting them turned s_23/s_41 from ties into losses)
1221
+ fp_price = EARLY * (e_far - WINDOW_DAYS)
1222
+ be = pred.index[(e_far >= WINDOW_DAYS)
1223
+ & (curves.min(axis=1) < plan_tsroute.ec_margin * park)
1224
+ & (fp_price <= plan_tsroute.ec_pmax)]
1225
+ chosen = chosen.union(be)
1226
  if len(chosen) == 0:
1227
  return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
1228
 
 
1229
  if plan_tsroute.floor_day > 0:
1230
  uncertain = (pred['p42'] < plan_tsroute.p_conf).values
1231
  curves[np.ix_(uncertain, np.arange(plan_tsroute.floor_day))] = np.inf
 
1248
  plan_tsroute.p_be = 0.0
1249
  plan_tsroute.floor_day = 0
1250
  plan_tsroute.p_conf = 0.5
1251
+ plan_tsroute.ec_margin = 0.0
1252
+ plan_tsroute.ec_pmax = 25.0
1253
 
1254
  def plan_tspig(pred, included, locations, start_time, day_q,
1255
  travel_costs=None, settings=None):
 
1484
  PLANNERS['tsroute'].p_be = cfg.get('p_be', 0.0)
1485
  PLANNERS['tsroute'].floor_day = cfg.get('floor_day', 0)
1486
  PLANNERS['tsroute'].p_conf = cfg.get('p_conf', 0.5)
1487
+ PLANNERS['tsroute'].ec_margin = cfg.get('ec_margin', 0.0)
1488
+ PLANNERS['tsroute'].ec_pmax = cfg.get('ec_pmax', 25.0)
1489
  global EMERGENCY_DAY
1490
  EMERGENCY_DAY = cfg.get('emergency_day', WINDOW_DAYS + 3)
1491
  optimize_days.restarts = int(cfg.get('restarts', 2))
 
1526
  df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
1527
  for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'),
1528
  ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'),
1529
+ ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm')]:
1530
  if key in df.columns and df[key].nunique(dropna=False) > 1:
1531
  df['name'] += '/' + tag + df[key].fillna(0).astype(str)
1532
  mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
 
1535
  return mean
1536
 
1537
 
1538
+ ALL_ORIGINS = range(8, 42) # merged dev+test block (protocol 2026-08-18)
1539
+
1540
+
1541
+ def selection_stability(df, n_random=2000, n_blocks=6, seed=0):
1542
+ """
1543
+ Repeated-split champion selection over the merged origin block.
1544
+
1545
+ Training is walk-forward and time-locked, so only the *selection* step is
1546
+ resampled: choose the best config on half the origins, measure it on the
1547
+ other half. Random iid splits estimate selection frequency; blocked splits
1548
+ (contiguous chunks) respect the correlation between origins whose 42-day
1549
+ windows overlap. Acceptance rule (EXPERIMENTS.md): a challenger should be
1550
+ selected in >= 60% of random splits AND be modal under blocked splits.
1551
+ """
1552
+ from itertools import combinations
1553
+
1554
+ df = df.copy()
1555
+ df['name'] = (df['model'] + '/' + df['planner'] + '/p' +
1556
+ df['p_star'].astype(str))
1557
+ # distinguish configs that differ only in planner knobs (same tags as summarize)
1558
+ for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'),
1559
+ ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'),
1560
+ ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm')]:
1561
+ if key in df.columns and df[key].nunique(dropna=False) > 1:
1562
+ df['name'] += '/' + tag + df[key].fillna(0).astype(str)
1563
+ pv = df.pivot_table(index='origin', columns='name', values='total_cost')
1564
+ pv = pv.sort_index()
1565
+ names = pv.columns.tolist()
1566
+ X = pv.values
1567
+ n = len(pv)
1568
+ rng = np.random.RandomState(seed)
1569
+
1570
+ def run(splits, label):
1571
+ sel = {m: 0 for m in names}
1572
+ best = {m: 0 for m in names}
1573
+ regrets = []
1574
+ for A in splits:
1575
+ B = np.setdiff1d(np.arange(n), A)
1576
+ i = X[A].mean(0).argmin()
1577
+ j = X[B].mean(0).argmin()
1578
+ sel[names[i]] += 1
1579
+ best[names[j]] += 1
1580
+ regrets.append(X[B].mean(0)[i] - X[B].mean(0)[j])
1581
+ k = len(splits)
1582
+ print(f'\n--- selection stability: {label} ({k} splits) ---')
1583
+ print(f"{'config':40s} {'selected%':>9s} {'best-holdout%':>13s} {'mean':>8s}")
1584
+ for m in sorted(names, key=lambda m: -sel[m]):
1585
+ print(f'{m:40s} {100*sel[m]/k:9.1f} {100*best[m]/k:13.1f} '
1586
+ f'{X[:, names.index(m)].mean():8.1f}')
1587
+ print(f'mean selection regret {np.mean(regrets):.1f} '
1588
+ f'(90th pct {np.percentile(regrets, 90):.1f})')
1589
+
1590
+ half = n // 2
1591
+ run([rng.choice(n, half, replace=False) for _ in range(n_random)],
1592
+ f'random {half}/{n - half}')
1593
+ blocks = np.array_split(np.arange(n), n_blocks)
1594
+ run([np.concatenate([blocks[i] for i in c])
1595
+ for c in combinations(range(n_blocks), n_blocks // 2)],
1596
+ f'blocked ({n_blocks} chunks choose {n_blocks // 2})')
1597
+
1598
+
1599
  def main():
1600
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
1601
  table = load_features(dataset_path)
 
1638
  df = walk_forward(table, ctx, DEV_ORIGINS, configs)
1639
  df.to_csv('/tmp/dev_results.csv', index=False)
1640
  summarize(df, f'DEV s_8..s_23 ({len(configs)} configs)')
1641
+ elif phase == 'panel':
1642
+ # merged-block evaluation (protocol of 2026-08-18): score configs on all
1643
+ # 34 usable origins, then repeated-split selection stability
1644
+ import json
1645
+ configs = json.loads(Path('/tmp/panel_configs.json').read_text())
1646
+ df = walk_forward(table, ctx, ALL_ORIGINS, configs)
1647
+ df.to_csv('/tmp/panel_results.csv', index=False)
1648
+ summarize(df, f'PANEL s_{ALL_ORIGINS[0]}..s_{ALL_ORIGINS[-1]}')
1649
+ selection_stability(df)
1650
  else:
1651
  # phase 'dev2' or 'test': configs from /tmp/<phase>_configs.json
1652
  import json
batteryswap_example/planner.py CHANGED
@@ -101,6 +101,8 @@ class ECRoutePlanner(Planner):
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
 
 
101
  feats = feats.join(phys).reindex(locations['battery'])
102
  pred = self.model.predict(feats)
103
  plan_tsroute.alpha = 0.8
104
+ plan_tsroute.ec_margin = 0.8 # phase-11 break-even top-up
105
+ plan_tsroute.ec_pmax = 25.0
106
  return plan_tsroute(pred, None, locations, origin, 0.05,
107
  travel_costs=travel_costs, settings=settings)
108
 
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:d768c5ac63b5308c6d0c446fa7e468fdb78fc94df843d8f9d6a8dc9ac71e288c
3
- size 318867
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1d263e1282560dc608868cb9ce3360007c2230b360bd4ea272b260bd3b08b0d0
3
+ size 318953
batteryswap_example/submit.py CHANGED
@@ -43,6 +43,14 @@ MODEL_KIND = 'logbag'
43
  # late saturates ~1100 regardless - the remaining misses are voltage-silent
44
  # deaths that no inclusion appetite can catch.
45
  ALPHA = float(os.environ.get('BATTERYSWAP_ALPHA', '0.8'))
 
 
 
 
 
 
 
 
46
 
47
 
48
  def build_split_table(locations, timeseries, eol_times, scen_starts, log=print):
@@ -118,6 +126,8 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
118
 
119
  pred = model.predict(feats)
120
  plan_tsroute.alpha = ALPHA
 
 
121
  plan = plan_tsroute(pred, None, locs, T, 0.05,
122
  travel_costs=scenario['travel_costs'],
123
  settings=scenario['settings'])
 
43
  # late saturates ~1100 regardless - the remaining misses are voltage-silent
44
  # deaths that no inclusion appetite can catch.
45
  ALPHA = float(os.environ.get('BATTERYSWAP_ALPHA', '0.8'))
46
+ # Break-even top-up (phase 11): additionally include every FRESH battery
47
+ # (imputed EOL beyond the window) whose best-day expected cost beats parking,
48
+ # gated to fire only where a wrong inclusion costs <= EC_PMAX. Empty at
49
+ # dev-like prices; widens near a split's data end, where false swaps are
50
+ # nearly free but misses still cost 10/day. Panel 1702.0 vs 1715.8, selected
51
+ # in 63% of random half-splits, never beaten in a decided blocked split.
52
+ EC_MARGIN = float(os.environ.get('BATTERYSWAP_EC_MARGIN', '0.8'))
53
+ EC_PMAX = float(os.environ.get('BATTERYSWAP_EC_PMAX', '25'))
54
 
55
 
56
  def build_split_table(locations, timeseries, eol_times, scen_starts, log=print):
 
126
 
127
  pred = model.predict(feats)
128
  plan_tsroute.alpha = ALPHA
129
+ plan_tsroute.ec_margin = EC_MARGIN
130
+ plan_tsroute.ec_pmax = EC_PMAX
131
  plan = plan_tsroute(pred, None, locs, T, 0.05,
132
  travel_costs=scenario['travel_costs'],
133
  settings=scenario['settings'])