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 filesPanel 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 +121 -1
- batteryswap_example/models_cv.py +153 -4
- batteryswap_example/planner.py +2 -0
- batteryswap_example/planners/best.pickle +2 -2
- batteryswap_example/submit.py +10 -0
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 (
|
|
|
|
| 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).
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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'])
|