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 filesPanel 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 +21 -0
- batteryswap_example/models_cv.py +64 -9
- batteryswap_example/planner.py +1 -0
- batteryswap_example/submit.py +5 -0
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 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 =
|
| 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'])
|