CarlAlbertCode commited on
Commit
63098dc
·
1 Parent(s): ba502b0

Minimise documentation and remove unused build scripts

Browse files
README.md CHANGED
@@ -10,60 +10,28 @@ tags:
10
 
11
  # BatterySwapAI 2026 — MnesisLab
12
 
13
- Causal battery-swap planning. Current submission **011**: hierarchical Wiener
14
- first-passage reranking plus operational capacity policy, over the byte-preserved
15
- public V05 base.
16
 
17
- ## Deployed artifact
18
 
19
- `submission_artifacts/workload_planner.joblib` SHA-256
20
- `336713ecfec29f070e2209b71906d1016755363d9fd570a80e40040e7ec2db13`
21
 
22
  | setting | value |
23
  |---|---|
24
  | FPT rank residual weight | 0.15 |
25
  | capacity lookback | 42 days |
26
  | emergency operational scale | 0.5 |
27
- | scheduled fraction | 0.046 |
28
- | capacity weekly limit fraction | 0.95 (public V05 default) |
29
- | embedded public V05 base | `63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3` |
30
 
31
- ## Submission history
32
 
33
- | # | public score | note |
34
- |---|---:|---|
35
- | V05 | 1541.31 | causal anchor, revision `dfa8acbc` |
36
- | 007 | 1563.95 | identity + temperature rerank |
37
- | **008** | **1517.22** | FPT residual + emergency repricing |
38
- | 009 | 1528.06 | FPT residual 0.25, emergency buffer 3 |
39
- | 010 | pending | weekly margin 0.95 -> 0.98 on the 008 stack |
40
- | 011 | pending | workload variant, scheduled_fraction 0.046 |
41
 
42
- ## Causal and official contract
43
-
44
- - Every scenario uses only readings with `end_time <= scenario.start_time`.
45
- - EOL is reconstructed exactly as the evaluator defines it: strict `10 < T < 30`,
46
- daily median, days with fewer than five readings masked, seven-calendar-day
47
- rolling median with `min_periods=3`, first smoothed voltage `<= 2.40 V`.
48
- The reconstruction matches all 82 observed train EOL devices; censored devices
49
- remain censored.
50
- - Runtime uses `batteryswap_public==0.3.4`, CPU only, no network, and emits every
51
- live battery exactly once with a valid plan date.
52
-
53
- ## Evidence discipline
54
-
55
- Local totals are not comparable with the public leaderboard, and neither 010 nor 011
56
- carries a validated local gain: measured against the shipped 008 configuration, 010 is
57
- -5.25 (paired t = -1.49, worse in the first chronological half) and 011 is a wash locally,
58
- motivated only by the public/local difference in early-swap pricing. 008 (1517.22) remains
59
- the anchor. Every artifact passes 57/57 tests with byte-identical replays and 19,890 rows
60
- well inside the 30-minute budget.
61
-
62
- Local totals are not directly comparable with the public leaderboard; candidates
63
- are supported by paired deltas against the same clean reference, never by claiming
64
- a public score in advance.
65
-
66
- ## License
67
-
68
- Participant code is MIT (`LICENSE`). Third-party notices are recorded in
69
- `THIRD_PARTY_LICENSES.md`; reproduction steps are in `REPRODUCIBILITY.md`.
 
10
 
11
  # BatterySwapAI 2026 — MnesisLab
12
 
13
+ Causal battery-swap planning: hierarchical Wiener first-passage reranking over a
14
+ degradation model, with a cost-aware capacity and routing policy.
 
15
 
16
+ ## Artifact
17
 
18
+ `submission_artifacts/weekly99_planner.joblib`, loaded by `script.py`.
 
19
 
20
  | setting | value |
21
  |---|---|
22
  | FPT rank residual weight | 0.15 |
23
  | capacity lookback | 42 days |
24
  | emergency operational scale | 0.5 |
25
+ | capacity weekly limit fraction | 0.99 |
 
 
26
 
27
+ ## Contract
28
 
29
+ - Each scenario uses only readings with `end_time <= scenario.start_time`.
30
+ - EOL is reconstructed as the evaluator defines it: strict `10 < T < 30`, daily median,
31
+ days with fewer than five readings masked, seven-calendar-day rolling median with
32
+ `min_periods=3`, first smoothed voltage `<= 2.40 V`. This matches all 82 observed train
33
+ EOL devices; censored devices remain censored.
34
+ - Runtime: `batteryswap_public==0.3.4`, CPU only, no network, every live battery emitted
35
+ once with a valid plan date, 19,890 rows on the train split.
 
36
 
37
+ MIT licensed (`LICENSE`). Third-party notices in `THIRD_PARTY_LICENSES.md`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
REPRODUCIBILITY.md CHANGED
@@ -1,114 +1,34 @@
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.
 
1
+ # Reproducing
2
 
3
+ Dataset revision `7f423ac4cb6ab146f7ea7a37872eb4dfc3c9705c`; place the train split in
4
+ `data/raw/train/`.
5
 
6
+ The artifact wraps a byte-preserved base model
7
+ (`63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3`) and differs from it
8
+ only in planner policy constants. `scripts/build_hierarchical_fpt_submission.py` rebuilds
9
+ it and asserts the embedded base hash.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  ```bash
12
+ docker build -t batteryswap .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
 
 
 
 
 
 
14
  docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
15
+ batteryswap /app/env/bin/python3 scripts/build_hierarchical_fpt_submission.py \
16
+ --output submission_artifacts/weekly99_planner.joblib \
17
+ --manifest submission_artifacts/weekly99_planner.json
18
  ```
19
 
20
+ Run and test:
 
21
 
22
  ```bash
23
+ docker run --rm --network none -e BATTERYSWAP_SPLITS=train \
24
+ -e BATTERYSWAP_SUBMISSION_PATH=/out/submission.csv \
 
 
 
25
  -v "$(pwd)/data/raw":/tmp/data:ro -v "$(pwd)/artifacts":/out \
26
+ batteryswap /app/env/bin/python3 script.py
 
27
 
28
+ docker run --rm -v "$(pwd)":/work -w /work -e PYTHONPATH=/work/src \
29
+ batteryswap /app/env/bin/python3 -m pytest -q
 
 
 
 
 
 
30
  ```
31
 
32
+ Expected: 19,890 rows, byte-identical across repeated runs, inside the 30-minute and
33
+ 32 GB limits. Re-pickling is not guaranteed byte-reproducible; determinism is checked on
34
+ the emitted CSV.
 
 
 
 
 
 
 
scripts/build_hierarchical_fpt_submission.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the frozen hierarchical-FPT wrapper around the public V05 artifact.
2
+
3
+ The wrapper carries two operational corrections on top of V05's policy, each
4
+ validated independently over 48 dates x 27 bases:
5
+
6
+ * ``capacity_lookback_days`` 14 -> 42 (V06): the repair pass may relocate work
7
+ across the whole horizon instead of a fortnight. -4.68, t=-4.38.
8
+ * ``emergency_operational_scale`` 0 -> 0.5 (V07): a missed battery becomes its own
9
+ working day with a dedicated round trip, measured at 65.56 beyond the late
10
+ penalty against the flat 2.0 the selection assumed. -14.62, t=-4.11.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import importlib.metadata
18
+ import io
19
+ import json
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ import joblib
24
+ from dataclasses import replace
25
+
26
+ from batteryswapai.hierarchical_fpt import (
27
+ EOL_VOLTAGE,
28
+ HORIZON_DAYS,
29
+ MAX_STATE_STALENESS_DAYS,
30
+ MIN_WEEKLY_INCREMENTS,
31
+ RANK_RESIDUAL_WEIGHT,
32
+ SCHEMA_VERSION,
33
+ TERMINAL_WEEKS,
34
+ HierarchicalFPTPlanner,
35
+ )
36
+
37
+
38
+ PUBLIC_V05_GIT_SPEC = "public/v05:submission_artifacts/planner.joblib"
39
+ PUBLIC_V05_SHA256 = "63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3"
40
+
41
+ CAPACITY_LOOKBACK_DAYS = 42
42
+ EMERGENCY_OPERATIONAL_SCALE = 0.5
43
+ CAPACITY_WEEKLY_LIMIT_FRACTION = 0.99
44
+
45
+
46
+ def parse_args() -> argparse.Namespace:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument(
49
+ "--output",
50
+ type=Path,
51
+ default=Path("submission_artifacts/hierarchical_fpt_planner.joblib"),
52
+ )
53
+ parser.add_argument(
54
+ "--manifest",
55
+ type=Path,
56
+ default=Path("submission_artifacts/hierarchical_fpt_planner.json"),
57
+ )
58
+ parser.add_argument("--base-git-spec", default=PUBLIC_V05_GIT_SPEC)
59
+ return parser.parse_args()
60
+
61
+
62
+ def _sha_bytes(payload: bytes) -> str:
63
+ return hashlib.sha256(payload).hexdigest()
64
+
65
+
66
+ def _sha(path: Path) -> str:
67
+ return hashlib.sha256(path.read_bytes()).hexdigest()
68
+
69
+
70
+ def _git_bytes(spec: str) -> bytes:
71
+ completed = subprocess.run(
72
+ ["git", "show", spec], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
73
+ )
74
+ return completed.stdout
75
+
76
+
77
+ def main() -> None:
78
+ args = parse_args()
79
+ repo = Path(__file__).resolve().parents[1]
80
+ payload = _git_bytes(args.base_git_spec)
81
+ base_sha = _sha_bytes(payload)
82
+ if base_sha != PUBLIC_V05_SHA256:
83
+ raise AssertionError(
84
+ f"public V05 SHA mismatch: expected {PUBLIC_V05_SHA256}, found {base_sha}"
85
+ )
86
+ base_planner = joblib.load(io.BytesIO(payload))
87
+ if int(base_planner.policy.capacity_lookback_days) != 14:
88
+ raise AssertionError("embedded planner is not public V05 capacity policy")
89
+ base_planner.policy = replace(
90
+ base_planner.policy,
91
+ capacity_lookback_days=CAPACITY_LOOKBACK_DAYS,
92
+ emergency_operational_scale=EMERGENCY_OPERATIONAL_SCALE,
93
+ capacity_weekly_limit_fraction=CAPACITY_WEEKLY_LIMIT_FRACTION,
94
+ )
95
+ wrapper = HierarchicalFPTPlanner(
96
+ base_planner=base_planner,
97
+ base_artifact_sha256=base_sha,
98
+ )
99
+ args.output.parent.mkdir(parents=True, exist_ok=True)
100
+ joblib.dump(wrapper, args.output, compress=3)
101
+ loaded = joblib.load(args.output)
102
+ if loaded.base_artifact_sha256 != PUBLIC_V05_SHA256:
103
+ raise AssertionError("serialized wrapper lost its public V05 provenance")
104
+ manifest = {
105
+ "artifact": args.output.as_posix(),
106
+ "artifact_sha256": _sha(args.output),
107
+ "schema_version": SCHEMA_VERSION,
108
+ "base_artifact": args.base_git_spec,
109
+ "base_artifact_sha256": base_sha,
110
+ "configuration": {
111
+ "eol_voltage": EOL_VOLTAGE,
112
+ "horizon_days": HORIZON_DAYS,
113
+ "terminal_nonoverlap_weeks": TERMINAL_WEEKS,
114
+ "minimum_weekly_increments": MIN_WEEKLY_INCREMENTS,
115
+ "maximum_state_staleness_days": MAX_STATE_STALENESS_DAYS,
116
+ "rank_residual_weight": RANK_RESIDUAL_WEIGHT,
117
+ "capacity_lookback_days": CAPACITY_LOOKBACK_DAYS,
118
+ "emergency_operational_scale": EMERGENCY_OPERATIONAL_SCALE,
119
+ "capacity_weekly_limit_fraction": CAPACITY_WEEKLY_LIMIT_FRACTION,
120
+ "outer_pool": "leave current target building out",
121
+ "smoothing": (
122
+ "strict 10<T<30; daily median count>=5; rolling seven-calendar-day "
123
+ "median min_periods=3; terminal calendar day strictly before cutoff"
124
+ ),
125
+ },
126
+ "promotion_evidence": {
127
+ "official_48": "artifacts/hierarchical_fpt_official.json",
128
+ "robust_48x27": "artifacts/hierarchical_fpt_grid.cases.json",
129
+ },
130
+ "source_sha256": {
131
+ name: _sha(repo / name)
132
+ for name in (
133
+ "src/batteryswapai/hierarchical_fpt.py",
134
+ "src/batteryswapai/identity_ensemble.py",
135
+ "src/batteryswapai/competition_planner.py",
136
+ "scripts/experiment_hierarchical_fpt.py",
137
+ "scripts/evaluate_hierarchical_fpt_grid.py",
138
+ "scripts/build_hierarchical_fpt_submission.py",
139
+ "script.py",
140
+ "Dockerfile",
141
+ "requirements.txt",
142
+ )
143
+ },
144
+ "runtime_versions": {
145
+ package: importlib.metadata.version(package)
146
+ for package in (
147
+ "batteryswap_public", "joblib", "numpy", "pandas", "scipy",
148
+ "scikit-learn",
149
+ )
150
+ },
151
+ }
152
+ args.manifest.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
153
+ print(json.dumps(manifest, indent=2))
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
scripts/build_identity_submission.py DELETED
@@ -1,490 +0,0 @@
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 DELETED
@@ -1,450 +0,0 @@
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/verify_identity_feature_parity.py DELETED
@@ -1,261 +0,0 @@
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()