Commit ·
dfa8acb
1
Parent(s): 3c49095
Submission 006: official scenario interface and causal degradation model
Browse files- README.md +29 -55
- REPRODUCIBILITY.md +11 -5
- script.py +5 -7
- scripts/train_submission.py +20 -4
- scripts/validate_submission.py +25 -5
- src/batteryswapai/competition_features.py +113 -1
- src/batteryswapai/competition_model.py +267 -1
- submission_artifacts/planner.joblib +2 -2
- submission_artifacts/planner.json +27 -13
- tests/test_competition_features.py +137 -4
- tests/test_competition_model.py +50 -0
- tests/test_competition_planner.py +54 -0
README.md
CHANGED
|
@@ -1,73 +1,47 @@
|
|
| 1 |
-
# BatterySwapAI 2026 —
|
| 2 |
|
| 3 |
-
Candidate.
|
|
|
|
| 4 |
|
| 5 |
-
- Building-held-out score: **
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
- Event ranking, building-held-out: **AP 0.5258**, AUC 0.9640
|
| 9 |
- Mean scheduled batteries: **15.75**
|
| 10 |
-
- Tests: **
|
| 11 |
-
- Artifact SHA-256: `
|
| 12 |
-
- Base: `
|
| 13 |
|
| 14 |
## What changed
|
| 15 |
|
| 16 |
-
**
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
**
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
| 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 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
the
|
| 63 |
-
add/remove passes, device weighting on its own, and five- and six-way blends.
|
| 64 |
|
| 65 |
## Artifact
|
| 66 |
|
| 67 |
-
Trained
|
| 68 |
-
|
| 69 |
-
54 s and 1.5 GB per split; deterministic and byte-identical offline.
|
| 70 |
|
| 71 |
-
## How to
|
| 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
|
| 2 |
|
| 3 |
Dataset revision:
|
| 4 |
|
|
@@ -27,10 +27,10 @@ pytest
|
|
| 27 |
Expected checks:
|
| 28 |
|
| 29 |
```text
|
| 30 |
-
|
| 31 |
-
building-held-out score:
|
| 32 |
mean scheduled batteries: 15.75
|
| 33 |
-
artifact SHA-256:
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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,
|
| 33 |
locations,
|
| 34 |
timeseries,
|
| 35 |
hidden_eol_times,
|
| 36 |
scenarios,
|
| 37 |
):
|
| 38 |
-
snapshot =
|
| 39 |
-
|
| 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,
|
| 54 |
-
snapshot =
|
|
|
|
|
|
|
| 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,
|
| 123 |
locations, timeseries, eol_times, scenarios
|
| 124 |
):
|
| 125 |
-
snapshot =
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 94 |
locations, timeseries, eol_times, scenarios
|
| 95 |
):
|
| 96 |
-
snapshot =
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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":
|
| 23 |
-
"capacity_limit_penalty_multiplier": 1.
|
| 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": "
|
| 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 |
-
"
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"
|
| 139 |
-
"room_change": 6.
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"total_cost":
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
assert
|
|
|
|
| 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)
|