CarlAlbertCode commited on
Commit
0febf0f
·
1 Parent(s): 5117a31

BatterySwapAI submission 001

Browse files
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ .env
7
+
8
+ data/
9
+ artifacts/
10
+ submission.csv
11
+ *.log
12
+
13
+ *.joblib
14
+ !submission_artifacts/planner.joblib
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM huggingface/competitions:latest
2
+
3
+ ENV BATTERYSWAP_SPLITS=train
4
+ ENV BATTERYSWAP_SUBMISSION_PATH=submission.csv
5
+ ENV PYTHONPATH=/app/src
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt ./
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY src/ ./src/
13
+ COPY submission_artifacts/ ./submission_artifacts/
14
+ COPY script.py ./
15
+
16
+ CMD python3 script.py
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CarlAlbertCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,3 +1,49 @@
1
  ---
2
  license: mit
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
  ---
4
+
5
+ # BatterySwapAI 2026
6
+
7
+ MnesisLab submission repository for the BatterySwapAI 2026 Challenge.
8
+
9
+ ## Runtime
10
+
11
+ The evaluator runs:
12
+
13
+ ```bash
14
+ python3 script.py
15
+ ```
16
+
17
+ The trained planner is stored at:
18
+
19
+ ```text
20
+ submission_artifacts/planner.joblib
21
+ ```
22
+
23
+ The program reads competition data from `/tmp/data` and writes `submission.csv`.
24
+
25
+ No network access is required during evaluation.
26
+
27
+ ## Model
28
+
29
+ The solution uses past battery voltage and temperature observations to estimate near-term failure risk and remaining useful life.
30
+
31
+ The planner then schedules replacement work using the published timing, travel, worker-capacity, and replacement costs.
32
+
33
+ ## Verification
34
+
35
+ The stored artifact produced this train-split sanity score with the competition Docker environment:
36
+
37
+ ```text
38
+ 462.11682986111094
39
+ ```
40
+
41
+ The device-held-out local validation cost is:
42
+
43
+ ```text
44
+ 1325.70
45
+ ```
46
+
47
+ The second value is a local validation result. It is not a competition leaderboard score.
48
+
49
+ See `REPRODUCIBILITY.md` for the training and validation procedure.
REPRODUCIBILITY.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reproduction
2
+
3
+ ## Data
4
+
5
+ Training uses only the public BatterySwapAI 2026 dataset:
6
+
7
+ ```text
8
+ batteryswapaichallenge/BatterySwapAI-2026-Public
9
+ ```
10
+
11
+ Dataset revision:
12
+
13
+ ```text
14
+ 7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c
15
+ ```
16
+
17
+ The competition dataset is not stored in this repository.
18
+
19
+ Download the public dataset with:
20
+
21
+ ```bash
22
+ hf download batteryswapaichallenge/BatterySwapAI-2026-Public \
23
+ --repo-type dataset \
24
+ --revision 7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c \
25
+ --local-dir data/raw
26
+ ```
27
+
28
+ The training split must be available at:
29
+
30
+ ```text
31
+ data/raw/train
32
+ ```
33
+
34
+ ## Environment
35
+
36
+ Use Python 3.10 or later.
37
+
38
+ ```bash
39
+ python -m venv .venv
40
+ source .venv/bin/activate
41
+ python -m pip install --upgrade pip
42
+ pip install -e . -r requirements.dev.txt
43
+ ```
44
+
45
+ ## Training
46
+
47
+ Run:
48
+
49
+ ```bash
50
+ python scripts/train_submission.py
51
+ ```
52
+
53
+ Training uses random seed `2026`.
54
+
55
+ The command creates:
56
+
57
+ ```text
58
+ submission_artifacts/planner.joblib
59
+ submission_artifacts/planner.json
60
+ ```
61
+
62
+ No pretrained model or external training dataset is used.
63
+
64
+ ## Validation
65
+
66
+ Run:
67
+
68
+ ```bash
69
+ python scripts/validate_submission.py
70
+ ```
71
+
72
+ Validation groups complete battery IDs so one battery does not occur in both the training and validation side of an outer fold.
73
+
74
+ The stored planner configuration includes:
75
+
76
+ ```text
77
+ event_risk_threshold = 0.5
78
+ prediction_offset_days = -5.0
79
+ risk_calibration_scale = 1.5
80
+ building_batch_window_days = 4
81
+ capacity_lookahead_days = 21
82
+ capacity_operational_cost_weight = 1.5
83
+ ```
84
+
85
+ The current device-held-out validation cost is:
86
+
87
+ ```text
88
+ 1325.70
89
+ ```
90
+
91
+ This is a local validation result, not a leaderboard score.
92
+
93
+ ## Stored artifact
94
+
95
+ SHA-256:
96
+
97
+ ```text
98
+ 8d75f66c4cabf06c71eefc1714db726b86d02f6108829c51caabde89b0b794ff
99
+ ```
100
+
101
+ Training rows:
102
+
103
+ ```text
104
+ 19890
105
+ ```
106
+
107
+ Training devices:
108
+
109
+ ```text
110
+ 458
111
+ ```
112
+
113
+ ## Competition-format verification
114
+
115
+ Build:
116
+
117
+ ```bash
118
+ docker build -t batteryswapai-2026 .
119
+ ```
120
+
121
+ Run the public train split:
122
+
123
+ ```bash
124
+ docker run --rm \
125
+ -e BATTERYSWAP_SPLITS=train \
126
+ -e BATTERYSWAP_SUBMISSION_PATH=submission.csv \
127
+ -v "$(pwd)/data/raw:/tmp/data" \
128
+ batteryswapai-2026 \
129
+ conda run --no-capture-output -p /app/env /bin/bash -c \
130
+ "python3 script.py && python3 -m batteryswap_public.metric"
131
+ ```
132
+
133
+ The verified train cost of the stored artifact is:
134
+
135
+ ```text
136
+ 462.11682986111094
137
+ ```
THIRD_PARTY_LICENSES.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party licenses
2
+
3
+ This table lists the direct third-party packages used by the project. No third-party package source code or external dataset is copied into this repository.
4
+
5
+ | Package | Declared / verified version | Source | License |
6
+ |---|---:|---|---|
7
+ | NumPy | >=2.2.0 / 2.5.2 training, 2.2.6 parity | https://numpy.org/ | BSD-3-Clause and bundled permissive licenses |
8
+ | pandas | >=2.3.0 / 3.0.5 training, 2.3.3 parity | https://pandas.pydata.org/ | BSD-3-Clause |
9
+ | scikit-learn | >=1.7.0 / 1.9.0 training, 1.7.2 parity | https://scikit-learn.org/ | BSD-3-Clause |
10
+ | joblib | >=1.5.3 / 1.5.3 | https://joblib.readthedocs.io/ | BSD-3-Clause |
11
+ | fastparquet | >=2026.5.0 / 2026.5.0 | https://github.com/dask/fastparquet | Apache-2.0 |
12
+ | pydantic-settings | >=2.14.1 / 2.15.0 parity | https://github.com/pydantic/pydantic-settings | MIT |
13
+ | structlog | >=25.5.0 / 26.1.0 parity | https://www.structlog.org/ | MIT or Apache-2.0 |
14
+ | huggingface-hub | >=0.34 | https://github.com/huggingface/huggingface_hub | Apache-2.0 |
15
+ | PyYAML | >=6.0 | https://pyyaml.org/ | MIT |
16
+ | batteryswap_public | >=0.1.0 / 0.3.4 | https://pypi.org/project/batteryswap-public/ | Organizer package; PyPI metadata does not declare a license |
17
+
18
+ `batteryswap_public` is required by the official example and evaluation interface. Its PyPI metadata does not state a license. Obtain written organizer confirmation before the competition deadline.
pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=75", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "batteryswapai-2026"
7
+ version = "0.1.0"
8
+ description = "BatterySwapAI 2026 submission"
9
+ requires-python = ">=3.10"
10
+
11
+ [tool.setuptools.packages.find]
12
+ where = ["src"]
13
+
14
+ [tool.pytest.ini_options]
15
+ pythonpath = ["src"]
16
+ testpaths = ["tests"]
17
+ addopts = "-q"
requirements.dev.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ -r requirements.txt
2
+ huggingface-hub>=0.34
3
+ pyarrow==25.0.1
4
+ pytest>=8.3
5
+ pyyaml>=6.0
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime packages used by the competition submission.
2
+ batteryswap_public>=0.1.0
3
+ fastparquet>=2026.5.0
4
+ joblib>=1.5.3
5
+ numpy>=2.2.0
6
+ pandas>=2.3.0
7
+ pydantic-settings>=2.14.1
8
+ scikit-learn>=1.7.0
9
+ structlog>=25.5.0
script.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import joblib
7
+ import pandas as pd
8
+ from batteryswap_public.utils import iterate_scenarios, load_dataset
9
+
10
+ from batteryswapai.competition_features import build_daily_features, scenario_snapshot
11
+
12
+
13
+ def main() -> None:
14
+ dataset_path = Path(os.environ.get("BATTERYSWAP_DATASET_PATH", "/tmp/data"))
15
+ artifact_path = Path(
16
+ os.environ.get("BATTERYSWAP_PLANNER_PATH", "submission_artifacts/planner.joblib")
17
+ )
18
+ splits = os.environ.get("BATTERYSWAP_SPLITS", "public,private").split(",")
19
+ output_path = Path(os.environ.get("BATTERYSWAP_SUBMISSION_PATH", "submission.csv"))
20
+ planner = joblib.load(artifact_path)
21
+
22
+ plans = []
23
+ for split in splits:
24
+ locations, timeseries, hidden_eol_times, scenarios = load_dataset(dataset_path / split)
25
+ daily = build_daily_features(timeseries)
26
+ for scenario, locs, _, _ in iterate_scenarios(
27
+ locations, timeseries, hidden_eol_times, scenarios
28
+ ):
29
+ snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
30
+ plan = planner.plan_snapshot(
31
+ snapshot,
32
+ locs,
33
+ scenario["travel_costs"],
34
+ scenario["settings"],
35
+ scenario["start_time"],
36
+ )
37
+ plan["split"] = split
38
+ plan["scenario"] = scenario["name"]
39
+ plans.append(plan)
40
+
41
+ submission = pd.concat(plans, ignore_index=True)
42
+ submission.to_csv(output_path, index=False)
43
+ if not output_path.exists():
44
+ raise RuntimeError(f"Submission was not created: {output_path}")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
scripts/train_submission.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import importlib.metadata
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import joblib
10
+ import pandas as pd
11
+ from batteryswap_public.evaluate import evaluate_plan
12
+ from batteryswap_public.utils import iterate_scenarios, load_dataset
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 fit_event_time_model
20
+ from batteryswapai.competition_planner import CompetitionPlanner, PlannerPolicy
21
+
22
+
23
+ DATASET_REVISION = "7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c"
24
+
25
+
26
+ def parse_args() -> argparse.Namespace:
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
29
+ parser.add_argument(
30
+ "--artifact", type=Path, default=Path("submission_artifacts/planner.joblib")
31
+ )
32
+ parser.add_argument("--quantile", type=float, default=0.05)
33
+ parser.add_argument("--event-risk-threshold", type=float, default=0.50)
34
+ parser.add_argument("--prediction-offset-days", type=float, default=-5.0)
35
+ parser.add_argument("--calibration-folds", type=int, default=5)
36
+ parser.add_argument("--skip-evaluation", action="store_true")
37
+ return parser.parse_args()
38
+
39
+
40
+ def main() -> None:
41
+ args = parse_args()
42
+ locations, timeseries, eol_times, scenarios = load_dataset(args.dataset_path)
43
+ daily = build_daily_features(timeseries)
44
+
45
+ snapshots = []
46
+ for scenario, locs, _, _ in iterate_scenarios(locations, timeseries, eol_times, scenarios):
47
+ snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
48
+ snapshot = attach_training_targets(
49
+ snapshot,
50
+ eol_times,
51
+ unobserved_eol_days=float(scenario["settings"].unobserved_eol_days),
52
+ )
53
+ snapshots.append(snapshot)
54
+ training = pd.concat(snapshots, ignore_index=True)
55
+
56
+ model = fit_event_time_model(
57
+ training,
58
+ quantile=args.quantile,
59
+ dataset_revision=DATASET_REVISION,
60
+ calibration_folds=args.calibration_folds,
61
+ )
62
+ planner = CompetitionPlanner(
63
+ model,
64
+ PlannerPolicy(
65
+ event_risk_threshold=args.event_risk_threshold,
66
+ prediction_offset_days=args.prediction_offset_days,
67
+ use_expected_cost=True,
68
+ capacity_lookahead_days=21,
69
+ ),
70
+ )
71
+ args.artifact.parent.mkdir(parents=True, exist_ok=True)
72
+ joblib.dump(planner, args.artifact, compress=3)
73
+
74
+ report = {
75
+ "dataset_revision": DATASET_REVISION,
76
+ "artifact": args.artifact.as_posix(),
77
+ "quantile": args.quantile,
78
+ "event_risk_threshold": args.event_risk_threshold,
79
+ "prediction_offset_days": args.prediction_offset_days,
80
+ "calibration_folds": args.calibration_folds,
81
+ "planner_policy": {
82
+ "expected_cost": True,
83
+ "risk_calibration_scale": planner.policy.risk_calibration_scale,
84
+ "expected_service_cost_hours": planner.policy.expected_service_cost_hours,
85
+ "expected_gain_margin": planner.policy.expected_gain_margin,
86
+ "prediction_offset_days": planner.policy.prediction_offset_days,
87
+ "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
88
+ "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
89
+ "stale_risk_factor": planner.policy.stale_risk_factor,
90
+ "building_batch_window_days": planner.policy.building_batch_window_days,
91
+ "capacity_lookahead_days": planner.policy.capacity_lookahead_days,
92
+ "capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
93
+ "exact_route_capacity": True,
94
+ },
95
+ "training_rows": len(training),
96
+ "training_devices": int(training["battery"].nunique()),
97
+ "features": model.feature_columns,
98
+ "artifact_sha256": hashlib.sha256(args.artifact.read_bytes()).hexdigest(),
99
+ "runtime_versions": {
100
+ package: importlib.metadata.version(package)
101
+ for package in (
102
+ "batteryswap_public",
103
+ "fastparquet",
104
+ "joblib",
105
+ "numpy",
106
+ "pandas",
107
+ "scikit-learn",
108
+ )
109
+ },
110
+ "validation_report": "artifacts/submission_cv_final.json",
111
+ }
112
+
113
+ if not args.skip_evaluation:
114
+ scores = []
115
+ for scenario, locs, _, not_dead in iterate_scenarios(
116
+ locations, timeseries, eol_times, scenarios
117
+ ):
118
+ snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
119
+ plan = planner.plan_snapshot(
120
+ snapshot,
121
+ locs,
122
+ scenario["travel_costs"],
123
+ scenario["settings"],
124
+ scenario["start_time"],
125
+ )
126
+ _, _, score = evaluate_plan(
127
+ plan,
128
+ locs,
129
+ scenario["travel_costs"],
130
+ scenario["settings"],
131
+ eol_times=not_dead,
132
+ start_time=pd.Timestamp(scenario["start_time"]),
133
+ verbose=0,
134
+ )
135
+ scores.append(score)
136
+ report["optimistic_train_score"] = pd.concat(scores, axis=1).mean(axis=1).to_dict()
137
+
138
+ report_path = args.artifact.with_suffix(".json")
139
+ report_path.write_text(json.dumps(report, indent=2, default=float), encoding="utf-8")
140
+ print(json.dumps(report, indent=2, default=float))
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
scripts/validate_submission.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from batteryswap_public.evaluate import evaluate_plan
10
+ from batteryswap_public.utils import iterate_scenarios, load_dataset
11
+ 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 fit_event_time_model
20
+ from batteryswapai.competition_planner import CompetitionPlanner, PlannerPolicy
21
+ from train_submission import DATASET_REVISION
22
+
23
+
24
+ def _numbers(value: str) -> list[float]:
25
+ return [float(item.strip()) for item in value.split(",") if item.strip()]
26
+
27
+
28
+ def parse_args() -> argparse.Namespace:
29
+ parser = argparse.ArgumentParser()
30
+ parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
31
+ parser.add_argument("--folds", type=int, default=5)
32
+ parser.add_argument("--quantile", type=float, default=0.05)
33
+ parser.add_argument("--inner-calibration-folds", type=int, default=3)
34
+ parser.add_argument("--risk-scales", default="1.50")
35
+ parser.add_argument("--gain-margins", default="10")
36
+ parser.add_argument("--service-costs", default="2")
37
+ parser.add_argument("--offsets", default="-5")
38
+ parser.add_argument(
39
+ "--output", type=Path, default=Path("artifacts/submission_cv_final.json")
40
+ )
41
+ return parser.parse_args()
42
+
43
+
44
+ def main() -> None:
45
+ args = parse_args()
46
+ locations, timeseries, eol_times, scenarios = load_dataset(args.dataset_path)
47
+ daily = build_daily_features(timeseries)
48
+
49
+ snapshots = []
50
+ scenario_inputs = {}
51
+ for scenario, locs, _, not_dead in iterate_scenarios(
52
+ locations, timeseries, eol_times, scenarios
53
+ ):
54
+ snapshot = scenario_snapshot(daily, locs, scenario["name"], scenario["start_time"])
55
+ snapshot = attach_training_targets(
56
+ snapshot,
57
+ eol_times,
58
+ unobserved_eol_days=float(scenario["settings"].unobserved_eol_days),
59
+ )
60
+ snapshots.append(snapshot)
61
+ scenario_inputs[scenario["name"]] = (scenario, locs, not_dead)
62
+ training = pd.concat(snapshots, ignore_index=True)
63
+
64
+ oof_risk = np.full(len(training), np.nan)
65
+ oof_rul = np.full(len(training), np.nan)
66
+ splitter = GroupKFold(n_splits=args.folds)
67
+ groups = training["battery"].astype(str)
68
+ for fold_number, (train_index, valid_index) in enumerate(
69
+ splitter.split(training, groups=groups), start=1
70
+ ):
71
+ model = fit_event_time_model(
72
+ training.iloc[train_index],
73
+ quantile=args.quantile,
74
+ dataset_revision=DATASET_REVISION,
75
+ random_state=2026 + fold_number,
76
+ calibration_folds=args.inner_calibration_folds,
77
+ )
78
+ valid = training.iloc[valid_index]
79
+ oof_risk[valid_index] = model.predict_event_risk(valid)
80
+ oof_rul[valid_index] = model.predict_rul(valid)
81
+
82
+ due = (
83
+ training["event_observed"].astype(bool)
84
+ & training["target_rul_days"].between(0.0, 42.0)
85
+ ).astype(int)
86
+ classification = {
87
+ "positive_rows": int(due.sum()),
88
+ "total_rows": len(due),
89
+ "roc_auc": float(roc_auc_score(due, oof_risk)),
90
+ "average_precision": float(average_precision_score(due, oof_risk)),
91
+ }
92
+ adjusted_oof_risk = oof_risk.copy()
93
+ gaps = pd.to_numeric(training["data_gap_days"], errors="coerce").to_numpy(dtype=float)
94
+ recent_gap = (gaps > 0.0) & (gaps <= 7.0)
95
+ stale = (gaps > 7.0) | ~np.isfinite(gaps)
96
+ adjusted_oof_risk[recent_gap] *= 0.75
97
+ adjusted_oof_risk[stale] *= 0.10
98
+ classification["freshness_adjusted_roc_auc"] = float(
99
+ roc_auc_score(due, adjusted_oof_risk)
100
+ )
101
+ classification["freshness_adjusted_average_precision"] = float(
102
+ average_precision_score(due, adjusted_oof_risk)
103
+ )
104
+
105
+ observed = training["event_observed"].astype(bool)
106
+ observed_rul_error = np.abs(
107
+ oof_rul[observed] - training.loc[observed, "target_rul_days"].to_numpy()
108
+ )
109
+ due_mask = due.astype(bool).to_numpy()
110
+ due_rul_error = np.abs(
111
+ oof_rul[due_mask] - training.loc[due_mask, "target_rul_days"].to_numpy()
112
+ )
113
+ classification["all_observed_rul_mae_days"] = float(np.mean(observed_rul_error))
114
+ classification["due_within_horizon_rul_mae_days"] = float(np.mean(due_rul_error))
115
+
116
+ policy_results = []
117
+ placeholder_model = model
118
+ for risk_scale in _numbers(args.risk_scales):
119
+ for gain_margin in _numbers(args.gain_margins):
120
+ for service_cost in _numbers(args.service_costs):
121
+ for offset in _numbers(args.offsets):
122
+ planner = CompetitionPlanner(
123
+ placeholder_model,
124
+ PlannerPolicy(
125
+ event_risk_threshold=0.50,
126
+ prediction_offset_days=offset,
127
+ use_expected_cost=True,
128
+ risk_calibration_scale=risk_scale,
129
+ expected_service_cost_hours=service_cost,
130
+ expected_gain_margin=gain_margin,
131
+ capacity_lookahead_days=21,
132
+ ),
133
+ )
134
+ scores = []
135
+ scheduled_counts = []
136
+ for scenario_name, (scenario, locs, not_dead) in scenario_inputs.items():
137
+ mask = training["scenario"].eq(scenario_name).to_numpy()
138
+ snapshot = training.loc[mask]
139
+ risk = oof_risk[mask]
140
+ rul = oof_rul[mask]
141
+ plan = planner.plan_snapshot(
142
+ snapshot,
143
+ locs,
144
+ scenario["travel_costs"],
145
+ scenario["settings"],
146
+ scenario["start_time"],
147
+ predicted_rul=rul,
148
+ predicted_risk=risk,
149
+ )
150
+ start = pd.Timestamp(scenario["start_time"])
151
+ horizon_end = start + pd.Timedelta(
152
+ days=scenario["settings"].planning_window_days
153
+ )
154
+ scheduled_counts.append(int(plan["day"].le(horizon_end).sum()))
155
+ _, _, score = evaluate_plan(
156
+ plan,
157
+ locs,
158
+ scenario["travel_costs"],
159
+ scenario["settings"],
160
+ eol_times=not_dead,
161
+ start_time=start,
162
+ verbose=0,
163
+ )
164
+ scores.append(score)
165
+ mean_score = pd.concat(scores, axis=1).mean(axis=1)
166
+ policy_results.append(
167
+ {
168
+ "risk_calibration_scale": risk_scale,
169
+ "expected_gain_margin": gain_margin,
170
+ "expected_service_cost_hours": service_cost,
171
+ "prediction_offset_days": offset,
172
+ "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
173
+ "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
174
+ "stale_risk_factor": planner.policy.stale_risk_factor,
175
+ "building_batch_window_days": planner.policy.building_batch_window_days,
176
+ "capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
177
+ "mean_scheduled_batteries": float(np.mean(scheduled_counts)),
178
+ **{key: float(value) for key, value in mean_score.items()},
179
+ }
180
+ )
181
+
182
+ policy_results.sort(key=lambda item: item["total_cost"])
183
+ report = {
184
+ "dataset_revision": DATASET_REVISION,
185
+ "folds": args.folds,
186
+ "inner_calibration_folds": args.inner_calibration_folds,
187
+ "quantile": args.quantile,
188
+ "classification": classification,
189
+ "policies": policy_results,
190
+ }
191
+ args.output.parent.mkdir(parents=True, exist_ok=True)
192
+ args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
193
+ predictions = training[["scenario", "battery", "target_rul_days", "event_observed"]].copy()
194
+ predictions["oof_event_risk"] = oof_risk
195
+ predictions["oof_rul_days"] = oof_rul
196
+ predictions.to_csv(args.output.with_suffix(".csv"), index=False)
197
+ print(json.dumps(report, indent=2))
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
src/batteryswapai/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """BatterySwapAI 2026 competition toolkit."""
2
+
3
+ __version__ = "0.1.0"
src/batteryswapai/competition_features.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ ROLLING_WINDOWS_DAYS = (3, 7, 14, 30, 60, 90, 180, 365)
10
+
11
+ NON_FEATURE_COLUMNS = {
12
+ "scenario",
13
+ "scenario_time",
14
+ "device_id",
15
+ "battery",
16
+ "building",
17
+ "room",
18
+ "start_time",
19
+ "end_time",
20
+ "effective_eol",
21
+ "target_rul_days",
22
+ "event_observed",
23
+ }
24
+
25
+
26
+ def _as_columns(timeseries: pd.DataFrame) -> pd.DataFrame:
27
+ frame = timeseries
28
+ if "device_id" not in frame.columns or "end_time" not in frame.columns:
29
+ frame = frame.reset_index()
30
+ required = {"device_id", "end_time", "voltage", "temperature"}
31
+ missing = required - set(frame.columns)
32
+ if missing:
33
+ raise ValueError(f"Battery time series is missing columns: {sorted(missing)}")
34
+ return frame[["device_id", "end_time", "voltage", "temperature"]].copy()
35
+
36
+
37
+ def build_daily_features(
38
+ timeseries: pd.DataFrame,
39
+ windows: Iterable[int] = ROLLING_WINDOWS_DAYS,
40
+ ) -> pd.DataFrame:
41
+ """Build past-only daily features for all scenarios."""
42
+
43
+ frame = _as_columns(timeseries)
44
+ frame["end_time"] = pd.to_datetime(frame["end_time"], errors="coerce")
45
+ frame["voltage"] = pd.to_numeric(frame["voltage"], errors="coerce")
46
+ frame["temperature"] = pd.to_numeric(frame["temperature"], errors="coerce")
47
+ frame = frame.dropna(subset=["device_id", "end_time", "voltage"])
48
+ frame["day"] = frame["end_time"].dt.floor("D")
49
+
50
+ daily = (
51
+ frame.groupby(["device_id", "day"], observed=True, sort=True)
52
+ .agg(
53
+ voltage_median=("voltage", "median"),
54
+ voltage_mean=("voltage", "mean"),
55
+ voltage_min=("voltage", "min"),
56
+ voltage_max=("voltage", "max"),
57
+ voltage_std=("voltage", "std"),
58
+ temperature_median=("temperature", "median"),
59
+ temperature_std=("temperature", "std"),
60
+ observations_day=("voltage", "size"),
61
+ )
62
+ .reset_index()
63
+ .sort_values(["device_id", "day"])
64
+ .reset_index(drop=True)
65
+ )
66
+
67
+ stable = frame[frame["temperature"].between(10.0, 30.0, inclusive="both")]
68
+ stable_daily = (
69
+ stable.groupby(["device_id", "day"], observed=True, sort=True)["voltage"]
70
+ .median()
71
+ .rename("stable_voltage_median")
72
+ .reset_index()
73
+ )
74
+ daily = daily.merge(stable_daily, on=["device_id", "day"], how="left")
75
+ daily["stable_voltage_median"] = daily["stable_voltage_median"].fillna(daily["voltage_median"])
76
+
77
+ grouped = daily.groupby("device_id", observed=True, sort=False)
78
+ first_day = grouped["day"].transform("min")
79
+ daily["history_days"] = (daily["day"] - first_day).dt.total_seconds() / 86400.0
80
+ daily["days_since_previous"] = grouped["day"].diff().dt.total_seconds() / 86400.0
81
+
82
+ for window in tuple(int(value) for value in windows):
83
+ min_periods = max(2, window // 4)
84
+ rolled = (
85
+ grouped["stable_voltage_median"]
86
+ .rolling(window, min_periods=min_periods)
87
+ .agg(["mean", "min", "max", "std"])
88
+ .reset_index(level=0, drop=True)
89
+ .reindex(daily.index)
90
+ )
91
+ for statistic in ("mean", "min", "max", "std"):
92
+ daily[f"voltage_{statistic}_{window}d"] = rolled[statistic]
93
+
94
+ daily[f"temperature_mean_{window}d"] = (
95
+ grouped["temperature_median"]
96
+ .rolling(window, min_periods=min_periods)
97
+ .mean()
98
+ .reset_index(level=0, drop=True)
99
+ .reindex(daily.index)
100
+ )
101
+ lag_voltage = grouped["stable_voltage_median"].shift(window - 1)
102
+ lag_day = grouped["day"].shift(window - 1)
103
+ span = (daily["day"] - lag_day).dt.total_seconds() / 86400.0
104
+ daily[f"voltage_slope_{window}d"] = (
105
+ daily["stable_voltage_median"] - lag_voltage
106
+ ) / span.replace(0.0, np.nan)
107
+ daily[f"span_{window}d"] = span
108
+
109
+ daily["voltage_recent_vs_30d"] = daily["voltage_mean_7d"] - daily["voltage_mean_30d"]
110
+ daily["voltage_recent_vs_90d"] = daily["voltage_mean_14d"] - daily["voltage_mean_90d"]
111
+ daily["voltage_range_day"] = daily["voltage_max"] - daily["voltage_min"]
112
+ return daily
113
+
114
+
115
+ def scenario_snapshot(
116
+ daily_features: pd.DataFrame,
117
+ locations: pd.DataFrame,
118
+ scenario_name: str,
119
+ scenario_time: pd.Timestamp | str,
120
+ ) -> pd.DataFrame:
121
+ """Get the last feature row visible at the scenario time."""
122
+
123
+ start = pd.Timestamp(scenario_time).normalize()
124
+ alive = locations.copy()
125
+ if "battery" not in alive.columns:
126
+ raise ValueError("locations must contain a battery column")
127
+
128
+ available = daily_features[daily_features["day"] <= start]
129
+ latest = available.groupby("device_id", observed=True, sort=False).tail(1)
130
+ snapshot = alive.merge(latest, left_on="battery", right_on="device_id", how="left")
131
+ snapshot["scenario"] = scenario_name
132
+ snapshot["scenario_time"] = start
133
+
134
+ snapshot["location_age_days"] = (
135
+ start - pd.to_datetime(snapshot["start_time"], errors="coerce").dt.normalize()
136
+ ).dt.total_seconds() / 86400.0
137
+ snapshot["data_gap_days"] = (
138
+ start - pd.to_datetime(snapshot["day"], errors="coerce")
139
+ ).dt.total_seconds() / 86400.0
140
+ snapshot["censor_proxy_rul_days"] = (
141
+ pd.to_datetime(snapshot["end_time"], errors="coerce").dt.normalize()
142
+ + pd.Timedelta(days=30)
143
+ - start
144
+ ).dt.total_seconds() / 86400.0
145
+ snapshot["scenario_month_sin"] = np.sin(2.0 * np.pi * start.month / 12.0)
146
+ snapshot["scenario_month_cos"] = np.cos(2.0 * np.pi * start.month / 12.0)
147
+
148
+ snapshot["voltage_rank_global"] = snapshot["stable_voltage_median"].rank(pct=True)
149
+ snapshot["voltage_rank_building"] = snapshot.groupby("building", observed=True)[
150
+ "stable_voltage_median"
151
+ ].rank(pct=True)
152
+ snapshot["voltage_rank_room"] = snapshot.groupby("room", observed=True)[
153
+ "stable_voltage_median"
154
+ ].rank(pct=True)
155
+ snapshot["building_battery_count"] = snapshot.groupby("building", observed=True)[
156
+ "battery"
157
+ ].transform("size")
158
+ snapshot["room_battery_count"] = snapshot.groupby("room", observed=True)["battery"].transform(
159
+ "size"
160
+ )
161
+ return snapshot
162
+
163
+
164
+ def attach_training_targets(
165
+ snapshot: pd.DataFrame,
166
+ eol_times: pd.Series,
167
+ unobserved_eol_days: float = 30.0,
168
+ ) -> pd.DataFrame:
169
+ """Attach public training targets."""
170
+
171
+ result = snapshot.copy()
172
+ observed = pd.to_datetime(result["battery"].map(eol_times), errors="coerce")
173
+ assumed = (
174
+ pd.to_datetime(result["end_time"], errors="coerce").dt.normalize()
175
+ + pd.to_timedelta(unobserved_eol_days, unit="D")
176
+ )
177
+ result["event_observed"] = observed.notna().astype("int8")
178
+ result["effective_eol"] = observed.fillna(assumed)
179
+ result["target_rul_days"] = (
180
+ result["effective_eol"] - pd.to_datetime(result["scenario_time"])
181
+ ).dt.total_seconds() / 86400.0
182
+ return result
183
+
184
+
185
+ def numeric_feature_columns(snapshot: pd.DataFrame) -> list[str]:
186
+ return [
187
+ column
188
+ for column in snapshot.columns
189
+ if column not in NON_FEATURE_COLUMNS
190
+ and not column.lower().startswith("unnamed")
191
+ and pd.api.types.is_numeric_dtype(snapshot[column])
192
+ ]
src/batteryswapai/competition_model.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.ensemble import (
8
+ HistGradientBoostingClassifier,
9
+ HistGradientBoostingRegressor,
10
+ RandomForestClassifier,
11
+ )
12
+ from sklearn.impute import SimpleImputer
13
+ from sklearn.linear_model import LogisticRegression
14
+ from sklearn.model_selection import GroupKFold
15
+
16
+ from .competition_features import numeric_feature_columns
17
+
18
+
19
+ def _logit(values: np.ndarray) -> np.ndarray:
20
+ clipped = np.clip(values, 1e-5, 1.0 - 1e-5)
21
+ return np.log(clipped / (1.0 - clipped))
22
+
23
+
24
+ @dataclass
25
+ class FrozenLogisticCalibrator:
26
+ """Minimal binary logistic inference independent of sklearn pickle internals."""
27
+
28
+ coefficients: np.ndarray
29
+ intercept: float
30
+
31
+ @classmethod
32
+ def from_estimator(cls, estimator: LogisticRegression) -> "FrozenLogisticCalibrator":
33
+ return cls(
34
+ coefficients=np.asarray(estimator.coef_[0], dtype=float).copy(),
35
+ intercept=float(estimator.intercept_[0]),
36
+ )
37
+
38
+ def predict_proba(self, values: np.ndarray) -> np.ndarray:
39
+ scores = np.asarray(values, dtype=float) @ self.coefficients + self.intercept
40
+ positive = np.empty_like(scores, dtype=float)
41
+ nonnegative = scores >= 0.0
42
+ positive[nonnegative] = 1.0 / (1.0 + np.exp(-scores[nonnegative]))
43
+ exp_scores = np.exp(scores[~nonnegative])
44
+ positive[~nonnegative] = exp_scores / (1.0 + exp_scores)
45
+ return np.column_stack([1.0 - positive, positive])
46
+
47
+
48
+ def _clean(frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
49
+ return frame.reindex(columns=columns).replace([np.inf, -np.inf], np.nan).astype(float)
50
+
51
+
52
+ def _derived_features(base: pd.DataFrame, kind: str) -> pd.DataFrame:
53
+ derived: dict[str, pd.Series] = {}
54
+ if kind in {"physics", "all"}:
55
+ for level in ("voltage_mean_3d", "voltage_mean_7d", "voltage_max_3d"):
56
+ for window in (30, 60, 90, 180):
57
+ rate = np.maximum(-base[f"voltage_slope_{window}d"], 1e-5)
58
+ derived[f"knee_{level}_{window}"] = np.clip(
59
+ (base[level] - 2.40) / rate,
60
+ -100.0,
61
+ 1000.0,
62
+ )
63
+ if kind == "all":
64
+ for short, long in ((3, 14), (7, 30), (14, 60), (30, 90), (30, 180), (60, 365)):
65
+ derived[f"slope_accel_{short}_{long}"] = (
66
+ base[f"voltage_slope_{short}d"] - base[f"voltage_slope_{long}d"]
67
+ )
68
+ derived[f"mean_drop_{short}_{long}"] = (
69
+ base[f"voltage_mean_{short}d"] - base[f"voltage_mean_{long}d"]
70
+ )
71
+ for window in (3, 7, 14, 30, 60, 90):
72
+ derived[f"volt_temp_{window}"] = base[f"voltage_mean_{window}d"] / (
73
+ base[f"temperature_mean_{window}d"] + 273.15
74
+ )
75
+ return pd.concat([base, pd.DataFrame(derived, index=base.index)], axis=1)
76
+
77
+
78
+ def _event_estimators(random_state: int) -> dict[str, object]:
79
+ common = dict(
80
+ loss="log_loss",
81
+ learning_rate=0.05,
82
+ max_iter=240,
83
+ early_stopping=True,
84
+ validation_fraction=0.12,
85
+ n_iter_no_change=25,
86
+ random_state=random_state,
87
+ )
88
+ return {
89
+ "weighted_hist": HistGradientBoostingClassifier(
90
+ max_leaf_nodes=15,
91
+ min_samples_leaf=35,
92
+ l2_regularization=4.0,
93
+ class_weight={0: 1.0, 1: 5.0},
94
+ **common,
95
+ ),
96
+ "forest": RandomForestClassifier(
97
+ n_estimators=350,
98
+ min_samples_leaf=5,
99
+ max_features=0.7,
100
+ class_weight="balanced_subsample",
101
+ n_jobs=-1,
102
+ random_state=random_state,
103
+ ),
104
+ "all_hist": HistGradientBoostingClassifier(
105
+ max_leaf_nodes=31,
106
+ min_samples_leaf=25,
107
+ l2_regularization=5.0,
108
+ **common,
109
+ ),
110
+ "physics_hist": HistGradientBoostingClassifier(
111
+ max_leaf_nodes=31,
112
+ min_samples_leaf=25,
113
+ l2_regularization=5.0,
114
+ **common,
115
+ ),
116
+ }
117
+
118
+
119
+ def _fit_event_estimators(
120
+ estimators: dict[str, object],
121
+ base: pd.DataFrame,
122
+ all_features: pd.DataFrame,
123
+ physics_features: pd.DataFrame,
124
+ target: np.ndarray,
125
+ ) -> tuple[dict[str, object], SimpleImputer]:
126
+ imputer = SimpleImputer(strategy="median")
127
+ forest_features = imputer.fit_transform(base)
128
+ estimators["weighted_hist"].fit(base, target)
129
+ estimators["forest"].fit(forest_features, target)
130
+ estimators["all_hist"].fit(all_features, target)
131
+ estimators["physics_hist"].fit(physics_features, target)
132
+ return estimators, imputer
133
+
134
+
135
+ def _event_components(
136
+ estimators: dict[str, object],
137
+ imputer: SimpleImputer,
138
+ base: pd.DataFrame,
139
+ all_features: pd.DataFrame,
140
+ physics_features: pd.DataFrame,
141
+ ) -> np.ndarray:
142
+ return np.column_stack(
143
+ [
144
+ estimators["weighted_hist"].predict_proba(base)[:, 1],
145
+ estimators["forest"].predict_proba(imputer.transform(base))[:, 1],
146
+ estimators["all_hist"].predict_proba(all_features)[:, 1],
147
+ estimators["physics_hist"].predict_proba(physics_features)[:, 1],
148
+ ]
149
+ )
150
+
151
+
152
+ @dataclass
153
+ class EventTimeModel:
154
+ event_estimators: dict[str, object]
155
+ event_imputer: SimpleImputer
156
+ event_calibrator: FrozenLogisticCalibrator
157
+ rul_estimator: HistGradientBoostingRegressor
158
+ feature_columns: list[str]
159
+ all_feature_columns: list[str]
160
+ physics_feature_columns: list[str]
161
+ quantile: float
162
+ horizon_days: int
163
+ dataset_revision: str
164
+
165
+ def _feature_sets(
166
+ self, snapshot: pd.DataFrame
167
+ ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
168
+ base = _clean(snapshot, self.feature_columns)
169
+ all_features = _clean(_derived_features(base, "all"), self.all_feature_columns)
170
+ physics_features = _clean(
171
+ _derived_features(base, "physics"), self.physics_feature_columns
172
+ )
173
+ return base, all_features, physics_features
174
+
175
+ def predict_event_risk(self, snapshot: pd.DataFrame) -> np.ndarray:
176
+ base, all_features, physics_features = self._feature_sets(snapshot)
177
+ components = _event_components(
178
+ self.event_estimators,
179
+ self.event_imputer,
180
+ base,
181
+ all_features,
182
+ physics_features,
183
+ )
184
+ stacked = _logit(components)
185
+ return np.asarray(self.event_calibrator.predict_proba(stacked)[:, 1])
186
+
187
+ def predict_rul(self, snapshot: pd.DataFrame) -> np.ndarray:
188
+ base = _clean(snapshot, self.feature_columns)
189
+ return np.asarray(self.rul_estimator.predict(base), dtype=float)
190
+
191
+
192
+ def fit_event_time_model(
193
+ training_snapshots: pd.DataFrame,
194
+ *,
195
+ quantile: float = 0.05,
196
+ horizon_days: int = 42,
197
+ rul_training_max_days: float = 90.0,
198
+ dataset_revision: str = "unknown",
199
+ random_state: int = 2026,
200
+ calibration_folds: int = 5,
201
+ max_iter: int | None = None,
202
+ ) -> EventTimeModel:
203
+ del max_iter # retained for compatibility with earlier validation commands
204
+ work = training_snapshots.dropna(subset=["target_rul_days"]).copy()
205
+ columns = numeric_feature_columns(work)
206
+ base = _clean(work, columns)
207
+ all_features = _derived_features(base, "all")
208
+ physics_features = _derived_features(base, "physics")
209
+ all_columns = list(all_features.columns)
210
+ physics_columns = list(physics_features.columns)
211
+ target_rul = pd.to_numeric(work["target_rul_days"], errors="coerce").astype(float)
212
+ event_target = (
213
+ work["event_observed"].astype(bool)
214
+ & target_rul.between(0.0, float(horizon_days))
215
+ ).astype("int8").to_numpy()
216
+
217
+ groups = work["battery"].astype(str).to_numpy()
218
+ folds = min(int(calibration_folds), len(np.unique(groups)))
219
+ if folds < 2:
220
+ raise ValueError("At least two device groups are required for event calibration")
221
+ oof_components = np.full((len(work), 4), np.nan)
222
+ splitter = GroupKFold(n_splits=folds)
223
+ for fold_number, (train_index, valid_index) in enumerate(
224
+ splitter.split(base, groups=groups), start=1
225
+ ):
226
+ fold_estimators = _event_estimators(random_state + fold_number)
227
+ fold_estimators, fold_imputer = _fit_event_estimators(
228
+ fold_estimators,
229
+ base.iloc[train_index],
230
+ all_features.iloc[train_index],
231
+ physics_features.iloc[train_index],
232
+ event_target[train_index],
233
+ )
234
+ oof_components[valid_index] = _event_components(
235
+ fold_estimators,
236
+ fold_imputer,
237
+ base.iloc[valid_index],
238
+ all_features.iloc[valid_index],
239
+ physics_features.iloc[valid_index],
240
+ )
241
+
242
+ fitted_calibrator = LogisticRegression(C=0.03, max_iter=1000)
243
+ fitted_calibrator.fit(_logit(oof_components), event_target)
244
+ calibrator = FrozenLogisticCalibrator.from_estimator(fitted_calibrator)
245
+
246
+ estimators = _event_estimators(random_state)
247
+ estimators, imputer = _fit_event_estimators(
248
+ estimators,
249
+ base,
250
+ all_features,
251
+ physics_features,
252
+ event_target,
253
+ )
254
+
255
+ observed_near_horizon = (
256
+ work["event_observed"].astype(bool)
257
+ & target_rul.gt(0.0)
258
+ & target_rul.le(float(rul_training_max_days))
259
+ )
260
+ rul_estimator = HistGradientBoostingRegressor(
261
+ loss="quantile",
262
+ quantile=quantile,
263
+ learning_rate=0.055,
264
+ max_iter=260,
265
+ max_leaf_nodes=19,
266
+ min_samples_leaf=30,
267
+ l2_regularization=3.0,
268
+ early_stopping=True,
269
+ validation_fraction=0.12,
270
+ n_iter_no_change=25,
271
+ random_state=random_state,
272
+ )
273
+ rul_estimator.fit(base.loc[observed_near_horizon], target_rul.loc[observed_near_horizon])
274
+ return EventTimeModel(
275
+ estimators,
276
+ imputer,
277
+ calibrator,
278
+ rul_estimator,
279
+ columns,
280
+ all_columns,
281
+ physics_columns,
282
+ quantile,
283
+ horizon_days,
284
+ dataset_revision,
285
+ )
src/batteryswapai/competition_planner.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from batteryswap_public.interfaces import Planner
8
+
9
+ from .competition_features import build_daily_features, scenario_snapshot
10
+ from .competition_model import EventTimeModel
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PlannerPolicy:
15
+ event_risk_threshold: float = 0.10
16
+ prediction_offset_days: float = -25.0
17
+ building_batch_window_days: int = 4
18
+ unscheduled_margin_days: int = 1
19
+ capacity_lookback_days: int = 14
20
+ capacity_lookahead_days: int = 7
21
+ capacity_daily_limit_fraction: float = 1.0
22
+ capacity_weekly_limit_fraction: float = 1.0
23
+ capacity_limit_penalty_multiplier: float = 1.0
24
+ capacity_operational_cost_weight: float = 1.5
25
+ use_expected_cost: bool = False
26
+ risk_calibration_scale: float = 1.50
27
+ expected_event_day_shift: float = 0.0
28
+ expected_service_cost_hours: float = 2.0
29
+ expected_gain_margin: float = 10.0
30
+ expected_emergency_buffer_days: float = 6.0
31
+ stale_risk_cutoff_days: float = 7.0
32
+ recent_gap_risk_factor: float = 0.75
33
+ stale_risk_factor: float = 0.10
34
+
35
+
36
+ class CompetitionPlanner(Planner):
37
+ def __init__(self, model: EventTimeModel, policy: PlannerPolicy | None = None):
38
+ self.model = model
39
+ self.policy = policy or PlannerPolicy()
40
+
41
+ def plan(self, battery_data, locations, travel_costs, settings):
42
+ flat = battery_data.reset_index()
43
+ start_time = pd.to_datetime(flat["end_time"], errors="coerce").max().normalize()
44
+ daily = build_daily_features(battery_data)
45
+ snapshot = scenario_snapshot(
46
+ daily,
47
+ locations,
48
+ scenario_name="direct",
49
+ scenario_time=start_time,
50
+ )
51
+ return self.plan_snapshot(snapshot, locations, travel_costs, settings, start_time)
52
+
53
+ def plan_snapshot(
54
+ self,
55
+ snapshot: pd.DataFrame,
56
+ locations: pd.DataFrame,
57
+ travel_costs: pd.DataFrame,
58
+ settings,
59
+ start_time: pd.Timestamp | str,
60
+ predicted_rul: np.ndarray | None = None,
61
+ predicted_risk: np.ndarray | None = None,
62
+ ) -> pd.DataFrame:
63
+ start = pd.Timestamp(start_time).normalize()
64
+ horizon = int(settings.planning_window_days)
65
+ predictions = (
66
+ self.model.predict_rul(snapshot) if predicted_rul is None else np.asarray(predicted_rul, dtype=float)
67
+ )
68
+ risks = (
69
+ self.model.predict_event_risk(snapshot)
70
+ if predicted_risk is None
71
+ else np.asarray(predicted_risk, dtype=float)
72
+ )
73
+ if "data_gap_days" in snapshot.columns:
74
+ gaps = pd.to_numeric(snapshot["data_gap_days"], errors="coerce").to_numpy(
75
+ dtype=float
76
+ )
77
+ freshness_factor = np.ones(len(snapshot), dtype=float)
78
+ recent_gap = (gaps > 0.0) & (gaps <= self.policy.stale_risk_cutoff_days)
79
+ stale = (gaps > self.policy.stale_risk_cutoff_days) | ~np.isfinite(gaps)
80
+ freshness_factor[recent_gap] = self.policy.recent_gap_risk_factor
81
+ freshness_factor[stale] = self.policy.stale_risk_factor
82
+ risks = risks * freshness_factor
83
+ raw_predictions = predictions.copy()
84
+ predictions = raw_predictions + self.policy.prediction_offset_days
85
+
86
+ work = snapshot[["battery", "building", "room"]].copy()
87
+ work["predicted_rul"] = predictions
88
+ work["predicted_risk"] = risks
89
+ work["target_day"] = np.floor(np.clip(predictions, 0.0, horizon + 1.0)).astype(int)
90
+ if self.policy.use_expected_cost:
91
+ if "censor_proxy_rul_days" in snapshot.columns:
92
+ proxy_rul = pd.to_numeric(
93
+ snapshot["censor_proxy_rul_days"], errors="coerce"
94
+ ).to_numpy(dtype=float)
95
+ else:
96
+ proxy_rul = (
97
+ pd.to_datetime(snapshot["end_time"], errors="coerce").dt.normalize()
98
+ + pd.to_timedelta(float(settings.unobserved_eol_days), unit="D")
99
+ - start
100
+ ).dt.total_seconds().to_numpy() / 86400.0
101
+ planned_day = np.clip(predictions, 0.0, float(horizon))
102
+ expected_event_day = np.clip(
103
+ raw_predictions + self.policy.expected_event_day_shift,
104
+ 0.0,
105
+ float(horizon),
106
+ )
107
+ calibrated_risk = np.clip(
108
+ risks * self.policy.risk_calibration_scale,
109
+ 0.0,
110
+ 1.0,
111
+ )
112
+ scheduled_event_cost = (
113
+ float(settings.early_replacement_penalty_daily)
114
+ * np.maximum(expected_event_day - planned_day, 0.0)
115
+ + float(settings.late_replacement_penalty_daily)
116
+ * np.maximum(planned_day - expected_event_day, 0.0)
117
+ )
118
+ emergency_day = float(horizon) + self.policy.expected_emergency_buffer_days
119
+ no_schedule_event_cost = (
120
+ float(settings.late_replacement_penalty_daily)
121
+ * np.maximum(emergency_day - expected_event_day, 0.0)
122
+ + self.policy.expected_service_cost_hours
123
+ )
124
+ healthy_early_cost = float(settings.early_replacement_penalty_daily) * np.maximum(
125
+ proxy_rul - planned_day,
126
+ 0.0,
127
+ )
128
+ expected_gain = (
129
+ calibrated_risk * (no_schedule_event_cost - scheduled_event_cost)
130
+ - (1.0 - calibrated_risk) * healthy_early_cost
131
+ - self.policy.expected_service_cost_hours
132
+ )
133
+ work["expected_gain"] = expected_gain
134
+ work["scheduled"] = (
135
+ (expected_gain > self.policy.expected_gain_margin)
136
+ & (predictions <= horizon)
137
+ )
138
+ else:
139
+ work["scheduled"] = (
140
+ (risks >= self.policy.event_risk_threshold) & (predictions <= horizon)
141
+ )
142
+
143
+ candidates = work[work["scheduled"]].copy()
144
+ candidates["assigned_day"] = candidates["target_day"]
145
+ if not candidates.empty:
146
+ candidates = self._batch_buildings(candidates)
147
+ candidates = self._balance_capacity(
148
+ candidates,
149
+ travel_costs,
150
+ settings,
151
+ horizon,
152
+ )
153
+
154
+ unscheduled_day = start + pd.Timedelta(
155
+ days=horizon + self.policy.unscheduled_margin_days
156
+ )
157
+ planned_rows: list[dict] = []
158
+ if not candidates.empty:
159
+ for assigned_day, day_frame in candidates.groupby("assigned_day", sort=True):
160
+ ordered = self._route_day(day_frame, travel_costs, settings.base_location)
161
+ date = start + pd.Timedelta(days=int(assigned_day))
162
+ planned_rows.extend({"day": date, "battery": battery} for battery in ordered)
163
+
164
+ scheduled_ids = set(candidates["battery"]) if not candidates.empty else set()
165
+ remaining = work[~work["battery"].isin(scheduled_ids)].sort_values(
166
+ ["building", "room", "battery"]
167
+ )
168
+ planned_rows.extend(
169
+ {"day": unscheduled_day, "battery": battery} for battery in remaining["battery"]
170
+ )
171
+ return pd.DataFrame(planned_rows, columns=["day", "battery"]).reset_index(drop=True)
172
+
173
+ def _batch_buildings(self, candidates: pd.DataFrame) -> pd.DataFrame:
174
+ batches = []
175
+ tolerance = self.policy.building_batch_window_days
176
+ for _, building_rows in candidates.groupby("building", observed=True, sort=False):
177
+ ordered = building_rows.sort_values(["target_day", "room", "battery"])
178
+ cluster_start: int | None = None
179
+ for _, row in ordered.iterrows():
180
+ target = int(row["target_day"])
181
+ if cluster_start is None or target - cluster_start > tolerance:
182
+ cluster_start = target
183
+ record = row.to_dict()
184
+ record["assigned_day"] = cluster_start
185
+ batches.append(record)
186
+ return pd.DataFrame(batches)
187
+
188
+ def _balance_capacity(self, candidates, travel_costs, settings, horizon):
189
+ """Move whole building visits to nearby days to avoid hard labor penalties."""
190
+
191
+ distances = travel_costs.set_index(["from", "to"])["hours"]
192
+ base = settings.base_location
193
+ clusters = []
194
+ for (building, assigned_day), rows in candidates.groupby(
195
+ ["building", "assigned_day"], observed=True, sort=True
196
+ ):
197
+ travel = 0.0
198
+ building_change = 0.0
199
+ if building != base:
200
+ travel = float(distances.loc[(base, building)]) + float(
201
+ distances.loc[(building, base)]
202
+ )
203
+ building_change = float(settings.time_per_building_change_hours)
204
+ room_time = float(rows["room"].nunique()) * float(settings.time_per_room_change_hours)
205
+ swap_time = len(rows) * float(settings.time_per_battery_hours)
206
+ clusters.append(
207
+ {
208
+ "building": building,
209
+ "target": int(assigned_day),
210
+ "indices": rows.index.to_list(),
211
+ "hours": travel + building_change + room_time + swap_time,
212
+ "batteries": len(rows),
213
+ }
214
+ )
215
+
216
+ day_hours: dict[int, float] = {}
217
+ day_indices: dict[int, list[int]] = {}
218
+
219
+ def exact_day_hours(indices: list[int]) -> float:
220
+ rows = candidates.loc[indices]
221
+ ordered = self._route_day(rows, travel_costs, base)
222
+ by_battery = rows.set_index("battery")
223
+ current_building = base
224
+ current_room = settings.base_room
225
+ hours = 0.0
226
+ for battery in ordered:
227
+ building = str(by_battery.loc[battery, "building"])
228
+ room = str(by_battery.loc[battery, "room"])
229
+ if building != current_building:
230
+ hours += float(distances.loc[(current_building, building)])
231
+ hours += float(settings.time_per_building_change_hours)
232
+ current_building = building
233
+ if room != current_room:
234
+ hours += float(settings.time_per_room_change_hours)
235
+ current_room = room
236
+ hours += float(settings.time_per_battery_hours)
237
+ hours += float(distances.loc[(current_building, base)])
238
+ return hours
239
+
240
+ for cluster in sorted(clusters, key=lambda item: (item["target"], -item["hours"])):
241
+ target = cluster["target"]
242
+ first = max(0, target - self.policy.capacity_lookback_days)
243
+ last = min(horizon, target + self.policy.capacity_lookahead_days)
244
+ best: tuple[float, int] | None = None
245
+ for day in range(first, last + 1):
246
+ proposed_indices = day_indices.get(day, []) + cluster["indices"]
247
+ projected_day = exact_day_hours(proposed_indices)
248
+ week = day // 7
249
+ projected_week = sum(
250
+ hours
251
+ for assigned_day, hours in day_hours.items()
252
+ if assigned_day // 7 == week and assigned_day != day
253
+ ) + projected_day
254
+ guarded_daily_limit = (
255
+ float(settings.worker_limit_daily_hours)
256
+ * self.policy.capacity_daily_limit_fraction
257
+ )
258
+ guarded_weekly_limit = (
259
+ float(settings.worker_limit_weekly_hours)
260
+ * self.policy.capacity_weekly_limit_fraction
261
+ )
262
+ overtime = max(projected_day - float(settings.overtime_start), 0.0)
263
+ daily_penalty = (
264
+ float(settings.worker_limit_daily_penalty)
265
+ * self.policy.capacity_limit_penalty_multiplier
266
+ if projected_day > guarded_daily_limit
267
+ else 0.0
268
+ )
269
+ weekly_penalty = (
270
+ float(settings.worker_limit_weekly_penalty)
271
+ * self.policy.capacity_limit_penalty_multiplier
272
+ if projected_week >= guarded_weekly_limit
273
+ else 0.0
274
+ )
275
+ timing = cluster["batteries"] * (
276
+ 0.5 * (target - day) if day < target else 2.0 * (day - target)
277
+ )
278
+ marginal_operational = projected_day - day_hours.get(day, 0.0)
279
+ cost = (
280
+ timing
281
+ + self.policy.capacity_operational_cost_weight
282
+ * marginal_operational
283
+ + overtime * float(settings.overtime_penalty_factor)
284
+ + daily_penalty
285
+ + weekly_penalty
286
+ )
287
+ option = (cost, day)
288
+ if best is None or option < best:
289
+ best = option
290
+ assert best is not None
291
+ chosen_day = best[1]
292
+ chosen_indices = day_indices.get(chosen_day, []) + cluster["indices"]
293
+ day_indices[chosen_day] = chosen_indices
294
+ day_hours[chosen_day] = exact_day_hours(chosen_indices)
295
+ candidates.loc[cluster["indices"], "assigned_day"] = chosen_day
296
+ return candidates
297
+
298
+ @staticmethod
299
+ def _route_day(day_frame: pd.DataFrame, travel_costs: pd.DataFrame, base: str) -> list[str]:
300
+ distances = travel_costs.set_index(["from", "to"])["hours"]
301
+ remaining = set(day_frame["building"].astype(str))
302
+ current = base
303
+ building_order: list[str] = []
304
+ while remaining:
305
+ next_building = min(
306
+ remaining,
307
+ key=lambda building: (float(distances.loc[(current, building)]), building),
308
+ )
309
+ building_order.append(next_building)
310
+ remaining.remove(next_building)
311
+ current = next_building
312
+
313
+ # Nearest-neighbour is a good seed but can leave an expensive final
314
+ # leg. A deterministic 2-opt pass materially improves multi-building
315
+ # days while staying tiny compared with the inference budget.
316
+ def route_cost(order: list[str]) -> float:
317
+ points = [base, *order, base]
318
+ return sum(
319
+ float(distances.loc[(left, right)])
320
+ for left, right in zip(points, points[1:])
321
+ )
322
+
323
+ improved = True
324
+ best_cost = route_cost(building_order)
325
+ while improved and len(building_order) >= 3:
326
+ improved = False
327
+ for left in range(len(building_order) - 1):
328
+ for right in range(left + 1, len(building_order)):
329
+ candidate = (
330
+ building_order[:left]
331
+ + list(reversed(building_order[left : right + 1]))
332
+ + building_order[right + 1 :]
333
+ )
334
+ candidate_cost = route_cost(candidate)
335
+ if candidate_cost + 1e-12 < best_cost:
336
+ building_order = candidate
337
+ best_cost = candidate_cost
338
+ improved = True
339
+
340
+ batteries: list[str] = []
341
+ for building in building_order:
342
+ local = day_frame[day_frame["building"] == building].sort_values(
343
+ ["room", "target_day", "battery"]
344
+ )
345
+ batteries.extend(local["battery"].astype(str).tolist())
346
+ return batteries
submission_artifacts/planner.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8d75f66c4cabf06c71eefc1714db726b86d02f6108829c51caabde89b0b794ff
3
+ size 3923932
submission_artifacts/planner.json ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dataset_revision": "7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c",
3
+ "artifact": "submission_artifacts/planner.joblib",
4
+ "quantile": 0.05,
5
+ "event_risk_threshold": 0.5,
6
+ "prediction_offset_days": -5.0,
7
+ "calibration_folds": 5,
8
+ "planner_policy": {
9
+ "expected_cost": true,
10
+ "risk_calibration_scale": 1.5,
11
+ "expected_service_cost_hours": 2.0,
12
+ "expected_gain_margin": 10.0,
13
+ "prediction_offset_days": -5.0,
14
+ "stale_risk_cutoff_days": 7.0,
15
+ "recent_gap_risk_factor": 0.75,
16
+ "stale_risk_factor": 0.1,
17
+ "building_batch_window_days": 4,
18
+ "capacity_lookahead_days": 21,
19
+ "capacity_operational_cost_weight": 1.5,
20
+ "exact_route_capacity": true
21
+ },
22
+ "training_rows": 19890,
23
+ "training_devices": 458,
24
+ "features": [
25
+ "voltage_median",
26
+ "voltage_mean",
27
+ "voltage_min",
28
+ "voltage_max",
29
+ "voltage_std",
30
+ "temperature_median",
31
+ "temperature_std",
32
+ "observations_day",
33
+ "stable_voltage_median",
34
+ "history_days",
35
+ "days_since_previous",
36
+ "voltage_mean_3d",
37
+ "voltage_min_3d",
38
+ "voltage_max_3d",
39
+ "voltage_std_3d",
40
+ "temperature_mean_3d",
41
+ "voltage_slope_3d",
42
+ "span_3d",
43
+ "voltage_mean_7d",
44
+ "voltage_min_7d",
45
+ "voltage_max_7d",
46
+ "voltage_std_7d",
47
+ "temperature_mean_7d",
48
+ "voltage_slope_7d",
49
+ "span_7d",
50
+ "voltage_mean_14d",
51
+ "voltage_min_14d",
52
+ "voltage_max_14d",
53
+ "voltage_std_14d",
54
+ "temperature_mean_14d",
55
+ "voltage_slope_14d",
56
+ "span_14d",
57
+ "voltage_mean_30d",
58
+ "voltage_min_30d",
59
+ "voltage_max_30d",
60
+ "voltage_std_30d",
61
+ "temperature_mean_30d",
62
+ "voltage_slope_30d",
63
+ "span_30d",
64
+ "voltage_mean_60d",
65
+ "voltage_min_60d",
66
+ "voltage_max_60d",
67
+ "voltage_std_60d",
68
+ "temperature_mean_60d",
69
+ "voltage_slope_60d",
70
+ "span_60d",
71
+ "voltage_mean_90d",
72
+ "voltage_min_90d",
73
+ "voltage_max_90d",
74
+ "voltage_std_90d",
75
+ "temperature_mean_90d",
76
+ "voltage_slope_90d",
77
+ "span_90d",
78
+ "voltage_mean_180d",
79
+ "voltage_min_180d",
80
+ "voltage_max_180d",
81
+ "voltage_std_180d",
82
+ "temperature_mean_180d",
83
+ "voltage_slope_180d",
84
+ "span_180d",
85
+ "voltage_mean_365d",
86
+ "voltage_min_365d",
87
+ "voltage_max_365d",
88
+ "voltage_std_365d",
89
+ "temperature_mean_365d",
90
+ "voltage_slope_365d",
91
+ "span_365d",
92
+ "voltage_recent_vs_30d",
93
+ "voltage_recent_vs_90d",
94
+ "voltage_range_day",
95
+ "location_age_days",
96
+ "data_gap_days",
97
+ "censor_proxy_rul_days",
98
+ "scenario_month_sin",
99
+ "scenario_month_cos",
100
+ "voltage_rank_global",
101
+ "voltage_rank_building",
102
+ "voltage_rank_room",
103
+ "building_battery_count",
104
+ "room_battery_count"
105
+ ],
106
+ "artifact_sha256": "8d75f66c4cabf06c71eefc1714db726b86d02f6108829c51caabde89b0b794ff",
107
+ "runtime_versions": {
108
+ "batteryswap_public": "0.3.4",
109
+ "fastparquet": "2026.5.0",
110
+ "joblib": "1.5.3",
111
+ "numpy": "2.5.2",
112
+ "pandas": "3.0.5",
113
+ "scikit-learn": "1.9.0"
114
+ },
115
+ "validation_report": "artifacts/submission_cv_final.json",
116
+ "optimistic_train_score": {
117
+ "early_swap": 204.9375,
118
+ "building_change": 8.520833333333334,
119
+ "weekly_limit": 20.833333333333332,
120
+ "room_change": 5.489583333333333,
121
+ "battery_swap": 3.1458333333333335,
122
+ "late_swap": 98.125,
123
+ "travel": 34.465079861111114,
124
+ "overtime": 55.34966666666666,
125
+ "daily_limit": 31.25,
126
+ "total_cost": 462.11682986111094
127
+ }
128
+ }
tests/test_competition_features.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from batteryswapai.competition_features import build_daily_features, scenario_snapshot
5
+
6
+
7
+ def test_scenario_snapshot_never_uses_future_sensor_rows():
8
+ raw = pd.DataFrame(
9
+ {
10
+ "device_id": ["d_a"] * 6,
11
+ "end_time": pd.to_datetime(
12
+ [
13
+ "2026-01-01 10:00",
14
+ "2026-01-01 12:00",
15
+ "2026-01-02 10:00",
16
+ "2026-01-02 12:00",
17
+ "2026-01-03 10:00",
18
+ "2026-01-03 12:00",
19
+ ]
20
+ ),
21
+ "voltage": [3.1, 3.0, 2.9, 2.8, 1.0, 1.0],
22
+ "temperature": [20.0] * 6,
23
+ }
24
+ ).set_index(["device_id", "end_time"])
25
+ locations = pd.DataFrame(
26
+ {
27
+ "battery": ["d_a"],
28
+ "building": ["b_a"],
29
+ "room": ["r_a"],
30
+ "start_time": pd.to_datetime(["2025-01-01"]),
31
+ "end_time": pd.to_datetime(["2026-02-01"]),
32
+ }
33
+ )
34
+
35
+ daily = build_daily_features(raw, windows=(3, 7, 14, 30, 60, 90, 180, 365))
36
+ snapshot = scenario_snapshot(daily, locations, "s_0", "2026-01-02")
37
+ assert snapshot.loc[0, "day"] == pd.Timestamp("2026-01-02")
38
+ assert np.isclose(snapshot.loc[0, "voltage_median"], 2.85)
39
+
40
+
41
+ def test_temperature_filter_falls_back_when_no_stable_reading_exists():
42
+ raw = pd.DataFrame(
43
+ {
44
+ "device_id": ["d_a", "d_a"],
45
+ "end_time": pd.to_datetime(["2026-01-01 10:00", "2026-01-01 12:00"]),
46
+ "voltage": [3.0, 2.8],
47
+ "temperature": [-5.0, 40.0],
48
+ }
49
+ ).set_index(["device_id", "end_time"])
50
+ daily = build_daily_features(raw)
51
+ assert daily.loc[0, "stable_voltage_median"] == daily.loc[0, "voltage_median"]
tests/test_competition_model.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from batteryswapai.competition_model import FrozenLogisticCalibrator
6
+
7
+
8
+ def test_frozen_logistic_calibrator_is_stable_and_normalized() -> None:
9
+ calibrator = FrozenLogisticCalibrator(
10
+ coefficients=np.array([1.5, -0.5]),
11
+ intercept=-0.25,
12
+ )
13
+ values = np.array([[0.0, 0.0], [1000.0, -1000.0], [-1000.0, 1000.0]])
14
+
15
+ probabilities = calibrator.predict_proba(values)
16
+
17
+ assert probabilities.shape == (3, 2)
18
+ assert np.isfinite(probabilities).all()
19
+ np.testing.assert_allclose(probabilities.sum(axis=1), 1.0)
20
+ assert probabilities[1, 1] == 1.0
21
+ assert probabilities[2, 1] == 0.0
tests/test_competition_planner.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ from batteryswap_public.evaluate import EvaluationSettings, check_plan_valid
4
+
5
+ from batteryswapai.competition_planner import CompetitionPlanner, PlannerPolicy
6
+
7
+
8
+ class DummyEventTimeModel:
9
+ def predict_event_risk(self, snapshot):
10
+ return np.array([0.9, 0.8, 0.01])
11
+
12
+ def predict_rul(self, snapshot):
13
+ return np.array([20.0, 22.0, 5.0])
14
+
15
+
16
+ def _inputs():
17
+ locations = pd.DataFrame(
18
+ {
19
+ "battery": ["d_a", "d_b", "d_c"],
20
+ "building": ["b_remote", "b_remote", "b_base"],
21
+ "room": ["r_1", "r_1", "r_base"],
22
+ "start_time": pd.to_datetime(["2025-01-01"] * 3),
23
+ "end_time": pd.to_datetime(["2026-12-01"] * 3),
24
+ }
25
+ )
26
+ travel = pd.DataFrame(
27
+ [
28
+ {"from": "b_base", "to": "b_base", "hours": 0.0},
29
+ {"from": "b_base", "to": "b_remote", "hours": 1.0},
30
+ {"from": "b_remote", "to": "b_base", "hours": 1.0},
31
+ {"from": "b_remote", "to": "b_remote", "hours": 0.0},
32
+ ]
33
+ )
34
+ settings = EvaluationSettings(base_location="b_base", base_room="r_base")
35
+ return locations, travel, settings
36
+
37
+
38
+ def test_plan_is_complete_and_batches_same_building():
39
+ locations, travel, settings = _inputs()
40
+ planner = CompetitionPlanner(
41
+ DummyEventTimeModel(),
42
+ PlannerPolicy(
43
+ event_risk_threshold=0.1,
44
+ prediction_offset_days=0.0,
45
+ building_batch_window_days=3,
46
+ ),
47
+ )
48
+ plan = planner.plan_snapshot(
49
+ locations,
50
+ locations,
51
+ travel,
52
+ settings,
53
+ "2026-01-01",
54
+ )
55
+ check_plan_valid(plan, locations, start_time=pd.Timestamp("2026-01-01"))
56
+ days = plan.set_index("battery")["day"]
57
+ assert days["d_a"] == days["d_b"]
58
+ assert days["d_c"] > pd.Timestamp("2026-02-12")
59
+
60
+
61
+ def test_prediction_arrays_can_be_supplied_for_oof_validation():
62
+ locations, travel, settings = _inputs()
63
+ planner = CompetitionPlanner(DummyEventTimeModel())
64
+ plan = planner.plan_snapshot(
65
+ locations,
66
+ locations,
67
+ travel,
68
+ settings,
69
+ "2026-01-01",
70
+ predicted_rul=np.array([5.0, 6.0, 7.0]),
71
+ predicted_risk=np.array([0.9, 0.01, 0.01]),
72
+ )
73
+ inside = plan[plan["day"] <= pd.Timestamp("2026-02-12")]
74
+ assert inside["battery"].tolist() == ["d_a"]
75
+
76
+
77
+ def test_stale_sensor_history_reduces_proactive_risk():
78
+ locations, travel, settings = _inputs()
79
+ snapshot = locations.copy()
80
+ snapshot["data_gap_days"] = [0.0, 8.0, 0.0]
81
+ planner = CompetitionPlanner(
82
+ DummyEventTimeModel(),
83
+ PlannerPolicy(event_risk_threshold=0.5, prediction_offset_days=0.0),
84
+ )
85
+
86
+ plan = planner.plan_snapshot(
87
+ snapshot,
88
+ locations,
89
+ travel,
90
+ settings,
91
+ "2026-01-01",
92
+ predicted_rul=np.array([5.0, 5.0, 5.0]),
93
+ predicted_risk=np.array([0.6, 0.6, 0.01]),
94
+ )
95
+
96
+ inside = plan[plan["day"] <= pd.Timestamp("2026-02-12")]
97
+ assert inside["battery"].tolist() == ["d_a"]
98
+
99
+
100
+ def test_capacity_balancing_accounts_for_marginal_route_cost():
101
+ locations = pd.DataFrame(
102
+ {
103
+ "battery": ["d_a", "d_b"],
104
+ "building": ["b_a", "b_b"],
105
+ "room": ["r_a", "r_b"],
106
+ "start_time": pd.to_datetime(["2025-01-01"] * 2),
107
+ "end_time": pd.to_datetime(["2026-12-01"] * 2),
108
+ }
109
+ )
110
+ travel = pd.DataFrame(
111
+ [
112
+ {"from": left, "to": right, "hours": 0.0 if left == right else 0.1 if "b_base" not in (left, right) else 1.0}
113
+ for left in ("b_base", "b_a", "b_b")
114
+ for right in ("b_base", "b_a", "b_b")
115
+ ]
116
+ )
117
+ settings = EvaluationSettings(base_location="b_base", base_room="r_base")
118
+ planner = CompetitionPlanner(
119
+ DummyEventTimeModel(),
120
+ PlannerPolicy(
121
+ prediction_offset_days=0.0,
122
+ building_batch_window_days=0,
123
+ ),
124
+ )
125
+
126
+ plan = planner.plan_snapshot(
127
+ locations,
128
+ locations,
129
+ travel,
130
+ settings,
131
+ "2026-01-01",
132
+ predicted_rul=np.array([1.0, 2.0]),
133
+ predicted_risk=np.array([0.9, 0.9]),
134
+ )
135
+
136
+ days = plan.set_index("battery")["day"]
137
+ assert days["d_a"] == days["d_b"]
138
+
139
+
140
+ def test_official_planner_interface_has_a_working_fallback():
141
+ locations, travel, settings = _inputs()
142
+ battery_data = pd.DataFrame(
143
+ {
144
+ "device_id": ["d_a", "d_b", "d_c"],
145
+ "end_time": pd.to_datetime(["2026-01-01"] * 3),
146
+ "voltage": [2.7, 2.8, 3.0],
147
+ "temperature": [20.0, 20.0, 20.0],
148
+ }
149
+ ).set_index(["device_id", "end_time"])
150
+ planner = CompetitionPlanner(
151
+ DummyEventTimeModel(),
152
+ PlannerPolicy(prediction_offset_days=0.0),
153
+ )
154
+ plan = planner.plan(battery_data, locations, travel, settings)
155
+ check_plan_valid(plan, locations, start_time=pd.Timestamp("2026-01-01"))