BatterySwapAI 2026 — Sløyfe

Submission for the BatterySwapAI 2026 challenge.

Roughly 414 vibration sensors are in service in any planning window. About nine of them will reach end-of-life during the next six weeks. The task is to find those nine and send a technician — no more, no fewer.

Approach

The planner ranks every battery by the average of two percentile ranks derived from its smoothed voltage curve, calibrates that rank into a probability of dying inside the window, and takes as many as the calibrated probabilities say will die. The visits are then chained into as few trips as the day allows.

There is no trained model. The stored artefact is 1193 bytes: two ranking column names, a step calibration held as two arrays, and a schedule. That is a deliberate, measured choice — gradient boosting scored +359 ± 70 and logistic regression +292 ± 76 against this baseline, paired over 48 scenarios and validated out-of-fold on buildings. With 24 buildings of genuinely independent data, every fitted degree of freedom costs more variance than it captures.

Three decisions carry the result:

Which batteries. Not a voltage threshold, and not a fixed count. Thresholds let the count swing from 19 to 107 per scenario; a fixed count cannot fit a scenario with two dying batteries and one with nineteen. Instead the count follows the expected number of deaths:

n = round(1.5 x sum of calibrated P(dies in window)),  bounded to [8, 24]

The sum of calibrated probabilities estimates how many will actually die (correlation 0.73 with the truth). The multiplier is above one because a miss costs several times a wasted swap. Against a fixed twelve this is −100.1 ± 36.5, t = −2.74, winning 30 of 48 scenarios. The bounds matter more than the multiplier — the sweep is flat around the optimum (1.25 → 1925, 1.5 → 1889, 1.75 → 1929) and the bounds cap the damage if a split's base rate differs from this one's.

All 210 pairs of the 21 features were searched — v_min_30 + v_med_7 is the best. Percentile ranks rather than raw volts, because a ranking carries no scale, which is what lets a calibration fitted here transfer to a split with different buildings.

When. As early as the schedule allows. Replacing a battery one day late costs twenty times as much as one day early (late_replacement_penalty_daily = 10.0 against early = 0.5), so within the window it always pays to be early. Placing swaps near a predicted end-of-life instead was measured across the full range and loses monotonically — see What did not work.

How the visits are laid out. Nearest-neighbour route, chained into trips of up to 44 hours rather than one building per day. Travel is 2 x the sum of distances to the buildings visited, so ordering alone cannot change it; only chaining can, by removing the return to base between buildings. Measured on the public split:

public travel
One building per day 1735.42 75.31
Chained, 24-hour trips 1668.06 56.20
Chained, 44-hour trips 1595.10 50.61

This is the one place where the local measurement and the public split disagree in direction, twice. Chaining costs 118 on the training split (t = 3.17) and won 67 and then 73 on the public one. The daily-limit penalty rose locally and fell on public both times. The public numbers decide.

Pipeline

Stage Where What happens
Preprocessing battery_features() Last 180 days per device, then batteryswap_public.utils.smooth_series() with its default parameters
Feature generation battery_features() 21 features: voltage levels, OLS slopes over 14/30/60/90 days, curvature, threshold crossings, projected days to 2.4 V, data coverage, device age, room-relative deviations. Fully vectorised
Selection planner.selection_score(), .choose() Mean of percentile ranks of v_min_30 and v_med_7, calibrated to a probability; count follows the expected number of deaths, bounded to [8, 24]
Calibration train.fit_calibration() Isotonic regression, rank → P(dies in window). Out-of-fold over 24 buildings, stored as StepCalibration — two arrays, no sklearn in the pickle
Scheduling planner.schedule_buildings() Nearest-neighbour route, chained into trips of up to 44 hours
Serialization save_planner() pickle.dump to batteryswap_example/planners/best.pickle
Inference script.py Unchanged from the template

Parameter selection

Every parameter was chosen by grid search against the competition's own evaluator over all 48 training scenarios, not by judgement:

Parameter Value Search
DEATH_MULTIPLIER 1.5 Swept 1.25 / 1.5 / 1.75 → 1925 / 1889 / 1929. Flat around the optimum
COUNT_BOUNDS 8, 24 Caps the damage if a split's base rate differs from this one's
SCHEDULE route, 44 h Chosen on the public split, against the local measurement — see Approach
rank_features v_min_30, v_med_7 All 210 pairs of 21 features
lookback_days 180 90, 180 and 365 give identical recall
SMOOTH_PARAMS library defaults See below

Why the library defaults, even though sharper measured better

smooth_series() defaults to a 7-day rolling window and a 10–30 °C band. Those are tuned to reject temperature noise in general, not to catch a steep drop early — and a lithium cell holds an almost flat voltage for two years before falling in weeks.

Sharpening it measurably helps on the training split:

Smoothing Recall train
7-day window, 10–30 °C (default) 55.0 % 2011
3-day window, 5–35 °C 57.6 % 1782

It was still reverted. On the public split the same change scored worse: 1851 against 1735. The temperature band had been tuned against the training buildings, and the splits are split by building — the documentation notes that placement influences ambient temperature. The defaults generalised better, so the defaults are what ships.

The diagnosis behind it is worth recording. The filter discards 15 % of readings overall, but between 33.9 % and 96.8 % retained depending on the building. And it discards more from the devices that matter: units that reached end-of-life retain 77.9 % of their readings against 86.0 % for those that did not.

Reproducing

Requires Python 3.10+, Docker, and the competition dataset in dataset/.

python -m venv venv
source venv/bin/activate
pip install -r requirements.txt -r requirements.dev.txt
export BATTERYSWAP_DATASET_PATH=./dataset

Rebuild the stored planner from scratch — this regenerates best.pickle:

python -m batteryswap_example.train

Expected output: total_cost 1911.02 on the training split. The pickle it writes is byte-identical to the one in e205611, the best submission.

Other modes:

python -m lab.run20        # all twenty planners measured against each other
python -m lab.compare      # paired per-scenario tests between them
python sweep.py            # experiment bench
python sjekk.py            # full pre-submission check

Determinism

The planner contains no randomness — selection is a deterministic ranking and scheduling is deterministic given the buildings. Rebuilding best.pickle on the same dataset reproduces the same file and the same score.

The unused classifier code in train_due_classifier() uses random_state=42 throughout. It is kept as documentation of an approach that was measured and rejected; it is not part of the submission path.

Verifying a submission

sjekk.py runs the whole chain and reports five checks:

  1. check_plan_valid() passes for all 48 scenarios
  2. Local score against the reference plans
  3. Docker — pickle.load() in Python 3.10 against 3.14 locally, submission.csv parses, and the score matches the local run to six decimals
  4. best.pickle is stored inline rather than as a Git LFS pointer
  5. Worst-case planning time per scenario against the 15-second budget

Check 4 matters more than it looks. .gitattributes routes every *.pickle to LFS, and an LFS pointer is 128 bytes of text that pickle.load() cannot read. The last line of .gitattributes exempts the planner, exactly as the upstream template does for its own pickle.

Results

Mean total_cost over all 48 training scenarios, and the public leaderboard score of the corresponding submission:

train public
Unmodified example code 8673.16 22099.52
Swap nothing at all 3324.68 —
Ranking planner, one building per day 2011.24 1735.42 1f798f1
Chained trips, 24 hours 1980.46 1668.06 567215b
Chained trips, 44 hours 2032.96 1595.10 a3be5a5
Expected-deaths count, floor 12 2040.66 1601.06 e3c97eb
Smoothing window 2 1854.94 1530.36 d2c558d
This submission, window 3 1911.02 1525.77 e205611
Oracle: swap only the dying, on the exact day 205.24 —

The best public score is 1525.77, eighth of twenty-seven — the configuration this repository reproduces. The two splits are divided by building, and they have disagreed about the direction of a change four times — see the note under How the visits are laid out and the SMOOTH_WINDOW comment in batteryswap_example/features.py. The public column is the one that decides.

Where the remaining gap sits, against the leaders on the public split:

this entry 8th at the time 1st
late_swap 744.79 654.17 591.67
early_swap 367.44 475.19 279.22
travel 49.01 56.73 36.56
overtime 112.13 117.57 62.83
daily_limit 131.25 166.67 66.67

Logistics is now ahead of the eighth-placed entry on three of four components. What is left is selection: late_swap against a leader at 591.67.

Runtime

1.5 s per scenario against a budget of roughly 15 s, CPU only. Well inside the 30-minute wall-clock limit for 96 plans.

What did not work

Seventeen approaches were measured and rejected over four days. Each is documented with its numbers in the commit that tested it; the summary is here so the next person does not repeat them.

Selection. Gradient boosting reached out-of-fold average precision 0.272–0.327 and logistic regression 0.252–0.287, against 0.339 for a single raw feature. With 24 buildings of genuinely independent data, every fitted degree of freedom costs more variance than it captures. Curve extrapolation — fitting (V − 2.4) / |slope| per device with temperature as a covariate — reached 44.2 % recall against 55.0 %; on a curve this flat, a battery in the knee already has a low level, so rate and level point at the same devices. Device temperature as a ranking feature scored 3.1 %: it is a cross-sectional property, not a timing signal.

Survival analysis, the one the organisers signposted. lifelines>=0.30.0 sits in the competition's own requirements.txt, and the data is exactly the shape it is built for: 379 of 461 devices never reach end-of-life inside the training window, so the labels are censored. A classifier sees only the binary outcome; a survival model also sees how long each survivor lasted, and that a device dying on day 60 is closer than one dying on day 300.

Cox proportional hazards, scored as S(42 days | features) and fed through the same calibration and count machinery, out-of-fold on buildings:

train recall paired vs the ranking
Percentile ranking (shipped) 2034.3 57.7 % —
Cox, two covariates 2741.7 50.0 % +707.4 ± 71.4, t = +9.91, wins 2/48
Cox, four covariates 2599.2 46.4 % +564.9 ± 75.1, t = +7.53, wins 7/48

It loses on 46 of 48 scenarios. That completes a pattern worth stating plainly, because it is the central result of this entry:

logistic regression   +292 ± 76
gradient boosting     +359 ± 70
Cox proportional      +707 ± 71

Every fitted weighting loses to two raw percentile ranks, and the loss grows with the number of degrees of freedom. Three model families, one conclusion, monotone in parameters. With 24 buildings of genuinely independent data — devices in the same room share temperature, installation date and battery batch — there is not enough signal to fit anything. The ranking works precisely because it fits nothing.

Routing. Seven strategies, none better than alphabetical order: distance ordering, far/near alternation, urgency ordering, nearest-neighbour tours, packing several buildings per day, travel-aware selection, and clustering selection.

The common cause is worth stating once. Decomposing the 50.5 hours of travel per scenario on the training split:

Emergency visits 21.7 43 % — caused by misses, not routable
The single most distant building 16.7 33 % — unavoidable if its battery is swapped
Everything else 12.5 25 % — the only addressable surface

The whole travel budget is 50 hours and a single miss costs 330. Every attempt to reach those 12 hours traded away selection accuracy, and that trade can never pay. Late swaps and travel are one problem, not two: each miss costs 330 in penalty plus 4.7 hours of emergency driving. Only better selection cuts both.

Travel is the visible symptom; selection is the disease. The public leaderboard publishes cost components per team, and ours stand out in one column. Travel: 75.3 against 36.6 for the leader and 45.5 for fourth place — the worst in the top fifteen, with the next-worst at 59.0.

Across the fourteen teams above 2200, travel almost perfectly determines the two largest penalties, and is uncorrelated with misses:

travel <-> overtime      +0.981
travel <-> daily_limit   +0.937
travel <-> late_swap     -0.009

Matching fourth place's travel would carry overtime and the daily limit down with it — about 196 points, or 1735 to roughly 1539.

Travel is 2 x the sum of distances to the buildings visited, so order cannot change it. Only three things can: fewer buildings, nearer buildings, or chaining several into one trip. All three were measured on the calibrated planner:

Mechanism train travel late_swap Recall Buildings
As shipped 1955.3 50.1 1090.4 56.9 % 10.6
Chaining (route, pack 36 h) 2041.8 42.8 1079.6 — 11.0
Bias toward near buildings (0.06) 2209.8 43.5 1347.5 49.0 % 10.2
Cluster into fewer buildings (0.02) 2817.8 38.2 1831.2 30.9 % 8.9

The last row is the important one. Clustering reaches 38.2 — essentially the leader's 36.6 — and it costs 740 in late swaps, because recall collapses from 57 % to 31 %. Every mechanism that cuts travel pays for it in misses, and the exchange rate is never favourable.

Which settles what the leaderboard first appeared to say. The leader has both low travel (36.6) and low late swaps (591.7 against our 807.1). That combination is not reachable by any scheduling trick, because we measured all three and each one trades one for the other. It is reachable only with a sharper selection signal: when the ranking is right, twelve swaps do not have to be scattered across eleven buildings on the chance that one of them is dying.

Our travel is not a logistics failure. It is what a 57 % recall looks like from the outside.

Two changes measured better on train and worse on public: the sharper smoothing above, and a weekly working-hour budget that cut weekly_limit by 71 as intended but cost 171 in delay. Both were reverted. The training split is a necessary but not sufficient signal — the splits differ by building, and so does what generalises.

Layout

Path
batteryswap_example/features.py Smoothing and the 21 features
batteryswap_example/planner.py The planner: scoring, count, schedule, three-level crash protection
batteryswap_example/train.py Fits the calibration and writes the pickle
lab/ Twenty planners built end-to-end and measured against each other; lab/RESULTS.md has the log
batteryswap_example/planners/best.pickle The stored planner. Committed, loaded by script.py
sweep.py Experiment bench. Caches features so an idea can be tested in seconds
sjekk.py Pre-submission check
script.py, Dockerfile, requirements.txt Unchanged from the template

The dataset belongs to Soundsensing (all rights reserved) and is not included here.

Docker

docker build -t batteryswapai-2026-example .
docker run --rm -v ./dataset:/tmp/data batteryswapai-2026-example \
  bash -c "/app/env/bin/python3 script.py && /app/env/bin/python3 -m batteryswap_public.metric"

Dockerfile sets BATTERYSWAP_SPLITS=train, so a local Docker run scores the training split. The competition environment overrides this to public,private; the two numbers are not comparable.

License

MIT — see LICENSE. All dependencies come from the competition's own requirements.txt and are unmodified.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support