Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
2ba75d6
·
1 Parent(s): 2662738

Phase 14: building day-splitting (sd7) + censoring-aware pricing adopted

Browse files

Panel 1679.2 vs 1702.0, sd7 selected in 100% of random and blocked
splits; censoring-aware curves -32 on TRUNC=30 counterfactual, inert on
covered windows. Hazard-WHEN and park_mobility rejected. All-48 Docker
metric 2146.9 (was 2175.4).

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

EXPERIMENTS.md CHANGED
@@ -461,6 +461,27 @@ is the signature of an internal cell failure, not an external stressor:
461
  nothing in temperature, telemetry health, or spatial context flags it before
462
  the plunge starts.
463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  ## Test-look ledger
465
 
466
  8 looks total (now historical - see protocol change above); look 9 = the
 
461
  nothing in temperature, telemetry health, or spatial context flags it before
462
  the plunge starts.
463
 
464
+ ## Phase 14 - planner structure audit (ADOPTED: building day-splitting + censoring-aware pricing)
465
+
466
+ With WHO-prediction mined out (phases 6-13) and ops already beating the
467
+ leader's, the audit targeted WHEN precision and pricing fidelity. Four
468
+ candidates, all CV-adjudicated:
469
+
470
+ | candidate | verdict | evidence |
471
+ |---|---|---|
472
+ | **split_days=7**: sub-group buildings by best-day bucket so the day search may split a building across days (merge moves can rejoin at zero travel cost - same-day groups share the mobility simulation) | **ADOPTED** | panel **1679.2 vs 1702.0**, selected in **100% of random AND 100% of blocked** half-splits; sd14 also wins (1694.4) but sd7 dominates |
473
+ | **censoring-aware cost curves**: death mass beyond the split's data end cannot be charged late/emergency (never in shipped eol_times) - price it via the end_time+30 imputation branch and drop it from park | **ADOPTED** | byte-inert on all covered windows (panel identical to the decimal); counterfactual TRUNC=30 (last 12 window days unobservable): **1302.2 vs 1334.4** (-32), 11/16 wins - early drops 572->480 as the planner stops paying bookkeeping on unscoreable deaths |
474
+ | hazard-based WHEN (logbag WHO + DiscreteHazard conditional bucket-CDF day quantiles) | rejected | panel 1728.4 (+26); s_8's -26 did not generalize - the 4-knot GBR quantiles are the better day estimator |
475
+ | park_mobility (emergency also pays travel/overtime) | rejected | +0.8 to +2.9 on the counterfactual - noise; emergency-cost underestimation was not the binding error |
476
+
477
+ One-visit-day-per-building had been implicit in plan_tsroute since phase 5
478
+ (`offs = bldg.map(day_of)`): a building holding both a dies-day-5 and a
479
+ dies-day-40 inclusion was forced onto one compromise day, paying both sides.
480
+
481
+ New champion: **logbag / tsroute alpha=0.8 + break-even top-up (m0.8/pm25,
482
+ fresh-only) + split_days=7 + censoring-aware two-sided cost curves**.
483
+ Panel 1679.2 (from 1715.8 pre-phase-11).
484
+
485
  ## Test-look ledger
486
 
487
  8 looks total (now historical - see protocol change above); look 9 = the
batteryswap_example/models_cv.py CHANGED
@@ -779,6 +779,28 @@ class CohortGate:
779
  return out
780
 
781
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  MODELS = {
783
  'rank-v': RankVoltage,
784
  'logistic': lambda: SklearnRUL('logistic'),
@@ -832,6 +854,7 @@ MODELS = {
832
  'logbag_fht2': lambda: LogBagFeats(['fht_days']),
833
  'cg_all': lambda: CohortGate('all'),
834
  'cg_silent': lambda: CohortGate('silent'),
 
835
  }
836
 
837
 
@@ -915,6 +938,8 @@ def _batch_days(offs, locations, start_time, max_groups_per_day=2):
915
 
916
  EARLY, LATE = 0.5, 10.0
917
  EMERGENCY_DAY = WINDOW_DAYS + 3 # emergencies run just after the plan ends
 
 
918
 
919
 
920
  def plan_expcost(pred, included, locations, start_time, day_q):
@@ -1004,15 +1029,30 @@ def cost_curves(pred, locations, start_time, blend=0.0):
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
1017
 
1018
 
@@ -1232,19 +1272,29 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
1232
  curves = pd.DataFrame(curves, index=pred.index).loc[chosen]
1233
  bldg = locations.set_index('battery')['building'].reindex(chosen)
1234
  room = locations.set_index('battery')['room']
 
 
 
 
 
 
 
 
 
1235
  groups = {g: sorted(m.index, key=lambda b: room.get(b, ''))
1236
- for g, m in curves.groupby(bldg)}
1237
  travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack()
1238
 
1239
  day_of = optimize_days(groups, curves, locations, travel_hours,
1240
  settings, start_time)
1241
 
1242
- offs = bldg.map(day_of).astype(float)
1243
  days = start_time + pd.to_timedelta(offs.values, unit='D')
1244
  return assemble(pd.Series(days, index=offs.index), locations, start_time)
1245
 
1246
 
1247
  plan_tsroute.alpha = 0.8
 
1248
  plan_tsroute.p_be = 0.0
1249
  plan_tsroute.floor_day = 0
1250
  plan_tsroute.p_conf = 0.5
@@ -1486,6 +1536,9 @@ def walk_forward(table, scenario_ctx, origins, configs):
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,7 +1579,8 @@ def summarize(df, label):
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')
@@ -1557,7 +1611,8 @@ def selection_stability(df, n_random=2000, n_blocks=6, seed=0):
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')
 
779
  return out
780
 
781
 
782
+ class LogbagDH:
783
+ """
784
+ Champion WHO (logbag p42, identical training pairs) + hazard WHEN: day
785
+ quantiles from DiscreteHazard's conditional bucket CDF instead of the
786
+ 4-knot GBR quantile models. Isolates the value of a better death-day
787
+ estimator without touching inclusion.
788
+ """
789
+
790
+ def fit(self, train):
791
+ self.who = SklearnRUL('logbag').fit(train)
792
+ aug = train.copy()
793
+ aug['duration'] = train['days_to_eol'].fillna(10000.0).clip(lower=0.5)
794
+ aug['event'] = train['dies42'].astype(int)
795
+ self.when = DiscreteHazard().fit(aug)
796
+ return self
797
+
798
+ def predict(self, rows):
799
+ out = self.when.predict(rows) # day_q from hazard CDF
800
+ out['p42'] = self.who.predict(rows)['p42'].values
801
+ return out
802
+
803
+
804
  MODELS = {
805
  'rank-v': RankVoltage,
806
  'logistic': lambda: SklearnRUL('logistic'),
 
854
  'logbag_fht2': lambda: LogBagFeats(['fht_days']),
855
  'cg_all': lambda: CohortGate('all'),
856
  'cg_silent': lambda: CohortGate('silent'),
857
+ 'logbag_dh': LogbagDH,
858
  }
859
 
860
 
 
938
 
939
  EARLY, LATE = 0.5, 10.0
940
  EMERGENCY_DAY = WINDOW_DAYS + 3 # emergencies run just after the plan ends
941
+ CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation
942
+ PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime)
943
 
944
 
945
  def plan_expcost(pred, included, locations, start_time, day_q):
 
1029
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1030
  + 30.0).values
1031
 
1032
+ # censoring-aware: deaths after the split's data end are never in the
1033
+ # shipped eol_times, so the evaluator cannot charge late/emergency for
1034
+ # them - it prices those devices via the end_time+30 imputation instead
1035
+ # (i.e. our survivor branch). Move unobservable death mass to the
1036
+ # survivor branch and drop it from the parking (emergency) estimate.
1037
+ # Inert whenever data extends >= 42d past the origin (all train panel
1038
+ # origins); bites on near-data-end scenarios (runtime splits, s_42-47).
1039
+ if CENSOR_AWARE:
1040
+ D = (end.max() - start_time).total_seconds() / 86400
1041
+ pmf = pmf * (t <= D)[None, :]
1042
+ obs_frac = pmf.sum(axis=1)
1043
+
1044
  delta = grid[None, :, None] - t[None, None, :] # 1 x 43 x 42
1045
  pen = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
1046
  curves = p42[:, None] * (pmf[:, None, :] * pen).sum(axis=2)
1047
  surv_delta = e_far[:, None] - grid[None, :]
1048
+ curves += (1 - p42 * obs_frac)[:, None] * np.where(surv_delta >= 0,
1049
+ EARLY * surv_delta,
1050
+ LATE * -surv_delta)
1051
+
1052
+ # an emergency also pays its own travel/overtime, not just late-days;
1053
+ # PARK_MOBILITY adds that per expected observable emergency
1054
+ park = (p42 * (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
1055
+ + PARK_MOBILITY * p42 * obs_frac)
1056
  return curves, park
1057
 
1058
 
 
1272
  curves = pd.DataFrame(curves, index=pred.index).loc[chosen]
1273
  bldg = locations.set_index('battery')['building'].reindex(chosen)
1274
  room = locations.set_index('battery')['room']
1275
+ key = bldg
1276
+ if plan_tsroute.split_days > 0:
1277
+ # one visit day per building forces urgent and insurance swaps in the
1278
+ # same building onto a compromise day; sub-group by best-day bucket so
1279
+ # the optimizer MAY split a building across days (merge moves can
1280
+ # always rejoin them at zero travel cost - same-day groups share the
1281
+ # mobility simulation)
1282
+ bucket = curves.values.argmin(axis=1) // int(plan_tsroute.split_days)
1283
+ key = bldg.astype(str) + '~' + pd.Series(bucket, index=curves.index).astype(str)
1284
  groups = {g: sorted(m.index, key=lambda b: room.get(b, ''))
1285
+ for g, m in curves.groupby(key)}
1286
  travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack()
1287
 
1288
  day_of = optimize_days(groups, curves, locations, travel_hours,
1289
  settings, start_time)
1290
 
1291
+ offs = key.map(day_of).astype(float)
1292
  days = start_time + pd.to_timedelta(offs.values, unit='D')
1293
  return assemble(pd.Series(days, index=offs.index), locations, start_time)
1294
 
1295
 
1296
  plan_tsroute.alpha = 0.8
1297
+ plan_tsroute.split_days = 0
1298
  plan_tsroute.p_be = 0.0
1299
  plan_tsroute.floor_day = 0
1300
  plan_tsroute.p_conf = 0.5
 
1536
  PLANNERS['tsroute'].p_conf = cfg.get('p_conf', 0.5)
1537
  PLANNERS['tsroute'].ec_margin = cfg.get('ec_margin', 0.0)
1538
  PLANNERS['tsroute'].ec_pmax = cfg.get('ec_pmax', 25.0)
1539
+ PLANNERS['tsroute'].split_days = cfg.get('split_days', 0)
1540
+ global PARK_MOBILITY
1541
+ PARK_MOBILITY = cfg.get('park_mobility', 0.0)
1542
  global EMERGENCY_DAY
1543
  EMERGENCY_DAY = cfg.get('emergency_day', WINDOW_DAYS + 3)
1544
  optimize_days.restarts = int(cfg.get('restarts', 2))
 
1579
  df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
1580
  for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'),
1581
  ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'),
1582
+ ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm'),
1583
+ ('split_days', 'sd'), ('park_mobility', 'pk')]:
1584
  if key in df.columns and df[key].nunique(dropna=False) > 1:
1585
  df['name'] += '/' + tag + df[key].fillna(0).astype(str)
1586
  mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
 
1611
  # distinguish configs that differ only in planner knobs (same tags as summarize)
1612
  for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'),
1613
  ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'),
1614
+ ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm'),
1615
+ ('split_days', 'sd'), ('park_mobility', 'pk')]:
1616
  if key in df.columns and df[key].nunique(dropna=False) > 1:
1617
  df['name'] += '/' + tag + df[key].fillna(0).astype(str)
1618
  pv = df.pivot_table(index='origin', columns='name', values='total_cost')
batteryswap_example/planner.py CHANGED
@@ -103,6 +103,7 @@ class ECRoutePlanner(Planner):
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
 
 
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
+ plan_tsroute.split_days = 7 # phase-14 building day-splitting
107
  return plan_tsroute(pred, None, locations, origin, 0.05,
108
  travel_costs=travel_costs, settings=settings)
109
 
batteryswap_example/submit.py CHANGED
@@ -51,6 +51,10 @@ ALPHA = float(os.environ.get('BATTERYSWAP_ALPHA', '0.8'))
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):
@@ -128,6 +132,7 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
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'])
 
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
+ # Phase 14: let the day search split a building across days by best-day
55
+ # bucket (7d) instead of forcing one visit day per building. Panel 1679.2 vs
56
+ # 1702.0, selected in 100% of random AND blocked half-splits.
57
+ SPLIT_DAYS = int(os.environ.get('BATTERYSWAP_SPLIT_DAYS', '7'))
58
 
59
 
60
  def build_split_table(locations, timeseries, eol_times, scen_starts, log=print):
 
132
  plan_tsroute.alpha = ALPHA
133
  plan_tsroute.ec_margin = EC_MARGIN
134
  plan_tsroute.ec_pmax = EC_PMAX
135
+ plan_tsroute.split_days = SPLIT_DAYS
136
  plan = plan_tsroute(pred, None, locs, T, 0.05,
137
  travel_costs=scenario['travel_costs'],
138
  settings=scenario['settings'])