CarlAlbertCode commited on
Commit
2bb61d5
·
verified ·
1 Parent(s): dfa8acb

Submission 007 - temperature-gated causal identity ensemble

Browse files

Primary 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 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
- artifacts/
10
- submission.csv
11
- *.log
 
 
 
 
 
 
 
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:latest
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
- # Fail instead of silently benchmarking against a different competition runtime.
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 — V05 (official scenario interface)
2
 
3
- Candidate. Not submitted. V04 remains the best public result (1407.63), but it was produced by a
4
- pipeline that read sensor rows timestamped after the scenario start.
 
5
 
6
- - Building-held-out score, official cut: **1598.26**
7
- - Late / early: **754.79 / 618.06**
8
- - Event ranking: AP **0.4170**, AUC 0.9586
9
- - Mean scheduled batteries: **15.75**
10
- - Tests: **44/44 passed**
11
- - Artifact SHA-256: `63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3`
12
- - Base: `research/v4-first-place` at `5ade97b8ab7b33e10a00f9d182a7ebcde69bc89c`
13
 
14
- ## What changed
 
 
 
 
15
 
16
- **Official scenario input.** `iterate_scenarios` yields a `cut` truncated at
17
- `end_time <= scenario.start_time`, and `make_submissions` passes exactly that to the planner.
18
- Our pipeline discarded it and rebuilt features from the whole split, so the cutoff day's bucket
19
- carried that day's later hourly readings — 7,274 to 9,847 rows per scenario. Inference now
20
- consumes the cut, and `scenario_history` truncates defensively at the same boundary.
21
 
22
- **Degradation dynamics.** Threshold-crossing forecasts trained on every observed sensor
23
- transition plus biweekly voltage bins back to one year, blended into the event ranking as a 0.25
24
- residual nudge. Worth −76.0 against the same blend under the official cut.
 
 
25
 
26
- ## Validation
 
 
27
 
28
- Historical families re-baselined on identical building-held-out folds and the official cut:
 
 
29
 
30
- | generation | public | AP | total | late | early |
31
- |---|---|---|---|---|---|
32
- | **V05** | — | 0.4170 | **1598.26** | 754.79 | 618.06 |
33
- | V04 | 1407.63 | 0.4099 | 1674.27 | 831.88 | 623.12 |
34
- | V03 | 1700.12 | 0.3947 | 1757.11 | 657.29 | 826.50 |
35
- | V01 | 1576.57 | 0.3972 | 1766.77 | 892.08 | 598.99 |
36
 
37
- Removing the post-cutoff readings costs about 290 points: the same V04 model scores 1384.08 under
38
- the old pipeline and 1674.27 under the official cut.
 
 
 
 
 
 
 
 
 
 
39
 
40
- ## Artifact
41
 
42
- Trained and serialised under scikit-learn `1.7.2`, numpy `2.2.6`, pandas `2.3.3`.
43
- 215 s and 1.68 GB per split; deterministic and byte-identical with `--network none`.
 
 
 
 
 
 
44
 
45
- ## How to reproduce
 
 
46
 
47
- See `REPRODUCIBILITY.md`.
 
 
 
 
 
 
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 V05
2
 
3
- Dataset revision:
4
 
5
- ```text
6
- 7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c
7
- ```
 
8
 
9
- Put the public train split in `data/raw/train/`.
10
 
11
- ## Install
12
 
13
  ```bash
14
- python -m venv .venv
15
- source .venv/bin/activate
16
- pip install -e . -r requirements.txt -r requirements.dev.txt
17
  ```
18
 
19
- ## Train and validate
20
 
21
  ```bash
22
- python scripts/train_submission.py
23
- python scripts/validate_submission.py --outer-group building
24
- pytest
 
 
 
 
 
 
 
25
  ```
26
 
27
- Expected checks:
 
 
 
 
 
28
 
29
- ```text
30
- 44/44 tests pass
31
- building-held-out score: 1598.26
32
- mean scheduled batteries: 15.75
33
- artifact SHA-256: 63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3
 
 
 
34
  ```
35
 
36
- Train the artifact under the evaluator runtime, not a local interpreter:
37
 
38
  ```bash
39
- docker run --rm -v "$(pwd)":/work -v "$(pwd)/data/raw":/data:ro -w /work \
40
- -e PYTHONPATH=/work/src batteryswap-v4 \
41
- /app/env/bin/python3 scripts/train_submission.py --dataset-path /data/train
 
 
42
  ```
43
 
44
- ## Docker check
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  ```bash
47
- docker build -t batteryswap-v4 .
 
48
  ```
49
 
 
 
 
50
  ```bash
51
- docker run --rm -e BATTERYSWAP_SPLITS=train -v "$(pwd)/data/raw":/tmp/data:ro batteryswap-v4 \
52
- bash -c "/app/env/bin/python3 script.py && /app/env/bin/python3 -m batteryswap_public.metric"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ```
54
 
55
- The Docker run must produce all 19,890 required rows under the evaluator runtime.
56
- Measured: 215 s per split, 1.68 GB peak RSS, byte-identical output across repeated runs.
 
 
57
 
58
- ## Artifact reproducibility
59
 
60
- Retraining reproduces the model but not the exact bytes: independently trained artifacts differ
61
- in serialisation while agreeing to 5e-11 in predicted risk and producing byte-identical plans.
62
- Verify a rebuild by comparing plans, not SHAs.
 
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 | >=2.2.0 / 2.5.2 training, 2.2.6 parity | https://numpy.org/ | BSD-3-Clause and bundled permissive licenses |
8
- | pandas | >=2.3.0 / 3.0.5 training, 2.3.3 parity | https://pandas.pydata.org/ | BSD-3-Clause |
9
- | scikit-learn | >=1.7.0 / 1.9.0 training, 1.7.2 parity | https://scikit-learn.org/ | BSD-3-Clause |
10
- | joblib | >=1.5.3 / 1.5.3 | https://joblib.readthedocs.io/ | BSD-3-Clause |
11
- | fastparquet | >=2026.5.0 / 2026.5.0 | https://github.com/dask/fastparquet | Apache-2.0 |
12
- | pydantic-settings | >=2.14.1 / 2.15.0 parity | https://github.com/pydantic/pydantic-settings | MIT |
13
- | structlog | >=25.5.0 / 26.1.0 parity | https://www.structlog.org/ | MIT or Apache-2.0 |
14
- | huggingface-hub | >=0.34 | https://github.com/huggingface/huggingface_hub | Apache-2.0 |
15
- | PyYAML | >=6.0 | https://pyyaml.org/ | MIT |
16
- | batteryswap_public | >=0.1.0 / 0.3.4 | https://pypi.org/project/batteryswap-public/ | Organizer package; PyPI metadata does not declare a license |
 
 
 
17
 
18
  `batteryswap_public` is required by the official example and evaluation interface. Its PyPI metadata does not state a license. Obtain written organizer confirmation before the competition deadline.
 
 
 
 
 
 
 
 
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 submission"
 
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>=0.34
3
  pyarrow==25.0.1
4
- pytest>=8.3
5
- pyyaml>=6.0
 
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
- # Supporting packages used by the project tooling.
10
- pydantic-settings>=2.14.1
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("BATTERYSWAP_PLANNER_PATH", "submission_artifacts/planner.joblib")
 
 
 
20
  )
21
- splits = os.environ.get("BATTERYSWAP_SPLITS", "public,private").split(",")
 
 
 
 
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, hidden_eol_times, scenarios = load_dataset(dataset_path / split)
 
 
 
 
 
29
 
30
  for scenario, locs, visible_history, _ in iterate_scenarios(
31
  locations,
32
  timeseries,
33
- hidden_eol_times,
34
  scenarios,
35
  ):
36
  snapshot = scenario_history_snapshot(
@@ -40,19 +81,33 @@ def main() -> None:
40
  scenario["start_time"],
41
  )
42
 
43
- plan = planner.plan_snapshot(
44
- snapshot,
45
- locs,
46
- scenario["travel_costs"],
47
- scenario["settings"],
48
- scenario["start_time"],
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
- training.iloc[train_index],
139
  quantile=args.quantile,
140
  dataset_revision=DATASET_REVISION,
141
  random_state=2026 + fold_number,
142
  calibration_folds=args.inner_calibration_folds,
143
- trajectory=build_trajectory_matrix(daily),
144
- eol_times=eol_times,
145
  )
146
- valid = training.iloc[valid_index]
147
  for name, values in model.predict_risk_components(valid).items():
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
- for risk_scale in _numbers(args.risk_scales):
207
- for gain_margin in _numbers(args.gain_margins):
208
- for service_cost in _numbers(args.service_costs):
209
- for offset in _numbers(args.offsets):
210
- for fraction, minimum, maximum in _schedule_plans(
211
- args.schedule_fractions,
212
- args.schedule_quotas,
213
- args.schedule_bands,
214
- ):
215
- planner = CompetitionPlanner(
216
- placeholder_model,
217
- PlannerPolicy(
218
- event_risk_threshold=0.50,
219
- prediction_offset_days=offset,
220
- use_expected_cost=True,
221
- risk_calibration_scale=risk_scale,
222
- expected_service_cost_hours=service_cost,
223
- expected_gain_margin=gain_margin,
224
- capacity_lookahead_days=21,
225
- scheduled_fraction=fraction,
226
- minimum_scheduled_batteries=minimum,
227
- maximum_scheduled_batteries=maximum,
228
- ),
229
- )
230
- scores = []
231
- scheduled_counts = []
232
- for scenario_name, (scenario, locs, not_dead) in scenario_inputs.items():
233
- mask = training["scenario"].eq(scenario_name).to_numpy()
234
- snapshot = training.loc[mask]
235
- risk = oof_risk[mask]
236
- rul = oof_rul[mask]
237
- survivor = oof_survivor[mask]
238
- plan = planner.plan_snapshot(
239
- snapshot,
240
- locs,
241
- scenario["travel_costs"],
242
- scenario["settings"],
243
- scenario["start_time"],
244
- predicted_rul=rul,
245
- predicted_risk=risk,
246
- predicted_survivor_rul=(
247
- None if np.isnan(survivor).all() else survivor
248
- ),
249
- )
250
- start = pd.Timestamp(scenario["start_time"])
251
- horizon_end = start + pd.Timedelta(
252
- days=scenario["settings"].planning_window_days
253
- )
254
- scheduled_counts.append(int(plan["day"].le(horizon_end).sum()))
255
- _, _, score = evaluate_plan(
256
- plan,
257
- locs,
258
- scenario["travel_costs"],
259
- scenario["settings"],
260
- eol_times=not_dead,
261
- start_time=start,
262
- verbose=0,
263
- )
264
- scores.append(score)
265
- mean_score = pd.concat(scores, axis=1).mean(axis=1)
266
- policy_results.append(
267
- {
268
- "risk_calibration_scale": risk_scale,
269
- "expected_gain_margin": gain_margin,
270
- "expected_service_cost_hours": service_cost,
271
- "prediction_offset_days": offset,
272
- "scheduled_fraction": fraction,
273
- "minimum_scheduled_batteries": minimum,
274
- "maximum_scheduled_batteries": maximum,
275
- "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
276
- "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
277
- "stale_risk_factor": planner.policy.stale_risk_factor,
278
- "building_batch_window_days": planner.policy.building_batch_window_days,
279
- "capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
280
- "mean_scheduled_batteries": float(np.mean(scheduled_counts)),
281
- **{key: float(value) for key, value in mean_score.items()},
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:63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3
3
- size 15508289
 
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": 14,
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": "63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3",
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
- "daily_limit": 12.5,
150
- "weekly_limit": 43.75,
151
- "late_swap": 75.83333333333333,
152
  "battery_swap": 4.026041666666667,
153
- "room_change": 6.791666666666667,
154
- "overtime": 57.756944444444464,
155
- "early_swap": 386.8645833333333,
156
- "building_change": 10.520833333333334,
157
- "travel": 36.906100694444454,
158
- "total_cost": 634.9495034722223
159
  }
160
  }
 
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