Commit ·
0febf0f
1
Parent(s): 5117a31
BatterySwapAI submission 001
Browse files- .gitignore +14 -0
- Dockerfile +16 -0
- LICENSE +21 -0
- README.md +46 -0
- REPRODUCIBILITY.md +137 -0
- THIRD_PARTY_LICENSES.md +18 -0
- pyproject.toml +17 -0
- requirements.dev.txt +5 -0
- requirements.txt +9 -0
- script.py +48 -0
- scripts/train_submission.py +144 -0
- scripts/validate_submission.py +201 -0
- src/batteryswapai/__init__.py +3 -0
- src/batteryswapai/competition_features.py +192 -0
- src/batteryswapai/competition_model.py +285 -0
- src/batteryswapai/competition_planner.py +346 -0
- submission_artifacts/planner.joblib +3 -0
- submission_artifacts/planner.json +128 -0
- tests/test_competition_features.py +51 -0
- tests/test_competition_model.py +21 -0
- tests/test_competition_planner.py +155 -0
.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"))
|