You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

BatterySwapAI 2026

Competition solution for RUL forecasting and cost-aware battery-swap planning. The submitted entry point is script.py.

  • Task 1 is bsai/: a Wiener first-passage model for the 2.4 V crossing, driven by within-day features the official smoothing discards.
  • Task 2 is batteryswap_solution/: the scheduling and routing optimizer.

They meet at the versioned forecast contract in batteryswap_solution/forecast.py, so the model can be replaced without touching any scheduling code.

This README describes what currently ships, including the two changes made after the V7/V8 model work: the stale-transmission veto and the probability scale. The model itself, its measurements and the approaches that failed are in docs/V7_IMPLEMENTATION.md and docs/PLAN_V7_MARGIN.md; the previous generation is in docs/V6_IMPLEMENTATION.md. Where those documents and this one disagree about what is shipped, this one is current β€” they predate the veto and the scale.

Where it stands

What script.py actually ships, and what each change scored on the public leaderboard:

evaluated commit change public total
7792b78 V8 Wiener model + V10 planner budgets 1985.43
89573f5 + stale-transmission veto 1555.80
328a3ac + probability scale 1.5 1472.99
585bc16 probability scale 1.8 1530.96

Full public decomposition of the best entry (328a3ac, 1472.99): late_swap 632.29, early_swap 547.76, daily_limit 89.58, overtime 74.67, weekly_limit 64.58, travel 40.74, building_change 11.96, room_change 7.11, battery_swap 4.28.

The probability scale is at its measured optimum. Raising it trades late cost for early cost, and 1.8 overshot: between 1.5 and 1.8, late_swap fell 632.29 -> 600.00 (-32) while early_swap rose 547.76 -> 621.58 (+74). At 1.5 late is still the larger of the two; at 1.8 early has overtaken it, which is the crossover. The scale therefore ships at 1.5.

scale early late public
1.0 381.43 872.29 1555.80
1.5 547.76 632.29 1472.99
1.8 621.58 600.00 1530.96

The shipped configuration, all of it explicit in source rather than defaulted:

setting value where
Task 1 artifact models/v7_wiener.joblib script.py: DEFAULT_MODEL_PATH
probability scale 1.5 script.py: PROBABILITY_SCALE
volatility scale 1.0 script.py: load_forecaster()
stale-transmission veto v_stale_days > 14 and margin < 0.1 bsai/forecaster.py
solver seconds 1.0 script.py
local / uncertain search 240 / 240 script.py
robust emergency samples 0 script.py

For reference, out-of-fold by building over all 48 train scenarios:

configuration mean total cost
all-defer (service nothing) 3324.7
shipped v3 2644.9
V6 hazard classifier 2526.0
V7 Wiener 2293.2
perfect knowledge, this planner (scenarios 0-11) 77.8

Local validation does not track public on the early/late axis. In production mode the totals agree closely (local 1937.57 against public 1985.43 at 7792b78), but the composition is inverted: locally the planner is late-heavy (early 671 / late 1020), on public it is early-heavy (early 962 / late 658). Tuning that axis against local validation points the wrong way, which is why the veto was justified from a due-rate table rather than from a local cost delta.

Runtime projects to roughly 15-16 minutes for the 96 public and private scenarios against the 30-minute limit.

The change that mattered was not the model class. A two-line control -- rank by margin / -slope, no model at all -- matched V6 exactly (precision 0.309 against 0.300 at twelve swaps), so fifty-one features of gradient boosting were worth nothing over a straight line. smooth_series collapses 8.5 million hourly readings into 360,847 daily numbers, and every V6 feature was a function of that one collapsed series. The within-day voltage response to the daily temperature cycle -- a proxy for internal resistance, the documented knee precursor -- separates due from not-due with AUC 0.871 on exactly the population the smoothed series cannot rank at all.

The local mean is a weak predictor of the leaderboard: v3 scored 2644.9 locally and 4252.3 on public. What that public score actually reflected was over-servicing in scenarios where the observation window is closing β€” 41 swaps per scenario against roughly 9.5 due. That defect is measured and fixed here (predicted-to-actual due ratio 2.21 -> ~1.0 in the closing scenarios), which is a difference local validation cannot score.

Submission record

Submitted repository: Rabrie10/BatterySwapAI2026-MR (owner/repository-name, not a URL).

# evaluated commit SHA public total
1 7792b780f329c410966623b215c019551447ed96 1985.43
2 89573f5bf9a175f4392fe5cffffdf8ffeb1048db 1555.80
3 328a3ac813373203e06bbb169ae274c1fd638e48 1472.99
4 585bc16347afeeaab286d20c181484d63906d487 1530.96

The three best evaluated submissions, in order, are 328a3ac (1472.99), 585bc16 (1530.96) and 89573f5 (1555.80).

None of these commits will be rewritten, amended or removed. They remain reachable from main; later commits are additions on top, never history rewrites. Note that 585bc16 and later carry the probability scale at 1.8, which measured worse β€” the current head restores 1.5, the value that produced the best score. Each evaluated commit is preserved exactly as it was scored.

What the problem actually is

Lower score is better. Per scenario the evaluator charges work time, travel, overtime, flat 100-point daily and weekly capacity penalties, 0.5 per day for swapping early, and 10 per day for swapping late. A battery that reaches EOL inside the 42-day window and is not serviced gets a dedicated emergency visit after the window, costing between 60 and 480 hours.

On the train split roughly 9.5 batteries per scenario genuinely reach EOL inside the window, out of about 420 alive. Servicing nothing at all scores 3324.7. Perfect knowledge with this planner scores 77.8 on scenarios 0-11. The whole competition is the question of which batteries to touch.

Task 1: bsai/

module role
smoothing.py Incremental, exact reimplementation of smooth_series. Pinned to the official function to 1e-12, including the partial-day boundary.
features.py 64 causal features: the smoothed grid, plus the within-day statistics that grid discards.
shape.py Incremental within-day statistics from the raw hourly readings: voltage spread and the dV/dT response to the daily temperature cycle.
wiener.py Wiener first passage: learned drift and volatility, closed-form crossing probability.
hazard.py Cutoff sampling and the previous multi-horizon classifier, kept for comparison.
margin.py Quantile regression on the running minimum; measured, not shipped.
forecaster.py Adapter to the Task 2 forecast contract: the censoring branch, the uniform probability scale, and the stale-transmission veto.
validation.py Out-of-fold dispatch, so no device is ever scored by a model that saw its building.
runtime.py Wall-clock governor for the 30-minute evaluation budget.

Three design decisions that came from measurement rather than habit:

The target is a distribution, not a number. A mean RUL regressor measured on train has MAE 25 days but is optimistic by +17 days exactly where it matters (true RUL under 14 days) and pessimistic by 21 days at 70-120 days. That is regression to the mean, in the direction that makes you late, and late costs twenty times what early costs.

Censoring is known, not estimated. locations.end_time is handed to plan(), so for each battery we know the last day an EOL could be recorded, and the evaluator's substitute EOL for the unrecorded case is exactly normalize(end_time + unobserved_eol_days). The predicted CDF is capped at its own value at that horizon. This is what makes the closing scenarios stop demanding service on their own, with no hand-tuned survivor gate.

Temperature is a first-order driver. Within-device, residual voltage tracks residual temperature at +0.00463 V/degC, positive in 100% of 454 train devices. The 4.87 degC indoor annual swing is 0.023 V, which near the knee is about two weeks of remaining life, and EOL incidence is 1.76x higher in Nov-Mar than in May-Sep. Features include temperature-compensated levels and slopes plus the expected seasonal temperature change across the planning window.

A battery that has stopped transmitting cannot become due. EOL is defined from the timeseries, so a device with no recent readings cannot record a 2.4 V crossing, and every swap spent on one is waste. Within the near-threshold band the due rate falls from 30.8% while transmitting to about 5% past 14 days of silence, against a break-even service probability near 15% (mean early cost per wasted swap about 45, against about 261 per miss). bsai/forecaster.py therefore vetoes service when v_stale_days > 14 and margin < 0.1, where margin is the last smoothed voltage minus 2.4 and v_stale_days is the gap between the last day the smoothed series has a value and the cutoff. It fires on about 9.6 batteries per scenario.

The effect exists only inside the near-threshold band β€” across the whole population staleness barely moves the due rate (2.4% transmitting against 1.5-2.7% stale) β€” so this is a veto justified by cost asymmetry, not a ranking feature. Both thresholds are fixed constants; neither is swept or configurable.

Note that features.py also exposes a feature named staleness, which is not the same quantity: DeviceView.value_at_or_before clamps its index to the end of the series, so it measures gaps inside the series and reads about zero for exactly the stopped devices this rule targets. The veto computes silence against the unclamped cutoff ordinal instead.

Service volume is set by a uniform probability scale. After the veto removed the sub-break-even population, every serviced margin band clears break-even (the marginal band at 0.273 precision) while service ran at 13.6 per scenario against 12.3 due, with late costing more than twice early. PROBABILITY_SCALE = 1.5 in script.py multiplies the whole CDF, raising service to roughly 19 per scenario. The veto is applied after the scaling, so vetoed batteries are never resurrected by it, and the veto condition depends only on margin and v_stale_days, both independent of the scale.

Task 2: batteryswap_solution/

  • forecast.py: the versioned Task 1 -> Task 2 contract and its validation.
  • costs.py: expected early, late, deferred-emergency and evaluator-unobserved timing costs, in the unconditional-pmf convention.
  • optimizer.py: joint CP-SAT service/defer and day assignment under room, building, overtime, daily and weekly constraints.
  • routing.py: exact Held-Karp routes for small days, insertion + 2-opt above.
  • replay.py: evaluator-exact fast replay, checked against batteryswap_public.evaluate_plan() before every returned plan.
  • planner.py: the official Planner.plan() adapter, local search, and an all-defer safety fallback.

notebooks/

Exploratory analysis and experiments carried out while developing the solution: data exploration, trajectory modelling and manual investigation of individual devices. They record how the approach was arrived at and are not part of the submission path β€” nothing under notebooks/ is imported by script.py, and none of it runs at evaluation time.

Reproducing the submission

Both commands are deterministic given the seeds in the source.

python tools/train_wiener.py --stride 4

Writes models/v7_wiener.joblib (the shipped artifact, fitted on every building), outputs/v7_folds.joblib (the five fold models, used for validation and not needed at submission time), and docs/v7_training_report.json.

Known discrepancy β€” this command does not reproduce the shipped artifact byte-for-byte. tools/train_wiener.py selects best_scale by calibration gap and assigns it to the model before dumping; that selection is 1.4, while the committed models/v7_wiener.joblib carries 1.0. A model regenerated from this source therefore arrives with a different volatility scale. Everything else reproduces exactly: a regenerated run matches docs/v7_training_report.json in every substantive field (n=88013, positives 862, AUC 0.9823, PR-AUC 0.4303, predicted/actual 0.637, every precision@k), differing only in wall-clock seconds.

The submission is insulated from this: script.py pins volatility_scale = 1.0 explicitly after loading, so a regenerated artifact arriving at 1.4 cannot change what ships. 1.0 is the value that produced the scores in the table above; 1.4 measured worse (+10.1 against baseline on 42 train scenarios) despite being better calibrated in isolation (predicted/actual 1.025 against 0.637).

python tools/validate_v6.py --folds outputs/v7_folds.joblib     --model models/v7_wiener.joblib --volatility-scale 1.0

Scores the production planner over all 48 train scenarios using predictions from models that never saw the device's own building, and prints the anchors so a result is never read in isolation.

Two cautions when comparing a validation run against the shipped build:

  • tools/validate_v6.py constructs its own PlannerConfig and does not set robust_emergency_samples, so it inherits the dataclass default of 4, while script.py ships 0. A run left on defaults is not measuring the submitted configuration.
  • --volatility-scale is honoured only in the out-of-fold branch of build_forecaster; under --production the flag is ignored. A production-mode sweep over it measures nothing but wall-clock noise.

Out-of-fold numbers are also not comparable to the public leaderboard on service volume: out-of-fold runs at roughly 29 swaps per scenario against the shipped 19, which is a different operating regime, not a pessimistic estimate of the same one. Use --production when the question is what the submission does.

Local submission generation:

python script.py

with BATTERYSWAP_DATASET_PATH pointing at the dataset and BATTERYSWAP_SPLITS=train. The official run uses public,private.

Validation protocol

Folds are grouped by building, because the public and private splits contain different buildings and the observed EOL rate per training building spans 0.043 to 0.833. A random split leaks that structure and flatters every number.

The 48 train scenarios are not 48 independent samples: they start a week apart and each covers six weeks, so adjacent windows overlap by roughly 85% and the effective sample size is nearer eight. tools/validate_v6.py --blocks reports non-overlapping block means for that reason, and differences under about 100 on the 48-scenario mean should be treated as noise.

Tests

python -m unittest discover -s tests -v

Coverage includes exact equality of the incremental smoothing cache against the official smooth_series (single-pass, incremental, and truncated-prefix), forecast-contract validity on an early and a late scenario, the censoring branches, the runtime governor, route optimization, plan completeness, and equality between the fast operational replay and batteryswap_public.evaluate_plan().

Environment overrides

  • BATTERYSWAP_MODEL_PATH -- Task 1 artifact, default models/v7_wiener.joblib
  • BATTERYSWAP_PLANNER_PATH -- load a pickled planner instead
  • BATTERYSWAP_SOLVER_SECONDS, BATTERYSWAP_LOCAL_SEARCH_EVALUATIONS, BATTERYSWAP_UNCERTAIN_LOCAL_SEARCH_EVALUATIONS, BATTERYSWAP_ROBUST_SAMPLES
  • BATTERYSWAP_LATE_RISK_MULTIPLIER, BATTERYSWAP_MINIMUM_EXPECTED_IMPROVEMENT
  • BATTERYSWAP_SOFT_DEADLINE, BATTERYSWAP_HARD_DEADLINE -- governor thresholds

Defaults in script.py are the shipped values, so the submission is correct with no variables set: solver seconds 1.0, local and uncertain search 240/240, BATTERYSWAP_ROBUST_SAMPLES 0 (note the PlannerConfig dataclass default is 4).

The probability scale, the volatility scale and the veto thresholds are deliberately not environment-configurable. They are constants in source (PROBABILITY_SCALE in script.py, STALE_DAYS_LIMIT and STALE_MARGIN_LIMIT in bsai/forecaster.py) so that what ships cannot depend on a variable being set at evaluation time.

Third-party components

All runtime dependencies are supplied by the competition image and pinned in requirements.txt. Changing that file does not affect the official runtime. The packages this solution actually imports, with the versions the reported results were produced under:

package version used license
Python 3.12.2 PSF-2.0
numpy 2.5.2 BSD-3-Clause
pandas 2.3.3 BSD-3-Clause
scikit-learn 1.9.0 BSD-3-Clause
scipy 1.18.0 BSD-3-Clause
joblib 1.5.3 BSD-3-Clause
fastparquet 2026.5.0 Apache-2.0
ortools 9.15.6755 Apache-2.0
batteryswap_public 0.3.4 competition terms

No external datasets and no third-party pretrained model weights are used. The only model artifacts are produced by this repository's own training code from the competition-supplied training data.

Tracked artifacts under models/: v7_wiener.joblib is the shipped Task 1 model. cox_baseline.pkl, risk_forecaster.pkl and v9_blend.joblib are earlier generations, retained for provenance and not loaded by script.py β€” only DEFAULT_MODEL_PATH is read at submission time.

Raw competition data is not committed: data/ is untracked in its entirety and must be supplied at run time via BATTERYSWAP_DATASET_PATH.

Participant-authored code in this repository is MIT licensed; see the root-level LICENSE file.

Data provenance and preprocessing

Input is the competition-supplied dataset only β€” battery_metrics.parquet, devices.csv, eol_times.csv and scenarios.json. No external or augmented data.

Preprocessing is a single deterministic step: bsai/smoothing.py collapses the hourly readings to one value per device-day, and is an exact incremental reimplementation of the official batteryswap_public.utils.smooth_series, pinned to it to 1e-12 including the partial-day boundary and verified by unit tests (single-pass, incremental, and truncated-prefix). All 64 features in bsai/features.py are computed from that smoothed grid plus within-day statistics from the raw hourly readings, using prefix quantities only, so every feature at a cutoff depends on data at or before that cutoff.

Training seeds are fixed in source (bsai/hazard.py: DEFAULT_PARAMS, random_state=20260821). Fold assignment is GroupKFold over building, not random, so it does not depend on a seed.

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