Submission 007 - temperature-gated causal identity ensemble
Browse filesPrimary artifact SHA-256: 3fdac6f588b779f6a1aa6bd224ed7ad460b7165eda5e7b1060ef5484edf4b804. Preserves the successful V05 artifact byte-for-byte as submission_artifacts/v05_planner.joblib (63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3). Verified CPU/no-network Docker package; 57 tests and deterministic 19,890-row replay passed.
- .dockerignore +10 -0
- .gitignore +21 -4
- Dockerfile +3 -5
- README.md +53 -34
- REPRODUCIBILITY.md +86 -34
- THIRD_PARTY_LICENSES.md +20 -10
- pyproject.toml +23 -1
- requirements.dev.txt +3 -3
- requirements.txt +3 -4
- script.py +66 -11
- scripts/build_identity_submission.py +490 -0
- scripts/evaluate_clean_identity_candidate.py +450 -0
- scripts/train_submission.py +6 -0
- scripts/validate_submission.py +193 -82
- scripts/verify_identity_feature_parity.py +261 -0
- src/batteryswapai/competition_planner.py +46 -0
- src/batteryswapai/identity_ensemble.py +1291 -0
- submission_artifacts/planner.joblib +2 -2
- submission_artifacts/planner.json +12 -11
- submission_artifacts/temperature_planner.joblib +3 -0
- submission_artifacts/temperature_planner.json +196 -0
- submission_artifacts/v05_planner.joblib +3 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.pytest_cache
|
| 6 |
+
.env
|
| 7 |
+
artifacts
|
| 8 |
+
data
|
| 9 |
+
submission.csv
|
| 10 |
+
catboost_info
|
.gitignore
CHANGED
|
@@ -5,10 +5,27 @@ __pycache__/
|
|
| 5 |
.venv/
|
| 6 |
.env
|
| 7 |
|
| 8 |
-
data
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
*.joblib
|
| 14 |
!submission_artifacts/planner.joblib
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
.venv/
|
| 6 |
.env
|
| 7 |
|
| 8 |
+
# Competition data and generated artifacts
|
| 9 |
+
data/raw/*
|
| 10 |
+
!data/raw/.gitkeep
|
| 11 |
+
data/processed/*
|
| 12 |
+
!data/processed/.gitkeep
|
| 13 |
+
artifacts/*
|
| 14 |
+
!artifacts/.gitkeep
|
| 15 |
+
submission_artifacts/*
|
| 16 |
+
!submission_artifacts/planner.json
|
| 17 |
+
!submission_artifacts/identity_planner.json
|
| 18 |
+
!submission_artifacts/temperature_planner.json
|
| 19 |
|
| 20 |
+
# Model files
|
| 21 |
+
*.cbm
|
| 22 |
+
*.pkl
|
| 23 |
*.joblib
|
| 24 |
!submission_artifacts/planner.joblib
|
| 25 |
+
!submission_artifacts/identity_planner.joblib
|
| 26 |
+
!submission_artifacts/temperature_planner.joblib
|
| 27 |
+
!submission_artifacts/v05_planner.joblib
|
| 28 |
+
|
| 29 |
+
# Local training logs
|
| 30 |
+
catboost_info/
|
| 31 |
+
submission.csv
|
Dockerfile
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
FROM huggingface/competitions:
|
| 2 |
-
|
| 3 |
-
ENV BATTERYSWAP_SPLITS=train
|
| 4 |
|
| 5 |
ENV BATTERYSWAP_SUBMISSION_PATH=submission.csv
|
| 6 |
ENV PYTHONPATH=/app/src
|
|
@@ -10,8 +8,8 @@ WORKDIR /app
|
|
| 10 |
COPY requirements.txt ./
|
| 11 |
RUN /app/env/bin/python3 -m pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
RUN /app/env/bin/python3 -c "import importlib.metadata as m; expected={'batteryswap_public':'0.3.4','fastparquet':'2026.5.0','joblib':'1.5.3','numpy':'2.2.6','pandas':'2.3.3','scikit-learn':'1.7.2'}; actual={name:m.version(name) for name in expected}; assert actual == expected, (actual, expected); print(actual)"
|
| 15 |
|
| 16 |
COPY src/ ./src/
|
| 17 |
COPY submission_artifacts/ ./submission_artifacts/
|
|
|
|
| 1 |
+
FROM huggingface/competitions@sha256:6cea4ff69a6832761484f48c07ccfbf49f701f285ffcb9fc72a4ecfb81b6b4e5
|
|
|
|
|
|
|
| 2 |
|
| 3 |
ENV BATTERYSWAP_SUBMISSION_PATH=submission.csv
|
| 4 |
ENV PYTHONPATH=/app/src
|
|
|
|
| 8 |
COPY requirements.txt ./
|
| 9 |
RUN /app/env/bin/python3 -m pip install --no-cache-dir -r requirements.txt
|
| 10 |
|
| 11 |
+
# Verifies evaluator package versions.
|
| 12 |
+
RUN /app/env/bin/python3 -c "import importlib.metadata as m; expected={'batteryswap_public':'0.3.4','fastparquet':'2026.5.0','joblib':'1.5.3','numpy':'2.2.6','pandas':'2.3.3','pydantic-settings':'2.15.0','scikit-learn':'1.7.2','scipy':'1.14.1','structlog':'26.1.0'}; actual={name:m.version(name) for name in expected}; assert actual == expected, (actual, expected); print(actual)"
|
| 13 |
|
| 14 |
COPY src/ ./src/
|
| 15 |
COPY submission_artifacts/ ./submission_artifacts/
|
README.md
CHANGED
|
@@ -1,47 +1,66 @@
|
|
| 1 |
-
# BatterySwapAI 2026 —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
- Late / early: **754.79 / 618.06**
|
| 8 |
-
- Event ranking: AP **0.4170**, AUC 0.9586
|
| 9 |
-
- Mean scheduled batteries: **15.75**
|
| 10 |
-
- Tests: **44/44 passed**
|
| 11 |
-
- Artifact SHA-256: `63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3`
|
| 12 |
-
- Base: `research/v4-first-place` at `5ade97b8ab7b33e10a00f9d182a7ebcde69bc89c`
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
`end_time <= scenario.start_time`, and `make_submissions` passes exactly that to the planner.
|
| 18 |
-
Our pipeline discarded it and rebuilt features from the whole split, so the cutoff day's bucket
|
| 19 |
-
carried that day's later hourly readings — 7,274 to 9,847 rows per scenario. Inference now
|
| 20 |
-
consumes the cut, and `scenario_history` truncates defensively at the same boundary.
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|---|---|---|---|---|---|
|
| 32 |
-
| **V05** | — | 0.4170 | **1598.26** | 754.79 | 618.06 |
|
| 33 |
-
| V04 | 1407.63 | 0.4099 | 1674.27 | 831.88 | 623.12 |
|
| 34 |
-
| V03 | 1700.12 | 0.3947 | 1757.11 | 657.29 | 826.50 |
|
| 35 |
-
| V01 | 1576.57 | 0.3972 | 1766.77 | 892.08 | 598.99 |
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
##
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BatterySwapAI 2026 — temperature-gated identity candidate
|
| 2 |
|
| 3 |
+
Local candidate only. It has not been submitted to the evaluator. The publicly proven V05 artifact
|
| 4 |
+
remains preserved in the Hugging Face repository and its history (`1541.3141`, Hub revision
|
| 5 |
+
`dfa8acbc`, artifact SHA-256 `63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3`).
|
| 6 |
|
| 7 |
+
## Current result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
The primary candidate combines the V06 full-lookback planner with two independently fitted identity-preserving
|
| 10 |
+
rank signals (censored long-term first-passage AFT and K=8 cross-building trajectory similarity),
|
| 11 |
+
then applies a seasonal temperature forecast only to batteries whose forecast minimum smoothed
|
| 12 |
+
voltage is at most **2.50 V**. The 2.50 V gate is fixed in production and preserves the planner's
|
| 13 |
+
risk multiset within each freshness stratum.
|
| 14 |
|
| 15 |
+
Leakage-clean outer replay on all 48 official train scenarios:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
| candidate | mean total | delta vs clean operational reference | first / second half | paired t |
|
| 18 |
+
|---|---:|---:|---:|---:|
|
| 19 |
+
| **temperature <=2.50 V (primary)** | **1534.8431** | **-78.4134** | **-110.9689 / -45.8578** | **-2.3034** |
|
| 20 |
+
| identity-only fallback | 1569.8205 | -43.4359 | -75.3522 / -11.5196 | -1.1667 |
|
| 21 |
+
| clean V05 predictions + V06/V07 policy reference | 1613.2564 | — | — | — |
|
| 22 |
|
| 23 |
+
The primary improved 23 scenarios, tied 12, and worsened 13. It passes the predeclared gate:
|
| 24 |
+
delta <= -40, neither chronological half worse, and paired t <= -2. The unrestricted temperature
|
| 25 |
+
rerank and the tighter 2.40 V gate were both weaker; no post-result grid search is used.
|
| 26 |
|
| 27 |
+
These local absolute totals are not directly comparable with the public leaderboard. The evidence
|
| 28 |
+
supports the candidate by paired delta against the same clean reference, not by claiming a public
|
| 29 |
+
score. The public V05 result remains the only submitted anchor.
|
| 30 |
|
| 31 |
+
## Causal and official contract
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
- Every scenario uses only readings with `end_time <= scenario.start_time`.
|
| 34 |
+
- EOL is reconstructed exactly as the organizer evaluator does: strict `10 < temperature < 30`,
|
| 35 |
+
daily median, days with fewer than five readings masked, seven-calendar-day rolling median with
|
| 36 |
+
`min_periods=3`, and first smoothed voltage `<= 2.40 V`.
|
| 37 |
+
- The reconstruction matches all 82 observed train EOL devices exactly; censored devices remain
|
| 38 |
+
censored.
|
| 39 |
+
- Each outer validation fold rebuilds trajectory, EOL, and model state from outer-train batteries
|
| 40 |
+
only. Assertions require zero held-out battery, building, trajectory, and EOL overlap.
|
| 41 |
+
- Production feature replay matches all 19,890 frozen causal rows; see
|
| 42 |
+
`artifacts/identity_feature_parity.json` after running the release checks.
|
| 43 |
+
- Runtime uses `batteryswap_public==0.3.4`, CPU only, no network, and emits every live battery once
|
| 44 |
+
with a valid plan date.
|
| 45 |
|
| 46 |
+
## Artifacts
|
| 47 |
|
| 48 |
+
- `submission_artifacts/temperature_planner.joblib`: primary candidate and the default in
|
| 49 |
+
`script.py`.
|
| 50 |
+
- `submission_artifacts/identity_planner.joblib`: identity-only fallback.
|
| 51 |
+
- `submission_artifacts/planner.joblib`: byte-preserved V06 full-lookback base, SHA-256
|
| 52 |
+
`3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569`.
|
| 53 |
+
- During the Hub upload, the public V05 artifact is archived byte-for-byte as
|
| 54 |
+
`submission_artifacts/v05_planner.joblib` before the local V06 base is placed at
|
| 55 |
+
`submission_artifacts/planner.joblib`.
|
| 56 |
|
| 57 |
+
Each candidate manifest records its artifact hash, raw dataset hashes, source hashes, fitted AFT
|
| 58 |
+
parameters, donor-library hashes, temperature coefficient/gate, dependency versions, and the
|
| 59 |
+
unchanged embedded V06 base hash. Exact release commands and evidence are in `REPRODUCIBILITY.md`.
|
| 60 |
|
| 61 |
+
## External eligibility checks
|
| 62 |
+
|
| 63 |
+
Participant code is MIT. The organizer-required `batteryswap_public` package does not declare a
|
| 64 |
+
license in its PyPI metadata, and the official evaluator base includes an NVIDIA container license
|
| 65 |
+
notice. These are recorded in `THIRD_PARTY_LICENSES.md`; organizer confirmation is the only
|
| 66 |
+
remaining user-owned licensing check.
|
REPRODUCIBILITY.md
CHANGED
|
@@ -1,62 +1,114 @@
|
|
| 1 |
-
# Reproduce
|
| 2 |
|
| 3 |
-
Dataset revision:
|
| 4 |
|
| 5 |
-
``
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
```bash
|
| 14 |
-
|
| 15 |
-
source .venv/bin/activate
|
| 16 |
-
pip install -e . -r requirements.txt -r requirements.dev.txt
|
| 17 |
```
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
```bash
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
```
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 34 |
```
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
```bash
|
| 39 |
-
docker run --rm -v "$(pwd)":/work -
|
| 40 |
-
-
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
```
|
| 43 |
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
```bash
|
| 47 |
-
docker
|
|
|
|
| 48 |
```
|
| 49 |
|
|
|
|
|
|
|
|
|
|
| 50 |
```bash
|
| 51 |
-
docker
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
```
|
| 54 |
|
| 55 |
-
The
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
##
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 1 |
+
# Reproduce the temperature-gated candidate
|
| 2 |
|
| 3 |
+
Dataset revision: `7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c`.
|
| 4 |
|
| 5 |
+
Place the official train split in `data/raw/train/`. All fitting commands below rebuild features
|
| 6 |
+
from raw official data; persisted experiment rows are optional equality checks, never training
|
| 7 |
+
inputs. Do not replace the pinned local V06 full-lookback base artifact. The public V05 artifact is
|
| 8 |
+
preserved separately on the Hub and on the `public/v05` Git branch.
|
| 9 |
|
| 10 |
+
## Build both fallbacks and the primary
|
| 11 |
|
| 12 |
+
Build the pinned evaluator image:
|
| 13 |
|
| 14 |
```bash
|
| 15 |
+
docker build -t batteryswap-temperature .
|
|
|
|
|
|
|
| 16 |
```
|
| 17 |
|
| 18 |
+
Use the evaluator interpreter for fitting:
|
| 19 |
|
| 20 |
```bash
|
| 21 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 22 |
+
batteryswap-temperature /app/env/bin/python3 scripts/build_identity_submission.py \
|
| 23 |
+
--output submission_artifacts/identity_planner.joblib \
|
| 24 |
+
--manifest submission_artifacts/identity_planner.json
|
| 25 |
+
|
| 26 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 27 |
+
batteryswap-temperature /app/env/bin/python3 scripts/build_identity_submission.py \
|
| 28 |
+
--with-seasonal \
|
| 29 |
+
--output submission_artifacts/temperature_planner.joblib \
|
| 30 |
+
--manifest submission_artifacts/temperature_planner.json
|
| 31 |
```
|
| 32 |
|
| 33 |
+
Each build records the exact resulting artifact hash in its manifest. Re-pickling the wrapped V06
|
| 34 |
+
estimator is not guaranteed to reproduce identical artifact bytes; release determinism is therefore
|
| 35 |
+
checked on the two emitted submission CSVs below. The builder asserts the raw row count, all 82
|
| 36 |
+
exact EOL crossings, AFT reference equality when available, expected donor-library dimensions
|
| 37 |
+
(82 devices, 24 buildings, 7,257 endpoints), rounded temperature coefficient, and byte preservation
|
| 38 |
+
of the local V06 base artifact.
|
| 39 |
|
| 40 |
+
## Leakage-clean validation
|
| 41 |
+
|
| 42 |
+
Generate outer predictions with all auxiliary state restricted to the outer-train batteries:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 46 |
+
batteryswap-temperature /app/env/bin/python3 scripts/validate_submission.py \
|
| 47 |
+
--outer-group building --output artifacts/clean_v05_outer_oof.json
|
| 48 |
```
|
| 49 |
|
| 50 |
+
Replay production features and the exact evaluator:
|
| 51 |
|
| 52 |
```bash
|
| 53 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 54 |
+
batteryswap-temperature /app/env/bin/python3 scripts/verify_identity_feature_parity.py
|
| 55 |
+
|
| 56 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 57 |
+
batteryswap-temperature /app/env/bin/python3 scripts/evaluate_clean_identity_candidate.py
|
| 58 |
```
|
| 59 |
|
| 60 |
+
Expected primary evidence:
|
| 61 |
+
|
| 62 |
+
```text
|
| 63 |
+
rows: 19,890
|
| 64 |
+
clean operational reference mean total: 1613.256409722222
|
| 65 |
+
temperature <=2.50 V mean total: 1534.843055555556
|
| 66 |
+
mean paired delta: -78.413354166667
|
| 67 |
+
chronological halves: -110.968895833333 / -45.857812500000
|
| 68 |
+
paired t: -2.303438305449
|
| 69 |
+
strict promotion gate: PASS
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
The clean prediction CSV fingerprint is
|
| 73 |
+
`c810f68e73f62f15cf1d4d19574d5a1ec4ab0916edde3096bca9ad6b00332cbb`.
|
| 74 |
+
|
| 75 |
+
## Tests and evaluator-runtime release gate
|
| 76 |
|
| 77 |
```bash
|
| 78 |
+
docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
|
| 79 |
+
batteryswap-temperature /app/env/bin/python3 -m pytest -q
|
| 80 |
```
|
| 81 |
|
| 82 |
+
Rebuild the image after the final artifacts are frozen, then run the submission twice with network
|
| 83 |
+
disabled. Use separate output filenames and compare their SHA-256 hashes:
|
| 84 |
+
|
| 85 |
```bash
|
| 86 |
+
docker build -t batteryswap-temperature .
|
| 87 |
+
|
| 88 |
+
docker run --rm --network none \
|
| 89 |
+
-e BATTERYSWAP_SPLITS=train \
|
| 90 |
+
-e BATTERYSWAP_SUBMISSION_PATH=/out/submission-1.csv \
|
| 91 |
+
-v "$(pwd)/data/raw":/tmp/data:ro -v "$(pwd)/artifacts":/out \
|
| 92 |
+
batteryswap-temperature \
|
| 93 |
+
bash -lc 'time /app/env/bin/python3 script.py'
|
| 94 |
+
|
| 95 |
+
docker run --rm --network none \
|
| 96 |
+
-e BATTERYSWAP_SPLITS=train \
|
| 97 |
+
-e BATTERYSWAP_SUBMISSION_PATH=/out/submission-2.csv \
|
| 98 |
+
-v "$(pwd)/data/raw":/tmp/data:ro -v "$(pwd)/artifacts":/out \
|
| 99 |
+
batteryswap-temperature \
|
| 100 |
+
bash -lc 'time /app/env/bin/python3 script.py'
|
| 101 |
+
|
| 102 |
+
sha256sum artifacts/submission-1.csv artifacts/submission-2.csv
|
| 103 |
```
|
| 104 |
|
| 105 |
+
The hashes must match, every plan must pass the output invariants in `script.py`, elapsed time must
|
| 106 |
+
remain under 30 minutes per evaluator split, and peak container memory from `docker stats` must
|
| 107 |
+
remain below 32 GB. Record the measured final values in `artifacts/release_gate.json`; artifact
|
| 108 |
+
hashes are authoritative in the two candidate manifests.
|
| 109 |
|
| 110 |
+
## Deliberate staging only
|
| 111 |
|
| 112 |
+
No command in this document pushes, submits, or chooses a final. After reviewing the ranked
|
| 113 |
+
handoff, stage a chosen artifact explicitly with `BATTERYSWAP_PLANNER_PATH`; keep V05 available for
|
| 114 |
+
immediate rollback.
|
THIRD_PARTY_LICENSES.md
CHANGED
|
@@ -4,15 +4,25 @@ This table lists the direct third-party packages used by the project. No third-p
|
|
| 4 |
|
| 5 |
| Package | Declared / verified version | Source | License |
|
| 6 |
|---|---:|---|---|
|
| 7 |
-
| NumPy |
|
| 8 |
-
| pandas |
|
| 9 |
-
| scikit-learn |
|
| 10 |
-
|
|
| 11 |
-
|
|
| 12 |
-
|
|
| 13 |
-
|
|
| 14 |
-
|
|
| 15 |
-
|
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
| Package | Declared / verified version | Source | License |
|
| 6 |
|---|---:|---|---|
|
| 7 |
+
| NumPy | 2.2.6 | https://numpy.org/ | BSD-3-Clause and bundled permissive licenses |
|
| 8 |
+
| pandas | 2.3.3 | https://pandas.pydata.org/ | BSD-3-Clause |
|
| 9 |
+
| scikit-learn | 1.7.2 | https://scikit-learn.org/ | BSD-3-Clause |
|
| 10 |
+
| SciPy | 1.14.1 | https://scipy.org/ | BSD-3-Clause and bundled permissive licenses |
|
| 11 |
+
| joblib | 1.5.3 | https://joblib.readthedocs.io/ | BSD-3-Clause |
|
| 12 |
+
| fastparquet | 2026.5.0 | https://github.com/dask/fastparquet | Apache-2.0 |
|
| 13 |
+
| pydantic-settings | 2.15.0 | https://github.com/pydantic/pydantic-settings | MIT |
|
| 14 |
+
| structlog | 26.1.0 | https://www.structlog.org/ | MIT or Apache-2.0 |
|
| 15 |
+
| huggingface-hub | 0.34.4 (development/publishing only) | https://github.com/huggingface/huggingface_hub | Apache-2.0 |
|
| 16 |
+
| PyYAML | 6.0.2 (development only) | https://pyyaml.org/ | MIT |
|
| 17 |
+
| pyarrow | 25.0.1 (optional development parquet backend) | https://arrow.apache.org/ | Apache-2.0 |
|
| 18 |
+
| pytest | 9.0.2 (tests only) | https://pytest.org/ | MIT |
|
| 19 |
+
| batteryswap_public | 0.3.4 | https://pypi.org/project/batteryswap-public/ | Organizer package; PyPI metadata does not declare a license |
|
| 20 |
|
| 21 |
`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.
|
| 22 |
+
|
| 23 |
+
The submission image is the organizer/example base
|
| 24 |
+
`huggingface/competitions@sha256:6cea4ff69a6832761484f48c07ccfbf49f701f285ffcb9fc72a4ecfb81b6b4e5`.
|
| 25 |
+
It contains the NVIDIA Deep Learning Container license notice even though this entry is
|
| 26 |
+
CPU-only. Because this is the official evaluator base rather than participant code, it is
|
| 27 |
+
recorded separately; obtain organizer confirmation together with `batteryswap_public` if
|
| 28 |
+
prize-eligibility review requires an explicit exception.
|
pyproject.toml
CHANGED
|
@@ -5,8 +5,30 @@ build-backend = "setuptools.build_meta"
|
|
| 5 |
[project]
|
| 6 |
name = "batteryswapai-2026"
|
| 7 |
version = "0.1.0"
|
| 8 |
-
description = "BatterySwapAI 2026
|
|
|
|
| 9 |
requires-python = ">=3.10"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
[tool.setuptools.packages.find]
|
| 12 |
where = ["src"]
|
|
|
|
| 5 |
[project]
|
| 6 |
name = "batteryswapai-2026"
|
| 7 |
version = "0.1.0"
|
| 8 |
+
description = "Battery replacement planner for BatterySwapAI 2026"
|
| 9 |
+
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
| 11 |
+
dependencies = [
|
| 12 |
+
"batteryswap_public==0.3.4",
|
| 13 |
+
"fastparquet==2026.5.0",
|
| 14 |
+
"huggingface-hub==0.34.4",
|
| 15 |
+
"joblib==1.5.3",
|
| 16 |
+
"numpy==2.2.6",
|
| 17 |
+
"pandas==2.3.3",
|
| 18 |
+
"pydantic-settings==2.15.0",
|
| 19 |
+
"pyyaml==6.0.2",
|
| 20 |
+
"scipy==1.14.1",
|
| 21 |
+
"scikit-learn==1.7.2",
|
| 22 |
+
"structlog==26.1.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[project.optional-dependencies]
|
| 26 |
+
dev = ["pytest==9.0.2"]
|
| 27 |
+
parquet = ["pyarrow>=19.0"]
|
| 28 |
+
legacy-trees = ["catboost>=1.2.8", "lightgbm>=4.6"]
|
| 29 |
+
|
| 30 |
+
[project.scripts]
|
| 31 |
+
bsa = "batteryswapai.cli:main"
|
| 32 |
|
| 33 |
[tool.setuptools.packages.find]
|
| 34 |
where = ["src"]
|
requirements.dev.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-r requirements.txt
|
| 2 |
-
huggingface-hub
|
| 3 |
pyarrow==25.0.1
|
| 4 |
-
pytest
|
| 5 |
-
pyyaml
|
|
|
|
| 1 |
-r requirements.txt
|
| 2 |
+
huggingface-hub==0.34.4
|
| 3 |
pyarrow==25.0.1
|
| 4 |
+
pytest==9.0.2
|
| 5 |
+
pyyaml==6.0.2
|
requirements.txt
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
# Core packages observed in the competition runtime.
|
| 2 |
batteryswap_public==0.3.4
|
| 3 |
fastparquet==2026.5.0
|
| 4 |
joblib==1.5.3
|
| 5 |
numpy==2.2.6
|
| 6 |
pandas==2.3.3
|
| 7 |
scikit-learn==1.7.2
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
structlog>=25.5.0
|
|
|
|
|
|
|
| 1 |
batteryswap_public==0.3.4
|
| 2 |
fastparquet==2026.5.0
|
| 3 |
joblib==1.5.3
|
| 4 |
numpy==2.2.6
|
| 5 |
pandas==2.3.3
|
| 6 |
scikit-learn==1.7.2
|
| 7 |
+
scipy==1.14.1
|
| 8 |
|
| 9 |
+
pydantic-settings==2.15.0
|
| 10 |
+
structlog==26.1.0
|
|
|
script.py
CHANGED
|
@@ -13,24 +13,65 @@ from batteryswap_public.utils import iterate_scenarios, load_dataset
|
|
| 13 |
from batteryswapai.competition_features import scenario_history_snapshot
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def main() -> None:
|
| 17 |
dataset_path = Path(os.environ.get("BATTERYSWAP_DATASET_PATH", "/tmp/data"))
|
| 18 |
artifact_path = Path(
|
| 19 |
-
os.environ.get(
|
|
|
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
-
splits =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
output_path = Path(os.environ.get("BATTERYSWAP_SUBMISSION_PATH", "submission.csv"))
|
| 23 |
|
|
|
|
|
|
|
| 24 |
planner = joblib.load(artifact_path)
|
| 25 |
|
| 26 |
plans = []
|
| 27 |
for split in splits:
|
| 28 |
-
locations, timeseries,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
for scenario, locs, visible_history, _ in iterate_scenarios(
|
| 31 |
locations,
|
| 32 |
timeseries,
|
| 33 |
-
|
| 34 |
scenarios,
|
| 35 |
):
|
| 36 |
snapshot = scenario_history_snapshot(
|
|
@@ -40,19 +81,33 @@ def main() -> None:
|
|
| 40 |
scenario["start_time"],
|
| 41 |
)
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
|
|
|
| 51 |
plan["split"] = split
|
| 52 |
plan["scenario"] = scenario["name"]
|
| 53 |
plans.append(plan)
|
| 54 |
|
| 55 |
submission = pd.concat(plans, ignore_index=True)
|
|
|
|
|
|
|
| 56 |
submission.to_csv(output_path, index=False)
|
| 57 |
|
| 58 |
if not output_path.exists():
|
|
|
|
| 13 |
from batteryswapai.competition_features import scenario_history_snapshot
|
| 14 |
|
| 15 |
|
| 16 |
+
def _assert_complete_plan(
|
| 17 |
+
plan: pd.DataFrame, locations: pd.DataFrame, scenario_name: str
|
| 18 |
+
) -> None:
|
| 19 |
+
required = {"day", "battery"}
|
| 20 |
+
missing = required - set(plan.columns)
|
| 21 |
+
if missing:
|
| 22 |
+
raise AssertionError(
|
| 23 |
+
f"plan {scenario_name} lacks columns: {sorted(missing)}"
|
| 24 |
+
)
|
| 25 |
+
expected = locations["battery"].astype(str)
|
| 26 |
+
actual = plan["battery"].astype(str)
|
| 27 |
+
if len(plan) != len(locations) or actual.duplicated().any():
|
| 28 |
+
raise AssertionError(
|
| 29 |
+
f"plan {scenario_name} must contain each live battery exactly once"
|
| 30 |
+
)
|
| 31 |
+
if set(actual) != set(expected):
|
| 32 |
+
missing_batteries = sorted(set(expected) - set(actual))
|
| 33 |
+
extra_batteries = sorted(set(actual) - set(expected))
|
| 34 |
+
raise AssertionError(
|
| 35 |
+
f"plan {scenario_name} battery mismatch: "
|
| 36 |
+
f"missing={missing_batteries[:3]}, extra={extra_batteries[:3]}"
|
| 37 |
+
)
|
| 38 |
+
parsed_days = pd.to_datetime(plan["day"], errors="coerce")
|
| 39 |
+
if parsed_days.isna().any():
|
| 40 |
+
raise AssertionError(f"plan {scenario_name} contains an invalid day")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
def main() -> None:
|
| 44 |
dataset_path = Path(os.environ.get("BATTERYSWAP_DATASET_PATH", "/tmp/data"))
|
| 45 |
artifact_path = Path(
|
| 46 |
+
os.environ.get(
|
| 47 |
+
"BATTERYSWAP_PLANNER_PATH",
|
| 48 |
+
"submission_artifacts/temperature_planner.joblib",
|
| 49 |
+
)
|
| 50 |
)
|
| 51 |
+
splits = [
|
| 52 |
+
split.strip()
|
| 53 |
+
for split in os.environ.get("BATTERYSWAP_SPLITS", "public,private").split(",")
|
| 54 |
+
if split.strip()
|
| 55 |
+
]
|
| 56 |
output_path = Path(os.environ.get("BATTERYSWAP_SUBMISSION_PATH", "submission.csv"))
|
| 57 |
|
| 58 |
+
if not artifact_path.is_file():
|
| 59 |
+
raise FileNotFoundError(f"Planner artifact does not exist: {artifact_path}")
|
| 60 |
planner = joblib.load(artifact_path)
|
| 61 |
|
| 62 |
plans = []
|
| 63 |
for split in splits:
|
| 64 |
+
locations, timeseries, eol_times_for_iterator, scenarios = load_dataset(
|
| 65 |
+
dataset_path / split
|
| 66 |
+
)
|
| 67 |
+
reset_split = getattr(planner, "reset_split", None)
|
| 68 |
+
if reset_split is not None:
|
| 69 |
+
reset_split(split)
|
| 70 |
|
| 71 |
for scenario, locs, visible_history, _ in iterate_scenarios(
|
| 72 |
locations,
|
| 73 |
timeseries,
|
| 74 |
+
eol_times_for_iterator,
|
| 75 |
scenarios,
|
| 76 |
):
|
| 77 |
snapshot = scenario_history_snapshot(
|
|
|
|
| 81 |
scenario["start_time"],
|
| 82 |
)
|
| 83 |
|
| 84 |
+
plan_scenario = getattr(planner, "plan_scenario", None)
|
| 85 |
+
if plan_scenario is None:
|
| 86 |
+
plan = planner.plan_snapshot(
|
| 87 |
+
snapshot,
|
| 88 |
+
locs,
|
| 89 |
+
scenario["travel_costs"],
|
| 90 |
+
scenario["settings"],
|
| 91 |
+
scenario["start_time"],
|
| 92 |
+
)
|
| 93 |
+
else:
|
| 94 |
+
plan = plan_scenario(
|
| 95 |
+
visible_history,
|
| 96 |
+
snapshot,
|
| 97 |
+
locs,
|
| 98 |
+
scenario["travel_costs"],
|
| 99 |
+
scenario["settings"],
|
| 100 |
+
scenario["start_time"],
|
| 101 |
+
)
|
| 102 |
|
| 103 |
+
_assert_complete_plan(plan, locs, str(scenario["name"]))
|
| 104 |
plan["split"] = split
|
| 105 |
plan["scenario"] = scenario["name"]
|
| 106 |
plans.append(plan)
|
| 107 |
|
| 108 |
submission = pd.concat(plans, ignore_index=True)
|
| 109 |
+
if submission.duplicated(["split", "scenario", "battery"]).any():
|
| 110 |
+
raise AssertionError("submission contains duplicate split/scenario/battery rows")
|
| 111 |
submission.to_csv(output_path, index=False)
|
| 112 |
|
| 113 |
if not output_path.exists():
|
scripts/build_identity_submission.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fit and serialize the deployable LT/sim/seasonal identity wrapper.
|
| 2 |
+
|
| 3 |
+
This builder never overwrites the frozen base planner. OOF experiment rows are
|
| 4 |
+
used only for their strictly causal first-passage training covariates; the AFT
|
| 5 |
+
is refit on every train building and the similarity library is rebuilt directly
|
| 6 |
+
from the train split.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import dataclasses
|
| 13 |
+
import hashlib
|
| 14 |
+
import importlib.metadata
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import joblib
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from batteryswap_public.utils import iterate_scenarios, load_dataset
|
| 22 |
+
|
| 23 |
+
from batteryswapai.identity_ensemble import (
|
| 24 |
+
FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 25 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE,
|
| 26 |
+
CausalHistoryCache,
|
| 27 |
+
IdentityEnsembleModel,
|
| 28 |
+
IdentityEnsemblePlanner,
|
| 29 |
+
LongTermAFTResidual,
|
| 30 |
+
OriginalSimilarityResidual,
|
| 31 |
+
SCHEMA_VERSION,
|
| 32 |
+
SeasonalTemperatureResidual,
|
| 33 |
+
WeightedIdentityResidual,
|
| 34 |
+
build_similarity_donor_library,
|
| 35 |
+
exact_smoothed_voltage,
|
| 36 |
+
fit_aft,
|
| 37 |
+
fit_temperature_beta,
|
| 38 |
+
_first_passage_lifetime,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
DATASET_REVISION = "7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c"
|
| 43 |
+
EXPECTED_BASE_SHA256 = "3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569"
|
| 44 |
+
EXPECTED_ROWS = 19_890
|
| 45 |
+
EXPECTED_EOL_DEVICES = 82
|
| 46 |
+
EXPECTED_DONOR_DEVICES = 82
|
| 47 |
+
EXPECTED_DONOR_BUILDINGS = 24
|
| 48 |
+
EXPECTED_DONOR_ENDPOINTS = 7_257
|
| 49 |
+
CONTAINER_BASE = (
|
| 50 |
+
"huggingface/competitions@sha256:"
|
| 51 |
+
"6cea4ff69a6832761484f48c07ccfbf49f701f285ffcb9fc72a4ecfb81b6b4e5"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def parse_args() -> argparse.Namespace:
|
| 56 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 57 |
+
parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--base-artifact",
|
| 60 |
+
type=Path,
|
| 61 |
+
default=Path("submission_artifacts/planner.joblib"),
|
| 62 |
+
)
|
| 63 |
+
parser.add_argument(
|
| 64 |
+
"--lt-reference-rows",
|
| 65 |
+
type=Path,
|
| 66 |
+
default=Path("artifacts/lt_fp_aft_official.rows.csv"),
|
| 67 |
+
help=(
|
| 68 |
+
"Optional validation-only reference. Training covariates are always "
|
| 69 |
+
"rebuilt from raw train prefixes; this file is never required."
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
parser.add_argument(
|
| 73 |
+
"--output",
|
| 74 |
+
type=Path,
|
| 75 |
+
default=Path("submission_artifacts/identity_planner.joblib"),
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--manifest",
|
| 79 |
+
type=Path,
|
| 80 |
+
default=Path("submission_artifacts/identity_planner.json"),
|
| 81 |
+
)
|
| 82 |
+
parser.add_argument(
|
| 83 |
+
"--expected-base-sha256", default=EXPECTED_BASE_SHA256
|
| 84 |
+
)
|
| 85 |
+
parser.add_argument(
|
| 86 |
+
"--with-seasonal",
|
| 87 |
+
action="store_true",
|
| 88 |
+
help="Add the promoted <=2.50 V seasonal-temperature post-rerank.",
|
| 89 |
+
)
|
| 90 |
+
return parser.parse_args()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _sha256(path: Path) -> str:
|
| 94 |
+
digest = hashlib.sha256()
|
| 95 |
+
with path.open("rb") as handle:
|
| 96 |
+
while chunk := handle.read(1024 * 1024):
|
| 97 |
+
digest.update(chunk)
|
| 98 |
+
return digest.hexdigest()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _array_sha256(values: np.ndarray) -> str:
|
| 102 |
+
contiguous = np.ascontiguousarray(np.asarray(values))
|
| 103 |
+
if contiguous.dtype.hasobject:
|
| 104 |
+
payload = "\n".join(contiguous.astype(str).ravel()).encode("utf-8")
|
| 105 |
+
else:
|
| 106 |
+
payload = contiguous.view(np.uint8)
|
| 107 |
+
return hashlib.sha256(payload).hexdigest()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _dataset_manifest(dataset_path: Path) -> dict[str, str]:
|
| 111 |
+
return {
|
| 112 |
+
path.relative_to(dataset_path).as_posix(): _sha256(path)
|
| 113 |
+
for path in sorted(dataset_path.rglob("*"))
|
| 114 |
+
if path.is_file()
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _source_hashes(repo_root: Path) -> dict[str, str]:
|
| 119 |
+
paths = (
|
| 120 |
+
repo_root / "src/batteryswapai/identity_ensemble.py",
|
| 121 |
+
repo_root / "src/batteryswapai/competition_planner.py",
|
| 122 |
+
repo_root / "scripts/build_identity_submission.py",
|
| 123 |
+
repo_root / "scripts/experiment_lt_fp_aft.py",
|
| 124 |
+
repo_root / "scripts/experiment_similarity_eol.py",
|
| 125 |
+
repo_root / "scripts/experiment_identity_ensemble.py",
|
| 126 |
+
repo_root / "scripts/experiment_temperature_physics_ensemble.py",
|
| 127 |
+
repo_root / "scripts/evaluate_clean_identity_candidate.py",
|
| 128 |
+
repo_root / "scripts/verify_identity_feature_parity.py",
|
| 129 |
+
repo_root / "script.py",
|
| 130 |
+
repo_root / "Dockerfile",
|
| 131 |
+
repo_root / "requirements.txt",
|
| 132 |
+
repo_root / "pyproject.toml",
|
| 133 |
+
repo_root / "LICENSE",
|
| 134 |
+
repo_root / "THIRD_PARTY_LICENSES.md",
|
| 135 |
+
repo_root / "README.md",
|
| 136 |
+
repo_root / "REPRODUCIBILITY.md",
|
| 137 |
+
)
|
| 138 |
+
return {
|
| 139 |
+
path.relative_to(repo_root).as_posix(): _sha256(path)
|
| 140 |
+
for path in paths
|
| 141 |
+
if path.exists()
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _evidence_hashes(repo_root: Path) -> dict[str, str]:
|
| 146 |
+
paths = (
|
| 147 |
+
"artifacts/lt_fp_aft_official.json",
|
| 148 |
+
"artifacts/lt_fp_aft_official.rows.csv",
|
| 149 |
+
"artifacts/similarity_eol_official.json",
|
| 150 |
+
"artifacts/similarity_eol_official.rows.csv",
|
| 151 |
+
"artifacts/identity_ensemble_official.json",
|
| 152 |
+
"artifacts/identity_ensemble_official.rows.csv",
|
| 153 |
+
"artifacts/temperature_physics_ensemble_official.json",
|
| 154 |
+
"artifacts/temperature_physics_ensemble_official.rows.csv",
|
| 155 |
+
"artifacts/temperature_physics_globalbeta_diagnostic.json",
|
| 156 |
+
)
|
| 157 |
+
return {
|
| 158 |
+
name: _sha256(repo_root / name)
|
| 159 |
+
for name in paths
|
| 160 |
+
if (repo_root / name).exists()
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _assert_eol_reconstruction(
|
| 165 |
+
smoothed: pd.DataFrame, eol_times: pd.Series
|
| 166 |
+
) -> dict[str, int]:
|
| 167 |
+
crossings = (
|
| 168 |
+
smoothed[smoothed["smooth_voltage"].le(2.40)]
|
| 169 |
+
.groupby("device_id", observed=True)["end_time"]
|
| 170 |
+
.min()
|
| 171 |
+
)
|
| 172 |
+
crossings.index = crossings.index.astype(str)
|
| 173 |
+
observed = pd.to_datetime(eol_times.dropna(), errors="raise")
|
| 174 |
+
observed.index = observed.index.astype(str)
|
| 175 |
+
comparison = pd.DataFrame(
|
| 176 |
+
{
|
| 177 |
+
"official": observed.dt.normalize(),
|
| 178 |
+
"reconstructed": pd.to_datetime(crossings).dt.normalize(),
|
| 179 |
+
}
|
| 180 |
+
)
|
| 181 |
+
exact = int(
|
| 182 |
+
comparison.dropna()["official"].eq(comparison.dropna()["reconstructed"]).sum()
|
| 183 |
+
)
|
| 184 |
+
if len(observed) != EXPECTED_EOL_DEVICES or exact != EXPECTED_EOL_DEVICES:
|
| 185 |
+
raise AssertionError(
|
| 186 |
+
f"exact EOL reconstruction failed: observed={len(observed)}, exact={exact}"
|
| 187 |
+
)
|
| 188 |
+
return {"observed_eol_devices": len(observed), "exact_eol_matches": exact}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _build_aft_training_rows(
|
| 192 |
+
locations: pd.DataFrame,
|
| 193 |
+
timeseries: pd.DataFrame,
|
| 194 |
+
eol_times: pd.Series,
|
| 195 |
+
scenarios: list[dict],
|
| 196 |
+
) -> pd.DataFrame:
|
| 197 |
+
"""Rebuild the full-train AFT landmarks from official visible prefixes.
|
| 198 |
+
|
| 199 |
+
The serialized winner must be reproducible from the allowed train split alone.
|
| 200 |
+
Persisted OOF rows are useful parity evidence, but are deliberately not an input
|
| 201 |
+
to this builder.
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
cache = CausalHistoryCache(
|
| 205 |
+
beta_v_per_c=FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 206 |
+
split_id="train",
|
| 207 |
+
)
|
| 208 |
+
records: list[dict[str, object]] = []
|
| 209 |
+
for scenario, locs, visible, _ in iterate_scenarios(
|
| 210 |
+
locations, timeseries, eol_times, scenarios
|
| 211 |
+
):
|
| 212 |
+
start = pd.Timestamp(scenario["start_time"])
|
| 213 |
+
history = cache.update(visible, start)
|
| 214 |
+
for row in locs.itertuples(index=False):
|
| 215 |
+
battery = str(row.battery)
|
| 216 |
+
installation = pd.Timestamp(row.start_time)
|
| 217 |
+
lifetime, reliable = _first_passage_lifetime(
|
| 218 |
+
battery,
|
| 219 |
+
start,
|
| 220 |
+
installation,
|
| 221 |
+
history.smooth_lookup,
|
| 222 |
+
)
|
| 223 |
+
event_time = pd.to_datetime(eol_times.get(battery), errors="coerce")
|
| 224 |
+
event_observed = bool(pd.notna(event_time))
|
| 225 |
+
outcome_time = (
|
| 226 |
+
pd.Timestamp(event_time)
|
| 227 |
+
if event_observed
|
| 228 |
+
else pd.Timestamp(row.end_time)
|
| 229 |
+
)
|
| 230 |
+
records.append(
|
| 231 |
+
{
|
| 232 |
+
"scenario": str(scenario["name"]),
|
| 233 |
+
"battery": battery,
|
| 234 |
+
"fp_reliable": reliable,
|
| 235 |
+
"fp_lifetime_days": lifetime,
|
| 236 |
+
"landmark_age_days": float(
|
| 237 |
+
(start - installation) / pd.Timedelta(days=1)
|
| 238 |
+
),
|
| 239 |
+
"outcome_lifetime_days": float(
|
| 240 |
+
(outcome_time - installation) / pd.Timedelta(days=1)
|
| 241 |
+
),
|
| 242 |
+
"event_observed": event_observed,
|
| 243 |
+
}
|
| 244 |
+
)
|
| 245 |
+
result = pd.DataFrame(records)
|
| 246 |
+
if len(result) != EXPECTED_ROWS or result.duplicated(
|
| 247 |
+
["scenario", "battery"]
|
| 248 |
+
).any():
|
| 249 |
+
raise AssertionError(
|
| 250 |
+
"raw AFT landmark rebuild did not produce 19,890 unique rows"
|
| 251 |
+
)
|
| 252 |
+
return result
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _verify_optional_aft_reference(
|
| 256 |
+
rebuilt: pd.DataFrame, reference_path: Path
|
| 257 |
+
) -> dict[str, object] | None:
|
| 258 |
+
if not reference_path.exists():
|
| 259 |
+
return None
|
| 260 |
+
columns = [
|
| 261 |
+
"scenario",
|
| 262 |
+
"battery",
|
| 263 |
+
"fp_reliable",
|
| 264 |
+
"fp_lifetime_days",
|
| 265 |
+
"landmark_age_days",
|
| 266 |
+
"outcome_lifetime_days",
|
| 267 |
+
"event_observed",
|
| 268 |
+
]
|
| 269 |
+
reference = pd.read_csv(reference_path, usecols=columns)
|
| 270 |
+
if len(reference) != len(rebuilt):
|
| 271 |
+
raise AssertionError("optional LT reference row count differs from raw rebuild")
|
| 272 |
+
if not rebuilt[["scenario", "battery"]].astype(str).equals(
|
| 273 |
+
reference[["scenario", "battery"]].astype(str)
|
| 274 |
+
):
|
| 275 |
+
raise AssertionError("optional LT reference key order differs from raw rebuild")
|
| 276 |
+
for column in ("fp_reliable", "event_observed"):
|
| 277 |
+
if not np.array_equal(
|
| 278 |
+
rebuilt[column].to_numpy(bool),
|
| 279 |
+
reference[column].to_numpy(bool),
|
| 280 |
+
):
|
| 281 |
+
raise AssertionError(
|
| 282 |
+
f"raw AFT {column} flags differ from LT reference"
|
| 283 |
+
)
|
| 284 |
+
for column in (
|
| 285 |
+
"fp_lifetime_days",
|
| 286 |
+
"landmark_age_days",
|
| 287 |
+
"outcome_lifetime_days",
|
| 288 |
+
):
|
| 289 |
+
np.testing.assert_allclose(
|
| 290 |
+
rebuilt[column].to_numpy(float),
|
| 291 |
+
reference[column].to_numpy(float),
|
| 292 |
+
rtol=0.0,
|
| 293 |
+
atol=1e-12,
|
| 294 |
+
equal_nan=True,
|
| 295 |
+
)
|
| 296 |
+
return {
|
| 297 |
+
"path": reference_path.as_posix(),
|
| 298 |
+
"sha256": _sha256(reference_path),
|
| 299 |
+
"rows": len(reference),
|
| 300 |
+
"raw_rebuild_exact": True,
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def main() -> None:
|
| 305 |
+
args = parse_args()
|
| 306 |
+
repo_root = Path(__file__).resolve().parents[1]
|
| 307 |
+
base_sha_before = _sha256(args.base_artifact)
|
| 308 |
+
if base_sha_before != args.expected_base_sha256:
|
| 309 |
+
raise AssertionError(
|
| 310 |
+
"frozen base artifact changed: "
|
| 311 |
+
f"expected={args.expected_base_sha256}, actual={base_sha_before}"
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
locations, timeseries, eol_times, scenarios = load_dataset(args.dataset_path)
|
| 315 |
+
if len(scenarios) != 48:
|
| 316 |
+
raise AssertionError(f"expected 48 train scenarios, found {len(scenarios)}")
|
| 317 |
+
eol_times = eol_times.copy()
|
| 318 |
+
eol_times.index = eol_times.index.astype(str)
|
| 319 |
+
|
| 320 |
+
aft_rows = _build_aft_training_rows(
|
| 321 |
+
locations, timeseries, eol_times, scenarios
|
| 322 |
+
)
|
| 323 |
+
aft_reference = _verify_optional_aft_reference(
|
| 324 |
+
aft_rows, args.lt_reference_rows
|
| 325 |
+
)
|
| 326 |
+
aft_parameters, aft_diagnostics = fit_aft(aft_rows)
|
| 327 |
+
|
| 328 |
+
full_smoothed = exact_smoothed_voltage(timeseries)
|
| 329 |
+
smoothing_audit = _assert_eol_reconstruction(full_smoothed, eol_times)
|
| 330 |
+
mapping_rows = locations[["battery", "building"]].drop_duplicates()
|
| 331 |
+
if mapping_rows["battery"].duplicated().any():
|
| 332 |
+
raise AssertionError("a train battery maps to multiple buildings")
|
| 333 |
+
battery_building = dict(
|
| 334 |
+
zip(
|
| 335 |
+
mapping_rows["battery"].astype(str),
|
| 336 |
+
mapping_rows["building"].astype(str),
|
| 337 |
+
strict=True,
|
| 338 |
+
)
|
| 339 |
+
)
|
| 340 |
+
donor_library = build_similarity_donor_library(
|
| 341 |
+
full_smoothed, eol_times, battery_building
|
| 342 |
+
)
|
| 343 |
+
donor_counts = (
|
| 344 |
+
donor_library.donor_count,
|
| 345 |
+
donor_library.building_count,
|
| 346 |
+
donor_library.endpoint_count,
|
| 347 |
+
)
|
| 348 |
+
expected_counts = (
|
| 349 |
+
EXPECTED_DONOR_DEVICES,
|
| 350 |
+
EXPECTED_DONOR_BUILDINGS,
|
| 351 |
+
EXPECTED_DONOR_ENDPOINTS,
|
| 352 |
+
)
|
| 353 |
+
if donor_counts != expected_counts:
|
| 354 |
+
raise AssertionError(
|
| 355 |
+
f"full donor library changed: expected={expected_counts}, actual={donor_counts}"
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
fitted_beta, beta_diagnostics = fit_temperature_beta(timeseries)
|
| 359 |
+
if round(fitted_beta, 5) != FROZEN_TEMPERATURE_BETA_V_PER_C:
|
| 360 |
+
raise AssertionError(
|
| 361 |
+
"full-train temperature beta no longer reproduces the frozen value: "
|
| 362 |
+
f"fit={fitted_beta}, frozen={FROZEN_TEMPERATURE_BETA_V_PER_C}"
|
| 363 |
+
)
|
| 364 |
+
seasonal = SeasonalTemperatureResidual(
|
| 365 |
+
beta_v_per_c=FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 366 |
+
fitted_raw_beta_v_per_c=fitted_beta,
|
| 367 |
+
training_readings=int(beta_diagnostics["training_readings"]),
|
| 368 |
+
maximum_predicted_min_voltage=TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE,
|
| 369 |
+
)
|
| 370 |
+
ensemble = IdentityEnsembleModel(
|
| 371 |
+
identity_residuals=(
|
| 372 |
+
WeightedIdentityResidual(LongTermAFTResidual(aft_parameters), 0.50),
|
| 373 |
+
WeightedIdentityResidual(OriginalSimilarityResidual(donor_library), 0.50),
|
| 374 |
+
),
|
| 375 |
+
post_residuals=(seasonal,) if args.with_seasonal else (),
|
| 376 |
+
history_temperature_beta_v_per_c=FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 377 |
+
)
|
| 378 |
+
base_planner = joblib.load(args.base_artifact)
|
| 379 |
+
wrapper = IdentityEnsemblePlanner(
|
| 380 |
+
base_planner=base_planner,
|
| 381 |
+
ensemble=ensemble,
|
| 382 |
+
base_artifact_sha256=base_sha_before,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 386 |
+
joblib.dump(wrapper, args.output, compress=3)
|
| 387 |
+
base_sha_after = _sha256(args.base_artifact)
|
| 388 |
+
if base_sha_after != base_sha_before:
|
| 389 |
+
raise AssertionError("builder modified the frozen base artifact")
|
| 390 |
+
|
| 391 |
+
library_arrays = {
|
| 392 |
+
"values": donor_library.values,
|
| 393 |
+
"masks": donor_library.masks,
|
| 394 |
+
"donor_ids": donor_library.donor_ids,
|
| 395 |
+
"donor_buildings": donor_library.donor_buildings,
|
| 396 |
+
"donor_codes": donor_library.donor_codes,
|
| 397 |
+
"endpoint_days": donor_library.endpoint_days,
|
| 398 |
+
"residual_days": donor_library.residual_days,
|
| 399 |
+
"device_ids_by_code": donor_library.device_ids_by_code,
|
| 400 |
+
"device_buildings_by_code": donor_library.device_buildings_by_code,
|
| 401 |
+
}
|
| 402 |
+
manifest = {
|
| 403 |
+
"schema_version": SCHEMA_VERSION,
|
| 404 |
+
"dataset_revision": DATASET_REVISION,
|
| 405 |
+
"container_base": CONTAINER_BASE,
|
| 406 |
+
"artifact": args.output.as_posix(),
|
| 407 |
+
"artifact_sha256": _sha256(args.output),
|
| 408 |
+
"artifact_size_bytes": args.output.stat().st_size,
|
| 409 |
+
"base_artifact": args.base_artifact.as_posix(),
|
| 410 |
+
"base_artifact_sha256_before": base_sha_before,
|
| 411 |
+
"base_artifact_sha256_after": base_sha_after,
|
| 412 |
+
"base_artifact_byte_preserved": base_sha_before == base_sha_after,
|
| 413 |
+
"dataset_files": _dataset_manifest(args.dataset_path),
|
| 414 |
+
"training_source": {
|
| 415 |
+
"aft_landmarks": "rebuilt from official raw train visible prefixes",
|
| 416 |
+
"optional_lt_reference": aft_reference,
|
| 417 |
+
},
|
| 418 |
+
"source_sha256": _source_hashes(repo_root),
|
| 419 |
+
"validation_evidence_sha256": _evidence_hashes(repo_root),
|
| 420 |
+
"aft": {
|
| 421 |
+
"parameters": dataclasses.asdict(aft_parameters),
|
| 422 |
+
"diagnostics": aft_diagnostics,
|
| 423 |
+
},
|
| 424 |
+
"similarity_library": {
|
| 425 |
+
"donor_devices": donor_library.donor_count,
|
| 426 |
+
"donor_buildings": donor_library.building_count,
|
| 427 |
+
"donor_endpoints": donor_library.endpoint_count,
|
| 428 |
+
"prefix_lags": donor_library.values.shape[1],
|
| 429 |
+
"arrays": {
|
| 430 |
+
name: {
|
| 431 |
+
"shape": list(np.asarray(values).shape),
|
| 432 |
+
"dtype": str(np.asarray(values).dtype),
|
| 433 |
+
"sha256": _array_sha256(values),
|
| 434 |
+
}
|
| 435 |
+
for name, values in library_arrays.items()
|
| 436 |
+
},
|
| 437 |
+
},
|
| 438 |
+
"seasonal_temperature": {
|
| 439 |
+
"enabled": args.with_seasonal,
|
| 440 |
+
"frozen_beta_v_per_c": FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 441 |
+
"maximum_predicted_min_voltage": (
|
| 442 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE
|
| 443 |
+
if args.with_seasonal
|
| 444 |
+
else None
|
| 445 |
+
),
|
| 446 |
+
"full_train_fit": beta_diagnostics,
|
| 447 |
+
},
|
| 448 |
+
"ensemble": {
|
| 449 |
+
"identity_components": [
|
| 450 |
+
{"name": item.residual.name, "weight": item.weight}
|
| 451 |
+
for item in ensemble.identity_residuals
|
| 452 |
+
],
|
| 453 |
+
"post_components": [item.name for item in ensemble.post_residuals],
|
| 454 |
+
"freshness_stratified": True,
|
| 455 |
+
"raw_risk_multiset_preserved": True,
|
| 456 |
+
"planner_effective_risk_multiset_preserved": True,
|
| 457 |
+
"v07_emergency_scale": 0.75,
|
| 458 |
+
"v07_mean_trip_gate_hours": 8.0,
|
| 459 |
+
},
|
| 460 |
+
"smoothing_audit": smoothing_audit,
|
| 461 |
+
"runtime_versions": {
|
| 462 |
+
package: importlib.metadata.version(package)
|
| 463 |
+
for package in (
|
| 464 |
+
"batteryswap_public",
|
| 465 |
+
"fastparquet",
|
| 466 |
+
"joblib",
|
| 467 |
+
"numpy",
|
| 468 |
+
"pandas",
|
| 469 |
+
"pydantic-settings",
|
| 470 |
+
"scikit-learn",
|
| 471 |
+
"scipy",
|
| 472 |
+
"structlog",
|
| 473 |
+
)
|
| 474 |
+
},
|
| 475 |
+
"random_seeds": {
|
| 476 |
+
"base_model": 2026,
|
| 477 |
+
"identity_residuals": None,
|
| 478 |
+
},
|
| 479 |
+
"official_evaluator_run": False,
|
| 480 |
+
}
|
| 481 |
+
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
| 482 |
+
args.manifest.write_text(
|
| 483 |
+
json.dumps(manifest, indent=2, sort_keys=True, default=float),
|
| 484 |
+
encoding="utf-8",
|
| 485 |
+
)
|
| 486 |
+
print(json.dumps(manifest, indent=2, sort_keys=True, default=float))
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
if __name__ == "__main__":
|
| 490 |
+
main()
|
scripts/evaluate_clean_identity_candidate.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate the frozen identity+temperature rerank on leakage-clean base OOF risks.
|
| 2 |
+
|
| 3 |
+
The base predictions must come from outer-building fits whose trajectory matrix and
|
| 4 |
+
EOL targets contain outer-train batteries only. Identity signals are the already
|
| 5 |
+
frozen building-held-out AFT/similarity/temperature signals. No weights or policies
|
| 6 |
+
are searched here.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import hashlib
|
| 13 |
+
import json
|
| 14 |
+
import math
|
| 15 |
+
from dataclasses import replace
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import joblib
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from batteryswap_public.evaluate import evaluate_plan
|
| 22 |
+
from batteryswap_public.utils import iterate_scenarios, load_dataset
|
| 23 |
+
|
| 24 |
+
from batteryswapai.competition_planner import CompetitionPlanner
|
| 25 |
+
from batteryswapai.identity_ensemble import (
|
| 26 |
+
IdentityEnsembleModel,
|
| 27 |
+
ResidualSignal,
|
| 28 |
+
SeasonalTemperatureResidual,
|
| 29 |
+
planner_freshness_factors,
|
| 30 |
+
v07_emergency_scale,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
KEYS = ["scenario", "battery"]
|
| 35 |
+
COST_COLUMNS = (
|
| 36 |
+
"battery_swap",
|
| 37 |
+
"building_change",
|
| 38 |
+
"room_change",
|
| 39 |
+
"travel",
|
| 40 |
+
"overtime",
|
| 41 |
+
"daily_limit",
|
| 42 |
+
"weekly_limit",
|
| 43 |
+
"late_swap",
|
| 44 |
+
"early_swap",
|
| 45 |
+
"total_cost",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def parse_args() -> argparse.Namespace:
|
| 50 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 51 |
+
parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--clean-predictions",
|
| 54 |
+
type=Path,
|
| 55 |
+
default=Path("artifacts/clean_v05_outer_oof.csv"),
|
| 56 |
+
)
|
| 57 |
+
parser.add_argument(
|
| 58 |
+
"--artifact",
|
| 59 |
+
type=Path,
|
| 60 |
+
default=Path("submission_artifacts/identity_planner.joblib"),
|
| 61 |
+
)
|
| 62 |
+
parser.add_argument(
|
| 63 |
+
"--lt-rows",
|
| 64 |
+
type=Path,
|
| 65 |
+
default=Path("artifacts/lt_fp_aft_official.rows.csv"),
|
| 66 |
+
)
|
| 67 |
+
parser.add_argument(
|
| 68 |
+
"--similarity-rows",
|
| 69 |
+
type=Path,
|
| 70 |
+
default=Path("artifacts/similarity_eol_official.rows.csv"),
|
| 71 |
+
)
|
| 72 |
+
parser.add_argument(
|
| 73 |
+
"--temperature-rows",
|
| 74 |
+
type=Path,
|
| 75 |
+
default=Path("artifacts/temperature_physics_ensemble_official.rows.csv"),
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--output-prefix",
|
| 79 |
+
type=Path,
|
| 80 |
+
default=Path("artifacts/clean_identity_temperature_official"),
|
| 81 |
+
)
|
| 82 |
+
return parser.parse_args()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _sha256(path: Path) -> str:
|
| 86 |
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _bool(values: pd.Series) -> np.ndarray:
|
| 90 |
+
if pd.api.types.is_bool_dtype(values):
|
| 91 |
+
return values.to_numpy(bool)
|
| 92 |
+
return values.astype(str).str.lower().isin({"true", "1"}).to_numpy(bool)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _paired_t(delta: np.ndarray) -> float:
|
| 96 |
+
delta = np.asarray(delta, dtype=float)
|
| 97 |
+
standard_error = float(delta.std(ddof=1) / math.sqrt(len(delta)))
|
| 98 |
+
if standard_error == 0.0:
|
| 99 |
+
return -1e99 if delta.mean() < 0.0 else 1e99
|
| 100 |
+
return float(delta.mean() / standard_error)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _score(
|
| 104 |
+
planner: CompetitionPlanner,
|
| 105 |
+
scenario: dict,
|
| 106 |
+
locs: pd.DataFrame,
|
| 107 |
+
not_dead: pd.Series,
|
| 108 |
+
snapshot: pd.DataFrame,
|
| 109 |
+
risk: np.ndarray,
|
| 110 |
+
rul: np.ndarray,
|
| 111 |
+
survivor: np.ndarray,
|
| 112 |
+
) -> tuple[pd.DataFrame, dict[str, float]]:
|
| 113 |
+
plan = planner.plan_snapshot(
|
| 114 |
+
snapshot,
|
| 115 |
+
locs,
|
| 116 |
+
scenario["travel_costs"],
|
| 117 |
+
scenario["settings"],
|
| 118 |
+
scenario["start_time"],
|
| 119 |
+
predicted_risk=risk,
|
| 120 |
+
predicted_rul=rul,
|
| 121 |
+
predicted_survivor_rul=survivor,
|
| 122 |
+
)
|
| 123 |
+
_, _, score = evaluate_plan(
|
| 124 |
+
plan,
|
| 125 |
+
locs,
|
| 126 |
+
scenario["travel_costs"],
|
| 127 |
+
scenario["settings"],
|
| 128 |
+
eol_times=not_dead,
|
| 129 |
+
start_time=pd.Timestamp(scenario["start_time"]),
|
| 130 |
+
verbose=0,
|
| 131 |
+
)
|
| 132 |
+
return plan, {column: float(score[column]) for column in COST_COLUMNS}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def main() -> None:
|
| 136 |
+
args = parse_args()
|
| 137 |
+
clean = pd.read_csv(args.clean_predictions)
|
| 138 |
+
lt = pd.read_csv(
|
| 139 |
+
args.lt_rows, usecols=KEYS + ["fp_aft_risk_oof", "fp_reliable"]
|
| 140 |
+
)
|
| 141 |
+
similarity = pd.read_csv(
|
| 142 |
+
args.similarity_rows,
|
| 143 |
+
usecols=KEYS
|
| 144 |
+
+ [
|
| 145 |
+
"planner_data_gap_days",
|
| 146 |
+
"planner_freshness_factor",
|
| 147 |
+
"sim_neighbor_risk_oof",
|
| 148 |
+
"sim_neighbor_reliable",
|
| 149 |
+
],
|
| 150 |
+
)
|
| 151 |
+
temperature = pd.read_csv(
|
| 152 |
+
args.temperature_rows,
|
| 153 |
+
usecols=KEYS + ["reliable", "predicted_min_smooth_voltage"],
|
| 154 |
+
)
|
| 155 |
+
frames = {
|
| 156 |
+
"clean": clean,
|
| 157 |
+
"lt": lt,
|
| 158 |
+
"similarity": similarity,
|
| 159 |
+
"temperature": temperature,
|
| 160 |
+
}
|
| 161 |
+
for name, frame in frames.items():
|
| 162 |
+
frame["scenario"] = frame["scenario"].astype(str)
|
| 163 |
+
frame["battery"] = frame["battery"].astype(str)
|
| 164 |
+
if len(frame) != 19_890 or frame.duplicated(KEYS).any():
|
| 165 |
+
raise AssertionError(f"{name} does not contain 19,890 unique keys")
|
| 166 |
+
if not clean[KEYS].equals(frame[KEYS]):
|
| 167 |
+
raise AssertionError(f"{name} key order differs from clean base")
|
| 168 |
+
|
| 169 |
+
rows = clean[
|
| 170 |
+
KEYS
|
| 171 |
+
+ [
|
| 172 |
+
"oof_event_risk",
|
| 173 |
+
"oof_rul_days",
|
| 174 |
+
"oof_survivor_rul",
|
| 175 |
+
"target_rul_days",
|
| 176 |
+
"event_observed",
|
| 177 |
+
]
|
| 178 |
+
].copy()
|
| 179 |
+
for column in (
|
| 180 |
+
"planner_data_gap_days",
|
| 181 |
+
"planner_freshness_factor",
|
| 182 |
+
"sim_neighbor_risk_oof",
|
| 183 |
+
"sim_neighbor_reliable",
|
| 184 |
+
):
|
| 185 |
+
rows[column] = similarity[column].to_numpy()
|
| 186 |
+
rows["fp_aft_risk_oof"] = lt["fp_aft_risk_oof"].to_numpy()
|
| 187 |
+
rows["fp_reliable"] = lt["fp_reliable"].to_numpy()
|
| 188 |
+
rows["temperature_reliable"] = temperature["reliable"].to_numpy()
|
| 189 |
+
rows["temperature_predicted_min_voltage"] = temperature[
|
| 190 |
+
"predicted_min_smooth_voltage"
|
| 191 |
+
].to_numpy(float)
|
| 192 |
+
rows["temperature_urgency"] = -rows[
|
| 193 |
+
"temperature_predicted_min_voltage"
|
| 194 |
+
].to_numpy(float)
|
| 195 |
+
|
| 196 |
+
wrapper = joblib.load(args.artifact)
|
| 197 |
+
identity_only = IdentityEnsembleModel(wrapper.ensemble.identity_residuals)
|
| 198 |
+
temperature_ensemble = IdentityEnsembleModel(
|
| 199 |
+
wrapper.ensemble.identity_residuals,
|
| 200 |
+
(SeasonalTemperatureResidual(),),
|
| 201 |
+
)
|
| 202 |
+
row_index = rows.set_index(KEYS)
|
| 203 |
+
locations, timeseries, eol_times, scenarios = load_dataset(args.dataset_path)
|
| 204 |
+
case_rows: list[dict[str, object]] = []
|
| 205 |
+
risk_rows: list[pd.DataFrame] = []
|
| 206 |
+
scale_counts = {"0.0": 0, "0.75": 0}
|
| 207 |
+
|
| 208 |
+
for scenario, locs, _, not_dead in iterate_scenarios(
|
| 209 |
+
locations,
|
| 210 |
+
timeseries,
|
| 211 |
+
eol_times,
|
| 212 |
+
scenarios,
|
| 213 |
+
):
|
| 214 |
+
name = str(scenario["name"])
|
| 215 |
+
batteries = locs["battery"].astype(str).to_numpy()
|
| 216 |
+
keys = pd.MultiIndex.from_arrays(
|
| 217 |
+
[np.repeat(name, len(batteries)), batteries], names=KEYS
|
| 218 |
+
)
|
| 219 |
+
aligned = row_index.reindex(keys)
|
| 220 |
+
if aligned.isna().all(axis=1).any():
|
| 221 |
+
raise AssertionError(f"clean row alignment failed for {name}")
|
| 222 |
+
base = aligned["oof_event_risk"].to_numpy(float)
|
| 223 |
+
rul = aligned["oof_rul_days"].to_numpy(float)
|
| 224 |
+
survivor = aligned["oof_survivor_rul"].to_numpy(float)
|
| 225 |
+
freshness = aligned["planner_freshness_factor"].to_numpy(float)
|
| 226 |
+
expected_freshness = planner_freshness_factors(
|
| 227 |
+
aligned["planner_data_gap_days"].to_numpy(float),
|
| 228 |
+
wrapper.base_planner.policy,
|
| 229 |
+
)
|
| 230 |
+
np.testing.assert_array_equal(freshness, expected_freshness)
|
| 231 |
+
identity_signals = (
|
| 232 |
+
ResidualSignal(
|
| 233 |
+
aligned["fp_aft_risk_oof"].to_numpy(float),
|
| 234 |
+
_bool(aligned["fp_reliable"]),
|
| 235 |
+
),
|
| 236 |
+
ResidualSignal(
|
| 237 |
+
aligned["sim_neighbor_risk_oof"].to_numpy(float),
|
| 238 |
+
_bool(aligned["sim_neighbor_reliable"]),
|
| 239 |
+
),
|
| 240 |
+
)
|
| 241 |
+
temperature_urgency = aligned["temperature_urgency"].to_numpy(float)
|
| 242 |
+
temperature_reliable = _bool(aligned["temperature_reliable"])
|
| 243 |
+
predicted_min_voltage = aligned[
|
| 244 |
+
"temperature_predicted_min_voltage"
|
| 245 |
+
].to_numpy(float)
|
| 246 |
+
temperature_signals = {
|
| 247 |
+
"temperature_all": ResidualSignal(
|
| 248 |
+
temperature_urgency, temperature_reliable
|
| 249 |
+
),
|
| 250 |
+
"temperature_below_250": ResidualSignal(
|
| 251 |
+
temperature_urgency,
|
| 252 |
+
temperature_reliable & (predicted_min_voltage <= 2.50),
|
| 253 |
+
),
|
| 254 |
+
"temperature_below_240": ResidualSignal(
|
| 255 |
+
temperature_urgency,
|
| 256 |
+
temperature_reliable & (predicted_min_voltage <= 2.40),
|
| 257 |
+
),
|
| 258 |
+
}
|
| 259 |
+
identity_risk = identity_only.rerank_from_signals(
|
| 260 |
+
base, freshness, batteries, identity_signals
|
| 261 |
+
)
|
| 262 |
+
temperature_risks = {
|
| 263 |
+
name: temperature_ensemble.rerank_from_signals(
|
| 264 |
+
base,
|
| 265 |
+
freshness,
|
| 266 |
+
batteries,
|
| 267 |
+
identity_signals,
|
| 268 |
+
(signal,),
|
| 269 |
+
)
|
| 270 |
+
for name, signal in temperature_signals.items()
|
| 271 |
+
}
|
| 272 |
+
snapshot = locs[["battery", "building", "room"]].copy()
|
| 273 |
+
snapshot["data_gap_days"] = aligned[
|
| 274 |
+
"planner_data_gap_days"
|
| 275 |
+
].to_numpy(float)
|
| 276 |
+
scale = v07_emergency_scale(
|
| 277 |
+
snapshot, scenario["travel_costs"], scenario["settings"]
|
| 278 |
+
)
|
| 279 |
+
scale_counts[str(scale)] += 1
|
| 280 |
+
planner = CompetitionPlanner(
|
| 281 |
+
None,
|
| 282 |
+
replace(
|
| 283 |
+
wrapper.base_planner.policy,
|
| 284 |
+
emergency_operational_scale=scale,
|
| 285 |
+
),
|
| 286 |
+
)
|
| 287 |
+
arm_scores: dict[str, dict[str, float]] = {}
|
| 288 |
+
arm_plans: dict[str, pd.DataFrame] = {}
|
| 289 |
+
risks_by_arm = {
|
| 290 |
+
"baseline": base,
|
| 291 |
+
"identity": identity_risk,
|
| 292 |
+
**temperature_risks,
|
| 293 |
+
}
|
| 294 |
+
for arm, risk in risks_by_arm.items():
|
| 295 |
+
arm_plans[arm], arm_scores[arm] = _score(
|
| 296 |
+
planner, scenario, locs, not_dead, snapshot, risk, rul, survivor
|
| 297 |
+
)
|
| 298 |
+
start = pd.Timestamp(scenario["start_time"])
|
| 299 |
+
horizon_end = start + pd.Timedelta(
|
| 300 |
+
days=float(scenario["settings"].planning_window_days)
|
| 301 |
+
)
|
| 302 |
+
selected = {
|
| 303 |
+
arm: set(plan.loc[plan["day"].le(horizon_end), "battery"].astype(str))
|
| 304 |
+
for arm, plan in arm_plans.items()
|
| 305 |
+
}
|
| 306 |
+
expected_quota = min(max(int(round(0.038 * len(locs))), 8), 24)
|
| 307 |
+
if any(len(value) != expected_quota for value in selected.values()):
|
| 308 |
+
raise AssertionError(f"fixed quota drifted in {name}")
|
| 309 |
+
case: dict[str, object] = {
|
| 310 |
+
"scenario": name,
|
| 311 |
+
"start_time": start.isoformat(),
|
| 312 |
+
"effective_emergency_scale": scale,
|
| 313 |
+
"quota": expected_quota,
|
| 314 |
+
}
|
| 315 |
+
for arm, score in arm_scores.items():
|
| 316 |
+
case.update({f"{arm}_{key}": value for key, value in score.items()})
|
| 317 |
+
case_rows.append(case)
|
| 318 |
+
risk_rows.append(
|
| 319 |
+
pd.DataFrame(
|
| 320 |
+
{
|
| 321 |
+
"scenario": name,
|
| 322 |
+
"battery": batteries,
|
| 323 |
+
"planner_freshness_factor": freshness,
|
| 324 |
+
"clean_baseline_event_risk": base,
|
| 325 |
+
"clean_identity_event_risk": identity_risk,
|
| 326 |
+
**{
|
| 327 |
+
f"clean_{arm}_event_risk": risk
|
| 328 |
+
for arm, risk in temperature_risks.items()
|
| 329 |
+
},
|
| 330 |
+
"baseline_selected": [battery in selected["baseline"] for battery in batteries],
|
| 331 |
+
"identity_selected": [battery in selected["identity"] for battery in batteries],
|
| 332 |
+
**{
|
| 333 |
+
f"{arm}_selected": [
|
| 334 |
+
battery in selected[arm] for battery in batteries
|
| 335 |
+
]
|
| 336 |
+
for arm in temperature_risks
|
| 337 |
+
},
|
| 338 |
+
}
|
| 339 |
+
)
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
cases = pd.DataFrame(case_rows).sort_values("start_time").reset_index(drop=True)
|
| 343 |
+
risks = pd.concat(risk_rows, ignore_index=True)
|
| 344 |
+
baseline = cases["baseline_total_cost"].to_numpy(float)
|
| 345 |
+
identity = cases["identity_total_cost"].to_numpy(float)
|
| 346 |
+
identity_delta = identity - baseline
|
| 347 |
+
temperature_deltas_vs_identity = {
|
| 348 |
+
arm: cases[f"{arm}_total_cost"].to_numpy(float) - identity
|
| 349 |
+
for arm in (
|
| 350 |
+
"temperature_all",
|
| 351 |
+
"temperature_below_250",
|
| 352 |
+
"temperature_below_240",
|
| 353 |
+
)
|
| 354 |
+
}
|
| 355 |
+
temperature_variants = {}
|
| 356 |
+
for arm, incremental in temperature_deltas_vs_identity.items():
|
| 357 |
+
values = cases[f"{arm}_total_cost"].to_numpy(float)
|
| 358 |
+
delta = values - baseline
|
| 359 |
+
temperature_variants[arm] = {
|
| 360 |
+
"total": float(values.mean()),
|
| 361 |
+
"delta_vs_baseline": float(delta.mean()),
|
| 362 |
+
"delta_vs_identity": float(incremental.mean()),
|
| 363 |
+
"paired_t_vs_baseline": _paired_t(delta),
|
| 364 |
+
"paired_t_vs_identity": _paired_t(incremental),
|
| 365 |
+
"first_half_delta_vs_baseline": float(delta[:24].mean()),
|
| 366 |
+
"second_half_delta_vs_baseline": float(delta[24:].mean()),
|
| 367 |
+
"first_half_delta_vs_identity": float(incremental[:24].mean()),
|
| 368 |
+
"second_half_delta_vs_identity": float(incremental[24:].mean()),
|
| 369 |
+
"improved": int((delta < 0).sum()),
|
| 370 |
+
"unchanged": int((delta == 0).sum()),
|
| 371 |
+
"worse": int((delta > 0).sum()),
|
| 372 |
+
"strict_gate_vs_baseline": bool(
|
| 373 |
+
delta.mean() <= -40.0
|
| 374 |
+
and delta[:24].mean() <= 0.0
|
| 375 |
+
and delta[24:].mean() <= 0.0
|
| 376 |
+
and _paired_t(delta) <= -2.0
|
| 377 |
+
),
|
| 378 |
+
}
|
| 379 |
+
candidate = cases["temperature_below_250_total_cost"].to_numpy(float)
|
| 380 |
+
delta = candidate - baseline
|
| 381 |
+
report = {
|
| 382 |
+
"experiment": (
|
| 383 |
+
"outer-clean V05 plus frozen LT/sim identity and promoted "
|
| 384 |
+
"seasonal temperature <=2.50 V gate"
|
| 385 |
+
),
|
| 386 |
+
"rows": len(risks),
|
| 387 |
+
"cases": len(cases),
|
| 388 |
+
"baseline_total": float(baseline.mean()),
|
| 389 |
+
"identity_total": float(identity.mean()),
|
| 390 |
+
"candidate_total": float(candidate.mean()),
|
| 391 |
+
"temperature_variants": temperature_variants,
|
| 392 |
+
"candidate_vs_baseline": {
|
| 393 |
+
"mean_delta": float(delta.mean()),
|
| 394 |
+
"paired_t": _paired_t(delta),
|
| 395 |
+
"first_half_delta": float(delta[:24].mean()),
|
| 396 |
+
"second_half_delta": float(delta[24:].mean()),
|
| 397 |
+
"improved": int((delta < 0).sum()),
|
| 398 |
+
"unchanged": int((delta == 0).sum()),
|
| 399 |
+
"worse": int((delta > 0).sum()),
|
| 400 |
+
},
|
| 401 |
+
"identity_vs_baseline": {
|
| 402 |
+
"mean_delta": float(identity_delta.mean()),
|
| 403 |
+
"paired_t": _paired_t(identity_delta),
|
| 404 |
+
"first_half_delta": float(identity_delta[:24].mean()),
|
| 405 |
+
"second_half_delta": float(identity_delta[24:].mean()),
|
| 406 |
+
},
|
| 407 |
+
"components": {
|
| 408 |
+
column: {
|
| 409 |
+
"baseline_mean": float(cases[f"baseline_{column}"].mean()),
|
| 410 |
+
"candidate_mean": float(
|
| 411 |
+
cases[f"temperature_below_250_{column}"].mean()
|
| 412 |
+
),
|
| 413 |
+
"mean_delta": float(
|
| 414 |
+
(
|
| 415 |
+
cases[f"temperature_below_250_{column}"]
|
| 416 |
+
- cases[f"baseline_{column}"]
|
| 417 |
+
).mean()
|
| 418 |
+
),
|
| 419 |
+
}
|
| 420 |
+
for column in COST_COLUMNS
|
| 421 |
+
},
|
| 422 |
+
"gate": {
|
| 423 |
+
"definition": "delta <= -40, both halves <= 0, paired_t <= -2",
|
| 424 |
+
"pass": bool(
|
| 425 |
+
delta.mean() <= -40.0
|
| 426 |
+
and delta[:24].mean() <= 0.0
|
| 427 |
+
and delta[24:].mean() <= 0.0
|
| 428 |
+
and _paired_t(delta) <= -2.0
|
| 429 |
+
),
|
| 430 |
+
},
|
| 431 |
+
"v07_scale_counts": scale_counts,
|
| 432 |
+
"fingerprints": {
|
| 433 |
+
"clean_predictions": _sha256(args.clean_predictions),
|
| 434 |
+
"artifact": _sha256(args.artifact),
|
| 435 |
+
"lt_rows": _sha256(args.lt_rows),
|
| 436 |
+
"similarity_rows": _sha256(args.similarity_rows),
|
| 437 |
+
"temperature_rows": _sha256(args.temperature_rows),
|
| 438 |
+
},
|
| 439 |
+
}
|
| 440 |
+
args.output_prefix.parent.mkdir(parents=True, exist_ok=True)
|
| 441 |
+
cases.to_csv(args.output_prefix.with_suffix(".cases.csv"), index=False)
|
| 442 |
+
risks.to_csv(args.output_prefix.with_suffix(".rows.csv"), index=False)
|
| 443 |
+
args.output_prefix.with_suffix(".json").write_text(
|
| 444 |
+
json.dumps(report, indent=2), encoding="utf-8"
|
| 445 |
+
)
|
| 446 |
+
print(json.dumps(report, indent=2))
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
if __name__ == "__main__":
|
| 450 |
+
main()
|
scripts/train_submission.py
CHANGED
|
@@ -38,7 +38,9 @@ def parse_args() -> argparse.Namespace:
|
|
| 38 |
parser.add_argument("--risk-calibration-scale", type=float, default=1.5)
|
| 39 |
parser.add_argument("--expected-gain-margin", type=float, default=10.0)
|
| 40 |
parser.add_argument("--expected-service-cost-hours", type=float, default=2.0)
|
|
|
|
| 41 |
parser.add_argument("--scheduled-fraction", type=float, default=0.038)
|
|
|
|
| 42 |
parser.add_argument("--weekly-guard-fraction", type=float, default=0.95)
|
| 43 |
parser.add_argument("--hard-limit-penalty-multiplier", type=float, default=1.5)
|
| 44 |
parser.add_argument("--minimum-scheduled-batteries", type=int, default=8)
|
|
@@ -83,12 +85,14 @@ def main() -> None:
|
|
| 83 |
risk_calibration_scale=args.risk_calibration_scale,
|
| 84 |
expected_gain_margin=args.expected_gain_margin,
|
| 85 |
expected_service_cost_hours=args.expected_service_cost_hours,
|
|
|
|
| 86 |
scheduled_fraction=args.scheduled_fraction,
|
| 87 |
capacity_weekly_limit_fraction=args.weekly_guard_fraction,
|
| 88 |
capacity_limit_penalty_multiplier=args.hard_limit_penalty_multiplier,
|
| 89 |
minimum_scheduled_batteries=args.minimum_scheduled_batteries,
|
| 90 |
maximum_scheduled_batteries=args.maximum_scheduled_batteries,
|
| 91 |
capacity_lookahead_days=21,
|
|
|
|
| 92 |
),
|
| 93 |
)
|
| 94 |
args.artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -103,7 +107,9 @@ def main() -> None:
|
|
| 103 |
"risk_calibration_scale": args.risk_calibration_scale,
|
| 104 |
"expected_gain_margin": args.expected_gain_margin,
|
| 105 |
"expected_service_cost_hours": args.expected_service_cost_hours,
|
|
|
|
| 106 |
"scheduled_fraction": args.scheduled_fraction,
|
|
|
|
| 107 |
"weekly_guard_fraction": args.weekly_guard_fraction,
|
| 108 |
"hard_limit_penalty_multiplier": args.hard_limit_penalty_multiplier,
|
| 109 |
"minimum_scheduled_batteries": args.minimum_scheduled_batteries,
|
|
|
|
| 38 |
parser.add_argument("--risk-calibration-scale", type=float, default=1.5)
|
| 39 |
parser.add_argument("--expected-gain-margin", type=float, default=10.0)
|
| 40 |
parser.add_argument("--expected-service-cost-hours", type=float, default=2.0)
|
| 41 |
+
parser.add_argument("--emergency-operational-scale", type=float, default=0.0)
|
| 42 |
parser.add_argument("--scheduled-fraction", type=float, default=0.038)
|
| 43 |
+
parser.add_argument("--capacity-lookback-days", type=int, default=42)
|
| 44 |
parser.add_argument("--weekly-guard-fraction", type=float, default=0.95)
|
| 45 |
parser.add_argument("--hard-limit-penalty-multiplier", type=float, default=1.5)
|
| 46 |
parser.add_argument("--minimum-scheduled-batteries", type=int, default=8)
|
|
|
|
| 85 |
risk_calibration_scale=args.risk_calibration_scale,
|
| 86 |
expected_gain_margin=args.expected_gain_margin,
|
| 87 |
expected_service_cost_hours=args.expected_service_cost_hours,
|
| 88 |
+
emergency_operational_scale=args.emergency_operational_scale,
|
| 89 |
scheduled_fraction=args.scheduled_fraction,
|
| 90 |
capacity_weekly_limit_fraction=args.weekly_guard_fraction,
|
| 91 |
capacity_limit_penalty_multiplier=args.hard_limit_penalty_multiplier,
|
| 92 |
minimum_scheduled_batteries=args.minimum_scheduled_batteries,
|
| 93 |
maximum_scheduled_batteries=args.maximum_scheduled_batteries,
|
| 94 |
capacity_lookahead_days=21,
|
| 95 |
+
capacity_lookback_days=args.capacity_lookback_days,
|
| 96 |
),
|
| 97 |
)
|
| 98 |
args.artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 107 |
"risk_calibration_scale": args.risk_calibration_scale,
|
| 108 |
"expected_gain_margin": args.expected_gain_margin,
|
| 109 |
"expected_service_cost_hours": args.expected_service_cost_hours,
|
| 110 |
+
"emergency_operational_scale": args.emergency_operational_scale,
|
| 111 |
"scheduled_fraction": args.scheduled_fraction,
|
| 112 |
+
"capacity_lookback_days": args.capacity_lookback_days,
|
| 113 |
"weekly_guard_fraction": args.weekly_guard_fraction,
|
| 114 |
"hard_limit_penalty_multiplier": args.hard_limit_penalty_multiplier,
|
| 115 |
"minimum_scheduled_batteries": args.minimum_scheduled_batteries,
|
scripts/validate_submission.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import argparse
|
|
|
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
@@ -74,10 +75,14 @@ def parse_args() -> argparse.Namespace:
|
|
| 74 |
parser.add_argument("--risk-scales", default="1.50")
|
| 75 |
parser.add_argument("--gain-margins", default="10")
|
| 76 |
parser.add_argument("--service-costs", default="2")
|
|
|
|
| 77 |
parser.add_argument("--offsets", default="-5")
|
| 78 |
parser.add_argument("--schedule-fractions", default="0.038")
|
| 79 |
parser.add_argument("--schedule-quotas", default="")
|
| 80 |
parser.add_argument("--schedule-bands", default="")
|
|
|
|
|
|
|
|
|
|
| 81 |
parser.add_argument(
|
| 82 |
"--predictions-csv",
|
| 83 |
type=Path,
|
|
@@ -118,6 +123,7 @@ def main() -> None:
|
|
| 118 |
oof_residual = np.full(len(training), np.nan)
|
| 119 |
oof_residual_weight = np.zeros(len(training))
|
| 120 |
blend_weights = (1.0, 0.0, 0.0, 0.0)
|
|
|
|
| 121 |
if args.predictions_csv is not None:
|
| 122 |
cached = pd.read_csv(args.predictions_csv).set_index(["scenario", "battery"])
|
| 123 |
keys = pd.MultiIndex.from_frame(training[["scenario", "battery"]])
|
|
@@ -134,16 +140,44 @@ def main() -> None:
|
|
| 134 |
for fold_number, (train_index, valid_index) in enumerate(
|
| 135 |
splitter.split(training, groups=groups), start=1
|
| 136 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
model = fit_event_time_model(
|
| 138 |
-
|
| 139 |
quantile=args.quantile,
|
| 140 |
dataset_revision=DATASET_REVISION,
|
| 141 |
random_state=2026 + fold_number,
|
| 142 |
calibration_folds=args.inner_calibration_folds,
|
| 143 |
-
trajectory=
|
| 144 |
-
eol_times=
|
| 145 |
)
|
| 146 |
-
valid =
|
| 147 |
for name, values in model.predict_risk_components(valid).items():
|
| 148 |
oof_components.setdefault(
|
| 149 |
name, np.full(len(training), np.nan)
|
|
@@ -157,6 +191,18 @@ def main() -> None:
|
|
| 157 |
survivor = model.predict_survivor_rul(valid)
|
| 158 |
if survivor is not None:
|
| 159 |
oof_survivor[valid_index] = survivor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
scenario_groups = training["scenario"].astype(str).to_numpy()
|
| 162 |
if oof_components:
|
|
@@ -202,85 +248,131 @@ def main() -> None:
|
|
| 202 |
classification["due_within_horizon_rul_mae_days"] = float(np.mean(due_rul_error))
|
| 203 |
|
| 204 |
policy_results = []
|
|
|
|
| 205 |
placeholder_model = None
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
policy_results.sort(key=lambda item: item["total_cost"])
|
| 286 |
report = {
|
|
@@ -288,6 +380,22 @@ def main() -> None:
|
|
| 288 |
"folds": args.folds,
|
| 289 |
"inner_calibration_folds": args.inner_calibration_folds,
|
| 290 |
"outer_group": args.outer_group,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
"quantile": args.quantile,
|
| 292 |
"classification": classification,
|
| 293 |
"policies": policy_results,
|
|
@@ -299,6 +407,9 @@ def main() -> None:
|
|
| 299 |
predictions["oof_rul_days"] = oof_rul
|
| 300 |
predictions["oof_survivor_rul"] = oof_survivor
|
| 301 |
predictions.to_csv(args.output.with_suffix(".csv"), index=False)
|
|
|
|
|
|
|
|
|
|
| 302 |
print(json.dumps(report, indent=2))
|
| 303 |
|
| 304 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import argparse
|
| 4 |
+
import itertools
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
|
|
| 75 |
parser.add_argument("--risk-scales", default="1.50")
|
| 76 |
parser.add_argument("--gain-margins", default="10")
|
| 77 |
parser.add_argument("--service-costs", default="2")
|
| 78 |
+
parser.add_argument("--emergency-operational-scales", default="0")
|
| 79 |
parser.add_argument("--offsets", default="-5")
|
| 80 |
parser.add_argument("--schedule-fractions", default="0.038")
|
| 81 |
parser.add_argument("--schedule-quotas", default="")
|
| 82 |
parser.add_argument("--schedule-bands", default="")
|
| 83 |
+
parser.add_argument("--capacity-lookback-days", type=int, default=42)
|
| 84 |
+
parser.add_argument("--weekly-guard-fractions", default="0.95")
|
| 85 |
+
parser.add_argument("--hard-limit-penalty-multipliers", default="1.5")
|
| 86 |
parser.add_argument(
|
| 87 |
"--predictions-csv",
|
| 88 |
type=Path,
|
|
|
|
| 123 |
oof_residual = np.full(len(training), np.nan)
|
| 124 |
oof_residual_weight = np.zeros(len(training))
|
| 125 |
blend_weights = (1.0, 0.0, 0.0, 0.0)
|
| 126 |
+
outer_fold_audits: list[dict[str, int]] = []
|
| 127 |
if args.predictions_csv is not None:
|
| 128 |
cached = pd.read_csv(args.predictions_csv).set_index(["scenario", "battery"])
|
| 129 |
keys = pd.MultiIndex.from_frame(training[["scenario", "battery"]])
|
|
|
|
| 140 |
for fold_number, (train_index, valid_index) in enumerate(
|
| 141 |
splitter.split(training, groups=groups), start=1
|
| 142 |
):
|
| 143 |
+
train_rows = training.iloc[train_index]
|
| 144 |
+
valid_rows = training.iloc[valid_index]
|
| 145 |
+
train_batteries = set(train_rows["battery"].astype(str))
|
| 146 |
+
valid_batteries = set(valid_rows["battery"].astype(str))
|
| 147 |
+
train_buildings = set(train_rows["building"].astype(str))
|
| 148 |
+
valid_buildings = set(valid_rows["building"].astype(str))
|
| 149 |
+
if train_batteries & valid_batteries:
|
| 150 |
+
raise AssertionError(f"outer fold {fold_number} leaks a battery")
|
| 151 |
+
if args.outer_group == "building" and train_buildings & valid_buildings:
|
| 152 |
+
raise AssertionError(f"outer fold {fold_number} leaks a building")
|
| 153 |
+
|
| 154 |
+
# The dense trajectory forecasters have future-voltage and EOL targets.
|
| 155 |
+
# Their matrix and labels must therefore obey the same outer split as the
|
| 156 |
+
# snapshot model. Passing the full matrix here made the old OOF report
|
| 157 |
+
# optimistic even though hidden inference itself remained train-only.
|
| 158 |
+
fold_daily = daily.loc[
|
| 159 |
+
daily["device_id"].astype(str).isin(train_batteries)
|
| 160 |
+
].copy()
|
| 161 |
+
fold_trajectory = build_trajectory_matrix(fold_daily)
|
| 162 |
+
trajectory_batteries = set(fold_trajectory["index"])
|
| 163 |
+
if trajectory_batteries & valid_batteries:
|
| 164 |
+
raise AssertionError(
|
| 165 |
+
f"outer fold {fold_number} leaks a trajectory battery"
|
| 166 |
+
)
|
| 167 |
+
fold_eol_times = eol_times.reindex(sorted(train_batteries)).copy()
|
| 168 |
+
if set(fold_eol_times.index.astype(str)) & valid_batteries:
|
| 169 |
+
raise AssertionError(f"outer fold {fold_number} leaks an EOL label")
|
| 170 |
+
|
| 171 |
model = fit_event_time_model(
|
| 172 |
+
train_rows,
|
| 173 |
quantile=args.quantile,
|
| 174 |
dataset_revision=DATASET_REVISION,
|
| 175 |
random_state=2026 + fold_number,
|
| 176 |
calibration_folds=args.inner_calibration_folds,
|
| 177 |
+
trajectory=fold_trajectory,
|
| 178 |
+
eol_times=fold_eol_times,
|
| 179 |
)
|
| 180 |
+
valid = valid_rows
|
| 181 |
for name, values in model.predict_risk_components(valid).items():
|
| 182 |
oof_components.setdefault(
|
| 183 |
name, np.full(len(training), np.nan)
|
|
|
|
| 191 |
survivor = model.predict_survivor_rul(valid)
|
| 192 |
if survivor is not None:
|
| 193 |
oof_survivor[valid_index] = survivor
|
| 194 |
+
outer_fold_audits.append(
|
| 195 |
+
{
|
| 196 |
+
"fold": fold_number,
|
| 197 |
+
"train_batteries": len(train_batteries),
|
| 198 |
+
"valid_batteries": len(valid_batteries),
|
| 199 |
+
"train_buildings": len(train_buildings),
|
| 200 |
+
"valid_buildings": len(valid_buildings),
|
| 201 |
+
"trajectory_batteries": len(trajectory_batteries),
|
| 202 |
+
"battery_overlap": 0,
|
| 203 |
+
"building_overlap": 0 if args.outer_group == "building" else -1,
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
|
| 207 |
scenario_groups = training["scenario"].astype(str).to_numpy()
|
| 208 |
if oof_components:
|
|
|
|
| 248 |
classification["due_within_horizon_rul_mae_days"] = float(np.mean(due_rul_error))
|
| 249 |
|
| 250 |
policy_results = []
|
| 251 |
+
case_results = []
|
| 252 |
placeholder_model = None
|
| 253 |
+
grid = itertools.product(
|
| 254 |
+
_numbers(args.risk_scales),
|
| 255 |
+
_numbers(args.gain_margins),
|
| 256 |
+
_numbers(args.service_costs),
|
| 257 |
+
_numbers(args.emergency_operational_scales),
|
| 258 |
+
_numbers(args.weekly_guard_fractions),
|
| 259 |
+
_numbers(args.hard_limit_penalty_multipliers),
|
| 260 |
+
_numbers(args.offsets),
|
| 261 |
+
_schedule_plans(
|
| 262 |
+
args.schedule_fractions,
|
| 263 |
+
args.schedule_quotas,
|
| 264 |
+
args.schedule_bands,
|
| 265 |
+
),
|
| 266 |
+
)
|
| 267 |
+
for (
|
| 268 |
+
risk_scale,
|
| 269 |
+
gain_margin,
|
| 270 |
+
service_cost,
|
| 271 |
+
emergency_scale,
|
| 272 |
+
weekly_guard,
|
| 273 |
+
hard_multiplier,
|
| 274 |
+
offset,
|
| 275 |
+
(fraction, minimum, maximum),
|
| 276 |
+
) in grid:
|
| 277 |
+
policy_id = f"p{len(policy_results):03d}"
|
| 278 |
+
planner = CompetitionPlanner(
|
| 279 |
+
placeholder_model,
|
| 280 |
+
PlannerPolicy(
|
| 281 |
+
event_risk_threshold=0.50,
|
| 282 |
+
prediction_offset_days=offset,
|
| 283 |
+
use_expected_cost=True,
|
| 284 |
+
risk_calibration_scale=risk_scale,
|
| 285 |
+
expected_service_cost_hours=service_cost,
|
| 286 |
+
expected_gain_margin=gain_margin,
|
| 287 |
+
emergency_operational_scale=emergency_scale,
|
| 288 |
+
capacity_lookahead_days=21,
|
| 289 |
+
capacity_lookback_days=args.capacity_lookback_days,
|
| 290 |
+
capacity_weekly_limit_fraction=weekly_guard,
|
| 291 |
+
capacity_limit_penalty_multiplier=hard_multiplier,
|
| 292 |
+
scheduled_fraction=fraction,
|
| 293 |
+
minimum_scheduled_batteries=minimum,
|
| 294 |
+
maximum_scheduled_batteries=maximum,
|
| 295 |
+
),
|
| 296 |
+
)
|
| 297 |
+
scores = []
|
| 298 |
+
scheduled_counts = []
|
| 299 |
+
for scenario_name, (scenario, locs, not_dead) in scenario_inputs.items():
|
| 300 |
+
mask = training["scenario"].eq(scenario_name).to_numpy()
|
| 301 |
+
snapshot = training.loc[mask]
|
| 302 |
+
risk = oof_risk[mask]
|
| 303 |
+
rul = oof_rul[mask]
|
| 304 |
+
survivor = oof_survivor[mask]
|
| 305 |
+
plan = planner.plan_snapshot(
|
| 306 |
+
snapshot,
|
| 307 |
+
locs,
|
| 308 |
+
scenario["travel_costs"],
|
| 309 |
+
scenario["settings"],
|
| 310 |
+
scenario["start_time"],
|
| 311 |
+
predicted_rul=rul,
|
| 312 |
+
predicted_risk=risk,
|
| 313 |
+
predicted_survivor_rul=(
|
| 314 |
+
None if np.isnan(survivor).all() else survivor
|
| 315 |
+
),
|
| 316 |
+
)
|
| 317 |
+
start = pd.Timestamp(scenario["start_time"])
|
| 318 |
+
horizon_end = start + pd.Timedelta(
|
| 319 |
+
days=scenario["settings"].planning_window_days
|
| 320 |
+
)
|
| 321 |
+
scheduled = plan["day"].le(horizon_end)
|
| 322 |
+
scheduled_count = int(scheduled.sum())
|
| 323 |
+
scheduled_counts.append(scheduled_count)
|
| 324 |
+
_, _, score = evaluate_plan(
|
| 325 |
+
plan,
|
| 326 |
+
locs,
|
| 327 |
+
scenario["travel_costs"],
|
| 328 |
+
scenario["settings"],
|
| 329 |
+
eol_times=not_dead,
|
| 330 |
+
start_time=start,
|
| 331 |
+
verbose=0,
|
| 332 |
+
)
|
| 333 |
+
scores.append(score)
|
| 334 |
+
required = pd.to_datetime(not_dead).between(
|
| 335 |
+
start,
|
| 336 |
+
horizon_end,
|
| 337 |
+
inclusive="right",
|
| 338 |
+
)
|
| 339 |
+
required_ids = set(not_dead.index[required].astype(str))
|
| 340 |
+
scheduled_ids = set(plan.loc[scheduled, "battery"].astype(str))
|
| 341 |
+
case_results.append(
|
| 342 |
+
{
|
| 343 |
+
"policy_id": policy_id,
|
| 344 |
+
"scenario": scenario_name,
|
| 345 |
+
"start_time": start.isoformat(),
|
| 346 |
+
"scheduled_count": scheduled_count,
|
| 347 |
+
"required_count": len(required_ids),
|
| 348 |
+
"true_positive_count": len(required_ids & scheduled_ids),
|
| 349 |
+
"missed_count": len(required_ids - scheduled_ids),
|
| 350 |
+
**{key: float(value) for key, value in score.items()},
|
| 351 |
+
}
|
| 352 |
+
)
|
| 353 |
+
mean_score = pd.concat(scores, axis=1).mean(axis=1)
|
| 354 |
+
policy_results.append(
|
| 355 |
+
{
|
| 356 |
+
"policy_id": policy_id,
|
| 357 |
+
"risk_calibration_scale": risk_scale,
|
| 358 |
+
"expected_gain_margin": gain_margin,
|
| 359 |
+
"expected_service_cost_hours": service_cost,
|
| 360 |
+
"emergency_operational_scale": emergency_scale,
|
| 361 |
+
"prediction_offset_days": offset,
|
| 362 |
+
"scheduled_fraction": fraction,
|
| 363 |
+
"minimum_scheduled_batteries": minimum,
|
| 364 |
+
"maximum_scheduled_batteries": maximum,
|
| 365 |
+
"capacity_weekly_limit_fraction": weekly_guard,
|
| 366 |
+
"capacity_limit_penalty_multiplier": hard_multiplier,
|
| 367 |
+
"stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
|
| 368 |
+
"recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
|
| 369 |
+
"stale_risk_factor": planner.policy.stale_risk_factor,
|
| 370 |
+
"building_batch_window_days": planner.policy.building_batch_window_days,
|
| 371 |
+
"capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
|
| 372 |
+
"mean_scheduled_batteries": float(np.mean(scheduled_counts)),
|
| 373 |
+
**{key: float(value) for key, value in mean_score.items()},
|
| 374 |
+
}
|
| 375 |
+
)
|
| 376 |
|
| 377 |
policy_results.sort(key=lambda item: item["total_cost"])
|
| 378 |
report = {
|
|
|
|
| 380 |
"folds": args.folds,
|
| 381 |
"inner_calibration_folds": args.inner_calibration_folds,
|
| 382 |
"outer_group": args.outer_group,
|
| 383 |
+
"prediction_source": (
|
| 384 |
+
args.predictions_csv.as_posix()
|
| 385 |
+
if args.predictions_csv is not None
|
| 386 |
+
else "generated by this run"
|
| 387 |
+
),
|
| 388 |
+
"outer_fold_trajectory_scope": (
|
| 389 |
+
"caller-supplied predictions; not refit"
|
| 390 |
+
if args.predictions_csv is not None
|
| 391 |
+
else "outer-train batteries only"
|
| 392 |
+
),
|
| 393 |
+
"outer_fold_eol_scope": (
|
| 394 |
+
"caller-supplied predictions; not refit"
|
| 395 |
+
if args.predictions_csv is not None
|
| 396 |
+
else "outer-train batteries only"
|
| 397 |
+
),
|
| 398 |
+
"outer_fold_audits": outer_fold_audits,
|
| 399 |
"quantile": args.quantile,
|
| 400 |
"classification": classification,
|
| 401 |
"policies": policy_results,
|
|
|
|
| 407 |
predictions["oof_rul_days"] = oof_rul
|
| 408 |
predictions["oof_survivor_rul"] = oof_survivor
|
| 409 |
predictions.to_csv(args.output.with_suffix(".csv"), index=False)
|
| 410 |
+
pd.DataFrame(case_results).to_csv(
|
| 411 |
+
args.output.with_suffix(".cases.csv"), index=False
|
| 412 |
+
)
|
| 413 |
print(json.dumps(report, indent=2))
|
| 414 |
|
| 415 |
|
scripts/verify_identity_feature_parity.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify production feature extraction against frozen causal experiment rows.
|
| 2 |
+
|
| 3 |
+
This is a release test, not a model fit. It replays every official train cut through
|
| 4 |
+
the deployable raw-history cache and compares AFT, similarity-query, and rounded-beta
|
| 5 |
+
seasonal physics features to the persisted experiment evidence.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
from batteryswap_public.utils import iterate_scenarios, load_dataset
|
| 18 |
+
|
| 19 |
+
from batteryswapai.identity_ensemble import (
|
| 20 |
+
FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 21 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE,
|
| 22 |
+
CausalHistoryCache,
|
| 23 |
+
_first_passage_lifetime,
|
| 24 |
+
_query_similarity,
|
| 25 |
+
_seasonal_physics_signal,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
KEYS = ["scenario", "battery"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def parse_args() -> argparse.Namespace:
|
| 33 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 34 |
+
parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
|
| 35 |
+
parser.add_argument(
|
| 36 |
+
"--lt-rows",
|
| 37 |
+
type=Path,
|
| 38 |
+
default=Path("artifacts/lt_fp_aft_official.rows.csv"),
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--similarity-rows",
|
| 42 |
+
type=Path,
|
| 43 |
+
default=Path("artifacts/similarity_eol_official.rows.csv"),
|
| 44 |
+
)
|
| 45 |
+
parser.add_argument(
|
| 46 |
+
"--temperature-rows",
|
| 47 |
+
type=Path,
|
| 48 |
+
default=Path("artifacts/temperature_physics_globalbeta_diagnostic.rows.csv"),
|
| 49 |
+
)
|
| 50 |
+
parser.add_argument(
|
| 51 |
+
"--output",
|
| 52 |
+
type=Path,
|
| 53 |
+
default=Path("artifacts/identity_feature_parity.json"),
|
| 54 |
+
)
|
| 55 |
+
return parser.parse_args()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _sha256(path: Path) -> str:
|
| 59 |
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _bool(values: pd.Series) -> np.ndarray:
|
| 63 |
+
if pd.api.types.is_bool_dtype(values):
|
| 64 |
+
return values.to_numpy(bool)
|
| 65 |
+
return values.astype(str).str.lower().isin({"true", "1"}).to_numpy(bool)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _max_abs(actual: np.ndarray, expected: np.ndarray) -> float:
|
| 69 |
+
finite = np.isfinite(actual) & np.isfinite(expected)
|
| 70 |
+
return float(np.max(np.abs(actual[finite] - expected[finite]))) if finite.any() else 0.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def main() -> None:
|
| 74 |
+
args = parse_args()
|
| 75 |
+
lag_columns = [f"sim_hi_lag_{lag}d" for lag in range(0, 337, 7)]
|
| 76 |
+
lt = pd.read_csv(
|
| 77 |
+
args.lt_rows,
|
| 78 |
+
usecols=KEYS
|
| 79 |
+
+ ["installation_start", "fp_lifetime_days", "fp_reliable"],
|
| 80 |
+
).set_index(KEYS)
|
| 81 |
+
similarity = pd.read_csv(
|
| 82 |
+
args.similarity_rows,
|
| 83 |
+
usecols=KEYS
|
| 84 |
+
+ ["sim_query_ready", "sim_staleness_days", *lag_columns],
|
| 85 |
+
).set_index(KEYS)
|
| 86 |
+
temperature = pd.read_csv(
|
| 87 |
+
args.temperature_rows,
|
| 88 |
+
usecols=KEYS + ["reliable", "predicted_min_smooth_voltage"],
|
| 89 |
+
).set_index(KEYS)
|
| 90 |
+
if not (len(lt) == len(similarity) == len(temperature) == 19_890):
|
| 91 |
+
raise AssertionError("feature references must contain 19,890 rows")
|
| 92 |
+
|
| 93 |
+
locations, timeseries, eol_times, scenarios = load_dataset(args.dataset_path)
|
| 94 |
+
cache = CausalHistoryCache(
|
| 95 |
+
beta_v_per_c=FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 96 |
+
split_id="train",
|
| 97 |
+
)
|
| 98 |
+
aft_max_error = 0.0
|
| 99 |
+
similarity_max_error = 0.0
|
| 100 |
+
similarity_staleness_max_error = 0.0
|
| 101 |
+
temperature_max_error = 0.0
|
| 102 |
+
aft_reliable_matches = 0
|
| 103 |
+
similarity_ready_matches = 0
|
| 104 |
+
temperature_reliable_matches = 0
|
| 105 |
+
temperature_gate_matches = 0
|
| 106 |
+
rows = 0
|
| 107 |
+
|
| 108 |
+
for scenario, locs, visible, _ in iterate_scenarios(
|
| 109 |
+
locations, timeseries, eol_times, scenarios
|
| 110 |
+
):
|
| 111 |
+
name = str(scenario["name"])
|
| 112 |
+
start = pd.Timestamp(scenario["start_time"])
|
| 113 |
+
history = cache.update(visible, start)
|
| 114 |
+
batteries = locs["battery"].astype(str).to_numpy()
|
| 115 |
+
keys = pd.MultiIndex.from_arrays(
|
| 116 |
+
[np.repeat(name, len(batteries)), batteries], names=KEYS
|
| 117 |
+
)
|
| 118 |
+
lt_expected = lt.reindex(keys)
|
| 119 |
+
sim_expected = similarity.reindex(keys)
|
| 120 |
+
temp_expected = temperature.reindex(keys)
|
| 121 |
+
if (
|
| 122 |
+
lt_expected.isna().all(axis=1).any()
|
| 123 |
+
or sim_expected.isna().all(axis=1).any()
|
| 124 |
+
or temp_expected.isna().all(axis=1).any()
|
| 125 |
+
):
|
| 126 |
+
raise AssertionError(f"reference alignment failed for {name}")
|
| 127 |
+
|
| 128 |
+
actual_lifetime = np.full(len(batteries), np.nan)
|
| 129 |
+
actual_aft_reliable = np.zeros(len(batteries), dtype=bool)
|
| 130 |
+
installation = pd.to_datetime(lt_expected["installation_start"])
|
| 131 |
+
for position, battery in enumerate(batteries):
|
| 132 |
+
lifetime, reliable = _first_passage_lifetime(
|
| 133 |
+
battery,
|
| 134 |
+
start,
|
| 135 |
+
pd.Timestamp(installation.iloc[position]),
|
| 136 |
+
history.smooth_lookup,
|
| 137 |
+
)
|
| 138 |
+
actual_lifetime[position] = lifetime
|
| 139 |
+
actual_aft_reliable[position] = reliable
|
| 140 |
+
expected_lifetime = lt_expected["fp_lifetime_days"].to_numpy(float)
|
| 141 |
+
expected_aft_reliable = _bool(lt_expected["fp_reliable"])
|
| 142 |
+
np.testing.assert_allclose(
|
| 143 |
+
actual_lifetime,
|
| 144 |
+
expected_lifetime,
|
| 145 |
+
rtol=0.0,
|
| 146 |
+
atol=1e-12,
|
| 147 |
+
equal_nan=True,
|
| 148 |
+
)
|
| 149 |
+
np.testing.assert_array_equal(actual_aft_reliable, expected_aft_reliable)
|
| 150 |
+
aft_max_error = max(aft_max_error, _max_abs(actual_lifetime, expected_lifetime))
|
| 151 |
+
aft_reliable_matches += int((actual_aft_reliable == expected_aft_reliable).sum())
|
| 152 |
+
|
| 153 |
+
query, ready, staleness = _query_similarity(
|
| 154 |
+
batteries, start, history.smooth_lookup
|
| 155 |
+
)
|
| 156 |
+
expected_query = sim_expected[lag_columns].to_numpy(float)
|
| 157 |
+
expected_ready = _bool(sim_expected["sim_query_ready"])
|
| 158 |
+
expected_staleness = sim_expected["sim_staleness_days"].to_numpy(float)
|
| 159 |
+
np.testing.assert_allclose(
|
| 160 |
+
query, expected_query, rtol=0.0, atol=1e-12, equal_nan=True
|
| 161 |
+
)
|
| 162 |
+
np.testing.assert_array_equal(ready, expected_ready)
|
| 163 |
+
np.testing.assert_allclose(
|
| 164 |
+
staleness, expected_staleness, rtol=0.0, atol=1e-12, equal_nan=True
|
| 165 |
+
)
|
| 166 |
+
similarity_max_error = max(
|
| 167 |
+
similarity_max_error, _max_abs(query, expected_query)
|
| 168 |
+
)
|
| 169 |
+
similarity_staleness_max_error = max(
|
| 170 |
+
similarity_staleness_max_error,
|
| 171 |
+
_max_abs(staleness, expected_staleness),
|
| 172 |
+
)
|
| 173 |
+
similarity_ready_matches += int((ready == expected_ready).sum())
|
| 174 |
+
|
| 175 |
+
temp_signal = _seasonal_physics_signal(
|
| 176 |
+
history,
|
| 177 |
+
batteries,
|
| 178 |
+
start,
|
| 179 |
+
FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 180 |
+
)
|
| 181 |
+
expected_temp_reliable = _bool(temp_expected["reliable"])
|
| 182 |
+
expected_urgency = -temp_expected[
|
| 183 |
+
"predicted_min_smooth_voltage"
|
| 184 |
+
].to_numpy(float)
|
| 185 |
+
np.testing.assert_array_equal(
|
| 186 |
+
temp_signal.reliable, expected_temp_reliable
|
| 187 |
+
)
|
| 188 |
+
np.testing.assert_allclose(
|
| 189 |
+
temp_signal.values,
|
| 190 |
+
expected_urgency,
|
| 191 |
+
rtol=0.0,
|
| 192 |
+
atol=2e-7,
|
| 193 |
+
equal_nan=True,
|
| 194 |
+
)
|
| 195 |
+
temperature_max_error = max(
|
| 196 |
+
temperature_max_error,
|
| 197 |
+
_max_abs(temp_signal.values, expected_urgency),
|
| 198 |
+
)
|
| 199 |
+
temperature_reliable_matches += int(
|
| 200 |
+
(temp_signal.reliable == expected_temp_reliable).sum()
|
| 201 |
+
)
|
| 202 |
+
gated_temp_signal = _seasonal_physics_signal(
|
| 203 |
+
history,
|
| 204 |
+
batteries,
|
| 205 |
+
start,
|
| 206 |
+
FROZEN_TEMPERATURE_BETA_V_PER_C,
|
| 207 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE,
|
| 208 |
+
)
|
| 209 |
+
expected_gated_reliable = expected_temp_reliable & (
|
| 210 |
+
temp_expected["predicted_min_smooth_voltage"].to_numpy(float)
|
| 211 |
+
<= TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE
|
| 212 |
+
)
|
| 213 |
+
np.testing.assert_array_equal(
|
| 214 |
+
gated_temp_signal.reliable, expected_gated_reliable
|
| 215 |
+
)
|
| 216 |
+
temperature_gate_matches += int(
|
| 217 |
+
(gated_temp_signal.reliable == expected_gated_reliable).sum()
|
| 218 |
+
)
|
| 219 |
+
rows += len(batteries)
|
| 220 |
+
|
| 221 |
+
if rows != 19_890:
|
| 222 |
+
raise AssertionError(f"feature replay produced {rows} rows")
|
| 223 |
+
report = {
|
| 224 |
+
"rows": rows,
|
| 225 |
+
"all_feature_flags_match": bool(
|
| 226 |
+
aft_reliable_matches
|
| 227 |
+
== similarity_ready_matches
|
| 228 |
+
== temperature_reliable_matches
|
| 229 |
+
== temperature_gate_matches
|
| 230 |
+
== rows
|
| 231 |
+
),
|
| 232 |
+
"aft": {
|
| 233 |
+
"reliable_flag_matches": aft_reliable_matches,
|
| 234 |
+
"maximum_absolute_lifetime_error_days": aft_max_error,
|
| 235 |
+
},
|
| 236 |
+
"similarity_query": {
|
| 237 |
+
"ready_flag_matches": similarity_ready_matches,
|
| 238 |
+
"maximum_absolute_prefix_error": similarity_max_error,
|
| 239 |
+
"maximum_absolute_staleness_error_days": similarity_staleness_max_error,
|
| 240 |
+
},
|
| 241 |
+
"seasonal_temperature": {
|
| 242 |
+
"reliable_flag_matches": temperature_reliable_matches,
|
| 243 |
+
"below_250_gate_flag_matches": temperature_gate_matches,
|
| 244 |
+
"maximum_predicted_min_voltage": (
|
| 245 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE
|
| 246 |
+
),
|
| 247 |
+
"maximum_absolute_urgency_error_v": temperature_max_error,
|
| 248 |
+
},
|
| 249 |
+
"evidence_sha256": {
|
| 250 |
+
"lt_rows": _sha256(args.lt_rows),
|
| 251 |
+
"similarity_rows": _sha256(args.similarity_rows),
|
| 252 |
+
"temperature_rows": _sha256(args.temperature_rows),
|
| 253 |
+
},
|
| 254 |
+
}
|
| 255 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 256 |
+
args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 257 |
+
print(json.dumps(report, indent=2))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
src/batteryswapai/competition_planner.py
CHANGED
|
@@ -28,6 +28,7 @@ class PlannerPolicy:
|
|
| 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
|
|
@@ -130,6 +131,7 @@ class CompetitionPlanner(Planner):
|
|
| 130 |
float(settings.late_replacement_penalty_daily)
|
| 131 |
* np.maximum(emergency_day - expected_event_day, 0.0)
|
| 132 |
+ self.policy.expected_service_cost_hours
|
|
|
|
| 133 |
)
|
| 134 |
survivor_rul = self._survivor_rul(
|
| 135 |
snapshot, horizon, predicted_survivor_rul
|
|
@@ -225,6 +227,50 @@ class CompetitionPlanner(Planner):
|
|
| 225 |
)
|
| 226 |
return pd.DataFrame(planned_rows, columns=["day", "battery"]).reset_index(drop=True)
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
def _survivor_rul(
|
| 229 |
self,
|
| 230 |
snapshot: pd.DataFrame,
|
|
|
|
| 28 |
expected_service_cost_hours: float = 2.0
|
| 29 |
expected_gain_margin: float = 10.0
|
| 30 |
expected_emergency_buffer_days: float = 6.0
|
| 31 |
+
emergency_operational_scale: float = 0.0
|
| 32 |
stale_risk_cutoff_days: float = 7.0
|
| 33 |
recent_gap_risk_factor: float = 0.75
|
| 34 |
stale_risk_factor: float = 0.10
|
|
|
|
| 131 |
float(settings.late_replacement_penalty_daily)
|
| 132 |
* np.maximum(emergency_day - expected_event_day, 0.0)
|
| 133 |
+ self.policy.expected_service_cost_hours
|
| 134 |
+
+ self._emergency_operational_cost(work, travel_costs, settings)
|
| 135 |
)
|
| 136 |
survivor_rul = self._survivor_rul(
|
| 137 |
snapshot, horizon, predicted_survivor_rul
|
|
|
|
| 227 |
)
|
| 228 |
return pd.DataFrame(planned_rows, columns=["day", "battery"]).reset_index(drop=True)
|
| 229 |
|
| 230 |
+
def _emergency_operational_cost(self, work, travel_costs, settings) -> np.ndarray:
|
| 231 |
+
"""What the evaluator actually charges for a battery left to the emergency queue.
|
| 232 |
+
|
| 233 |
+
A missed required battery becomes its own working day: a dedicated round trip from
|
| 234 |
+
base, the swap itself, then straight home. Measured over 144 train cases that costs
|
| 235 |
+
65.56 beyond the late penalty, against the flat 2.0 the selection assumed - a 33x
|
| 236 |
+
underestimate that hid the geography of a miss entirely. Scaling by distance is what
|
| 237 |
+
lets a remote candidate outrank an equally risky one next door.
|
| 238 |
+
"""
|
| 239 |
+
|
| 240 |
+
scale = float(self.policy.emergency_operational_scale)
|
| 241 |
+
if scale <= 0.0:
|
| 242 |
+
return np.zeros(len(work), dtype=float)
|
| 243 |
+
distances = travel_costs.set_index(["from", "to"])["hours"]
|
| 244 |
+
base = settings.base_location
|
| 245 |
+
buildings = work["building"].astype(str).to_numpy()
|
| 246 |
+
out = np.zeros(len(work), dtype=float)
|
| 247 |
+
cache: dict[str, float] = {}
|
| 248 |
+
for position, building in enumerate(buildings):
|
| 249 |
+
if building not in cache:
|
| 250 |
+
try:
|
| 251 |
+
leg = float(distances.loc[(base, building)])
|
| 252 |
+
except KeyError:
|
| 253 |
+
leg = 0.0
|
| 254 |
+
hours = (
|
| 255 |
+
2.0 * leg
|
| 256 |
+
+ float(settings.time_per_building_change_hours)
|
| 257 |
+
+ float(settings.time_per_room_change_hours)
|
| 258 |
+
+ float(settings.time_per_battery_hours)
|
| 259 |
+
)
|
| 260 |
+
overtime = float(settings.overtime_penalty_factor) * max(
|
| 261 |
+
hours - float(settings.overtime_start), 0.0
|
| 262 |
+
)
|
| 263 |
+
limits = 0.0
|
| 264 |
+
if hours > float(settings.worker_limit_daily_hours):
|
| 265 |
+
limits += float(settings.worker_limit_daily_penalty)
|
| 266 |
+
# each emergency day consumes this share of a week's budget
|
| 267 |
+
limits += float(settings.worker_limit_weekly_penalty) * min(
|
| 268 |
+
hours / float(settings.worker_limit_weekly_hours), 1.0
|
| 269 |
+
)
|
| 270 |
+
cache[building] = hours + overtime + limits
|
| 271 |
+
out[position] = cache[building]
|
| 272 |
+
return scale * out
|
| 273 |
+
|
| 274 |
def _survivor_rul(
|
| 275 |
self,
|
| 276 |
snapshot: pd.DataFrame,
|
src/batteryswapai/identity_ensemble.py
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Causal, deployable identity residuals for the BatterySwapAI planner.
|
| 2 |
+
|
| 3 |
+
The frozen base model supplies calibrated event risk and both RUL heads. This
|
| 4 |
+
module changes only which battery receives each already-calibrated risk value.
|
| 5 |
+
Every permutation is confined to one exact planner-freshness stratum, so both
|
| 6 |
+
the raw and planner-consumed risk multisets remain unchanged.
|
| 7 |
+
|
| 8 |
+
Only raw history supplied by ``iterate_scenarios`` may enter ``plan_scenario``.
|
| 9 |
+
The runtime cache is an incremental acceleration of that visible prefix; it is
|
| 10 |
+
never trained, serialized with observations, or populated from the full hidden
|
| 11 |
+
split.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import math
|
| 17 |
+
from dataclasses import dataclass, field, replace
|
| 18 |
+
from typing import Protocol
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pandas as pd
|
| 22 |
+
from scipy.optimize import minimize
|
| 23 |
+
from scipy.special import log_ndtr, ndtr
|
| 24 |
+
from scipy.stats import rankdata
|
| 25 |
+
|
| 26 |
+
from .competition_planner import CompetitionPlanner, PlannerPolicy
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
SCHEMA_VERSION = 1
|
| 30 |
+
HORIZON_DAYS = 42.0
|
| 31 |
+
EOL_VOLTAGE = 2.40
|
| 32 |
+
|
| 33 |
+
FP_WINDOWS_DAYS = (30, 60, 90, 180)
|
| 34 |
+
MIN_SMOOTHED_POINTS = 8
|
| 35 |
+
MIN_WINDOW_SPAN_FRACTION = 0.5
|
| 36 |
+
MIN_DEGRADATION_RATE = 1e-5
|
| 37 |
+
MAX_EXTRAPOLATION_DAYS = 730.0
|
| 38 |
+
MAX_SMOOTHED_STALENESS_DAYS = 7.0
|
| 39 |
+
MAX_LIFETIME_MAD_DAYS = 90.0
|
| 40 |
+
MIN_RELIABLE_WINDOWS = 3
|
| 41 |
+
|
| 42 |
+
NEIGHBORS = 8
|
| 43 |
+
PREFIX_LAGS_DAYS = tuple(range(0, 337, 7))
|
| 44 |
+
RECENCY_HALF_LIFE_DAYS = 42.0
|
| 45 |
+
BASELINE_VALID_POINTS = 30
|
| 46 |
+
MIN_PAIRED_LAGS = 8
|
| 47 |
+
MIN_WEIGHTED_COVERAGE = 0.50
|
| 48 |
+
MIN_EFFECTIVE_NEIGHBORS = 4.0
|
| 49 |
+
MIN_UNIQUE_DONOR_BUILDINGS = NEIGHBORS
|
| 50 |
+
MIN_EFFECTIVE_DONOR_BUILDINGS = 4.0
|
| 51 |
+
MAX_LOGIT_RESIDUAL = 1.0
|
| 52 |
+
INVERSE_DISTANCE_EPSILON = 1e-6
|
| 53 |
+
DISTANCE_BATCH_SIZE = 64
|
| 54 |
+
PREFIX_WEIGHTS = np.power(
|
| 55 |
+
2.0,
|
| 56 |
+
-np.asarray(PREFIX_LAGS_DAYS, dtype=float) / RECENCY_HALF_LIFE_DAYS,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
FROZEN_TEMPERATURE_BETA_V_PER_C = 0.00549
|
| 60 |
+
REFERENCE_TEMPERATURE_C = 20.0
|
| 61 |
+
TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE = 2.50
|
| 62 |
+
SEASONAL_LAG_DAYS = 364
|
| 63 |
+
DRIFT_WINDOW_DAYS = 90
|
| 64 |
+
MIN_DRIFT_POINTS = 30
|
| 65 |
+
MIN_DRIFT_SPAN_DAYS = 60
|
| 66 |
+
MIN_ANALOG_DAYS = 28
|
| 67 |
+
MAX_HEALTH_STALENESS_DAYS = 7
|
| 68 |
+
|
| 69 |
+
V07_EMERGENCY_OPERATIONAL_SCALE = 0.75
|
| 70 |
+
V07_MAX_MEAN_DEDICATED_TRIP_HOURS = 8.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _as_bool(values: pd.Series | np.ndarray) -> np.ndarray:
|
| 74 |
+
array = np.asarray(values)
|
| 75 |
+
if np.issubdtype(array.dtype, np.bool_):
|
| 76 |
+
return array.astype(bool, copy=False)
|
| 77 |
+
return np.asarray(
|
| 78 |
+
[str(value).strip().lower() in {"1", "true", "yes"} for value in array],
|
| 79 |
+
dtype=bool,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _rank(values: np.ndarray) -> np.ndarray:
|
| 84 |
+
values = np.asarray(values, dtype=float)
|
| 85 |
+
if not len(values) or not np.isfinite(values).all():
|
| 86 |
+
raise ValueError("rank input must be finite and non-empty")
|
| 87 |
+
return rankdata(values, method="average") / len(values)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _logit(values: np.ndarray) -> np.ndarray:
|
| 91 |
+
clipped = np.clip(np.asarray(values, dtype=float), 1e-6, 1.0 - 1e-6)
|
| 92 |
+
return np.log(clipped / (1.0 - clipped))
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def assign_multiset(
|
| 96 |
+
baseline: np.ndarray,
|
| 97 |
+
score: np.ndarray,
|
| 98 |
+
batteries: np.ndarray,
|
| 99 |
+
eligible: np.ndarray,
|
| 100 |
+
) -> np.ndarray:
|
| 101 |
+
"""Give high scores high baseline values, with deterministic battery ties."""
|
| 102 |
+
|
| 103 |
+
baseline = np.asarray(baseline, dtype=float)
|
| 104 |
+
score = np.asarray(score, dtype=float)
|
| 105 |
+
batteries = np.asarray(batteries, dtype=object)
|
| 106 |
+
eligible = np.asarray(eligible, dtype=bool)
|
| 107 |
+
if not (len(baseline) == len(score) == len(batteries) == len(eligible)):
|
| 108 |
+
raise ValueError("multiset assignment inputs have different lengths")
|
| 109 |
+
out = baseline.copy()
|
| 110 |
+
positions = np.flatnonzero(eligible)
|
| 111 |
+
if len(positions) < 2:
|
| 112 |
+
return out
|
| 113 |
+
if not np.isfinite(score[positions]).all():
|
| 114 |
+
raise ValueError("eligible multiset scores must be finite")
|
| 115 |
+
order = np.lexsort((batteries[positions].astype(str), score[positions]))
|
| 116 |
+
out[positions[order]] = np.sort(baseline[positions])
|
| 117 |
+
if not np.array_equal(out[~eligible], baseline[~eligible]):
|
| 118 |
+
raise AssertionError("ineligible rows changed during multiset assignment")
|
| 119 |
+
if not np.array_equal(np.sort(out), np.sort(baseline)):
|
| 120 |
+
raise AssertionError("risk multiset changed during assignment")
|
| 121 |
+
return out
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def planner_freshness_factors(
|
| 125 |
+
data_gap_days: np.ndarray, policy: PlannerPolicy
|
| 126 |
+
) -> np.ndarray:
|
| 127 |
+
gaps = np.asarray(data_gap_days, dtype=float)
|
| 128 |
+
factors = np.ones(len(gaps), dtype=float)
|
| 129 |
+
recent = (gaps > 0.0) & (gaps <= policy.stale_risk_cutoff_days)
|
| 130 |
+
stale = (gaps > policy.stale_risk_cutoff_days) | ~np.isfinite(gaps)
|
| 131 |
+
factors[recent] = policy.recent_gap_risk_factor
|
| 132 |
+
factors[stale] = policy.stale_risk_factor
|
| 133 |
+
return factors
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def mean_dedicated_trip_hours(
|
| 137 |
+
snapshot: pd.DataFrame, travel_costs: pd.DataFrame, settings
|
| 138 |
+
) -> float:
|
| 139 |
+
"""Frozen V07 inference-only geometry gate."""
|
| 140 |
+
|
| 141 |
+
distances = travel_costs.set_index(["from", "to"])["hours"]
|
| 142 |
+
if distances.index.has_duplicates:
|
| 143 |
+
raise ValueError("travel matrix paths must be unique")
|
| 144 |
+
base = str(settings.base_location)
|
| 145 |
+
base_room = str(settings.base_room)
|
| 146 |
+
hours: list[float] = []
|
| 147 |
+
for row in snapshot[["building", "room"]].itertuples(index=False):
|
| 148 |
+
building = str(row.building)
|
| 149 |
+
room = str(row.room)
|
| 150 |
+
try:
|
| 151 |
+
outbound = 0.0 if building == base else float(distances.loc[(base, building)])
|
| 152 |
+
inbound = float(distances.loc[(building, base)])
|
| 153 |
+
except KeyError as error:
|
| 154 |
+
raise ValueError(f"travel matrix lacks {base}<->{building}") from error
|
| 155 |
+
hours.append(
|
| 156 |
+
outbound
|
| 157 |
+
+ inbound
|
| 158 |
+
+ (building != base) * float(settings.time_per_building_change_hours)
|
| 159 |
+
+ (room != base_room) * float(settings.time_per_room_change_hours)
|
| 160 |
+
+ float(settings.time_per_battery_hours)
|
| 161 |
+
)
|
| 162 |
+
return float(np.mean(hours)) if hours else math.inf
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def v07_emergency_scale(
|
| 166 |
+
snapshot: pd.DataFrame, travel_costs: pd.DataFrame, settings
|
| 167 |
+
) -> float:
|
| 168 |
+
mean_hours = mean_dedicated_trip_hours(snapshot, travel_costs, settings)
|
| 169 |
+
return (
|
| 170 |
+
V07_EMERGENCY_OPERATIONAL_SCALE
|
| 171 |
+
if mean_hours <= V07_MAX_MEAN_DEDICATED_TRIP_HOURS
|
| 172 |
+
else 0.0
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _raw_columns(timeseries: pd.DataFrame) -> pd.DataFrame:
|
| 177 |
+
frame = timeseries
|
| 178 |
+
if "device_id" not in frame.columns or "end_time" not in frame.columns:
|
| 179 |
+
frame = frame.reset_index()
|
| 180 |
+
required = {"device_id", "end_time", "voltage", "temperature"}
|
| 181 |
+
missing = required - set(frame.columns)
|
| 182 |
+
if missing:
|
| 183 |
+
raise ValueError(f"battery time series lacks columns: {sorted(missing)}")
|
| 184 |
+
frame = frame[["device_id", "end_time", "voltage", "temperature"]].copy()
|
| 185 |
+
frame["device_id"] = frame["device_id"].astype(str)
|
| 186 |
+
frame["end_time"] = pd.to_datetime(frame["end_time"], errors="raise")
|
| 187 |
+
frame["voltage"] = pd.to_numeric(frame["voltage"], errors="coerce")
|
| 188 |
+
frame["temperature"] = pd.to_numeric(frame["temperature"], errors="coerce")
|
| 189 |
+
return frame
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def exact_smoothed_voltage(timeseries: pd.DataFrame) -> pd.DataFrame:
|
| 193 |
+
"""Reproduce ``batteryswap_public==0.3.4`` smoothing exactly."""
|
| 194 |
+
|
| 195 |
+
frame = _raw_columns(timeseries).dropna(
|
| 196 |
+
subset=["device_id", "end_time", "voltage", "temperature"]
|
| 197 |
+
)
|
| 198 |
+
stable = frame[
|
| 199 |
+
frame["temperature"].gt(10.0) & frame["temperature"].lt(30.0)
|
| 200 |
+
].reset_index(drop=True)
|
| 201 |
+
daily_parts: list[pd.DataFrame] = []
|
| 202 |
+
for device_id, group in stable.groupby("device_id", observed=True, sort=True):
|
| 203 |
+
indexed = group.set_index("end_time").sort_index()
|
| 204 |
+
resample = indexed[["voltage"]].resample("1D")
|
| 205 |
+
daily_quantile = resample.quantile(0.5)
|
| 206 |
+
daily_quantile = daily_quantile[resample.count() >= 5]
|
| 207 |
+
daily_quantile["device_id"] = str(device_id)
|
| 208 |
+
daily_parts.append(daily_quantile.reset_index())
|
| 209 |
+
if not daily_parts:
|
| 210 |
+
return pd.DataFrame(columns=["device_id", "end_time", "smooth_voltage"])
|
| 211 |
+
daily = pd.concat(daily_parts, ignore_index=True).sort_values(
|
| 212 |
+
["device_id", "end_time"], kind="stable"
|
| 213 |
+
)
|
| 214 |
+
rolled = (
|
| 215 |
+
daily.set_index("end_time")
|
| 216 |
+
.groupby("device_id", observed=True)[["voltage"]]
|
| 217 |
+
.rolling(window=7, min_periods=3)
|
| 218 |
+
.quantile(0.5)
|
| 219 |
+
.reset_index()
|
| 220 |
+
.rename(columns={"voltage": "smooth_voltage"})
|
| 221 |
+
)
|
| 222 |
+
return rolled.sort_values(["device_id", "end_time"], kind="stable").reset_index(
|
| 223 |
+
drop=True
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _daily_aggregates(timeseries: pd.DataFrame, beta_v_per_c: float) -> pd.DataFrame:
|
| 228 |
+
frame = _raw_columns(timeseries).dropna(
|
| 229 |
+
subset=["device_id", "end_time", "voltage", "temperature"]
|
| 230 |
+
)
|
| 231 |
+
frame = frame[
|
| 232 |
+
frame["temperature"].gt(10.0) & frame["temperature"].lt(30.0)
|
| 233 |
+
].copy()
|
| 234 |
+
if frame.empty:
|
| 235 |
+
return pd.DataFrame(
|
| 236 |
+
columns=[
|
| 237 |
+
"device_id",
|
| 238 |
+
"day",
|
| 239 |
+
"smooth_voltage_day",
|
| 240 |
+
"raw_voltage",
|
| 241 |
+
"temperature",
|
| 242 |
+
"health",
|
| 243 |
+
"count",
|
| 244 |
+
]
|
| 245 |
+
)
|
| 246 |
+
frame["day"] = frame["end_time"].dt.normalize()
|
| 247 |
+
frame["health"] = frame["voltage"] - beta_v_per_c * (
|
| 248 |
+
frame["temperature"] - REFERENCE_TEMPERATURE_C
|
| 249 |
+
)
|
| 250 |
+
grouped = frame.groupby(["device_id", "day"], observed=True, sort=True)
|
| 251 |
+
daily = grouped.agg(
|
| 252 |
+
raw_voltage=("voltage", "median"),
|
| 253 |
+
temperature=("temperature", "median"),
|
| 254 |
+
health=("health", "median"),
|
| 255 |
+
count=("voltage", "size"),
|
| 256 |
+
)
|
| 257 |
+
daily["smooth_voltage_day"] = grouped["voltage"].quantile(0.5)
|
| 258 |
+
daily = daily.reset_index()
|
| 259 |
+
invalid = daily["count"].lt(5)
|
| 260 |
+
daily.loc[
|
| 261 |
+
invalid,
|
| 262 |
+
["smooth_voltage_day", "raw_voltage", "temperature", "health"],
|
| 263 |
+
] = np.nan
|
| 264 |
+
return daily.sort_values(["device_id", "day"], kind="stable").reset_index(drop=True)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@dataclass
|
| 268 |
+
class CausalHistoryView:
|
| 269 |
+
smooth_lookup: dict[str, pd.Series]
|
| 270 |
+
devices: np.ndarray
|
| 271 |
+
device_index: dict[str, int]
|
| 272 |
+
day0: pd.Timestamp | None
|
| 273 |
+
raw_voltage: np.ndarray
|
| 274 |
+
temperature: np.ndarray
|
| 275 |
+
smooth_health: np.ndarray
|
| 276 |
+
|
| 277 |
+
@classmethod
|
| 278 |
+
def from_daily(cls, daily: pd.DataFrame) -> "CausalHistoryView":
|
| 279 |
+
if daily.empty:
|
| 280 |
+
empty = np.zeros((0, 1), dtype=np.float32)
|
| 281 |
+
return cls({}, np.asarray([], dtype=object), {}, None, empty, empty, empty)
|
| 282 |
+
|
| 283 |
+
lookup: dict[str, pd.Series] = {}
|
| 284 |
+
for device_id, group in daily.groupby("device_id", observed=True, sort=True):
|
| 285 |
+
group = group.sort_values("day", kind="stable")
|
| 286 |
+
index = pd.date_range(group["day"].min(), group["day"].max(), freq="1D")
|
| 287 |
+
index.name = "end_time"
|
| 288 |
+
raw = group.set_index("day")["smooth_voltage_day"].reindex(index)
|
| 289 |
+
lookup[str(device_id)] = raw.rolling(7, min_periods=3).quantile(0.5)
|
| 290 |
+
|
| 291 |
+
devices = np.asarray(sorted(daily["device_id"].astype(str).unique()), dtype=object)
|
| 292 |
+
device_index = {device: position for position, device in enumerate(devices)}
|
| 293 |
+
day0 = pd.Timestamp(daily["day"].min()).normalize()
|
| 294 |
+
day_n = pd.Timestamp(daily["day"].max()).normalize()
|
| 295 |
+
width = int((day_n - day0) / pd.Timedelta(days=1)) + 1
|
| 296 |
+
shape = (len(devices), width)
|
| 297 |
+
raw_voltage = np.full(shape, np.nan, dtype=np.float32)
|
| 298 |
+
temperature = np.full(shape, np.nan, dtype=np.float32)
|
| 299 |
+
health = np.full(shape, np.nan, dtype=np.float32)
|
| 300 |
+
rows = daily["device_id"].astype(str).map(device_index).to_numpy(dtype=int)
|
| 301 |
+
columns = ((pd.to_datetime(daily["day"]) - day0) / pd.Timedelta(days=1)).to_numpy(
|
| 302 |
+
dtype=int
|
| 303 |
+
)
|
| 304 |
+
raw_voltage[rows, columns] = daily["raw_voltage"].to_numpy(dtype=np.float32)
|
| 305 |
+
temperature[rows, columns] = daily["temperature"].to_numpy(dtype=np.float32)
|
| 306 |
+
health[rows, columns] = daily["health"].to_numpy(dtype=np.float32)
|
| 307 |
+
smooth_health = np.full(shape, np.nan, dtype=np.float32)
|
| 308 |
+
for row in range(len(devices)):
|
| 309 |
+
smooth_health[row] = (
|
| 310 |
+
pd.Series(health[row])
|
| 311 |
+
.rolling(7, min_periods=3)
|
| 312 |
+
.median()
|
| 313 |
+
.to_numpy(dtype=np.float32)
|
| 314 |
+
)
|
| 315 |
+
return cls(
|
| 316 |
+
lookup,
|
| 317 |
+
devices,
|
| 318 |
+
device_index,
|
| 319 |
+
day0,
|
| 320 |
+
raw_voltage,
|
| 321 |
+
temperature,
|
| 322 |
+
smooth_health,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@dataclass
|
| 327 |
+
class CausalHistoryCache:
|
| 328 |
+
"""Incremental daily aggregation of scenario-visible raw histories."""
|
| 329 |
+
|
| 330 |
+
beta_v_per_c: float = FROZEN_TEMPERATURE_BETA_V_PER_C
|
| 331 |
+
split_id: str | None = None
|
| 332 |
+
last_cutoff: pd.Timestamp | None = None
|
| 333 |
+
daily: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 334 |
+
seen_devices: set[str] = field(default_factory=set)
|
| 335 |
+
|
| 336 |
+
def reset(self, split_id: str | None = None) -> None:
|
| 337 |
+
self.split_id = split_id
|
| 338 |
+
self.last_cutoff = None
|
| 339 |
+
self.daily = pd.DataFrame()
|
| 340 |
+
self.seen_devices = set()
|
| 341 |
+
|
| 342 |
+
def update(
|
| 343 |
+
self, visible_history: pd.DataFrame, cutoff: pd.Timestamp | str
|
| 344 |
+
) -> CausalHistoryView:
|
| 345 |
+
cutoff = pd.Timestamp(cutoff)
|
| 346 |
+
flat = _raw_columns(visible_history)
|
| 347 |
+
if not flat.empty and flat["end_time"].max() > cutoff:
|
| 348 |
+
raise AssertionError(
|
| 349 |
+
f"visible history reaches {flat['end_time'].max()} after cutoff {cutoff}"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
rebuild = self.last_cutoff is None or cutoff <= self.last_cutoff
|
| 353 |
+
if rebuild:
|
| 354 |
+
self.daily = _daily_aggregates(flat, self.beta_v_per_c)
|
| 355 |
+
self.seen_devices = set(flat["device_id"].astype(str))
|
| 356 |
+
else:
|
| 357 |
+
overlap_day = self.last_cutoff.normalize()
|
| 358 |
+
current_devices = set(flat["device_id"].astype(str))
|
| 359 |
+
new_devices = current_devices - self.seen_devices
|
| 360 |
+
use = flat["end_time"].ge(overlap_day) | flat["device_id"].isin(new_devices)
|
| 361 |
+
replacement = _daily_aggregates(flat.loc[use], self.beta_v_per_c)
|
| 362 |
+
if not self.daily.empty:
|
| 363 |
+
replace_devices = current_devices | new_devices
|
| 364 |
+
keep = ~(
|
| 365 |
+
self.daily["device_id"].astype(str).isin(replace_devices)
|
| 366 |
+
& pd.to_datetime(self.daily["day"]).ge(overlap_day)
|
| 367 |
+
)
|
| 368 |
+
self.daily = self.daily.loc[keep]
|
| 369 |
+
self.daily = pd.concat([self.daily, replacement], ignore_index=True)
|
| 370 |
+
if not self.daily.empty:
|
| 371 |
+
self.daily = self.daily.sort_values(
|
| 372 |
+
["device_id", "day"], kind="stable"
|
| 373 |
+
).reset_index(drop=True)
|
| 374 |
+
if self.daily.duplicated(["device_id", "day"]).any():
|
| 375 |
+
raise AssertionError("incremental daily cache contains duplicate device-days")
|
| 376 |
+
self.seen_devices.update(current_devices)
|
| 377 |
+
self.last_cutoff = cutoff
|
| 378 |
+
return CausalHistoryView.from_daily(self.daily)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@dataclass(frozen=True)
|
| 382 |
+
class AFTParameters:
|
| 383 |
+
beta0: float
|
| 384 |
+
beta1: float
|
| 385 |
+
sigma: float
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def fit_aft(frame: pd.DataFrame) -> tuple[AFTParameters, dict[str, int | float]]:
|
| 389 |
+
required = {
|
| 390 |
+
"battery",
|
| 391 |
+
"fp_reliable",
|
| 392 |
+
"fp_lifetime_days",
|
| 393 |
+
"landmark_age_days",
|
| 394 |
+
"outcome_lifetime_days",
|
| 395 |
+
"event_observed",
|
| 396 |
+
}
|
| 397 |
+
missing = required - set(frame.columns)
|
| 398 |
+
if missing:
|
| 399 |
+
raise ValueError(f"AFT training frame lacks columns: {sorted(missing)}")
|
| 400 |
+
reliable = _as_bool(frame["fp_reliable"])
|
| 401 |
+
age_all = pd.to_numeric(frame["landmark_age_days"], errors="coerce").to_numpy(float)
|
| 402 |
+
lifetime_all = pd.to_numeric(
|
| 403 |
+
frame["outcome_lifetime_days"], errors="coerce"
|
| 404 |
+
).to_numpy(float)
|
| 405 |
+
fp_all = pd.to_numeric(frame["fp_lifetime_days"], errors="coerce").to_numpy(float)
|
| 406 |
+
eligible = (
|
| 407 |
+
reliable
|
| 408 |
+
& np.isfinite(fp_all)
|
| 409 |
+
& np.isfinite(age_all)
|
| 410 |
+
& np.isfinite(lifetime_all)
|
| 411 |
+
& (lifetime_all > age_all)
|
| 412 |
+
& (age_all > 0.0)
|
| 413 |
+
)
|
| 414 |
+
work = frame.loc[eligible].copy()
|
| 415 |
+
if work.empty:
|
| 416 |
+
raise ValueError("AFT fit has no reliable likelihood-eligible rows")
|
| 417 |
+
event = _as_bool(work["event_observed"])
|
| 418 |
+
event_devices = int(work.loc[event, "battery"].nunique())
|
| 419 |
+
devices = int(work["battery"].nunique())
|
| 420 |
+
if event_devices < 10 or devices < 20:
|
| 421 |
+
raise ValueError(
|
| 422 |
+
"AFT fit is not identifiable: "
|
| 423 |
+
f"event_devices={event_devices}, devices={devices}"
|
| 424 |
+
)
|
| 425 |
+
counts = work.groupby("battery", observed=True)["battery"].transform("size")
|
| 426 |
+
weights = 1.0 / counts.to_numpy(dtype=float)
|
| 427 |
+
x = np.log(pd.to_numeric(work["fp_lifetime_days"]).to_numpy(dtype=float))
|
| 428 |
+
age = pd.to_numeric(work["landmark_age_days"]).to_numpy(dtype=float)
|
| 429 |
+
outcome = pd.to_numeric(work["outcome_lifetime_days"]).to_numpy(dtype=float)
|
| 430 |
+
|
| 431 |
+
observed_x = x[event]
|
| 432 |
+
observed_y = np.log(outcome[event])
|
| 433 |
+
observed_w = weights[event]
|
| 434 |
+
design = np.column_stack([np.ones(len(observed_x)), observed_x])
|
| 435 |
+
initial_beta = np.linalg.lstsq(
|
| 436 |
+
design * np.sqrt(observed_w)[:, None],
|
| 437 |
+
observed_y * np.sqrt(observed_w),
|
| 438 |
+
rcond=None,
|
| 439 |
+
)[0]
|
| 440 |
+
initial_beta[0] = np.clip(initial_beta[0], -20.0, 20.0)
|
| 441 |
+
initial_beta[1] = np.clip(initial_beta[1], 0.0, 3.0)
|
| 442 |
+
residual = observed_y - (initial_beta[0] + initial_beta[1] * observed_x)
|
| 443 |
+
initial_sigma = float(
|
| 444 |
+
np.clip(np.sqrt(np.average(residual**2, weights=observed_w)), 0.1, 1.5)
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
def objective(raw: np.ndarray) -> float:
|
| 448 |
+
beta0, beta1, log_sigma = raw
|
| 449 |
+
sigma = float(np.exp(log_sigma))
|
| 450 |
+
mu = beta0 + beta1 * x
|
| 451 |
+
z_age = (np.log(age) - mu) / sigma
|
| 452 |
+
log_survival_age = log_ndtr(-z_age)
|
| 453 |
+
z_outcome = (np.log(outcome) - mu) / sigma
|
| 454 |
+
likelihood = np.empty(len(work), dtype=float)
|
| 455 |
+
likelihood[event] = (
|
| 456 |
+
-np.log(outcome[event])
|
| 457 |
+
- log_sigma
|
| 458 |
+
- 0.5 * math.log(2.0 * math.pi)
|
| 459 |
+
- 0.5 * z_outcome[event] ** 2
|
| 460 |
+
- log_survival_age[event]
|
| 461 |
+
)
|
| 462 |
+
likelihood[~event] = log_ndtr(-z_outcome[~event]) - log_survival_age[~event]
|
| 463 |
+
if not np.isfinite(likelihood).all():
|
| 464 |
+
return 1e100
|
| 465 |
+
return float(-np.sum(weights * likelihood))
|
| 466 |
+
|
| 467 |
+
fitted = minimize(
|
| 468 |
+
objective,
|
| 469 |
+
np.asarray(
|
| 470 |
+
[initial_beta[0], initial_beta[1], math.log(initial_sigma)], dtype=float
|
| 471 |
+
),
|
| 472 |
+
method="L-BFGS-B",
|
| 473 |
+
bounds=(
|
| 474 |
+
(-20.0, 20.0),
|
| 475 |
+
(0.0, 3.0),
|
| 476 |
+
(math.log(0.05), math.log(2.0)),
|
| 477 |
+
),
|
| 478 |
+
options={"maxiter": 1_000, "ftol": 1e-12, "gtol": 1e-8},
|
| 479 |
+
)
|
| 480 |
+
if not fitted.success or not np.isfinite(fitted.fun):
|
| 481 |
+
raise RuntimeError(f"three-parameter AFT optimization failed: {fitted.message}")
|
| 482 |
+
parameters = AFTParameters(
|
| 483 |
+
beta0=float(fitted.x[0]),
|
| 484 |
+
beta1=float(fitted.x[1]),
|
| 485 |
+
sigma=float(np.exp(fitted.x[2])),
|
| 486 |
+
)
|
| 487 |
+
diagnostics: dict[str, int | float] = {
|
| 488 |
+
"eligible_rows": int(len(work)),
|
| 489 |
+
"devices": devices,
|
| 490 |
+
"event_devices": event_devices,
|
| 491 |
+
"censored_devices": int(work.loc[~event, "battery"].nunique()),
|
| 492 |
+
"objective": float(fitted.fun),
|
| 493 |
+
"iterations": int(fitted.nit),
|
| 494 |
+
}
|
| 495 |
+
return parameters, diagnostics
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def _theil_sen_slope(days: np.ndarray, values: np.ndarray) -> float:
|
| 499 |
+
x = days.astype("datetime64[ns]").astype(np.int64) / 86_400_000_000_000.0
|
| 500 |
+
parts: list[np.ndarray] = []
|
| 501 |
+
for offset in range(1, len(x)):
|
| 502 |
+
delta_x = x[offset:] - x[:-offset]
|
| 503 |
+
usable = delta_x > 0.0
|
| 504 |
+
if usable.any():
|
| 505 |
+
parts.append((values[offset:] - values[:-offset])[usable] / delta_x[usable])
|
| 506 |
+
return float(np.median(np.concatenate(parts))) if parts else math.nan
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _first_passage_lifetime(
|
| 510 |
+
device_id: str,
|
| 511 |
+
cutoff: pd.Timestamp,
|
| 512 |
+
installation_start: pd.Timestamp,
|
| 513 |
+
lookup: dict[str, pd.Series],
|
| 514 |
+
) -> tuple[float, bool]:
|
| 515 |
+
series = lookup.get(str(device_id))
|
| 516 |
+
if series is None:
|
| 517 |
+
return math.nan, False
|
| 518 |
+
finite = series.dropna()
|
| 519 |
+
finite = finite[finite.index <= pd.Timestamp(cutoff).normalize()]
|
| 520 |
+
if finite.empty:
|
| 521 |
+
return math.nan, False
|
| 522 |
+
days = finite.index.to_numpy(dtype="datetime64[ns]")
|
| 523 |
+
values = finite.to_numpy(dtype=float)
|
| 524 |
+
last_day = pd.Timestamp(days[-1])
|
| 525 |
+
last_value = float(values[-1])
|
| 526 |
+
staleness = float((pd.Timestamp(cutoff) - last_day) / pd.Timedelta(days=1))
|
| 527 |
+
cutoff64 = np.datetime64(pd.Timestamp(cutoff).to_datetime64(), "ns")
|
| 528 |
+
lifetimes: list[float] = []
|
| 529 |
+
for window in FP_WINDOWS_DAYS:
|
| 530 |
+
low = cutoff64 - np.timedelta64(window - 1, "D")
|
| 531 |
+
use = days >= low
|
| 532 |
+
window_days = days[use]
|
| 533 |
+
window_values = values[use]
|
| 534 |
+
if len(window_values) < MIN_SMOOTHED_POINTS:
|
| 535 |
+
continue
|
| 536 |
+
span = float(
|
| 537 |
+
(pd.Timestamp(window_days[-1]) - pd.Timestamp(window_days[0]))
|
| 538 |
+
/ pd.Timedelta(days=1)
|
| 539 |
+
)
|
| 540 |
+
if span < window * MIN_WINDOW_SPAN_FRACTION:
|
| 541 |
+
continue
|
| 542 |
+
slope = _theil_sen_slope(window_days, window_values)
|
| 543 |
+
if not np.isfinite(slope) or slope >= -MIN_DEGRADATION_RATE:
|
| 544 |
+
continue
|
| 545 |
+
eta = float(
|
| 546 |
+
np.clip((last_value - EOL_VOLTAGE) / -slope, 0.0, MAX_EXTRAPOLATION_DAYS)
|
| 547 |
+
)
|
| 548 |
+
lifetime = float(
|
| 549 |
+
(last_day - installation_start) / pd.Timedelta(days=1)
|
| 550 |
+
) + eta
|
| 551 |
+
if np.isfinite(lifetime) and lifetime > 0.0:
|
| 552 |
+
lifetimes.append(lifetime)
|
| 553 |
+
if not lifetimes:
|
| 554 |
+
return math.nan, False
|
| 555 |
+
median = float(np.median(lifetimes))
|
| 556 |
+
mad = float(np.median(np.abs(np.asarray(lifetimes) - median)))
|
| 557 |
+
reliable = bool(
|
| 558 |
+
len(lifetimes) >= MIN_RELIABLE_WINDOWS
|
| 559 |
+
and 0.0 <= staleness <= MAX_SMOOTHED_STALENESS_DAYS
|
| 560 |
+
and mad <= MAX_LIFETIME_MAD_DAYS
|
| 561 |
+
)
|
| 562 |
+
return median, reliable
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
@dataclass(frozen=True)
|
| 566 |
+
class ResidualSignal:
|
| 567 |
+
values: np.ndarray
|
| 568 |
+
reliable: np.ndarray
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@dataclass(frozen=True)
|
| 572 |
+
class ResidualContext:
|
| 573 |
+
history: CausalHistoryView
|
| 574 |
+
batteries: np.ndarray
|
| 575 |
+
locations: pd.DataFrame
|
| 576 |
+
cutoff: pd.Timestamp
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
class IdentityRankResidual(Protocol):
|
| 580 |
+
name: str
|
| 581 |
+
|
| 582 |
+
def predict(self, context: ResidualContext) -> ResidualSignal: ...
|
| 583 |
+
|
| 584 |
+
def component_risk(
|
| 585 |
+
self,
|
| 586 |
+
baseline: np.ndarray,
|
| 587 |
+
batteries: np.ndarray,
|
| 588 |
+
signal: ResidualSignal,
|
| 589 |
+
) -> np.ndarray: ...
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
@dataclass(frozen=True)
|
| 593 |
+
class LongTermAFTResidual:
|
| 594 |
+
parameters: AFTParameters
|
| 595 |
+
name: str = "lt_fp_aft"
|
| 596 |
+
base_weight: float = 0.90
|
| 597 |
+
aft_weight: float = 0.10
|
| 598 |
+
|
| 599 |
+
def predict(self, context: ResidualContext) -> ResidualSignal:
|
| 600 |
+
location_index = context.locations.assign(
|
| 601 |
+
battery=context.locations["battery"].astype(str)
|
| 602 |
+
).set_index("battery", drop=False)
|
| 603 |
+
values = np.full(len(context.batteries), np.nan)
|
| 604 |
+
reliable = np.zeros(len(context.batteries), dtype=bool)
|
| 605 |
+
for position, battery in enumerate(context.batteries.astype(str)):
|
| 606 |
+
if battery not in location_index.index:
|
| 607 |
+
continue
|
| 608 |
+
installation = pd.Timestamp(location_index.loc[battery, "start_time"])
|
| 609 |
+
lifetime, is_reliable = _first_passage_lifetime(
|
| 610 |
+
battery,
|
| 611 |
+
context.cutoff,
|
| 612 |
+
installation,
|
| 613 |
+
context.history.smooth_lookup,
|
| 614 |
+
)
|
| 615 |
+
age = float((context.cutoff - installation) / pd.Timedelta(days=1))
|
| 616 |
+
if not is_reliable or not np.isfinite(lifetime) or lifetime <= 0.0 or age <= 0.0:
|
| 617 |
+
continue
|
| 618 |
+
mu = self.parameters.beta0 + self.parameters.beta1 * math.log(lifetime)
|
| 619 |
+
z_now = (math.log(age) - mu) / self.parameters.sigma
|
| 620 |
+
z_horizon = (math.log(age + HORIZON_DAYS) - mu) / self.parameters.sigma
|
| 621 |
+
survival_now = float(ndtr(-z_now))
|
| 622 |
+
probability = (float(ndtr(z_horizon)) - float(ndtr(z_now))) / max(
|
| 623 |
+
survival_now, np.finfo(float).tiny
|
| 624 |
+
)
|
| 625 |
+
values[position] = float(np.clip(probability, 0.0, 1.0))
|
| 626 |
+
reliable[position] = True
|
| 627 |
+
return ResidualSignal(values, reliable)
|
| 628 |
+
|
| 629 |
+
def component_risk(
|
| 630 |
+
self,
|
| 631 |
+
baseline: np.ndarray,
|
| 632 |
+
batteries: np.ndarray,
|
| 633 |
+
signal: ResidualSignal,
|
| 634 |
+
) -> np.ndarray:
|
| 635 |
+
positions = np.flatnonzero(signal.reliable)
|
| 636 |
+
if len(positions) < 2:
|
| 637 |
+
return np.asarray(baseline, dtype=float).copy()
|
| 638 |
+
score = np.zeros(len(baseline), dtype=float)
|
| 639 |
+
score[positions] = self.base_weight * _rank(
|
| 640 |
+
np.asarray(baseline)[positions]
|
| 641 |
+
) + self.aft_weight * _rank(signal.values[positions])
|
| 642 |
+
return assign_multiset(baseline, score, batteries, signal.reliable)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
@dataclass(frozen=True)
|
| 646 |
+
class SimilarityDonorLibrary:
|
| 647 |
+
values: np.ndarray
|
| 648 |
+
masks: np.ndarray
|
| 649 |
+
donor_ids: np.ndarray
|
| 650 |
+
donor_buildings: np.ndarray
|
| 651 |
+
donor_codes: np.ndarray
|
| 652 |
+
endpoint_days: np.ndarray
|
| 653 |
+
residual_days: np.ndarray
|
| 654 |
+
endpoint_groups: tuple[np.ndarray, ...]
|
| 655 |
+
device_ids_by_code: np.ndarray
|
| 656 |
+
device_buildings_by_code: np.ndarray
|
| 657 |
+
building_groups: tuple[np.ndarray, ...]
|
| 658 |
+
|
| 659 |
+
@property
|
| 660 |
+
def donor_count(self) -> int:
|
| 661 |
+
return len(self.endpoint_groups)
|
| 662 |
+
|
| 663 |
+
@property
|
| 664 |
+
def endpoint_count(self) -> int:
|
| 665 |
+
return len(self.values)
|
| 666 |
+
|
| 667 |
+
@property
|
| 668 |
+
def building_count(self) -> int:
|
| 669 |
+
return len(self.building_groups)
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _series_lookup(smoothed: pd.DataFrame) -> dict[str, pd.Series]:
|
| 673 |
+
lookup: dict[str, pd.Series] = {}
|
| 674 |
+
for device_id, group in smoothed.groupby("device_id", observed=True, sort=False):
|
| 675 |
+
ordered = group.sort_values("end_time", kind="stable")
|
| 676 |
+
days = pd.DatetimeIndex(ordered["end_time"]).normalize()
|
| 677 |
+
if days.has_duplicates:
|
| 678 |
+
raise AssertionError(f"smoothed grid has duplicate days for {device_id}")
|
| 679 |
+
lookup[str(device_id)] = pd.Series(
|
| 680 |
+
ordered["smooth_voltage"].to_numpy(dtype=float), index=days
|
| 681 |
+
)
|
| 682 |
+
return lookup
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def _normalised_prefix(
|
| 686 |
+
series: pd.Series, anchor: pd.Timestamp, baseline_voltage: float
|
| 687 |
+
) -> np.ndarray:
|
| 688 |
+
target_days = pd.Timestamp(anchor).normalize() - pd.to_timedelta(
|
| 689 |
+
np.asarray(PREFIX_LAGS_DAYS), unit="D"
|
| 690 |
+
)
|
| 691 |
+
voltage = series.reindex(target_days).to_numpy(dtype=float)
|
| 692 |
+
margin = float(baseline_voltage) - EOL_VOLTAGE
|
| 693 |
+
if not np.isfinite(margin) or margin <= 1e-6:
|
| 694 |
+
return np.full(len(PREFIX_LAGS_DAYS), np.nan)
|
| 695 |
+
return (voltage - EOL_VOLTAGE) / margin
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def build_similarity_donor_library(
|
| 699 |
+
full_smoothed: pd.DataFrame,
|
| 700 |
+
eol_times: pd.Series,
|
| 701 |
+
battery_building: dict[str, str],
|
| 702 |
+
) -> SimilarityDonorLibrary:
|
| 703 |
+
lookup = _series_lookup(full_smoothed)
|
| 704 |
+
values: list[np.ndarray] = []
|
| 705 |
+
donor_ids: list[str] = []
|
| 706 |
+
donor_buildings: list[str] = []
|
| 707 |
+
endpoint_days: list[np.datetime64] = []
|
| 708 |
+
residual_days: list[float] = []
|
| 709 |
+
observed = pd.to_datetime(eol_times.dropna(), errors="raise").sort_index()
|
| 710 |
+
for raw_device_id, raw_eol in observed.items():
|
| 711 |
+
device_id = str(raw_device_id)
|
| 712 |
+
building = str(battery_building.get(device_id, ""))
|
| 713 |
+
if not building:
|
| 714 |
+
continue
|
| 715 |
+
series = lookup.get(device_id)
|
| 716 |
+
if series is None:
|
| 717 |
+
continue
|
| 718 |
+
eol_day = pd.Timestamp(raw_eol).normalize()
|
| 719 |
+
pre_eol = series[series.index <= eol_day]
|
| 720 |
+
finite = pre_eol.dropna()
|
| 721 |
+
if len(finite) < BASELINE_VALID_POINTS:
|
| 722 |
+
continue
|
| 723 |
+
baseline = float(finite.iloc[:BASELINE_VALID_POINTS].median())
|
| 724 |
+
if not np.isfinite(baseline) or baseline <= EOL_VOLTAGE + 1e-6:
|
| 725 |
+
continue
|
| 726 |
+
baseline_ready = pd.Timestamp(finite.index[BASELINE_VALID_POINTS - 1]).normalize()
|
| 727 |
+
maximum_residual = int((eol_day - baseline_ready) / pd.Timedelta(days=1))
|
| 728 |
+
for residual in range(1, maximum_residual + 1, 7):
|
| 729 |
+
endpoint = eol_day - pd.Timedelta(days=residual)
|
| 730 |
+
vector = _normalised_prefix(pre_eol, endpoint, baseline)
|
| 731 |
+
if not np.isfinite(vector[0]) or np.isfinite(vector).sum() < MIN_PAIRED_LAGS:
|
| 732 |
+
continue
|
| 733 |
+
values.append(vector)
|
| 734 |
+
donor_ids.append(device_id)
|
| 735 |
+
donor_buildings.append(building)
|
| 736 |
+
endpoint_days.append(np.datetime64(endpoint, "ns"))
|
| 737 |
+
residual_days.append(float(residual))
|
| 738 |
+
if not values:
|
| 739 |
+
raise ValueError("full training split has no usable similarity donor endpoints")
|
| 740 |
+
donor_order = sorted(set(donor_ids))
|
| 741 |
+
donor_to_code = {device: code for code, device in enumerate(donor_order)}
|
| 742 |
+
donor_codes = np.asarray([donor_to_code[device] for device in donor_ids], dtype=int)
|
| 743 |
+
endpoint_groups = tuple(
|
| 744 |
+
np.flatnonzero(donor_codes == code) for code in range(len(donor_order))
|
| 745 |
+
)
|
| 746 |
+
device_buildings = np.asarray(
|
| 747 |
+
[donor_buildings[int(group[0])] for group in endpoint_groups], dtype=object
|
| 748 |
+
)
|
| 749 |
+
building_groups = tuple(
|
| 750 |
+
np.flatnonzero(device_buildings.astype(str) == building)
|
| 751 |
+
for building in sorted(set(device_buildings.astype(str)))
|
| 752 |
+
)
|
| 753 |
+
if len(endpoint_groups) < NEIGHBORS or len(building_groups) < NEIGHBORS:
|
| 754 |
+
raise ValueError("full donor library cannot supply K=8 distinct buildings")
|
| 755 |
+
value_array = np.asarray(values, dtype=float)
|
| 756 |
+
return SimilarityDonorLibrary(
|
| 757 |
+
values=value_array,
|
| 758 |
+
masks=np.isfinite(value_array),
|
| 759 |
+
donor_ids=np.asarray(donor_ids, dtype=object),
|
| 760 |
+
donor_buildings=np.asarray(donor_buildings, dtype=object),
|
| 761 |
+
donor_codes=donor_codes,
|
| 762 |
+
endpoint_days=np.asarray(endpoint_days, dtype="datetime64[ns]"),
|
| 763 |
+
residual_days=np.asarray(residual_days, dtype=float),
|
| 764 |
+
endpoint_groups=endpoint_groups,
|
| 765 |
+
device_ids_by_code=np.asarray(donor_order, dtype=object),
|
| 766 |
+
device_buildings_by_code=device_buildings,
|
| 767 |
+
building_groups=building_groups,
|
| 768 |
+
)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def _query_similarity(
|
| 772 |
+
batteries: np.ndarray,
|
| 773 |
+
cutoff: pd.Timestamp,
|
| 774 |
+
lookup: dict[str, pd.Series],
|
| 775 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 776 |
+
vectors = np.full((len(batteries), len(PREFIX_LAGS_DAYS)), np.nan)
|
| 777 |
+
ready = np.zeros(len(batteries), dtype=bool)
|
| 778 |
+
staleness = np.full(len(batteries), np.nan)
|
| 779 |
+
for position, battery in enumerate(batteries.astype(str)):
|
| 780 |
+
series = lookup.get(battery)
|
| 781 |
+
if series is None:
|
| 782 |
+
continue
|
| 783 |
+
finite = series.dropna()
|
| 784 |
+
finite = finite[finite.index <= cutoff.normalize()]
|
| 785 |
+
if finite.empty:
|
| 786 |
+
continue
|
| 787 |
+
anchor = pd.Timestamp(finite.index[-1]).normalize()
|
| 788 |
+
local_staleness = float((cutoff - anchor) / pd.Timedelta(days=1))
|
| 789 |
+
baseline = (
|
| 790 |
+
float(finite.iloc[:BASELINE_VALID_POINTS].median())
|
| 791 |
+
if len(finite) >= BASELINE_VALID_POINTS
|
| 792 |
+
else math.nan
|
| 793 |
+
)
|
| 794 |
+
vector = _normalised_prefix(series, anchor, baseline)
|
| 795 |
+
mask = np.isfinite(vector)
|
| 796 |
+
coverage = float(PREFIX_WEIGHTS[mask].sum() / PREFIX_WEIGHTS.sum())
|
| 797 |
+
vectors[position] = vector
|
| 798 |
+
staleness[position] = local_staleness
|
| 799 |
+
ready[position] = bool(
|
| 800 |
+
len(finite) >= BASELINE_VALID_POINTS
|
| 801 |
+
and np.isfinite(baseline)
|
| 802 |
+
and baseline > EOL_VOLTAGE + 1e-6
|
| 803 |
+
and 0.0 <= local_staleness <= MAX_SMOOTHED_STALENESS_DAYS
|
| 804 |
+
and int(mask.sum()) >= MIN_PAIRED_LAGS
|
| 805 |
+
and coverage >= MIN_WEIGHTED_COVERAGE
|
| 806 |
+
)
|
| 807 |
+
return vectors, ready, staleness
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def score_similarity(
|
| 811 |
+
query_values: np.ndarray,
|
| 812 |
+
query_ready: np.ndarray,
|
| 813 |
+
query_staleness: np.ndarray,
|
| 814 |
+
library: SimilarityDonorLibrary,
|
| 815 |
+
) -> ResidualSignal:
|
| 816 |
+
row_count = len(query_values)
|
| 817 |
+
risk = np.full(row_count, np.nan)
|
| 818 |
+
reliable_out = np.zeros(row_count, dtype=bool)
|
| 819 |
+
donor_values = np.nan_to_num(library.values, nan=0.0)
|
| 820 |
+
donor_mask = library.masks.astype(float)
|
| 821 |
+
donor_value_mask = donor_values * donor_mask
|
| 822 |
+
donor_square_mask = donor_values**2 * donor_mask
|
| 823 |
+
ready_positions = np.flatnonzero(np.asarray(query_ready, dtype=bool))
|
| 824 |
+
for begin in range(0, len(ready_positions), DISTANCE_BATCH_SIZE):
|
| 825 |
+
positions = ready_positions[begin : begin + DISTANCE_BATCH_SIZE]
|
| 826 |
+
raw_query = np.asarray(query_values[positions], dtype=float)
|
| 827 |
+
query_mask = np.isfinite(raw_query).astype(float)
|
| 828 |
+
query = np.nan_to_num(raw_query, nan=0.0)
|
| 829 |
+
weighted_mask = query_mask * PREFIX_WEIGHTS[None, :]
|
| 830 |
+
overlap_weight = weighted_mask @ donor_mask.T
|
| 831 |
+
paired_lags = query_mask @ donor_mask.T
|
| 832 |
+
query_weight = weighted_mask.sum(axis=1, keepdims=True)
|
| 833 |
+
coverage = overlap_weight / np.maximum(query_weight, np.finfo(float).tiny)
|
| 834 |
+
first = (weighted_mask * query**2) @ donor_mask.T
|
| 835 |
+
second = weighted_mask @ donor_square_mask.T
|
| 836 |
+
cross = (weighted_mask * query) @ donor_value_mask.T
|
| 837 |
+
numerator = np.maximum(first + second - 2.0 * cross, 0.0)
|
| 838 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 839 |
+
distance = np.sqrt(numerator / overlap_weight) / np.sqrt(coverage)
|
| 840 |
+
usable = (
|
| 841 |
+
(paired_lags >= MIN_PAIRED_LAGS)
|
| 842 |
+
& (coverage >= MIN_WEIGHTED_COVERAGE)
|
| 843 |
+
& np.isfinite(distance)
|
| 844 |
+
)
|
| 845 |
+
distance = np.where(usable, distance, np.inf)
|
| 846 |
+
block_size = len(positions)
|
| 847 |
+
best_distance = np.full((block_size, library.donor_count), np.inf)
|
| 848 |
+
best_endpoint = np.full((block_size, library.donor_count), -1, dtype=int)
|
| 849 |
+
for donor_code, endpoint_index in enumerate(library.endpoint_groups):
|
| 850 |
+
local = distance[:, endpoint_index]
|
| 851 |
+
choice = np.argmin(local, axis=1)
|
| 852 |
+
best_distance[:, donor_code] = local[np.arange(block_size), choice]
|
| 853 |
+
best_endpoint[:, donor_code] = endpoint_index[choice]
|
| 854 |
+
best_building_distance = np.full((block_size, library.building_count), np.inf)
|
| 855 |
+
best_building_endpoint = np.full(
|
| 856 |
+
(block_size, library.building_count), -1, dtype=int
|
| 857 |
+
)
|
| 858 |
+
for building_code, donor_codes in enumerate(library.building_groups):
|
| 859 |
+
local = best_distance[:, donor_codes]
|
| 860 |
+
choice = np.argmin(local, axis=1)
|
| 861 |
+
chosen_donor = donor_codes[choice]
|
| 862 |
+
best_building_distance[:, building_code] = local[
|
| 863 |
+
np.arange(block_size), choice
|
| 864 |
+
]
|
| 865 |
+
best_building_endpoint[:, building_code] = best_endpoint[
|
| 866 |
+
np.arange(block_size), chosen_donor
|
| 867 |
+
]
|
| 868 |
+
top_buildings = np.argsort(
|
| 869 |
+
best_building_distance, axis=1, kind="stable"
|
| 870 |
+
)[:, :NEIGHBORS]
|
| 871 |
+
top_distance = np.take_along_axis(
|
| 872 |
+
best_building_distance, top_buildings, axis=1
|
| 873 |
+
)
|
| 874 |
+
top_endpoint = np.take_along_axis(
|
| 875 |
+
best_building_endpoint, top_buildings, axis=1
|
| 876 |
+
)
|
| 877 |
+
has_k = np.isfinite(top_distance).all(axis=1) & (top_endpoint >= 0).all(axis=1)
|
| 878 |
+
safe_endpoint = np.maximum(top_endpoint, 0)
|
| 879 |
+
top_rul = np.maximum(
|
| 880 |
+
library.residual_days[safe_endpoint] - query_staleness[positions, None],
|
| 881 |
+
0.0,
|
| 882 |
+
)
|
| 883 |
+
inverse = 1.0 / (top_distance + INVERSE_DISTANCE_EPSILON)
|
| 884 |
+
inverse = np.where(has_k[:, None], inverse, 0.0)
|
| 885 |
+
weights = inverse / np.maximum(
|
| 886 |
+
inverse.sum(axis=1, keepdims=True), np.finfo(float).tiny
|
| 887 |
+
)
|
| 888 |
+
neighbor_risk = np.sum(weights * (top_rul <= HORIZON_DAYS), axis=1)
|
| 889 |
+
effective_neighbors = 1.0 / np.maximum(
|
| 890 |
+
np.sum(weights**2, axis=1), np.finfo(float).tiny
|
| 891 |
+
)
|
| 892 |
+
selected_buildings = library.donor_buildings[safe_endpoint]
|
| 893 |
+
unique_buildings = np.zeros(block_size)
|
| 894 |
+
effective_buildings = np.zeros(block_size)
|
| 895 |
+
for row in range(block_size):
|
| 896 |
+
by_building: dict[str, float] = {}
|
| 897 |
+
for building, weight in zip(
|
| 898 |
+
selected_buildings[row], weights[row], strict=True
|
| 899 |
+
):
|
| 900 |
+
name = str(building)
|
| 901 |
+
by_building[name] = by_building.get(name, 0.0) + float(weight)
|
| 902 |
+
unique_buildings[row] = len(by_building)
|
| 903 |
+
effective_buildings[row] = 1.0 / max(
|
| 904 |
+
sum(weight**2 for weight in by_building.values()),
|
| 905 |
+
np.finfo(float).tiny,
|
| 906 |
+
)
|
| 907 |
+
reliable = (
|
| 908 |
+
has_k
|
| 909 |
+
& (effective_neighbors >= MIN_EFFECTIVE_NEIGHBORS)
|
| 910 |
+
& (unique_buildings >= MIN_UNIQUE_DONOR_BUILDINGS)
|
| 911 |
+
& (effective_buildings >= MIN_EFFECTIVE_DONOR_BUILDINGS)
|
| 912 |
+
)
|
| 913 |
+
risk[positions] = np.where(has_k, neighbor_risk, np.nan)
|
| 914 |
+
reliable_out[positions] = reliable
|
| 915 |
+
return ResidualSignal(risk, reliable_out)
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
@dataclass(frozen=True)
|
| 919 |
+
class OriginalSimilarityResidual:
|
| 920 |
+
library: SimilarityDonorLibrary
|
| 921 |
+
name: str = "original_similarity_eol"
|
| 922 |
+
|
| 923 |
+
def predict(self, context: ResidualContext) -> ResidualSignal:
|
| 924 |
+
query, ready, staleness = _query_similarity(
|
| 925 |
+
context.batteries, context.cutoff, context.history.smooth_lookup
|
| 926 |
+
)
|
| 927 |
+
return score_similarity(query, ready, staleness, self.library)
|
| 928 |
+
|
| 929 |
+
def component_risk(
|
| 930 |
+
self,
|
| 931 |
+
baseline: np.ndarray,
|
| 932 |
+
batteries: np.ndarray,
|
| 933 |
+
signal: ResidualSignal,
|
| 934 |
+
) -> np.ndarray:
|
| 935 |
+
del batteries # Reference experiment deliberately used stable row-order ties.
|
| 936 |
+
baseline = np.asarray(baseline, dtype=float)
|
| 937 |
+
out = baseline.copy()
|
| 938 |
+
positions = np.flatnonzero(signal.reliable)
|
| 939 |
+
if len(positions) < 2:
|
| 940 |
+
return out
|
| 941 |
+
local_risk = signal.values[positions]
|
| 942 |
+
if not np.isfinite(local_risk).all():
|
| 943 |
+
raise ValueError("reliable similarity rows have non-finite risk")
|
| 944 |
+
score = _logit(baseline)
|
| 945 |
+
score[positions] += np.clip(
|
| 946 |
+
2.0 * local_risk - 1.0, -MAX_LOGIT_RESIDUAL, MAX_LOGIT_RESIDUAL
|
| 947 |
+
)
|
| 948 |
+
order = np.argsort(score[positions], kind="stable")
|
| 949 |
+
reassigned = np.empty(len(positions), dtype=float)
|
| 950 |
+
reassigned[order] = np.sort(baseline[positions])
|
| 951 |
+
out[positions] = reassigned
|
| 952 |
+
if not np.array_equal(out[~signal.reliable], baseline[~signal.reliable]):
|
| 953 |
+
raise AssertionError("unreliable similarity rows changed")
|
| 954 |
+
if not np.array_equal(np.sort(out), np.sort(baseline)):
|
| 955 |
+
raise AssertionError("similarity component changed risk multiset")
|
| 956 |
+
return out
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def fit_temperature_beta(timeseries: pd.DataFrame) -> tuple[float, dict[str, int | float]]:
|
| 960 |
+
frame = _raw_columns(timeseries).dropna(subset=["voltage", "temperature"])
|
| 961 |
+
frame = frame[
|
| 962 |
+
frame["temperature"].gt(10.0) & frame["temperature"].lt(30.0)
|
| 963 |
+
].copy()
|
| 964 |
+
frame["day"] = frame["end_time"].dt.normalize()
|
| 965 |
+
grouped = frame.groupby(["device_id", "day"], observed=True, sort=False)
|
| 966 |
+
count = grouped["voltage"].transform("size")
|
| 967 |
+
usable = count.ge(5)
|
| 968 |
+
centered_voltage = frame["voltage"] - grouped["voltage"].transform("mean")
|
| 969 |
+
centered_temperature = frame["temperature"] - grouped["temperature"].transform(
|
| 970 |
+
"mean"
|
| 971 |
+
)
|
| 972 |
+
x = centered_temperature[usable].to_numpy(dtype=float)
|
| 973 |
+
y = centered_voltage[usable].to_numpy(dtype=float)
|
| 974 |
+
denominator = float(np.dot(x, x))
|
| 975 |
+
if denominator <= 0.0:
|
| 976 |
+
raise ValueError("temperature coefficient has no within-device-day variation")
|
| 977 |
+
raw_beta = float(np.dot(x, y) / denominator)
|
| 978 |
+
return max(raw_beta, 0.0), {
|
| 979 |
+
"raw_beta_v_per_c": raw_beta,
|
| 980 |
+
"clipped_beta_v_per_c": max(raw_beta, 0.0),
|
| 981 |
+
"training_readings": int(usable.sum()),
|
| 982 |
+
"training_device_days": int(
|
| 983 |
+
frame.loc[usable, ["device_id", "day"]].drop_duplicates().shape[0]
|
| 984 |
+
),
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
def _seasonal_physics_signal(
|
| 989 |
+
history: CausalHistoryView,
|
| 990 |
+
batteries: np.ndarray,
|
| 991 |
+
cutoff: pd.Timestamp,
|
| 992 |
+
beta_v_per_c: float,
|
| 993 |
+
maximum_predicted_min_voltage: float | None = None,
|
| 994 |
+
) -> ResidualSignal:
|
| 995 |
+
count = len(batteries)
|
| 996 |
+
reliable = np.zeros(count, dtype=bool)
|
| 997 |
+
urgency = np.full(count, np.nan)
|
| 998 |
+
if history.day0 is None:
|
| 999 |
+
return ResidualSignal(urgency, reliable)
|
| 1000 |
+
cut_column = int((cutoff.normalize() - history.day0) / pd.Timedelta(days=1))
|
| 1001 |
+
forecast_offsets = np.arange(1, int(HORIZON_DAYS) + 1, dtype=int)
|
| 1002 |
+
for position, battery in enumerate(batteries.astype(str)):
|
| 1003 |
+
row = history.device_index.get(battery)
|
| 1004 |
+
if row is None or cut_column <= 0:
|
| 1005 |
+
continue
|
| 1006 |
+
history_start = max(0, cut_column - DRIFT_WINDOW_DAYS)
|
| 1007 |
+
health = history.smooth_health[row, history_start:cut_column].astype(float)
|
| 1008 |
+
finite = np.flatnonzero(np.isfinite(health))
|
| 1009 |
+
if len(finite) < MIN_DRIFT_POINTS:
|
| 1010 |
+
continue
|
| 1011 |
+
if finite[-1] - finite[0] < MIN_DRIFT_SPAN_DAYS:
|
| 1012 |
+
continue
|
| 1013 |
+
last_column = history_start + int(finite[-1])
|
| 1014 |
+
staleness = cut_column - 1 - last_column
|
| 1015 |
+
if staleness > MAX_HEALTH_STALENESS_DAYS:
|
| 1016 |
+
continue
|
| 1017 |
+
x = finite.astype(float)
|
| 1018 |
+
y = health[finite]
|
| 1019 |
+
centered = x - x.mean()
|
| 1020 |
+
denominator = float(np.dot(centered, centered))
|
| 1021 |
+
if denominator <= 0.0:
|
| 1022 |
+
continue
|
| 1023 |
+
slope = min(float(np.dot(centered, y - y.mean()) / denominator), 0.0)
|
| 1024 |
+
current_health = float(y[-1])
|
| 1025 |
+
analog_columns = cut_column + forecast_offsets - SEASONAL_LAG_DAYS
|
| 1026 |
+
valid_columns = (analog_columns >= 0) & (
|
| 1027 |
+
analog_columns < history.temperature.shape[1]
|
| 1028 |
+
)
|
| 1029 |
+
analog = np.full(int(HORIZON_DAYS), np.nan)
|
| 1030 |
+
analog[valid_columns] = history.temperature[
|
| 1031 |
+
row, analog_columns[valid_columns]
|
| 1032 |
+
].astype(float)
|
| 1033 |
+
finite_temperature = np.isfinite(analog)
|
| 1034 |
+
if int(finite_temperature.sum()) < MIN_ANALOG_DAYS:
|
| 1035 |
+
continue
|
| 1036 |
+
analog_filled = np.interp(
|
| 1037 |
+
forecast_offsets.astype(float),
|
| 1038 |
+
forecast_offsets[finite_temperature].astype(float),
|
| 1039 |
+
analog[finite_temperature],
|
| 1040 |
+
)
|
| 1041 |
+
forecast_health = current_health + slope * forecast_offsets
|
| 1042 |
+
forecast_raw = forecast_health + beta_v_per_c * (
|
| 1043 |
+
analog_filled - REFERENCE_TEMPERATURE_C
|
| 1044 |
+
)
|
| 1045 |
+
trailing = history.raw_voltage[
|
| 1046 |
+
row, max(0, cut_column - 6) : cut_column
|
| 1047 |
+
].astype(float)
|
| 1048 |
+
if len(trailing) < 6:
|
| 1049 |
+
trailing = np.pad(trailing, (6 - len(trailing), 0), constant_values=np.nan)
|
| 1050 |
+
combined = np.concatenate([trailing[-6:], forecast_raw])
|
| 1051 |
+
forecast_smooth = (
|
| 1052 |
+
pd.Series(combined).rolling(7, min_periods=3).median().to_numpy()[6:]
|
| 1053 |
+
)
|
| 1054 |
+
if not np.isfinite(forecast_smooth).all():
|
| 1055 |
+
continue
|
| 1056 |
+
predicted_min_voltage = float(np.min(forecast_smooth))
|
| 1057 |
+
urgency[position] = -predicted_min_voltage
|
| 1058 |
+
reliable[position] = bool(
|
| 1059 |
+
maximum_predicted_min_voltage is None
|
| 1060 |
+
or predicted_min_voltage <= maximum_predicted_min_voltage
|
| 1061 |
+
)
|
| 1062 |
+
return ResidualSignal(urgency, reliable)
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
class PostRankResidual(Protocol):
|
| 1066 |
+
name: str
|
| 1067 |
+
|
| 1068 |
+
def predict(self, context: ResidualContext) -> ResidualSignal: ...
|
| 1069 |
+
|
| 1070 |
+
def rerank(
|
| 1071 |
+
self,
|
| 1072 |
+
baseline: np.ndarray,
|
| 1073 |
+
batteries: np.ndarray,
|
| 1074 |
+
signal: ResidualSignal,
|
| 1075 |
+
) -> np.ndarray: ...
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
@dataclass(frozen=True)
|
| 1079 |
+
class SeasonalTemperatureResidual:
|
| 1080 |
+
beta_v_per_c: float = FROZEN_TEMPERATURE_BETA_V_PER_C
|
| 1081 |
+
fitted_raw_beta_v_per_c: float = FROZEN_TEMPERATURE_BETA_V_PER_C
|
| 1082 |
+
training_readings: int = 0
|
| 1083 |
+
maximum_predicted_min_voltage: float = TEMPERATURE_MAX_PREDICTED_MIN_VOLTAGE
|
| 1084 |
+
name: str = "seasonal_temperature_below_250_logit1"
|
| 1085 |
+
|
| 1086 |
+
def predict(self, context: ResidualContext) -> ResidualSignal:
|
| 1087 |
+
return _seasonal_physics_signal(
|
| 1088 |
+
context.history,
|
| 1089 |
+
context.batteries,
|
| 1090 |
+
context.cutoff,
|
| 1091 |
+
self.beta_v_per_c,
|
| 1092 |
+
self.maximum_predicted_min_voltage,
|
| 1093 |
+
)
|
| 1094 |
+
|
| 1095 |
+
def rerank(
|
| 1096 |
+
self,
|
| 1097 |
+
baseline: np.ndarray,
|
| 1098 |
+
batteries: np.ndarray,
|
| 1099 |
+
signal: ResidualSignal,
|
| 1100 |
+
) -> np.ndarray:
|
| 1101 |
+
positions = np.flatnonzero(signal.reliable)
|
| 1102 |
+
if len(positions) < 2:
|
| 1103 |
+
return np.asarray(baseline, dtype=float).copy()
|
| 1104 |
+
score = _logit(baseline)
|
| 1105 |
+
score[positions] += 2.0 * _rank(signal.values[positions]) - 1.0
|
| 1106 |
+
return assign_multiset(baseline, score, batteries, signal.reliable)
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
@dataclass(frozen=True)
|
| 1110 |
+
class WeightedIdentityResidual:
|
| 1111 |
+
residual: IdentityRankResidual
|
| 1112 |
+
weight: float
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
@dataclass(frozen=True)
|
| 1116 |
+
class IdentityEnsembleModel:
|
| 1117 |
+
"""Composable rank residuals followed by optional bounded post-residuals."""
|
| 1118 |
+
|
| 1119 |
+
identity_residuals: tuple[WeightedIdentityResidual, ...]
|
| 1120 |
+
post_residuals: tuple[PostRankResidual, ...] = ()
|
| 1121 |
+
history_temperature_beta_v_per_c: float = FROZEN_TEMPERATURE_BETA_V_PER_C
|
| 1122 |
+
|
| 1123 |
+
def predict_risk(
|
| 1124 |
+
self,
|
| 1125 |
+
baseline: np.ndarray,
|
| 1126 |
+
freshness: np.ndarray,
|
| 1127 |
+
context: ResidualContext,
|
| 1128 |
+
) -> np.ndarray:
|
| 1129 |
+
identity_signals = tuple(
|
| 1130 |
+
item.residual.predict(context) for item in self.identity_residuals
|
| 1131 |
+
)
|
| 1132 |
+
post_signals = tuple(residual.predict(context) for residual in self.post_residuals)
|
| 1133 |
+
return self.rerank_from_signals(
|
| 1134 |
+
baseline,
|
| 1135 |
+
freshness,
|
| 1136 |
+
context.batteries,
|
| 1137 |
+
identity_signals,
|
| 1138 |
+
post_signals,
|
| 1139 |
+
)
|
| 1140 |
+
|
| 1141 |
+
def rerank_from_signals(
|
| 1142 |
+
self,
|
| 1143 |
+
baseline: np.ndarray,
|
| 1144 |
+
freshness: np.ndarray,
|
| 1145 |
+
batteries: np.ndarray,
|
| 1146 |
+
identity_signals: tuple[ResidualSignal, ...],
|
| 1147 |
+
post_signals: tuple[ResidualSignal, ...] = (),
|
| 1148 |
+
) -> np.ndarray:
|
| 1149 |
+
"""Pure parity surface for already-computed causal component signals."""
|
| 1150 |
+
|
| 1151 |
+
baseline = np.asarray(baseline, dtype=float)
|
| 1152 |
+
freshness = np.asarray(freshness, dtype=float)
|
| 1153 |
+
batteries = np.asarray(batteries, dtype=object)
|
| 1154 |
+
if not (len(baseline) == len(freshness) == len(batteries)):
|
| 1155 |
+
raise ValueError("ensemble inputs have different lengths")
|
| 1156 |
+
if not np.isfinite(baseline).all() or not np.isfinite(freshness).all():
|
| 1157 |
+
raise ValueError("base risks and freshness factors must be finite")
|
| 1158 |
+
if not self.identity_residuals:
|
| 1159 |
+
identity = baseline.copy()
|
| 1160 |
+
else:
|
| 1161 |
+
if len(identity_signals) != len(self.identity_residuals):
|
| 1162 |
+
raise ValueError("identity signal count differs from configured residuals")
|
| 1163 |
+
identity = baseline.copy()
|
| 1164 |
+
for factor in np.sort(np.unique(freshness)):
|
| 1165 |
+
positions = np.flatnonzero(freshness == factor)
|
| 1166 |
+
local_base = baseline[positions]
|
| 1167 |
+
local_batteries = batteries[positions]
|
| 1168 |
+
components: list[np.ndarray] = []
|
| 1169 |
+
union = np.zeros(len(positions), dtype=bool)
|
| 1170 |
+
total_weight = 0.0
|
| 1171 |
+
borda = np.zeros(len(positions), dtype=float)
|
| 1172 |
+
for item, signal in zip(
|
| 1173 |
+
self.identity_residuals, identity_signals, strict=True
|
| 1174 |
+
):
|
| 1175 |
+
local_signal = ResidualSignal(
|
| 1176 |
+
signal.values[positions], signal.reliable[positions]
|
| 1177 |
+
)
|
| 1178 |
+
component = item.residual.component_risk(
|
| 1179 |
+
local_base, local_batteries, local_signal
|
| 1180 |
+
)
|
| 1181 |
+
components.append(component)
|
| 1182 |
+
union |= local_signal.reliable
|
| 1183 |
+
if item.weight > 0.0:
|
| 1184 |
+
borda += item.weight * _rank(component)
|
| 1185 |
+
total_weight += item.weight
|
| 1186 |
+
if total_weight <= 0.0:
|
| 1187 |
+
raise ValueError("identity residual weights must contain a positive value")
|
| 1188 |
+
borda /= total_weight
|
| 1189 |
+
identity[positions] = assign_multiset(
|
| 1190 |
+
local_base, borda, local_batteries, union
|
| 1191 |
+
)
|
| 1192 |
+
if not np.array_equal(
|
| 1193 |
+
np.sort(local_base * factor),
|
| 1194 |
+
np.sort(identity[positions] * factor),
|
| 1195 |
+
):
|
| 1196 |
+
raise AssertionError("identity changed planner-effective risk multiset")
|
| 1197 |
+
|
| 1198 |
+
treatment = identity
|
| 1199 |
+
if len(post_signals) != len(self.post_residuals):
|
| 1200 |
+
raise ValueError("post signal count differs from configured residuals")
|
| 1201 |
+
for residual, signal in zip(self.post_residuals, post_signals, strict=True):
|
| 1202 |
+
updated = treatment.copy()
|
| 1203 |
+
for factor in np.sort(np.unique(freshness)):
|
| 1204 |
+
positions = np.flatnonzero(freshness == factor)
|
| 1205 |
+
local_signal = ResidualSignal(
|
| 1206 |
+
signal.values[positions], signal.reliable[positions]
|
| 1207 |
+
)
|
| 1208 |
+
updated[positions] = residual.rerank(
|
| 1209 |
+
treatment[positions], batteries[positions], local_signal
|
| 1210 |
+
)
|
| 1211 |
+
if not np.array_equal(
|
| 1212 |
+
np.sort(treatment[positions] * factor),
|
| 1213 |
+
np.sort(updated[positions] * factor),
|
| 1214 |
+
):
|
| 1215 |
+
raise AssertionError(
|
| 1216 |
+
f"{residual.name} changed planner-effective risk multiset"
|
| 1217 |
+
)
|
| 1218 |
+
treatment = updated
|
| 1219 |
+
if not np.array_equal(np.sort(treatment), np.sort(baseline)):
|
| 1220 |
+
raise AssertionError("ensemble changed scenario raw risk multiset")
|
| 1221 |
+
if not np.array_equal(
|
| 1222 |
+
np.sort(treatment * freshness), np.sort(baseline * freshness)
|
| 1223 |
+
):
|
| 1224 |
+
raise AssertionError("ensemble changed scenario effective risk multiset")
|
| 1225 |
+
return treatment
|
| 1226 |
+
|
| 1227 |
+
|
| 1228 |
+
@dataclass
|
| 1229 |
+
class IdentityEnsemblePlanner:
|
| 1230 |
+
"""Deployable wrapper that preserves the frozen base planner and its heads."""
|
| 1231 |
+
|
| 1232 |
+
base_planner: CompetitionPlanner
|
| 1233 |
+
ensemble: IdentityEnsembleModel
|
| 1234 |
+
base_artifact_sha256: str
|
| 1235 |
+
schema_version: int = SCHEMA_VERSION
|
| 1236 |
+
_history_cache: CausalHistoryCache | None = field(
|
| 1237 |
+
default=None, init=False, repr=False, compare=False
|
| 1238 |
+
)
|
| 1239 |
+
|
| 1240 |
+
def reset_split(self, split_id: str | None = None) -> None:
|
| 1241 |
+
self._history_cache = CausalHistoryCache(
|
| 1242 |
+
beta_v_per_c=self.ensemble.history_temperature_beta_v_per_c,
|
| 1243 |
+
split_id=split_id,
|
| 1244 |
+
)
|
| 1245 |
+
|
| 1246 |
+
def plan_scenario(
|
| 1247 |
+
self,
|
| 1248 |
+
visible_history: pd.DataFrame,
|
| 1249 |
+
snapshot: pd.DataFrame,
|
| 1250 |
+
locations: pd.DataFrame,
|
| 1251 |
+
travel_costs: pd.DataFrame,
|
| 1252 |
+
settings,
|
| 1253 |
+
start_time: pd.Timestamp | str,
|
| 1254 |
+
) -> pd.DataFrame:
|
| 1255 |
+
if self.schema_version != SCHEMA_VERSION:
|
| 1256 |
+
raise RuntimeError(
|
| 1257 |
+
f"identity artifact schema {self.schema_version} != runtime {SCHEMA_VERSION}"
|
| 1258 |
+
)
|
| 1259 |
+
if self._history_cache is None:
|
| 1260 |
+
self.reset_split(None)
|
| 1261 |
+
assert self._history_cache is not None
|
| 1262 |
+
start = pd.Timestamp(start_time)
|
| 1263 |
+
history = self._history_cache.update(visible_history, start)
|
| 1264 |
+
batteries = snapshot["battery"].astype(str).to_numpy()
|
| 1265 |
+
if not np.array_equal(batteries, locations["battery"].astype(str).to_numpy()):
|
| 1266 |
+
raise AssertionError("snapshot and locations battery order differs")
|
| 1267 |
+
base_risk = self.base_planner.model.predict_event_risk(snapshot)
|
| 1268 |
+
predicted_rul = self.base_planner.model.predict_rul(snapshot)
|
| 1269 |
+
predicted_survivor = self.base_planner.model.predict_survivor_rul(snapshot)
|
| 1270 |
+
if predicted_survivor is None:
|
| 1271 |
+
predicted_survivor = np.full(len(snapshot), float(settings.planning_window_days))
|
| 1272 |
+
freshness = planner_freshness_factors(
|
| 1273 |
+
snapshot["data_gap_days"].to_numpy(dtype=float), self.base_planner.policy
|
| 1274 |
+
)
|
| 1275 |
+
context = ResidualContext(history, batteries, locations, start)
|
| 1276 |
+
treatment_risk = self.ensemble.predict_risk(base_risk, freshness, context)
|
| 1277 |
+
scale = v07_emergency_scale(snapshot, travel_costs, settings)
|
| 1278 |
+
policy = replace(
|
| 1279 |
+
self.base_planner.policy, emergency_operational_scale=float(scale)
|
| 1280 |
+
)
|
| 1281 |
+
planner = CompetitionPlanner(None, policy)
|
| 1282 |
+
return planner.plan_snapshot(
|
| 1283 |
+
snapshot,
|
| 1284 |
+
locations,
|
| 1285 |
+
travel_costs,
|
| 1286 |
+
settings,
|
| 1287 |
+
start,
|
| 1288 |
+
predicted_rul=predicted_rul,
|
| 1289 |
+
predicted_risk=treatment_risk,
|
| 1290 |
+
predicted_survivor_rul=predicted_survivor,
|
| 1291 |
+
)
|
submission_artifacts/planner.joblib
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569
|
| 3 |
+
size 15508355
|
submission_artifacts/planner.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
"expected_gain_margin": 10.0,
|
| 9 |
"expected_service_cost_hours": 2.0,
|
| 10 |
"scheduled_fraction": 0.038,
|
|
|
|
| 11 |
"weekly_guard_fraction": 0.95,
|
| 12 |
"hard_limit_penalty_multiplier": 1.5,
|
| 13 |
"minimum_scheduled_batteries": 8,
|
|
@@ -18,7 +19,7 @@
|
|
| 18 |
"prediction_offset_days": -5.0,
|
| 19 |
"building_batch_window_days": 4,
|
| 20 |
"unscheduled_margin_days": 1,
|
| 21 |
-
"capacity_lookback_days":
|
| 22 |
"capacity_lookahead_days": 21,
|
| 23 |
"capacity_daily_limit_fraction": 1.0,
|
| 24 |
"capacity_weekly_limit_fraction": 0.95,
|
|
@@ -135,7 +136,7 @@
|
|
| 135 |
"building_battery_count",
|
| 136 |
"room_battery_count"
|
| 137 |
],
|
| 138 |
-
"artifact_sha256": "
|
| 139 |
"runtime_versions": {
|
| 140 |
"batteryswap_public": "0.3.4",
|
| 141 |
"fastparquet": "2026.5.0",
|
|
@@ -146,15 +147,15 @@
|
|
| 146 |
},
|
| 147 |
"validation_report": "artifacts/submission_cv_final.json",
|
| 148 |
"optimistic_train_score": {
|
| 149 |
-
"
|
| 150 |
-
"
|
| 151 |
-
"
|
| 152 |
"battery_swap": 4.026041666666667,
|
| 153 |
-
"
|
| 154 |
-
"
|
| 155 |
-
"
|
| 156 |
-
"
|
| 157 |
-
"
|
| 158 |
-
"total_cost":
|
| 159 |
}
|
| 160 |
}
|
|
|
|
| 8 |
"expected_gain_margin": 10.0,
|
| 9 |
"expected_service_cost_hours": 2.0,
|
| 10 |
"scheduled_fraction": 0.038,
|
| 11 |
+
"capacity_lookback_days": 42,
|
| 12 |
"weekly_guard_fraction": 0.95,
|
| 13 |
"hard_limit_penalty_multiplier": 1.5,
|
| 14 |
"minimum_scheduled_batteries": 8,
|
|
|
|
| 19 |
"prediction_offset_days": -5.0,
|
| 20 |
"building_batch_window_days": 4,
|
| 21 |
"unscheduled_margin_days": 1,
|
| 22 |
+
"capacity_lookback_days": 42,
|
| 23 |
"capacity_lookahead_days": 21,
|
| 24 |
"capacity_daily_limit_fraction": 1.0,
|
| 25 |
"capacity_weekly_limit_fraction": 0.95,
|
|
|
|
| 136 |
"building_battery_count",
|
| 137 |
"room_battery_count"
|
| 138 |
],
|
| 139 |
+
"artifact_sha256": "3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569",
|
| 140 |
"runtime_versions": {
|
| 141 |
"batteryswap_public": "0.3.4",
|
| 142 |
"fastparquet": "2026.5.0",
|
|
|
|
| 147 |
},
|
| 148 |
"validation_report": "artifacts/submission_cv_final.json",
|
| 149 |
"optimistic_train_score": {
|
| 150 |
+
"building_change": 10.25,
|
| 151 |
+
"daily_limit": 14.583333333333334,
|
| 152 |
+
"travel": 32.68057291666667,
|
| 153 |
"battery_swap": 4.026041666666667,
|
| 154 |
+
"early_swap": 391.3229166666667,
|
| 155 |
+
"weekly_limit": 39.583333333333336,
|
| 156 |
+
"overtime": 51.89251388888891,
|
| 157 |
+
"room_change": 6.75,
|
| 158 |
+
"late_swap": 75.83333333333333,
|
| 159 |
+
"total_cost": 626.9220451388888
|
| 160 |
}
|
| 161 |
}
|
submission_artifacts/temperature_planner.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3fdac6f588b779f6a1aa6bd224ed7ad460b7165eda5e7b1060ef5484edf4b804
|
| 3 |
+
size 16513962
|
submission_artifacts/temperature_planner.json
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"aft": {
|
| 3 |
+
"diagnostics": {
|
| 4 |
+
"censored_devices": 374,
|
| 5 |
+
"devices": 450,
|
| 6 |
+
"eligible_rows": 10636,
|
| 7 |
+
"event_devices": 76,
|
| 8 |
+
"iterations": 27,
|
| 9 |
+
"objective": 549.3019842852074
|
| 10 |
+
},
|
| 11 |
+
"parameters": {
|
| 12 |
+
"beta0": 0.8736216147089645,
|
| 13 |
+
"beta1": 0.8894051370094506,
|
| 14 |
+
"sigma": 0.23315668503034834
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"artifact": "submission_artifacts/temperature_planner.joblib",
|
| 18 |
+
"artifact_sha256": "3fdac6f588b779f6a1aa6bd224ed7ad460b7165eda5e7b1060ef5484edf4b804",
|
| 19 |
+
"artifact_size_bytes": 16513962,
|
| 20 |
+
"base_artifact": "submission_artifacts/planner.joblib",
|
| 21 |
+
"base_artifact_byte_preserved": true,
|
| 22 |
+
"base_artifact_sha256_after": "3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569",
|
| 23 |
+
"base_artifact_sha256_before": "3cfca2e7dd2c05ddd84f2eca42a484168a1ab4ad3cd454806ef4e687fe8f1569",
|
| 24 |
+
"container_base": "huggingface/competitions@sha256:6cea4ff69a6832761484f48c07ccfbf49f701f285ffcb9fc72a4ecfb81b6b4e5",
|
| 25 |
+
"dataset_files": {
|
| 26 |
+
"battery_metrics.parquet": "d333e69cd1145ca025904511ee517d234a0616a9bc9c2d056a056a66ac7432e2",
|
| 27 |
+
"devices.csv": "59819728f367b05c09b15038b28239253419a640a7dfd7caed366dffa4f71eb2",
|
| 28 |
+
"eol_times.csv": "c02984228e6b791c75c65d3a1a9d1727864ff30dfd6c1147cd6784125e8ba189",
|
| 29 |
+
"scenarios.json": "49917e65fe5194af5a9f0a46413187bf47e4453f0e09860f6249f7ad654559f9"
|
| 30 |
+
},
|
| 31 |
+
"dataset_revision": "7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c",
|
| 32 |
+
"ensemble": {
|
| 33 |
+
"freshness_stratified": true,
|
| 34 |
+
"identity_components": [
|
| 35 |
+
{
|
| 36 |
+
"name": "lt_fp_aft",
|
| 37 |
+
"weight": 0.5
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "original_similarity_eol",
|
| 41 |
+
"weight": 0.5
|
| 42 |
+
}
|
| 43 |
+
],
|
| 44 |
+
"planner_effective_risk_multiset_preserved": true,
|
| 45 |
+
"post_components": [
|
| 46 |
+
"seasonal_temperature_below_250_logit1"
|
| 47 |
+
],
|
| 48 |
+
"raw_risk_multiset_preserved": true,
|
| 49 |
+
"v07_emergency_scale": 0.75,
|
| 50 |
+
"v07_mean_trip_gate_hours": 8.0
|
| 51 |
+
},
|
| 52 |
+
"official_evaluator_run": false,
|
| 53 |
+
"random_seeds": {
|
| 54 |
+
"base_model": 2026,
|
| 55 |
+
"identity_residuals": null
|
| 56 |
+
},
|
| 57 |
+
"runtime_versions": {
|
| 58 |
+
"batteryswap_public": "0.3.4",
|
| 59 |
+
"fastparquet": "2026.5.0",
|
| 60 |
+
"joblib": "1.5.3",
|
| 61 |
+
"numpy": "2.2.6",
|
| 62 |
+
"pandas": "2.3.3",
|
| 63 |
+
"pydantic-settings": "2.15.0",
|
| 64 |
+
"scikit-learn": "1.7.2",
|
| 65 |
+
"scipy": "1.14.1",
|
| 66 |
+
"structlog": "26.1.0"
|
| 67 |
+
},
|
| 68 |
+
"schema_version": 1,
|
| 69 |
+
"seasonal_temperature": {
|
| 70 |
+
"enabled": true,
|
| 71 |
+
"frozen_beta_v_per_c": 0.00549,
|
| 72 |
+
"full_train_fit": {
|
| 73 |
+
"clipped_beta_v_per_c": 0.005490315451361821,
|
| 74 |
+
"raw_beta_v_per_c": 0.005490315451361821,
|
| 75 |
+
"training_device_days": 327760,
|
| 76 |
+
"training_readings": 7203213
|
| 77 |
+
},
|
| 78 |
+
"maximum_predicted_min_voltage": 2.5
|
| 79 |
+
},
|
| 80 |
+
"similarity_library": {
|
| 81 |
+
"arrays": {
|
| 82 |
+
"device_buildings_by_code": {
|
| 83 |
+
"dtype": "object",
|
| 84 |
+
"sha256": "14952b5a526c6a2f56ab004728d3a2f08ade7588f7e4e7e099401522e376d3bc",
|
| 85 |
+
"shape": [
|
| 86 |
+
82
|
| 87 |
+
]
|
| 88 |
+
},
|
| 89 |
+
"device_ids_by_code": {
|
| 90 |
+
"dtype": "object",
|
| 91 |
+
"sha256": "d9c4639bc36ccd9d015b6693dec35c7623448b0615a3ea85bec023007c1a2cd0",
|
| 92 |
+
"shape": [
|
| 93 |
+
82
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
"donor_buildings": {
|
| 97 |
+
"dtype": "object",
|
| 98 |
+
"sha256": "69003f56da7d9f40a693d72092ab08b00e54c009c7a3c24a842389f4ce8f4fc1",
|
| 99 |
+
"shape": [
|
| 100 |
+
7257
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
"donor_codes": {
|
| 104 |
+
"dtype": "int64",
|
| 105 |
+
"sha256": "91ec85d3ad42e60422e736642e5bc70677df34a774bd1f12133801f8b3104171",
|
| 106 |
+
"shape": [
|
| 107 |
+
7257
|
| 108 |
+
]
|
| 109 |
+
},
|
| 110 |
+
"donor_ids": {
|
| 111 |
+
"dtype": "object",
|
| 112 |
+
"sha256": "bdd4bd413919359fcbc689f189a4f3513ede64ec73ccfae2486dafb189dc5c6a",
|
| 113 |
+
"shape": [
|
| 114 |
+
7257
|
| 115 |
+
]
|
| 116 |
+
},
|
| 117 |
+
"endpoint_days": {
|
| 118 |
+
"dtype": "datetime64[ns]",
|
| 119 |
+
"sha256": "4c12f0cf07d5fd8fb94d2de1badab8b9bd3728c6279d72f92939c241cba923d3",
|
| 120 |
+
"shape": [
|
| 121 |
+
7257
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
"masks": {
|
| 125 |
+
"dtype": "bool",
|
| 126 |
+
"sha256": "c6db4208d17c3ea575510d0ef484adb21010495a460415c94bc7fdacf81980a3",
|
| 127 |
+
"shape": [
|
| 128 |
+
7257,
|
| 129 |
+
49
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
"residual_days": {
|
| 133 |
+
"dtype": "float64",
|
| 134 |
+
"sha256": "eb425df66eb6136810506a663fb79c2b41ba3df4ff0c9241d2b8513e9e0d6f31",
|
| 135 |
+
"shape": [
|
| 136 |
+
7257
|
| 137 |
+
]
|
| 138 |
+
},
|
| 139 |
+
"values": {
|
| 140 |
+
"dtype": "float64",
|
| 141 |
+
"sha256": "767a4fc753a4d1971713de79259136a119defa3c7c3be952e7e2cdfe5627ad03",
|
| 142 |
+
"shape": [
|
| 143 |
+
7257,
|
| 144 |
+
49
|
| 145 |
+
]
|
| 146 |
+
}
|
| 147 |
+
},
|
| 148 |
+
"donor_buildings": 24,
|
| 149 |
+
"donor_devices": 82,
|
| 150 |
+
"donor_endpoints": 7257,
|
| 151 |
+
"prefix_lags": 49
|
| 152 |
+
},
|
| 153 |
+
"smoothing_audit": {
|
| 154 |
+
"exact_eol_matches": 82,
|
| 155 |
+
"observed_eol_devices": 82
|
| 156 |
+
},
|
| 157 |
+
"source_sha256": {
|
| 158 |
+
"Dockerfile": "84a3f71b2ba664f91ceb8a2980dd5397a002eed02af9fdd264325caf89158573",
|
| 159 |
+
"LICENSE": "02a0be95f0bbc0d5d656ef7983a9267141f1cd201b5c0ac7ff7c9b9c6c765985",
|
| 160 |
+
"README.md": "400e54feb8c26148079379ceb76a9d39c9af9a513aed842a74340df609a963a3",
|
| 161 |
+
"REPRODUCIBILITY.md": "cce05ccbaee8b0bf119ff7dac1c7660fcbd2c1ad2b405e6c5d9e9d57504fe56e",
|
| 162 |
+
"THIRD_PARTY_LICENSES.md": "c530152f46533b773acaffb76fb452fc7511765ce343fb35d3e7830edfb2f267",
|
| 163 |
+
"pyproject.toml": "154acd67853b696aecc62c4db1c29c44b50c8d57b10a5d8deb58148978344136",
|
| 164 |
+
"requirements.txt": "53d9c582b98b392c0517dc87c78949cd13b99b3f09ebfce601d0c8eadd4f2db3",
|
| 165 |
+
"script.py": "4c4537adef9531380a17161d720e864999a708497ddae31888e851b221480ea5",
|
| 166 |
+
"scripts/build_identity_submission.py": "5ad4fb55db9817da4295e69e98d843d0fdf3986d2b169b6f114f550911775bc4",
|
| 167 |
+
"scripts/evaluate_clean_identity_candidate.py": "d665105b8eff79f44d6a493ca241ab96f5c3b6ef06b16dd708908251845829f8",
|
| 168 |
+
"scripts/experiment_identity_ensemble.py": "06bc341f13eec3893ff0c1343a20bd085d9447f329d66d0ffb6c33f957598a40",
|
| 169 |
+
"scripts/experiment_lt_fp_aft.py": "3c0b8e6d1f5a4e36e5d84a3ce61c38d861a65e2adb999bee9515490c50db6c97",
|
| 170 |
+
"scripts/experiment_similarity_eol.py": "a84ef4d194b96afd17e630faff589011aff7f88c84835ca2ffdbccf8b75caa7b",
|
| 171 |
+
"scripts/experiment_temperature_physics_ensemble.py": "d3cc15e017691b06f4b093e8304e160a1139c5b1fa886a10b4eca9bc9d99b441",
|
| 172 |
+
"scripts/verify_identity_feature_parity.py": "a5ca2c72881d4760dee5b0d68ddc31f9e72fd0c18ac25fe3187984063facf30d",
|
| 173 |
+
"src/batteryswapai/competition_planner.py": "a56bfe549a3d6fcfe59217f7bd8a647533a4484fdbb26fe3ef8f1d6c84035a98",
|
| 174 |
+
"src/batteryswapai/identity_ensemble.py": "fa1f9aeaa89fba1bc5fc437c478c876544f2973960db18ef2c83f0042cc9df4a"
|
| 175 |
+
},
|
| 176 |
+
"training_source": {
|
| 177 |
+
"aft_landmarks": "rebuilt from official raw train visible prefixes",
|
| 178 |
+
"optional_lt_reference": {
|
| 179 |
+
"path": "artifacts/lt_fp_aft_official.rows.csv",
|
| 180 |
+
"raw_rebuild_exact": true,
|
| 181 |
+
"rows": 19890,
|
| 182 |
+
"sha256": "f8ce13b8b77453b01e81e1a788ab490dd0768eb7aec1162f3ef03492b0aaad11"
|
| 183 |
+
}
|
| 184 |
+
},
|
| 185 |
+
"validation_evidence_sha256": {
|
| 186 |
+
"artifacts/identity_ensemble_official.json": "6841c68160ef2decdd372bc98e1e4cfb33fab5eab83251133bc143f9ed5c113f",
|
| 187 |
+
"artifacts/identity_ensemble_official.rows.csv": "582673ce8e57707294175f26194a03b086f6021f19362568deae59e9285a2be8",
|
| 188 |
+
"artifacts/lt_fp_aft_official.json": "7446090f1a3e809a64526821176cc01f8d5302d93aa9c03431993e629b8c75a8",
|
| 189 |
+
"artifacts/lt_fp_aft_official.rows.csv": "f8ce13b8b77453b01e81e1a788ab490dd0768eb7aec1162f3ef03492b0aaad11",
|
| 190 |
+
"artifacts/similarity_eol_official.json": "07921417a05f81ad5c22d2b39ba241c4fe98a51ba0fb02583d2da489d824ff51",
|
| 191 |
+
"artifacts/similarity_eol_official.rows.csv": "18ee1f5ba39e10276c5a643841b9d3e8e4552aa8df53817a936d8b9de6585947",
|
| 192 |
+
"artifacts/temperature_physics_ensemble_official.json": "005dbcd8e5189c090b6b824b203b6ec6c012d1ceaea3b05bd04bd9970d9cc7eb",
|
| 193 |
+
"artifacts/temperature_physics_ensemble_official.rows.csv": "45959940bb44a7aacf98a20ff8bc5bbd7c4348aec1fa028f464eb260b066a9ca",
|
| 194 |
+
"artifacts/temperature_physics_globalbeta_diagnostic.json": "2352beb54b52a8b5f7767797c25c1e9282c5bab75f557d394d5c84419954d5e8"
|
| 195 |
+
}
|
| 196 |
+
}
|
submission_artifacts/v05_planner.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3
|
| 3 |
+
size 15508289
|