CarlAlbertCode commited on
Commit
dfa8acb
·
1 Parent(s): 3c49095

Submission 006: official scenario interface and causal degradation model

Browse files
README.md CHANGED
@@ -1,73 +1,47 @@
1
- # BatterySwapAI 2026 — V4
2
 
3
- Candidate. V1 remains the best confirmed public result (1576.57) until a better public score is recorded.
 
4
 
5
- - Building-held-out score: **1384.08**
6
- - Held-out late-swap / early-swap: **582.92 / 584.49**
7
- - Held-out labour penalties (overtime + daily + weekly): **151.28**
8
- - Event ranking, building-held-out: **AP 0.5258**, AUC 0.9640
9
  - Mean scheduled batteries: **15.75**
10
- - Tests: **34/34 passed**
11
- - Artifact SHA-256: `5cb8bf244aa8755fde47bc937761a71d240c6277862e7d533e44233b03171fdf`
12
- - Base: `versions/v3-generalization-fix` at `9de40de38cf75b0977ae452b1f86bda18af9892d`
13
 
14
  ## What changed
15
 
16
- **Scheduling.** The evaluator resets the day clock and *then* adds the trip home, so every
17
- return leg is billed again against the next working day's overtime and 24 h limit. On train
18
- plans that accounted for 96% of daily-limit cost and 25% of overtime. The scheduler now costs a
19
- day as carry-in plus its own hours, and routes weight the final leg by the overtime factor.
 
20
 
21
- **Selection.** Candidates are ranked against a learned survivor horizon — the median remaining
22
- life of batteries that outlive the window, about 177 days instead of V3's 42-day window, which
23
- under-priced a wasted swap sevenfold. Workload is a fixed 3.8% of fleet size inside a wide 8–24
24
- rail, so it cannot drift when calibration moves.
25
-
26
- **Event model.** Four views of the same 82 observed failures are ranked inside each scenario and
27
- mapped back onto the calibrated scale, so only the ordering changes: the base ensemble, a
28
- device-balanced refit, a room/building context model, and a discrete-time hazard model over
29
- 7 intervals. Precision at the operating point is what the score is made of — see below.
30
-
31
- ## Why precision is the target
32
-
33
- At the operating point, 9.7 of 16.5 scheduled batteries were false positives, and they cost
34
- 590 per scenario in wasted asset life — 41% of the whole score. True positives cost 63.
35
- The optimal replacement quantile is `1/(21p)` for a battery with failure probability `p`, so
36
- q=0.05 is right only when `p=1`; delaying low-probability jobs was measured and is worth ~10,
37
- because delay saves 0.5/day but risks 10/day. Precision is the only real lever, and it is
38
- limited by having 82 failures to learn from, not by the feature set.
39
 
40
  ## Validation
41
 
42
- Buildings are held out, so no battery, room or building is shared between fit and score.
43
- Blending happens over complete scenarios in validation exactly as it does at inference.
44
-
45
- The 48 shipped scenarios all use a handful of base locations, which understates difficulty and
46
- leaves too little power to separate candidates. Pairing every date with every real base location
47
- gives 768 cases and roughly four times the power. Measured there, on identical predictions:
48
-
49
- | planner + model | mean | vs V1 | t |
50
- |---|---|---|---|
51
- | V1 planner + base ensemble | 1672.16 | — | — |
52
- | V3 planner + base ensemble (band 16:20) | 1647.90 | −24.3 | −1.7 |
53
- | V4 planner + base ensemble | 1519.89 | −152.3 | −9.3 |
54
- | **V4 planner + blended model** | **1460.04** | **−212.1** | **−13.0** |
55
-
56
- V3's shipped configuration was never significantly better than V1.
57
 
58
- Under a ±30% calibration shift the total moves 7.8 points and the workload does not move at all.
59
- Widening or narrowing the 8–24 rail changes nothing, because the fraction never reaches it.
 
 
 
 
60
 
61
- Measured and rejected: physics extrapolation to the 2.380 V failure threshold, exact pricing of
62
- the censored horizon, late-window insurance placement, ranking by predicted RUL, marginal
63
- add/remove passes, device weighting on its own, and five- and six-way blends.
64
 
65
  ## Artifact
66
 
67
- Trained under the evaluator runtime: scikit-learn `1.7.2`, numpy `2.2.6`, pandas `2.3.3`.
68
- `submission_artifacts/planner.json` records the exact policy that runs.
69
- 54 s and 1.5 GB per split; deterministic and byte-identical offline.
70
 
71
- ## How to run
72
 
73
  See `REPRODUCIBILITY.md`.
 
1
+ # BatterySwapAI 2026 — V05 (official scenario interface)
2
 
3
+ Candidate. Not submitted. V04 remains the best public result (1407.63), but it was produced by a
4
+ pipeline that read sensor rows timestamped after the scenario start.
5
 
6
+ - Building-held-out score, official cut: **1598.26**
7
+ - Late / early: **754.79 / 618.06**
8
+ - Event ranking: AP **0.4170**, AUC 0.9586
 
9
  - Mean scheduled batteries: **15.75**
10
+ - Tests: **44/44 passed**
11
+ - Artifact SHA-256: `63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3`
12
+ - Base: `research/v4-first-place` at `5ade97b8ab7b33e10a00f9d182a7ebcde69bc89c`
13
 
14
  ## What changed
15
 
16
+ **Official scenario input.** `iterate_scenarios` yields a `cut` truncated at
17
+ `end_time <= scenario.start_time`, and `make_submissions` passes exactly that to the planner.
18
+ Our pipeline discarded it and rebuilt features from the whole split, so the cutoff day's bucket
19
+ carried that day's later hourly readings — 7,274 to 9,847 rows per scenario. Inference now
20
+ consumes the cut, and `scenario_history` truncates defensively at the same boundary.
21
 
22
+ **Degradation dynamics.** Threshold-crossing forecasts trained on every observed sensor
23
+ transition plus biweekly voltage bins back to one year, blended into the event ranking as a 0.25
24
+ residual nudge. Worth −76.0 against the same blend under the official cut.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  ## Validation
27
 
28
+ Historical families re-baselined on identical building-held-out folds and the official cut:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ | generation | public | AP | total | late | early |
31
+ |---|---|---|---|---|---|
32
+ | **V05** | — | 0.4170 | **1598.26** | 754.79 | 618.06 |
33
+ | V04 | 1407.63 | 0.4099 | 1674.27 | 831.88 | 623.12 |
34
+ | V03 | 1700.12 | 0.3947 | 1757.11 | 657.29 | 826.50 |
35
+ | V01 | 1576.57 | 0.3972 | 1766.77 | 892.08 | 598.99 |
36
 
37
+ Removing the post-cutoff readings costs about 290 points: the same V04 model scores 1384.08 under
38
+ the old pipeline and 1674.27 under the official cut.
 
39
 
40
  ## Artifact
41
 
42
+ Trained and serialised under scikit-learn `1.7.2`, numpy `2.2.6`, pandas `2.3.3`.
43
+ 215 s and 1.68 GB per split; deterministic and byte-identical with `--network none`.
 
44
 
45
+ ## How to reproduce
46
 
47
  See `REPRODUCIBILITY.md`.
REPRODUCIBILITY.md CHANGED
@@ -1,4 +1,4 @@
1
- # Reproduce V4
2
 
3
  Dataset revision:
4
 
@@ -27,10 +27,10 @@ pytest
27
  Expected checks:
28
 
29
  ```text
30
- 34/34 tests pass
31
- building-held-out score: 1384.08
32
  mean scheduled batteries: 15.75
33
- artifact SHA-256: 5cb8bf244aa8755fde47bc937761a71d240c6277862e7d533e44233b03171fdf
34
  ```
35
 
36
  Train the artifact under the evaluator runtime, not a local interpreter:
@@ -53,4 +53,10 @@ docker run --rm -e BATTERYSWAP_SPLITS=train -v "$(pwd)/data/raw":/tmp/data:ro ba
53
  ```
54
 
55
  The Docker run must produce all 19,890 required rows under the evaluator runtime.
56
- Measured: 54 s per split, 1.52 GB peak RSS, byte-identical output across repeated runs.
 
 
 
 
 
 
 
1
+ # Reproduce V05
2
 
3
  Dataset revision:
4
 
 
27
  Expected checks:
28
 
29
  ```text
30
+ 44/44 tests pass
31
+ building-held-out score: 1598.26
32
  mean scheduled batteries: 15.75
33
+ artifact SHA-256: 63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3
34
  ```
35
 
36
  Train the artifact under the evaluator runtime, not a local interpreter:
 
53
  ```
54
 
55
  The Docker run must produce all 19,890 required rows under the evaluator runtime.
56
+ Measured: 215 s per split, 1.68 GB peak RSS, byte-identical output across repeated runs.
57
+
58
+ ## Artifact reproducibility
59
+
60
+ Retraining reproduces the model but not the exact bytes: independently trained artifacts differ
61
+ in serialisation while agreeing to 5e-11 in predicted risk and producing byte-identical plans.
62
+ Verify a rebuild by comparing plans, not SHAs.
script.py CHANGED
@@ -4,14 +4,13 @@ import os
4
  import sys
5
  from pathlib import Path
6
 
7
- # Makes the local src package available when the evaluator runs script.py directly.
8
  sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
9
 
10
  import joblib
11
  import pandas as pd
12
  from batteryswap_public.utils import iterate_scenarios, load_dataset
13
 
14
- from batteryswapai.competition_features import build_daily_features, scenario_snapshot
15
 
16
 
17
  def main() -> None:
@@ -27,16 +26,15 @@ def main() -> None:
27
  plans = []
28
  for split in splits:
29
  locations, timeseries, hidden_eol_times, scenarios = load_dataset(dataset_path / split)
30
- daily = build_daily_features(timeseries)
31
 
32
- for scenario, locs, _, _ in iterate_scenarios(
33
  locations,
34
  timeseries,
35
  hidden_eol_times,
36
  scenarios,
37
  ):
38
- snapshot = scenario_snapshot(
39
- daily,
40
  locs,
41
  scenario["name"],
42
  scenario["start_time"],
@@ -62,4 +60,4 @@ def main() -> None:
62
 
63
 
64
  if __name__ == "__main__":
65
- main()
 
4
  import sys
5
  from pathlib import Path
6
 
 
7
  sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
8
 
9
  import joblib
10
  import pandas as pd
11
  from batteryswap_public.utils import iterate_scenarios, load_dataset
12
 
13
+ from batteryswapai.competition_features import scenario_history_snapshot
14
 
15
 
16
  def main() -> None:
 
26
  plans = []
27
  for split in splits:
28
  locations, timeseries, hidden_eol_times, scenarios = load_dataset(dataset_path / split)
 
29
 
30
+ for scenario, locs, visible_history, _ in iterate_scenarios(
31
  locations,
32
  timeseries,
33
  hidden_eol_times,
34
  scenarios,
35
  ):
36
+ snapshot = scenario_history_snapshot(
37
+ visible_history,
38
  locs,
39
  scenario["name"],
40
  scenario["start_time"],
 
60
 
61
 
62
  if __name__ == "__main__":
63
+ main()
scripts/train_submission.py CHANGED
@@ -13,6 +13,8 @@ from batteryswap_public.evaluate import evaluate_plan
13
  from batteryswap_public.utils import iterate_scenarios, load_dataset
14
 
15
  from batteryswapai.competition_features import (
 
 
16
  attach_training_targets,
17
  build_daily_features,
18
  scenario_snapshot,
@@ -37,6 +39,8 @@ def parse_args() -> argparse.Namespace:
37
  parser.add_argument("--expected-gain-margin", type=float, default=10.0)
38
  parser.add_argument("--expected-service-cost-hours", type=float, default=2.0)
39
  parser.add_argument("--scheduled-fraction", type=float, default=0.038)
 
 
40
  parser.add_argument("--minimum-scheduled-batteries", type=int, default=8)
41
  parser.add_argument("--maximum-scheduled-batteries", type=int, default=24)
42
  parser.add_argument("--calibration-folds", type=int, default=5)
@@ -50,8 +54,10 @@ def main() -> None:
50
  daily = build_daily_features(timeseries)
51
 
52
  snapshots = []
53
- for scenario, locs, _, _ in iterate_scenarios(locations, timeseries, eol_times, scenarios):
54
- snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
 
 
55
  snapshot = attach_training_targets(
56
  snapshot,
57
  eol_times,
@@ -65,6 +71,8 @@ def main() -> None:
65
  quantile=args.quantile,
66
  dataset_revision=DATASET_REVISION,
67
  calibration_folds=args.calibration_folds,
 
 
68
  )
69
  planner = CompetitionPlanner(
70
  model,
@@ -76,6 +84,8 @@ def main() -> None:
76
  expected_gain_margin=args.expected_gain_margin,
77
  expected_service_cost_hours=args.expected_service_cost_hours,
78
  scheduled_fraction=args.scheduled_fraction,
 
 
79
  minimum_scheduled_batteries=args.minimum_scheduled_batteries,
80
  maximum_scheduled_batteries=args.maximum_scheduled_batteries,
81
  capacity_lookahead_days=21,
@@ -94,11 +104,15 @@ def main() -> None:
94
  "expected_gain_margin": args.expected_gain_margin,
95
  "expected_service_cost_hours": args.expected_service_cost_hours,
96
  "scheduled_fraction": args.scheduled_fraction,
 
 
97
  "minimum_scheduled_batteries": args.minimum_scheduled_batteries,
98
  "maximum_scheduled_batteries": args.maximum_scheduled_batteries,
99
  "calibration_folds": args.calibration_folds,
100
  "planner_policy": dataclasses.asdict(planner.policy),
101
  "survivor_rul_head": model.survivor_rul_estimator is not None,
 
 
102
  "training_rows": len(training),
103
  "training_devices": int(training["battery"].nunique()),
104
  "features": model.feature_columns,
@@ -119,10 +133,12 @@ def main() -> None:
119
 
120
  if not args.skip_evaluation:
121
  scores = []
122
- for scenario, locs, _, not_dead in iterate_scenarios(
123
  locations, timeseries, eol_times, scenarios
124
  ):
125
- snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
 
 
126
  plan = planner.plan_snapshot(
127
  snapshot,
128
  locs,
 
13
  from batteryswap_public.utils import iterate_scenarios, load_dataset
14
 
15
  from batteryswapai.competition_features import (
16
+ build_trajectory_matrix,
17
+ scenario_history_snapshot,
18
  attach_training_targets,
19
  build_daily_features,
20
  scenario_snapshot,
 
39
  parser.add_argument("--expected-gain-margin", type=float, default=10.0)
40
  parser.add_argument("--expected-service-cost-hours", type=float, default=2.0)
41
  parser.add_argument("--scheduled-fraction", type=float, default=0.038)
42
+ parser.add_argument("--weekly-guard-fraction", type=float, default=0.95)
43
+ parser.add_argument("--hard-limit-penalty-multiplier", type=float, default=1.5)
44
  parser.add_argument("--minimum-scheduled-batteries", type=int, default=8)
45
  parser.add_argument("--maximum-scheduled-batteries", type=int, default=24)
46
  parser.add_argument("--calibration-folds", type=int, default=5)
 
54
  daily = build_daily_features(timeseries)
55
 
56
  snapshots = []
57
+ for scenario, locs, visible, _ in iterate_scenarios(locations, timeseries, eol_times, scenarios):
58
+ snapshot = scenario_history_snapshot(
59
+ visible, locs, scenario["name"], scenario["start_time"]
60
+ )
61
  snapshot = attach_training_targets(
62
  snapshot,
63
  eol_times,
 
71
  quantile=args.quantile,
72
  dataset_revision=DATASET_REVISION,
73
  calibration_folds=args.calibration_folds,
74
+ trajectory=build_trajectory_matrix(daily),
75
+ eol_times=eol_times,
76
  )
77
  planner = CompetitionPlanner(
78
  model,
 
84
  expected_gain_margin=args.expected_gain_margin,
85
  expected_service_cost_hours=args.expected_service_cost_hours,
86
  scheduled_fraction=args.scheduled_fraction,
87
+ capacity_weekly_limit_fraction=args.weekly_guard_fraction,
88
+ capacity_limit_penalty_multiplier=args.hard_limit_penalty_multiplier,
89
  minimum_scheduled_batteries=args.minimum_scheduled_batteries,
90
  maximum_scheduled_batteries=args.maximum_scheduled_batteries,
91
  capacity_lookahead_days=21,
 
104
  "expected_gain_margin": args.expected_gain_margin,
105
  "expected_service_cost_hours": args.expected_service_cost_hours,
106
  "scheduled_fraction": args.scheduled_fraction,
107
+ "weekly_guard_fraction": args.weekly_guard_fraction,
108
+ "hard_limit_penalty_multiplier": args.hard_limit_penalty_multiplier,
109
  "minimum_scheduled_batteries": args.minimum_scheduled_batteries,
110
  "maximum_scheduled_batteries": args.maximum_scheduled_batteries,
111
  "calibration_folds": args.calibration_folds,
112
  "planner_policy": dataclasses.asdict(planner.policy),
113
  "survivor_rul_head": model.survivor_rul_estimator is not None,
114
+ "trajectory_weight": model.trajectory_weight,
115
+ "forecast_heads": sorted(model.forecasters or {}),
116
  "training_rows": len(training),
117
  "training_devices": int(training["battery"].nunique()),
118
  "features": model.feature_columns,
 
133
 
134
  if not args.skip_evaluation:
135
  scores = []
136
+ for scenario, locs, visible, not_dead in iterate_scenarios(
137
  locations, timeseries, eol_times, scenarios
138
  ):
139
+ snapshot = scenario_history_snapshot(
140
+ visible, locs, scenario["name"], scenario["start_time"]
141
+ )
142
  plan = planner.plan_snapshot(
143
  snapshot,
144
  locs,
scripts/validate_submission.py CHANGED
@@ -12,11 +12,17 @@ from sklearn.metrics import average_precision_score, roc_auc_score
12
  from sklearn.model_selection import GroupKFold
13
 
14
  from batteryswapai.competition_features import (
 
 
15
  attach_training_targets,
16
  build_daily_features,
17
  scenario_snapshot,
18
  )
19
- from batteryswapai.competition_model import blend_risk_components, fit_event_time_model
 
 
 
 
20
  from batteryswapai.competition_planner import CompetitionPlanner, PlannerPolicy
21
  from train_submission import DATASET_REVISION
22
 
@@ -90,10 +96,12 @@ def main() -> None:
90
 
91
  snapshots = []
92
  scenario_inputs = {}
93
- for scenario, locs, _, not_dead in iterate_scenarios(
94
  locations, timeseries, eol_times, scenarios
95
  ):
96
- snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
 
 
97
  snapshot = attach_training_targets(
98
  snapshot,
99
  eol_times,
@@ -107,6 +115,8 @@ def main() -> None:
107
  oof_rul = np.full(len(training), np.nan)
108
  oof_survivor = np.full(len(training), np.nan)
109
  oof_components: dict[str, np.ndarray] = {}
 
 
110
  blend_weights = (1.0, 0.0, 0.0, 0.0)
111
  if args.predictions_csv is not None:
112
  cached = pd.read_csv(args.predictions_csv).set_index(["scenario", "battery"])
@@ -130,6 +140,8 @@ def main() -> None:
130
  dataset_revision=DATASET_REVISION,
131
  random_state=2026 + fold_number,
132
  calibration_folds=args.inner_calibration_folds,
 
 
133
  )
134
  valid = training.iloc[valid_index]
135
  for name, values in model.predict_risk_components(valid).items():
@@ -137,14 +149,22 @@ def main() -> None:
137
  name, np.full(len(training), np.nan)
138
  )[valid_index] = values
139
  blend_weights = model.blend_weights
 
 
 
 
140
  oof_rul[valid_index] = model.predict_rul(valid)
141
  survivor = model.predict_survivor_rul(valid)
142
  if survivor is not None:
143
  oof_survivor[valid_index] = survivor
144
 
 
145
  if oof_components:
146
- oof_risk[:] = blend_risk_components(
147
- oof_components, blend_weights, training["scenario"].astype(str).to_numpy()
 
 
 
148
  )
149
 
150
  due = (
 
12
  from sklearn.model_selection import GroupKFold
13
 
14
  from batteryswapai.competition_features import (
15
+ build_trajectory_matrix,
16
+ scenario_history_snapshot,
17
  attach_training_targets,
18
  build_daily_features,
19
  scenario_snapshot,
20
  )
21
+ from batteryswapai.competition_model import (
22
+ blend_risk_components,
23
+ blend_trajectory,
24
+ fit_event_time_model,
25
+ )
26
  from batteryswapai.competition_planner import CompetitionPlanner, PlannerPolicy
27
  from train_submission import DATASET_REVISION
28
 
 
96
 
97
  snapshots = []
98
  scenario_inputs = {}
99
+ for scenario, locs, visible, not_dead in iterate_scenarios(
100
  locations, timeseries, eol_times, scenarios
101
  ):
102
+ snapshot = scenario_history_snapshot(
103
+ visible, locs, scenario["name"], scenario["start_time"]
104
+ )
105
  snapshot = attach_training_targets(
106
  snapshot,
107
  eol_times,
 
115
  oof_rul = np.full(len(training), np.nan)
116
  oof_survivor = np.full(len(training), np.nan)
117
  oof_components: dict[str, np.ndarray] = {}
118
+ oof_residual = np.full(len(training), np.nan)
119
+ oof_residual_weight = np.zeros(len(training))
120
  blend_weights = (1.0, 0.0, 0.0, 0.0)
121
  if args.predictions_csv is not None:
122
  cached = pd.read_csv(args.predictions_csv).set_index(["scenario", "battery"])
 
140
  dataset_revision=DATASET_REVISION,
141
  random_state=2026 + fold_number,
142
  calibration_folds=args.inner_calibration_folds,
143
+ trajectory=build_trajectory_matrix(daily),
144
+ eol_times=eol_times,
145
  )
146
  valid = training.iloc[valid_index]
147
  for name, values in model.predict_risk_components(valid).items():
 
149
  name, np.full(len(training), np.nan)
150
  )[valid_index] = values
151
  blend_weights = model.blend_weights
152
+ residual, residual_weight = model._residual_risk(valid)
153
+ if residual is not None:
154
+ oof_residual[valid_index] = residual
155
+ oof_residual_weight[valid_index] = residual_weight
156
  oof_rul[valid_index] = model.predict_rul(valid)
157
  survivor = model.predict_survivor_rul(valid)
158
  if survivor is not None:
159
  oof_survivor[valid_index] = survivor
160
 
161
+ scenario_groups = training["scenario"].astype(str).to_numpy()
162
  if oof_components:
163
+ oof_risk[:] = blend_risk_components(oof_components, blend_weights, scenario_groups)
164
+ # Mirror inference: the trajectory nudge is applied over complete scenarios.
165
+ if np.any(oof_residual_weight > 0.0):
166
+ oof_risk[:] = blend_trajectory(
167
+ oof_risk, np.nan_to_num(oof_residual), oof_residual_weight, scenario_groups
168
  )
169
 
170
  due = (
src/batteryswapai/competition_features.py CHANGED
@@ -9,6 +9,11 @@ import pandas as pd
9
  ROLLING_WINDOWS_DAYS = (3, 7, 14, 30, 60, 90, 180, 365)
10
 
11
  CONTEXT_LOW_VOLTAGE = 2.45
 
 
 
 
 
12
  CONTEXT_COLUMNS = (
13
  "room_voltage_mean",
14
  "room_voltage_min",
@@ -40,6 +45,9 @@ NON_FEATURE_COLUMNS = {
40
  "scenario_month_cos",
41
  # Scenario-level context, used only by the auxiliary context model.
42
  *CONTEXT_COLUMNS,
 
 
 
43
  }
44
 
45
 
@@ -132,6 +140,103 @@ def build_daily_features(
132
  return daily
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  def scenario_snapshot(
136
  daily_features: pd.DataFrame,
137
  locations: pd.DataFrame,
@@ -178,7 +283,14 @@ def scenario_snapshot(
178
  snapshot["room_battery_count"] = snapshot.groupby("room", observed=True)["battery"].transform(
179
  "size"
180
  )
181
- return add_context_columns(snapshot)
 
 
 
 
 
 
 
182
 
183
 
184
  def add_context_columns(snapshot: pd.DataFrame) -> pd.DataFrame:
 
9
  ROLLING_WINDOWS_DAYS = (3, 7, 14, 30, 60, 90, 180, 365)
10
 
11
  CONTEXT_LOW_VOLTAGE = 2.45
12
+ TRAJECTORY_LAGS = tuple(range(0, 365, 14))
13
+ TRAJECTORY_COLUMNS = tuple(f"traj_lag{lag}" for lag in TRAJECTORY_LAGS)
14
+ TRAJECTORY_COVERAGE_COLUMN = "traj_coverage"
15
+ MIN_TRAJECTORY_COVERAGE = 0.5
16
+ TRAJECTORY_REFERENCE_OFFSET = 0
17
  CONTEXT_COLUMNS = (
18
  "room_voltage_mean",
19
  "room_voltage_min",
 
45
  "scenario_month_cos",
46
  # Scenario-level context, used only by the auxiliary context model.
47
  *CONTEXT_COLUMNS,
48
+ # Trajectory history, used only by the degradation models.
49
+ *TRAJECTORY_COLUMNS,
50
+ TRAJECTORY_COVERAGE_COLUMN,
51
  }
52
 
53
 
 
140
  return daily
141
 
142
 
143
+ def scenario_history(history: pd.DataFrame, scenario_time) -> pd.DataFrame:
144
+ """Restrict raw observations to what the scenario can see, exactly as iterate_scenarios does."""
145
+
146
+ start = pd.Timestamp(scenario_time)
147
+ frame = history
148
+ if "end_time" not in frame.columns:
149
+ frame = frame.reset_index()
150
+ visible = frame[pd.to_datetime(frame["end_time"]) <= start]
151
+ return visible.set_index(["device_id", "end_time"])
152
+
153
+
154
+ def scenario_history_snapshot(
155
+ history: pd.DataFrame,
156
+ locations: pd.DataFrame,
157
+ scenario_name: str,
158
+ scenario_time,
159
+ windows: Iterable[int] = ROLLING_WINDOWS_DAYS,
160
+ ) -> pd.DataFrame:
161
+ """Features for one scenario, built only from that scenario's visible history."""
162
+
163
+ visible = scenario_history(history, scenario_time)
164
+ return scenario_snapshot(
165
+ build_daily_features(visible, windows=windows), locations, scenario_name, scenario_time
166
+ )
167
+
168
+
169
+ def build_trajectory_matrix(daily_features: pd.DataFrame) -> dict:
170
+ """Dense device-by-day smoothed voltage grid, built once per split and cached."""
171
+
172
+ cached = daily_features.attrs.get("_trajectory_matrix")
173
+ if cached is not None:
174
+ return cached
175
+
176
+ frame = daily_features[["device_id", "day", "stable_voltage_median"]].dropna()
177
+ frame = frame.sort_values(["device_id", "day"])
178
+ smooth = frame.groupby("device_id", observed=True)["stable_voltage_median"].transform(
179
+ lambda values: values.rolling(7, min_periods=3).median()
180
+ )
181
+ frame = frame.assign(smooth=smooth).dropna(subset=["smooth"])
182
+ if frame.empty:
183
+ empty = {"index": {}, "origin": pd.Timestamp("2000-01-01"),
184
+ "grid": np.zeros((0, 1), dtype=np.float32),
185
+ "first": np.zeros(0, dtype=np.int64),
186
+ "observed_last": np.zeros(0, dtype=np.int64)}
187
+ daily_features.attrs["_trajectory_matrix"] = empty
188
+ return empty
189
+ devices = sorted(frame["device_id"].astype(str).unique())
190
+ index = {device: position for position, device in enumerate(devices)}
191
+ origin = frame["day"].min().normalize()
192
+ span = int((frame["day"].max().normalize() - origin).days) + 1
193
+ grid = np.full((len(devices), span), np.nan, dtype=np.float32)
194
+ rows = frame["device_id"].astype(str).map(index).to_numpy(dtype=np.int64)
195
+ columns = (frame["day"] - origin).dt.days.to_numpy(dtype=np.int64)
196
+ grid[rows, columns] = frame["smooth"].to_numpy()
197
+
198
+ # Carry the last reading forward to the end of the grid. Stopping at the device's
199
+ # final observation would make availability depend on whether it reported again later.
200
+ first = np.full(len(devices), np.iinfo(np.int64).max, dtype=np.int64)
201
+ observed_last = np.full(len(devices), -1, dtype=np.int64)
202
+ for position in range(len(devices)):
203
+ seen = np.flatnonzero(np.isfinite(grid[position]))
204
+ if seen.size == 0:
205
+ continue
206
+ low = int(seen[0])
207
+ segment = grid[position, low:]
208
+ carry = np.maximum.accumulate(
209
+ np.where(np.isfinite(segment), np.arange(segment.size), 0)
210
+ )
211
+ grid[position, low:] = segment[carry]
212
+ first[position] = low
213
+ observed_last[position] = int(seen[-1])
214
+
215
+ built = {"index": index, "origin": origin, "grid": grid, "first": first,
216
+ "observed_last": observed_last}
217
+ daily_features.attrs["_trajectory_matrix"] = built
218
+ return built
219
+
220
+
221
+ def trajectory_bins(matrix: dict, batteries, cutoff) -> np.ndarray:
222
+ """Voltage at the cutoff and every 14 days before it; never reads past the cutoff."""
223
+
224
+ grid = matrix["grid"]
225
+ first = matrix["first"]
226
+ rows = np.array([matrix["index"].get(str(name), -1) for name in batteries], dtype=np.int64)
227
+ cut = int((pd.Timestamp(cutoff).normalize() - matrix["origin"]).days)
228
+ lags = np.asarray(TRAJECTORY_LAGS, dtype=np.int64) + TRAJECTORY_REFERENCE_OFFSET
229
+ columns = cut - lags[None, :]
230
+ out = np.full((len(rows), lags.size), np.nan, dtype=float)
231
+ known = rows >= 0
232
+ if known.any():
233
+ safe = np.clip(columns, 0, grid.shape[1] - 1)
234
+ values = grid[rows[known][:, None], safe]
235
+ usable = (columns >= 0) & (columns >= first[rows[known]][:, None])
236
+ out[known] = np.where(usable, values, np.nan)
237
+ return out
238
+
239
+
240
  def scenario_snapshot(
241
  daily_features: pd.DataFrame,
242
  locations: pd.DataFrame,
 
283
  snapshot["room_battery_count"] = snapshot.groupby("room", observed=True)["battery"].transform(
284
  "size"
285
  )
286
+ snapshot = add_context_columns(snapshot)
287
+ bins = trajectory_bins(
288
+ build_trajectory_matrix(daily_features), snapshot["battery"].astype(str), start
289
+ )
290
+ for position, column in enumerate(TRAJECTORY_COLUMNS):
291
+ snapshot[column] = bins[:, position]
292
+ snapshot[TRAJECTORY_COVERAGE_COLUMN] = np.isfinite(bins).mean(axis=1)
293
+ return snapshot
294
 
295
 
296
  def add_context_columns(snapshot: pd.DataFrame) -> pd.DataFrame:
src/batteryswapai/competition_model.py CHANGED
@@ -16,6 +16,11 @@ from scipy.stats import rankdata
16
 
17
  from .competition_features import (
18
  CONTEXT_COLUMNS,
 
 
 
 
 
19
  add_context_columns,
20
  numeric_feature_columns,
21
  )
@@ -81,6 +86,169 @@ def _derived_features(base: pd.DataFrame, kind: str) -> pd.DataFrame:
81
 
82
 
83
  HAZARD_EDGES = (0, 14, 28, 42, 70, 120, 200, 365)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  def _context_features(snapshot: pd.DataFrame) -> pd.DataFrame:
@@ -280,6 +448,10 @@ class EventTimeModel:
280
  context_columns: list[str] | None = None
281
  hazard_estimator: object | None = None
282
  blend_weights: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0)
 
 
 
 
283
 
284
  def _feature_sets(
285
  self, snapshot: pd.DataFrame
@@ -324,15 +496,43 @@ class EventTimeModel:
324
  )
325
  return components
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  def predict_event_risk(self, snapshot: pd.DataFrame) -> np.ndarray:
328
  groups = (
329
  snapshot["scenario"].astype(str).to_numpy()
330
  if "scenario" in snapshot.columns
331
  else None
332
  )
333
- return blend_risk_components(
334
  self.predict_risk_components(snapshot), self.blend_weights, groups
335
  )
 
 
 
 
336
 
337
  def predict_rul(self, snapshot: pd.DataFrame) -> np.ndarray:
338
  base = _clean(snapshot, self.feature_columns)
@@ -372,6 +572,26 @@ def blend_risk_components(
372
  return _map_to_scale(blended, primary, groups)
373
 
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  def _map_to_scale(blended: np.ndarray, reference: np.ndarray, groups) -> np.ndarray:
376
  """Keep the calibrated marginal distribution and take only the new ordering."""
377
 
@@ -386,6 +606,38 @@ def _map_to_scale(blended: np.ndarray, reference: np.ndarray, groups) -> np.ndar
386
  return mapped
387
 
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  def _fit_survivor_estimator(
390
  base: pd.DataFrame,
391
  target_rul: pd.Series,
@@ -428,6 +680,8 @@ def fit_event_time_model(
428
  calibration_folds: int = 5,
429
  device_balanced: bool = False,
430
  with_auxiliary: bool = True,
 
 
431
  blend_weights: tuple[float, float, float, float] = (0.55, 0.15, 0.15, 0.15),
432
  max_iter: int | None = None,
433
  ) -> EventTimeModel:
@@ -517,6 +771,10 @@ def fit_event_time_model(
517
  )
518
 
519
  balanced = None
 
 
 
 
520
  context_estimators = None
521
  context_imputer = None
522
  context_columns = None
@@ -545,6 +803,10 @@ def fit_event_time_model(
545
  base, target_rul, work["event_observed"].astype(bool), random_state
546
  )
547
  weights_used = tuple(float(value) for value in blend_weights)
 
 
 
 
548
 
549
  return EventTimeModel(
550
  estimators,
@@ -564,4 +826,8 @@ def fit_event_time_model(
564
  context_columns,
565
  hazard_estimator,
566
  weights_used,
 
 
 
 
567
  )
 
16
 
17
  from .competition_features import (
18
  CONTEXT_COLUMNS,
19
+ MIN_TRAJECTORY_COVERAGE,
20
+ TRAJECTORY_COLUMNS,
21
+ TRAJECTORY_REFERENCE_OFFSET,
22
+ TRAJECTORY_COVERAGE_COLUMN,
23
+ TRAJECTORY_LAGS,
24
  add_context_columns,
25
  numeric_feature_columns,
26
  )
 
86
 
87
 
88
  HAZARD_EDGES = (0, 14, 28, 42, 70, 120, 200, 365)
89
+ FORECAST_HORIZONS = (30, 60, 90)
90
+ FORECAST_THRESHOLDS = (2.50, 2.45)
91
+ TRAJECTORY_BLEND_WEIGHT = 0.25
92
+
93
+
94
+ def _trajectory_features(bins: np.ndarray) -> pd.DataFrame:
95
+ """Shape of the degradation trajectory: levels, drops, steps and summaries."""
96
+
97
+ current = bins[:, 0]
98
+ highest = np.nanmax(bins, axis=1)
99
+ lowest = np.nanmin(bins, axis=1)
100
+ spread = np.nanstd(bins, axis=1)
101
+ columns: dict[str, np.ndarray] = {}
102
+ for position, lag in enumerate(TRAJECTORY_LAGS):
103
+ columns[f"v_lag{lag}"] = bins[:, position]
104
+ for position, lag in enumerate(TRAJECTORY_LAGS[1:], start=1):
105
+ columns[f"drop{lag}"] = current - bins[:, position]
106
+ columns[f"ndrop{lag}"] = (current - bins[:, position]) / np.where(
107
+ spread > 1e-6, spread, np.nan
108
+ )
109
+ steps = np.diff(bins, axis=1)
110
+ for position in range(steps.shape[1]):
111
+ columns[f"step{position}"] = -steps[:, position]
112
+ columns.update(
113
+ own_max=highest,
114
+ own_min=lowest,
115
+ own_std=spread,
116
+ cur_minus_max=current - highest,
117
+ cur_over_max=current / highest,
118
+ pos=(current - lowest) / np.where(highest - lowest > 1e-6, highest - lowest, np.nan),
119
+ fb50=np.nanmean(bins < 2.50, axis=1),
120
+ fb45=np.nanmean(bins < 2.45, axis=1),
121
+ mono=np.nanmean(steps < 0, axis=1),
122
+ )
123
+ return pd.DataFrame(columns)
124
+
125
+
126
+ def _snapshot_bins(snapshot: pd.DataFrame) -> np.ndarray | None:
127
+ if not set(TRAJECTORY_COLUMNS).issubset(snapshot.columns):
128
+ return None
129
+ return snapshot.reindex(columns=list(TRAJECTORY_COLUMNS)).to_numpy(dtype=float)
130
+
131
+
132
+ def _dense_transitions(matrix: dict, buildings: dict[str, str]):
133
+ """Every observed degradation transition, not just the terminal failures."""
134
+
135
+ grid = matrix["grid"]
136
+ first = matrix["first"]
137
+ devices = list(matrix["index"])
138
+ rows, cuts = [], []
139
+ for name, position in matrix["index"].items():
140
+ begin = int(first[position])
141
+ if begin >= grid.shape[1]:
142
+ continue
143
+ end = int(np.flatnonzero(np.isfinite(grid[position]))[-1])
144
+ start = begin + 180
145
+ for cut in range(start, end + 1, 14):
146
+ rows.append(position)
147
+ cuts.append(cut)
148
+ rows = np.asarray(rows, dtype=np.int64)
149
+ cuts = np.asarray(cuts, dtype=np.int64)
150
+ lags = np.asarray(TRAJECTORY_LAGS, dtype=np.int64) + TRAJECTORY_REFERENCE_OFFSET
151
+ columns = cuts[:, None] - lags[None, :]
152
+ safe = np.clip(columns, 0, grid.shape[1] - 1)
153
+ values = grid[rows[:, None], safe]
154
+ usable = (columns >= 0) & (columns >= first[rows][:, None])
155
+ bins = np.where(usable, values, np.nan)
156
+ names = np.array([devices[position] for position in rows])
157
+ where = np.array([buildings.get(name, "") for name in names])
158
+ return rows, cuts, bins, names, where
159
+
160
+
161
+ def _forecast_targets(matrix: dict, rows, cuts, eol_day: dict[str, float], names):
162
+ """Threshold crossings and future voltage, read only from observed history."""
163
+
164
+ grid = matrix["grid"]
165
+ last = matrix["observed_last"]
166
+ ends_at = np.array([eol_day.get(name, np.nan) for name in names], dtype=float)
167
+ targets: dict[tuple, np.ndarray] = {}
168
+ for horizon in FORECAST_HORIZONS:
169
+ finish = cuts + horizon
170
+ reachable = finish <= last[rows]
171
+ died = np.isfinite(ends_at) & (ends_at <= finish)
172
+ lowest = np.full(len(rows), np.nan)
173
+ for position in range(len(rows)):
174
+ begin = cuts[position] + 1
175
+ stop = min(int(finish[position]), int(last[rows[position]]))
176
+ if stop >= begin:
177
+ lowest[position] = np.nanmin(grid[rows[position], begin : stop + 1])
178
+ for threshold in FORECAST_THRESHOLDS:
179
+ label = np.where(
180
+ died, 1.0, np.where(reachable, (lowest <= threshold).astype(float), np.nan)
181
+ )
182
+ targets[(horizon, threshold)] = np.where(~reachable & ~died, np.nan, label)
183
+ future = np.where(reachable, grid[rows, np.clip(finish, 0, grid.shape[1] - 1)], np.nan)
184
+ targets[(horizon, "voltage")] = np.where(died, 2.30, future)
185
+ return targets
186
+
187
+
188
+ def _forecaster(random_state: int, regression: bool):
189
+ common = dict(learning_rate=0.06, max_iter=200, max_leaf_nodes=15, min_samples_leaf=40,
190
+ l2_regularization=5.0, early_stopping=True, validation_fraction=0.1,
191
+ n_iter_no_change=15, random_state=random_state)
192
+ if regression:
193
+ return HistGradientBoostingRegressor(**common)
194
+ return HistGradientBoostingClassifier(loss="log_loss", **common)
195
+
196
+
197
+ def _fit_forecasters(matrix, buildings, eol_day, random_state,
198
+ snapshot_features, snapshot_buildings):
199
+ """Learn degradation dynamics from dense transitions; score snapshots by held-out building."""
200
+
201
+ rows, cuts, bins, names, where = _dense_transitions(matrix, buildings)
202
+ if len(rows) < 500:
203
+ return None, None
204
+ features = _trajectory_features(bins)
205
+ targets = _forecast_targets(matrix, rows, cuts, eol_day, names)
206
+ unique = np.array(sorted(set(where) - {""}))
207
+ if unique.size < 3:
208
+ return None, None
209
+ assignment = {name: position % 3 for position, name in enumerate(unique)}
210
+ dense_fold = np.array([assignment.get(name, -1) for name in where])
211
+ snap_fold = np.array([assignment.get(str(name), -1) for name in snapshot_buildings])
212
+
213
+ fitted: dict[str, object] = {}
214
+ holdout: dict[str, np.ndarray] = {}
215
+ for (horizon, kind), values in targets.items():
216
+ regression = kind == "voltage"
217
+ name = f"v{horizon}" if regression else f"p{horizon}_{kind}"
218
+ usable = np.isfinite(values)
219
+ if usable.sum() < 200:
220
+ continue
221
+ estimator = _forecaster(random_state, regression)
222
+ estimator.fit(features[usable], values[usable])
223
+ fitted[name] = estimator
224
+ out = np.full(len(snapshot_features), np.nan)
225
+ for group in range(3):
226
+ train = usable & (dense_fold != group)
227
+ target_rows = np.flatnonzero(snap_fold == group)
228
+ if train.sum() < 100 or target_rows.size == 0:
229
+ continue
230
+ fold_model = _forecaster(random_state + group + 1, regression)
231
+ fold_model.fit(features[train], values[train])
232
+ block = snapshot_features.iloc[target_rows]
233
+ out[target_rows] = (
234
+ fold_model.predict(block) if regression
235
+ else fold_model.predict_proba(block)[:, 1]
236
+ )
237
+ holdout[name] = out
238
+ if not fitted:
239
+ return None, None
240
+ return fitted, pd.DataFrame(holdout)
241
+
242
+
243
+ def _forecast_features(forecasters, features: pd.DataFrame) -> pd.DataFrame:
244
+ out: dict[str, np.ndarray] = {}
245
+ for name, estimator in forecasters.items():
246
+ if name.startswith("v"):
247
+ out[name] = estimator.predict(features)
248
+ else:
249
+ out[name] = estimator.predict_proba(features)[:, 1]
250
+ return pd.DataFrame(out, index=features.index)
251
+
252
 
253
 
254
  def _context_features(snapshot: pd.DataFrame) -> pd.DataFrame:
 
448
  context_columns: list[str] | None = None
449
  hazard_estimator: object | None = None
450
  blend_weights: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0)
451
+ forecasters: dict | None = None
452
+ residual_model: object | None = None
453
+ residual_columns: list[str] | None = None
454
+ trajectory_weight: float = 0.0
455
 
456
  def _feature_sets(
457
  self, snapshot: pd.DataFrame
 
496
  )
497
  return components
498
 
499
+ def _residual_risk(self, snapshot: pd.DataFrame):
500
+ """Degradation-dynamics view; None when the trajectory history is unusable."""
501
+
502
+ if self.residual_model is None or self.forecasters is None:
503
+ return None, None
504
+ bins = _snapshot_bins(snapshot)
505
+ if bins is None:
506
+ return None, None
507
+ trajectory = _trajectory_features(bins)
508
+ forecast = _forecast_features(self.forecasters, trajectory)
509
+ frame = pd.concat([forecast, trajectory], axis=1)
510
+ frame = frame.reindex(columns=self.residual_columns).astype(float)
511
+ risk = self.residual_model.predict_proba(frame)[:, 1]
512
+ if TRAJECTORY_COVERAGE_COLUMN in snapshot.columns:
513
+ coverage = pd.to_numeric(
514
+ snapshot[TRAJECTORY_COVERAGE_COLUMN], errors="coerce"
515
+ ).to_numpy(dtype=float)
516
+ else:
517
+ coverage = np.isfinite(bins).mean(axis=1)
518
+ coverage = np.where(np.isfinite(coverage), coverage, 0.0)
519
+ # A battery without enough history keeps its blend position rather than moving on noise.
520
+ weight = np.where(coverage >= MIN_TRAJECTORY_COVERAGE, self.trajectory_weight, 0.0)
521
+ return risk, weight
522
+
523
  def predict_event_risk(self, snapshot: pd.DataFrame) -> np.ndarray:
524
  groups = (
525
  snapshot["scenario"].astype(str).to_numpy()
526
  if "scenario" in snapshot.columns
527
  else None
528
  )
529
+ primary = blend_risk_components(
530
  self.predict_risk_components(snapshot), self.blend_weights, groups
531
  )
532
+ risk, weight = self._residual_risk(snapshot)
533
+ if risk is None or not np.any(weight > 0.0):
534
+ return primary
535
+ return blend_trajectory(primary, risk, weight, groups)
536
 
537
  def predict_rul(self, snapshot: pd.DataFrame) -> np.ndarray:
538
  base = _clean(snapshot, self.feature_columns)
 
572
  return _map_to_scale(blended, primary, groups)
573
 
574
 
575
+ def blend_trajectory(primary, residual, weight, groups):
576
+ """Nudge the ranking by the degradation view, keeping the calibrated scale."""
577
+
578
+ primary = np.asarray(primary, dtype=float)
579
+ residual = np.asarray(residual, dtype=float)
580
+ weight = np.asarray(weight, dtype=float)
581
+ residual = np.where(np.isfinite(residual), residual, np.nanmin(residual[np.isfinite(residual)])
582
+ if np.isfinite(residual).any() else 0.0)
583
+ blended = np.empty(len(primary), dtype=float)
584
+ keys = [None] if groups is None else list(pd.unique(groups))
585
+ for key in keys:
586
+ mask = slice(None) if key is None else (groups == key)
587
+ size = len(primary[mask])
588
+ first = rankdata(primary[mask]) / max(size, 1)
589
+ second = rankdata(residual[mask]) / max(size, 1)
590
+ local = weight[mask]
591
+ blended[mask] = (1.0 - local) * first + local * second
592
+ return _map_to_scale(blended, primary, groups)
593
+
594
+
595
  def _map_to_scale(blended: np.ndarray, reference: np.ndarray, groups) -> np.ndarray:
596
  """Keep the calibrated marginal distribution and take only the new ordering."""
597
 
 
606
  return mapped
607
 
608
 
609
+ def _fit_trajectory_stack(work, trajectory, eol_times, event_target, random_state):
610
+ """Dense degradation forecasting plus the temporal-bin view, as one residual ranker."""
611
+
612
+ bins = _snapshot_bins(work)
613
+ if bins is None:
614
+ return None, None, None, 0.0
615
+ snapshot_features = _trajectory_features(bins)
616
+ buildings = dict(zip(work["battery"].astype(str), work["building"].astype(str)))
617
+ eol_day: dict[str, float] = {}
618
+ if eol_times is not None:
619
+ origin = trajectory["origin"]
620
+ for name, when in eol_times.items():
621
+ if pd.notna(when):
622
+ eol_day[str(name)] = float((pd.Timestamp(when).normalize() - origin).days)
623
+ forecasters, holdout = _fit_forecasters(
624
+ trajectory, buildings, eol_day, random_state,
625
+ snapshot_features, work["building"].astype(str).to_numpy(),
626
+ )
627
+ if forecasters is None:
628
+ return None, None, None, 0.0
629
+ frame = pd.concat([holdout.reset_index(drop=True), snapshot_features.reset_index(drop=True)], axis=1)
630
+ columns = list(frame.columns)
631
+ residual = HistGradientBoostingClassifier(
632
+ loss="log_loss", learning_rate=0.05, max_iter=300, max_leaf_nodes=15,
633
+ min_samples_leaf=30, l2_regularization=5.0, early_stopping=True,
634
+ validation_fraction=0.12, n_iter_no_change=25, class_weight={0: 1.0, 1: 5.0},
635
+ random_state=random_state,
636
+ )
637
+ residual.fit(frame.astype(float), event_target)
638
+ return forecasters, residual, columns, TRAJECTORY_BLEND_WEIGHT
639
+
640
+
641
  def _fit_survivor_estimator(
642
  base: pd.DataFrame,
643
  target_rul: pd.Series,
 
680
  calibration_folds: int = 5,
681
  device_balanced: bool = False,
682
  with_auxiliary: bool = True,
683
+ trajectory: dict | None = None,
684
+ eol_times: pd.Series | None = None,
685
  blend_weights: tuple[float, float, float, float] = (0.55, 0.15, 0.15, 0.15),
686
  max_iter: int | None = None,
687
  ) -> EventTimeModel:
 
771
  )
772
 
773
  balanced = None
774
+ forecasters = None
775
+ residual_model = None
776
+ residual_columns = None
777
+ trajectory_weight = 0.0
778
  context_estimators = None
779
  context_imputer = None
780
  context_columns = None
 
803
  base, target_rul, work["event_observed"].astype(bool), random_state
804
  )
805
  weights_used = tuple(float(value) for value in blend_weights)
806
+ if trajectory is not None:
807
+ forecasters, residual_model, residual_columns, trajectory_weight = _fit_trajectory_stack(
808
+ work, trajectory, eol_times, event_target, random_state
809
+ )
810
 
811
  return EventTimeModel(
812
  estimators,
 
826
  context_columns,
827
  hazard_estimator,
828
  weights_used,
829
+ forecasters,
830
+ residual_model,
831
+ residual_columns,
832
+ trajectory_weight,
833
  )
submission_artifacts/planner.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:5cb8bf244aa8755fde47bc937761a71d240c6277862e7d533e44233b03171fdf
3
- size 12590691
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3
3
+ size 15508289
submission_artifacts/planner.json CHANGED
@@ -8,6 +8,8 @@
8
  "expected_gain_margin": 10.0,
9
  "expected_service_cost_hours": 2.0,
10
  "scheduled_fraction": 0.038,
 
 
11
  "minimum_scheduled_batteries": 8,
12
  "maximum_scheduled_batteries": 24,
13
  "calibration_folds": 5,
@@ -19,8 +21,8 @@
19
  "capacity_lookback_days": 14,
20
  "capacity_lookahead_days": 21,
21
  "capacity_daily_limit_fraction": 1.0,
22
- "capacity_weekly_limit_fraction": 1.0,
23
- "capacity_limit_penalty_multiplier": 1.0,
24
  "capacity_operational_cost_weight": 1.5,
25
  "use_expected_cost": true,
26
  "risk_calibration_scale": 1.5,
@@ -40,6 +42,18 @@
40
  "selection_key": "expected_gain"
41
  },
42
  "survivor_rul_head": true,
 
 
 
 
 
 
 
 
 
 
 
 
43
  "training_rows": 19890,
44
  "training_devices": 458,
45
  "features": [
@@ -121,7 +135,7 @@
121
  "building_battery_count",
122
  "room_battery_count"
123
  ],
124
- "artifact_sha256": "5cb8bf244aa8755fde47bc937761a71d240c6277862e7d533e44233b03171fdf",
125
  "runtime_versions": {
126
  "batteryswap_public": "0.3.4",
127
  "fastparquet": "2026.5.0",
@@ -132,15 +146,15 @@
132
  },
133
  "validation_report": "artifacts/submission_cv_final.json",
134
  "optimistic_train_score": {
135
- "overtime": 57.55209027777776,
136
- "building_change": 10.625,
137
- "weekly_limit": 45.833333333333336,
138
- "late_swap": 72.5,
139
- "room_change": 6.770833333333333,
140
- "early_swap": 381.5833333333333,
141
- "daily_limit": 16.666666666666668,
142
- "travel": 36.408777777777786,
143
- "battery_swap": 4.020833333333333,
144
- "total_cost": 631.9608680555556
145
  }
146
  }
 
8
  "expected_gain_margin": 10.0,
9
  "expected_service_cost_hours": 2.0,
10
  "scheduled_fraction": 0.038,
11
+ "weekly_guard_fraction": 0.95,
12
+ "hard_limit_penalty_multiplier": 1.5,
13
  "minimum_scheduled_batteries": 8,
14
  "maximum_scheduled_batteries": 24,
15
  "calibration_folds": 5,
 
21
  "capacity_lookback_days": 14,
22
  "capacity_lookahead_days": 21,
23
  "capacity_daily_limit_fraction": 1.0,
24
+ "capacity_weekly_limit_fraction": 0.95,
25
+ "capacity_limit_penalty_multiplier": 1.5,
26
  "capacity_operational_cost_weight": 1.5,
27
  "use_expected_cost": true,
28
  "risk_calibration_scale": 1.5,
 
42
  "selection_key": "expected_gain"
43
  },
44
  "survivor_rul_head": true,
45
+ "trajectory_weight": 0.25,
46
+ "forecast_heads": [
47
+ "p30_2.45",
48
+ "p30_2.5",
49
+ "p60_2.45",
50
+ "p60_2.5",
51
+ "p90_2.45",
52
+ "p90_2.5",
53
+ "v30",
54
+ "v60",
55
+ "v90"
56
+ ],
57
  "training_rows": 19890,
58
  "training_devices": 458,
59
  "features": [
 
135
  "building_battery_count",
136
  "room_battery_count"
137
  ],
138
+ "artifact_sha256": "63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3",
139
  "runtime_versions": {
140
  "batteryswap_public": "0.3.4",
141
  "fastparquet": "2026.5.0",
 
146
  },
147
  "validation_report": "artifacts/submission_cv_final.json",
148
  "optimistic_train_score": {
149
+ "daily_limit": 12.5,
150
+ "weekly_limit": 43.75,
151
+ "late_swap": 75.83333333333333,
152
+ "battery_swap": 4.026041666666667,
153
+ "room_change": 6.791666666666667,
154
+ "overtime": 57.756944444444464,
155
+ "early_swap": 386.8645833333333,
156
+ "building_change": 10.520833333333334,
157
+ "travel": 36.906100694444454,
158
+ "total_cost": 634.9495034722223
159
  }
160
  }
tests/test_competition_features.py CHANGED
@@ -4,6 +4,8 @@ import pandas as pd
4
  from batteryswapai.competition_features import (
5
  build_daily_features,
6
  numeric_feature_columns,
 
 
7
  scenario_snapshot,
8
  )
9
 
@@ -36,10 +38,11 @@ def test_scenario_snapshot_never_uses_future_sensor_rows():
36
  }
37
  )
38
 
39
- daily = build_daily_features(raw, windows=(3, 7, 14, 30, 60, 90, 180, 365))
40
- snapshot = scenario_snapshot(daily, locations, "s_0", "2026-01-02")
41
- assert snapshot.loc[0, "day"] == pd.Timestamp("2026-01-02")
42
- assert np.isclose(snapshot.loc[0, "voltage_median"], 2.85)
 
43
 
44
 
45
  def test_temperature_filter_falls_back_when_no_stable_reading_exists():
@@ -73,3 +76,133 @@ def test_model_features_exclude_dataset_boundary_and_calendar_fields():
73
  assert "censor_proxy_rul_days" not in columns
74
  assert "scenario_month_sin" not in columns
75
  assert "scenario_month_cos" not in columns
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from batteryswapai.competition_features import (
5
  build_daily_features,
6
  numeric_feature_columns,
7
+ scenario_history,
8
+ scenario_history_snapshot,
9
  scenario_snapshot,
10
  )
11
 
 
38
  }
39
  )
40
 
41
+ # The 2026-01-02 readings are timestamped after the 00:00 scenario start, so the
42
+ # evaluator's cut excludes them and the freshest usable bucket is the previous day.
43
+ snapshot = scenario_history_snapshot(raw, locations, "s_0", "2026-01-02")
44
+ assert snapshot.loc[0, "day"] == pd.Timestamp("2026-01-01")
45
+ assert np.isclose(snapshot.loc[0, "voltage_median"], 3.05)
46
 
47
 
48
  def test_temperature_filter_falls_back_when_no_stable_reading_exists():
 
76
  assert "censor_proxy_rul_days" not in columns
77
  assert "scenario_month_sin" not in columns
78
  assert "scenario_month_cos" not in columns
79
+
80
+
81
+ def _voltage_history(days: int = 420, start: str = "2025-01-01") -> pd.DataFrame:
82
+ stamps = pd.date_range(start, periods=days, freq="D")
83
+ rows = []
84
+ for device, offset in (("d_a", 0.0), ("d_b", 0.12)):
85
+ rows.append(
86
+ pd.DataFrame(
87
+ {
88
+ "device_id": device,
89
+ "day": stamps,
90
+ "stable_voltage_median": np.linspace(2.90, 2.42, days) + offset,
91
+ }
92
+ )
93
+ )
94
+ return pd.concat(rows, ignore_index=True)
95
+
96
+
97
+ def test_trajectory_bins_never_read_past_the_cutoff():
98
+ """The bins must be identical whether or not future rows exist in the frame."""
99
+
100
+ from batteryswapai.competition_features import build_trajectory_matrix, trajectory_bins
101
+
102
+ history = _voltage_history()
103
+ cutoff = pd.Timestamp("2025-10-01")
104
+
105
+ full = build_trajectory_matrix(history.copy())
106
+ truncated = build_trajectory_matrix(history[history["day"] <= cutoff].copy())
107
+
108
+ from_full = trajectory_bins(full, ["d_a", "d_b"], cutoff)
109
+ from_truncated = trajectory_bins(truncated, ["d_a", "d_b"], cutoff)
110
+ assert np.allclose(from_full, from_truncated, equal_nan=True)
111
+ assert np.isfinite(from_full).any()
112
+
113
+
114
+ def test_trajectory_bins_are_missing_without_history():
115
+ from batteryswapai.competition_features import (
116
+ MIN_TRAJECTORY_COVERAGE,
117
+ build_trajectory_matrix,
118
+ trajectory_bins,
119
+ )
120
+
121
+ matrix = build_trajectory_matrix(_voltage_history(days=40, start="2025-01-01").copy())
122
+ early = trajectory_bins(matrix, ["d_a"], pd.Timestamp("2025-01-20"))
123
+ assert np.isfinite(early).mean() < MIN_TRAJECTORY_COVERAGE
124
+ unknown = trajectory_bins(matrix, ["d_missing"], pd.Timestamp("2025-01-20"))
125
+ assert not np.isfinite(unknown).any()
126
+
127
+
128
+ def test_scenario_history_matches_the_evaluator_cut():
129
+ """scenario_history must reproduce iterate_scenarios' truncation exactly."""
130
+
131
+ stamps = pd.date_range("2025-05-01", "2025-06-30", freq="6h")
132
+ frame = pd.DataFrame(
133
+ {
134
+ "device_id": "d_a",
135
+ "end_time": stamps,
136
+ "voltage": np.linspace(2.9, 2.5, len(stamps)),
137
+ "temperature": 20.0,
138
+ }
139
+ ).set_index(["device_id", "end_time"])
140
+ cutoff = pd.Timestamp("2025-06-01")
141
+
142
+ visible = scenario_history(frame, cutoff).reset_index()
143
+ assert visible["end_time"].max() == cutoff # endpoint is inclusive
144
+ assert not (visible["end_time"] > cutoff).any()
145
+ expected = frame.reset_index()
146
+ expected = expected[expected["end_time"] <= cutoff]
147
+ assert len(visible) == len(expected)
148
+
149
+
150
+ def test_features_ignore_readings_taken_after_the_scenario_start():
151
+ cutoff = pd.Timestamp("2025-06-01")
152
+ stamps = pd.date_range("2025-01-01", "2025-06-30", freq="6h")
153
+ frame = pd.DataFrame(
154
+ {
155
+ "device_id": "d_a",
156
+ "end_time": stamps,
157
+ "voltage": np.linspace(2.90, 2.40, len(stamps)),
158
+ "temperature": 20.0,
159
+ }
160
+ ).set_index(["device_id", "end_time"])
161
+ locations = pd.DataFrame(
162
+ {
163
+ "battery": ["d_a"],
164
+ "building": ["b_1"],
165
+ "room": ["r_1"],
166
+ "start_time": [pd.Timestamp("2024-01-01")],
167
+ "end_time": [pd.Timestamp("2025-06-30")],
168
+ }
169
+ )
170
+ flat = frame.reset_index()
171
+ already_cut = flat[flat["end_time"] <= cutoff].set_index(["device_id", "end_time"])
172
+
173
+ full = scenario_history_snapshot(frame, locations, "s", cutoff)
174
+ trimmed = scenario_history_snapshot(already_cut, locations, "s", cutoff)
175
+ columns = [c for c in full.columns if pd.api.types.is_numeric_dtype(full[c])]
176
+ assert np.allclose(
177
+ full[columns].astype(float).to_numpy(),
178
+ trimmed[columns].astype(float).to_numpy(),
179
+ equal_nan=True,
180
+ )
181
+
182
+
183
+ def test_trajectory_availability_does_not_depend_on_later_readings():
184
+ """A sensor gap must look the same whether or not the device reports again later."""
185
+
186
+ from batteryswapai.competition_features import build_trajectory_matrix, trajectory_bins
187
+
188
+ cutoff = pd.Timestamp("2025-06-01")
189
+ early = pd.date_range("2024-01-01", "2025-03-01", freq="D")
190
+ later = pd.date_range("2025-08-01", "2025-09-01", freq="D") # resumes AFTER the cutoff
191
+
192
+ def frame(days):
193
+ return pd.DataFrame(
194
+ {
195
+ "device_id": "d_a",
196
+ "day": days,
197
+ "stable_voltage_median": np.linspace(2.90, 2.60, len(days)),
198
+ }
199
+ )
200
+
201
+ without_future = build_trajectory_matrix(frame(early).copy())
202
+ with_future = build_trajectory_matrix(
203
+ pd.concat([frame(early), frame(later)], ignore_index=True).copy()
204
+ )
205
+ a = trajectory_bins(without_future, ["d_a"], cutoff)
206
+ b = trajectory_bins(with_future, ["d_a"], cutoff)
207
+ assert np.allclose(a, b, equal_nan=True)
208
+ assert np.array_equal(np.isfinite(a), np.isfinite(b))
tests/test_competition_model.py CHANGED
@@ -69,3 +69,53 @@ def test_context_columns_describe_one_scenario_and_are_not_model_features():
69
  assert not set(CONTEXT_COLUMNS) & set(numeric_feature_columns(snapshot))
70
  # the auxiliary model reads the precomputed columns rather than regrouping
71
  assert list(_context_features(snapshot).columns) == list(CONTEXT_COLUMNS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  assert not set(CONTEXT_COLUMNS) & set(numeric_feature_columns(snapshot))
70
  # the auxiliary model reads the precomputed columns rather than regrouping
71
  assert list(_context_features(snapshot).columns) == list(CONTEXT_COLUMNS)
72
+
73
+
74
+ def test_trajectory_blend_leaves_short_history_batteries_where_they_were():
75
+ import numpy as np
76
+ from batteryswapai.competition_model import blend_trajectory
77
+
78
+ groups = np.array(["s_0"] * 4)
79
+ primary = np.array([0.40, 0.30, 0.20, 0.10])
80
+ residual = np.array([0.10, 0.20, 0.30, 0.40]) # exactly reversed
81
+
82
+ unchanged = blend_trajectory(primary, residual, np.zeros(4), groups)
83
+ assert np.allclose(unchanged, primary)
84
+
85
+ nudged = blend_trajectory(primary, residual, np.full(4, 0.25), groups)
86
+ assert sorted(nudged) == sorted(primary) # calibrated scale is preserved
87
+ # a quarter weight is a nudge, not a takeover; a dominant weight does reorder
88
+ strong = blend_trajectory(primary, residual, np.full(4, 0.9), groups)
89
+ assert sorted(strong) == sorted(primary)
90
+ assert not np.allclose(strong, primary)
91
+
92
+ # a battery with no usable history keeps its own position while the others move
93
+ partial = blend_trajectory(primary, residual, np.array([0.0, 0.25, 0.25, 0.0]), groups)
94
+ assert sorted(partial) == sorted(primary)
95
+
96
+
97
+ def test_trajectory_blend_is_deterministic_and_scenario_local():
98
+ import numpy as np
99
+ from batteryswapai.competition_model import blend_trajectory
100
+
101
+ groups = np.array(["s_0", "s_0", "s_1", "s_1"])
102
+ primary = np.array([0.9, 0.1, 0.02, 0.01])
103
+ residual = np.array([0.1, 0.9, 0.01, 0.02])
104
+ first = blend_trajectory(primary, residual, np.full(4, 0.25), groups)
105
+ second = blend_trajectory(primary, residual, np.full(4, 0.25), groups)
106
+ assert np.array_equal(first, second)
107
+ # scenario 0 values never leak into scenario 1
108
+ assert sorted(first[:2]) == sorted(primary[:2])
109
+ assert sorted(first[2:]) == sorted(primary[2:])
110
+
111
+
112
+ def test_model_without_a_trajectory_stack_returns_the_blend_unchanged():
113
+ import numpy as np
114
+ import pandas as pd
115
+ from batteryswapai.competition_model import EventTimeModel
116
+
117
+ model = EventTimeModel.__new__(EventTimeModel)
118
+ model.residual_model = None
119
+ model.forecasters = None
120
+ risk, weight = model._residual_risk(pd.DataFrame({"battery": ["d_a"]}))
121
+ assert risk is None and weight is None
tests/test_competition_planner.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from types import SimpleNamespace
2
 
3
  import pytest
@@ -554,3 +555,56 @@ def test_planner_runs_with_an_artifact_that_has_no_survivor_head():
554
  planner = CompetitionPlanner(DummyEventTimeModel(), PlannerPolicy(use_expected_cost=True))
555
  plan = planner.plan_snapshot(snapshot, locations, travel, settings, "2025-09-01")
556
  check_plan_valid(plan, locations, start_time=pd.Timestamp("2025-09-01"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
  from types import SimpleNamespace
3
 
4
  import pytest
 
555
  planner = CompetitionPlanner(DummyEventTimeModel(), PlannerPolicy(use_expected_cost=True))
556
  plan = planner.plan_snapshot(snapshot, locations, travel, settings, "2025-09-01")
557
  check_plan_valid(plan, locations, start_time=pd.Timestamp("2025-09-01"))
558
+
559
+
560
+ def test_weekly_guard_band_reserves_headroom_below_the_hard_limit():
561
+ """The evaluator charges the weekly penalty at >= 24h, so the planner keeps a margin."""
562
+
563
+ policy = PlannerPolicy(capacity_weekly_limit_fraction=0.95,
564
+ capacity_limit_penalty_multiplier=1.5)
565
+ settings = EvaluationSettings(base_location="b_base", base_room="r_base")
566
+ guarded = float(settings.worker_limit_weekly_hours) * policy.capacity_weekly_limit_fraction
567
+ assert guarded < float(settings.worker_limit_weekly_hours)
568
+ assert guarded == pytest.approx(22.8)
569
+ assert policy.capacity_limit_penalty_multiplier > 1.0
570
+
571
+
572
+ def _train_split_available() -> bool:
573
+ from pathlib import Path
574
+
575
+ return Path("data/raw/train/scenarios.json").exists() and Path(
576
+ "submission_artifacts/planner.joblib"
577
+ ).exists()
578
+
579
+
580
+ @pytest.mark.skipif(not _train_split_available(), reason="train split or artifact absent")
581
+ def test_official_interface_matches_our_direct_scenario_path():
582
+ """make_submissions -> Planner.plan must reproduce our iterate_scenarios path exactly."""
583
+
584
+ import joblib
585
+ from batteryswap_public.utils import iterate_scenarios, load_dataset
586
+ from batteryswapai.competition_features import scenario_history_snapshot
587
+
588
+ locations, timeseries, eol_times, scenarios = load_dataset(Path("data/raw/train"))
589
+ subset = scenarios[:3]
590
+ planner = joblib.load("submission_artifacts/planner.joblib")
591
+
592
+ for scenario, locs, cut, _ in iterate_scenarios(locations, timeseries, eol_times, subset):
593
+ start = pd.Timestamp(scenario["start_time"])
594
+ rows = cut.reset_index()
595
+ # the planner is handed exactly the scenario-visible history
596
+ assert rows["end_time"].max() == start
597
+ assert not (rows["end_time"] > start).any()
598
+
599
+ official = planner.plan(cut, locs, scenario["travel_costs"], scenario["settings"])
600
+ snapshot = scenario_history_snapshot(cut, locs, scenario["name"], start)
601
+ direct = planner.plan_snapshot(
602
+ snapshot, locs, scenario["travel_costs"], scenario["settings"], start
603
+ )
604
+ pd.testing.assert_frame_equal(official, direct)
605
+
606
+ # ranking is deterministic across repeated calls
607
+ again = planner.plan_snapshot(
608
+ snapshot, locs, scenario["travel_costs"], scenario["settings"], start
609
+ )
610
+ pd.testing.assert_frame_equal(direct, again)