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/.
Rebuild the stored planner from scratch — this regenerates best.pickle:
python -m batteryswap_example.train
Expected output: total_cost1911.02 on the training split. The pickle it
writes is byte-identical to the one in e205611, the best submission.
Other modes:
bash
1python -m lab.run20 # all twenty planners measured against each other2python -m lab.compare # paired per-scenario tests between them3python sweep.py # experiment bench4python 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:
check_plan_valid() passes for all 48 scenarios
Local score against the reference plans
Docker — pickle.load() in Python 3.10 against 3.14 locally, submission.csv
parses, and the score matches the local run to six decimals
best.pickle is stored inline rather than as a Git LFS pointer
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:
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:
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.
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.