BatterySwapAI2026-sackriti
Reproducible CPU-only submission repository for the BatterySwapAI 2026
Challenge. The runtime entry point is script.py; it loads the committed
planner in batteryswap_sackriti/planners/best.pickle and writes the required
submission.csv with the official batteryswap_public helper.
The submitted tree contains no dataset, generated submission, cache, secret, or
network dependency. See SUBMISSION_021.md for the method
and its evidence, REPRODUCIBILITY.md for the complete
rebuild and validation procedure, THIRD_PARTY_NOTICES.md
for dependency provenance, and COMPETITION_COMPLIANCE.md
before publishing any new commit.
The idea
eol_times is not a latent quantity to be predicted. It is exactly the first
day on which the officially smoothed voltage β batteryswap_public.utils.smooth_series,
the organiser's own function β is at or below 2.40 V.
On the public split that rule reproduces the labels perfectly:
| Check | Result |
|---|---|
| Observed end-of-life dates reproduced to the day | 82 / 82 |
| Batteries that cross 2.40 V but carry no label | 0 |
| Batteries with a label that never cross | 0 |
The build re-runs that audit every time and prints the result, so a change in the organiser's data would surface immediately rather than silently.
This makes the task a first-passage problem, not a classification problem. The question is not "is this battery unhealthy?" but "when will this curve first touch this line, and how uncertain is that date?" Submissions 007β017 answered the first question with an Extra Trees classifier, a 0.45 probability threshold and a 20-battery cap. Submission 021 answers the second.
How it works
1. The official smoother, exactly, in 1.6 seconds. A vectorised
reimplementation whose output is bit-identical to smooth_series (max absolute
difference 0.0 across 336,096 public rows; also checked against the real function
in tests/test_crossing.py). Getting it right is subtle β the official smoother
resamples onto a dense daily grid before dropping days with fewer than five
in-band readings, so a sparse day becomes a blank slot rather than disappearing,
and the seven-slot rolling median spans seven calendar days rather than seven
observations. The obvious implementation loses 9,258 rows and misses a label.
Being fast is what makes it affordable to recompute the whole causal state at
every scenario.
2. A survival model over the drawdown distribution. Only 82 batteries ever
fail, so a classifier for "fails within 42 days" has 82 events to learn from no
matter how many features it is given β that, not the feature set, is the binding
constraint on the earlier submissions. But because the label is a crossing, the
event "fails within h days" is identical to "the voltage falls by at least
m within h days", where m is the battery's current margin. So the model is
trained on that general question with (h, m) supplied as inputs. Now every
battery-day is a labelled example at every margin β a healthy cell that drops
0.03 V in three weeks is direct evidence about the drawdown law. Training goes
from 454 positive scenario rows to 2.0 million labelled questions, and the
actual margin is oversampled so the model is sharpest where it is queried.
3. Seasonality is worth three weeks of ageing. The smoothed voltage carries a common seasonal swing of about 0.048 V peak-to-trough, against a typical near-failure decline of 0.0022 V/day. It is why failures cluster in autumn and winter (46 of 82 crossings fall between September and January). The planner estimates a cross-sectional common factor contemporaneously, fits a day-of-year climatology on strictly past data, and tells the model how much of the required fall the calendar will supply on its own over the queried horizon. Worth β104 on out-of-fold cost by itself.
4. Expected cost, not a threshold. A battery left out of the plan is retired by the evaluator after the window closes, at 10 hour-equivalents per day late β roughly 240 hours. A needless swap costs 0.5 per day of remaining life. Both options are priced for every battery from its own survival curve, including the evaluator's assumed end-of-life 30 days after a device's last reading (which makes a needless swap expensive early in the year and cheap late in it). A scheduler then decides selection, timing and routing together, pricing every candidate move with the evaluator's real accounting for travel, room and building changes, overtime and the daily and weekly caps. It plans 8 batteries in one scenario and 24 in another, where the champion always plans about 13 to 17.
5. Nothing label-derived at inference. The 2.40 V constant was read off the public labels, which the hidden split does not provide. The planner does not depend on it: a battery still present at a scenario cut is one the organiser has not retired, so its smoothed voltage has never touched the threshold, and the smallest historical minimum across live batteries bounds it from above. On the public split that bound lands within 0.018 V of 2.40 in every scenario. At inference the smoother, the features, the climatology and the threshold are all recomputed from the scenario's own leakage-safe telemetry cut.
Results
Lower is better. Model folds are disjoint by battery (or by building), so no held-out battery contributes a single training row; every scenario is then replanned from its own leakage-safe cut and scored by the official evaluator on complete plans.
| Validation view | Submission 016 | Submission 021 | Delta |
|---|---|---|---|
| Battery-disjoint out-of-fold, 48 scenarios | 1735.7828 | 1237.0882 | β498.6946 (β28.7%) |
| Building-disjoint out-of-fold, 48 scenarios | 1767.6100 | 1350.6761 | β416.9339 (β23.6%) |
Both fold structures agree on the direction and rough size of the gain; the building split is the harder test because it also removes the shared local environment.
At a matched workload of 20 batteries per scenario the model misses 1.79 mandatory batteries against the champion's reported 3.65 (3.40 with the Submission 017 rescue). For scale, a planner handed the true end-of-life dates and this same scheduler scores 123.48, so essentially all remaining cost is forecast uncertainty rather than routing.
Development history, each step measured end to end on the battery-disjoint view:
| Model | Cost | Misses/scenario |
|---|---|---|
| Drawdown model, base features | 1377.19 | 2.38 |
| plus seasonal common factor | 1272.81 | 2.15 |
| plus drawdown history and first-passage z-scores | 1288.10 | 2.23 |
| plus pre-smoothing band signals and temperature correction | 1252.82 | 2.08 |
| shipped two-seed ensemble | 1237.09 | 2.27 |
On not overfitting
The hidden split is what counts, so every reported number is out-of-fold and the guards are deliberate:
- A four-day timing shift scored 1224.57 against the shipped two-day value's 1237.09, and was rejected β its neighbours (1237.89 at three days, 1239.63 at five) show it is scheduling discreteness, not signal.
- Sharpening the survival curve (powers 0.7 to 2.0) and biasing selection (0.8Γ to 2.2Γ the cost of a miss) both make the result worse in either direction. The optimum sitting at the neutral value is evidence the model is calibrated rather than tuned.
- The model is heavily regularised: minimum leaf 200 on 2.0 M rows. That is why its full-fit public replay (716.29) is worse than Submission 017's (575.72) while its out-of-fold score is far better β a lightly regularised forest fitted on the public labels reproduces them well and generalises worse. The full-fit replay is a memorisation diagnostic, not a comparison between policies.
Locked production policy
| Control | Value |
|---|---|
| Model | drawdown survival classifier, gradient-boosted histograms |
| Base features | 122 causal, plus 12 horizon/margin query terms |
| Trees / learning rate / leaves / min leaf | 450 / 0.07 / 63 / 200 |
| Seed ensemble | 2 (seeds 0, 1) |
| Augmented training rows | ~2.0 M per seed |
| Horizon grid (training / query) | 12 horizons / 108 horizons to 260 days |
| End-of-life threshold | 2.40 V, re-derived label-free per scenario |
| Selection rule | expected cost of swapping vs. skipping |
| Candidate gain floor / cap | 0.5 h / 120 |
| Timing | expected-cost minimising day, shifted 2 days earlier |
| Scheduler | greedy insertion then move/drop local search, 4 passes |
| Feature history required | 560 days |
| Device batch at inference | 64 |
Runtime
The 48-scenario public replay takes about nine minutes against roughly twenty-five for Submission 017, and peaks at 1.5 GB against the evaluation container's 2 GB cap. Telemetry is walked in device batches because everything except the common factor is per battery; batching is verified to produce byte-identical plans. If a scenario ever raises, the planner degrades to a margin-ranked fallback plan rather than failing the whole submission.
Quick verification
After placing the official split under dataset/train/:
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt -r requirements.dev.txt
python -m batteryswap_sackriti.train_crossing \
--dataset-path dataset --split train \
--output-path batteryswap_sackriti/planners/best.pickle
python -m pytest
BATTERYSWAP_DATASET_PATH=dataset BATTERYSWAP_SPLITS=train \
BATTERYSWAP_SUBMISSION_PATH=submission.csv python script.py
BATTERYSWAP_DATASET_PATH=dataset BATTERYSWAP_SPLITS=train \
BATTERYSWAP_SUBMISSION_PATH=submission.csv \
python -m batteryswap_public.metric
submission.csv is a generated result and is intentionally ignored. The
repository is self-contained at inference time and does not call APIs, download
models, require a GPU, or use external training data.
Earlier submissions
Submissions 007 through 020 are a single line of work: an Extra Trees failure classifier, then reranking, rescue, value-gating and learned-replacement stages layered on top of it. Their artifacts are preserved unchanged:
| Artifact | SHA-256 | Policy |
|---|---|---|
planners/submission_019.pickle |
c363624d...aff4b |
agreement-gated value wrapper, replacements disabled |
planners/submission_017.pickle |
1a6b3e7a...a154c |
guarded stale-telemetry rescue over the 016 reranker |
planners/submission_007.pickle |
1ecf0ce1...830ca |
frozen trajectory model both of the above wrap |
batteryswap_sackriti/submission_020.py holds a separate learned-replacement
candidate on the same 016 base; it is unpromoted and does not affect the runtime
artifact. Their method and validation are recorded in
SUBMISSION_016.md, SUBMISSION_017.md,
SUBMISSION_019.md, RESEARCH_EVOLUTION.md
and EXPERIMENTS.md.
Submission 021 is not another layer on that stack. It replaces the classifier, the threshold and the cap outright, because the label turned out to be a rule rather than a target.
Publishing
Follow PUBLISHING.md. The numbered Hugging Face submission
refs are immutable, and the supported clean-history command is:
SUBMISSION_NAME=submission-021 ./scripts/publish_huggingface_clean.sh