Submission 008 - hierarchical Wiener first-passage rank residual plus emergency-cost pricing and full-horizon capacity lookback over the byte-preserved public V05 base
Browse filesWraps the public V05 artifact (63d71091) unchanged and adds three mechanisms,
each validated over 48 dates x 27 bases (1296 paired cases):
hierarchical Wiener first-passage rank residual, weight 0.15
capacity_lookback_days 14 -> 42
emergency_operational_scale 0 -> 0.5
Combined: -79.85 total, paired t -2.81, all 27 bases improve, both calendar
halves negative, block-bootstrap CI [-133.29, -25.55], P(improvement) 0.9984.
Every one of the nine cost components improves: late -55.58, early -13.21,
daily -7.25, travel -1.08, overtime -1.68, weekly -0.62. TP 6.208 -> 6.418,
misses 3.250 -> 3.040.
Offline replay under --network none: 19,890 rows, 370-381 s per split,
peak 1.53 GiB, byte-identical submission.csv across runs.
The temperature/identity rerank from submission 007 is not used.
|
@@ -16,6 +16,7 @@ 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
|
|
@@ -25,6 +26,7 @@ submission_artifacts/*
|
|
| 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/
|
|
|
|
| 16 |
!submission_artifacts/planner.json
|
| 17 |
!submission_artifacts/identity_planner.json
|
| 18 |
!submission_artifacts/temperature_planner.json
|
| 19 |
+
!submission_artifacts/hierarchical_fpt_planner.json
|
| 20 |
|
| 21 |
# Model files
|
| 22 |
*.cbm
|
|
|
|
| 26 |
!submission_artifacts/identity_planner.joblib
|
| 27 |
!submission_artifacts/temperature_planner.joblib
|
| 28 |
!submission_artifacts/v05_planner.joblib
|
| 29 |
+
!submission_artifacts/hierarchical_fpt_planner.joblib
|
| 30 |
|
| 31 |
# Local training logs
|
| 32 |
catboost_info/
|
|
@@ -45,7 +45,7 @@ def main() -> None:
|
|
| 45 |
artifact_path = Path(
|
| 46 |
os.environ.get(
|
| 47 |
"BATTERYSWAP_PLANNER_PATH",
|
| 48 |
-
"submission_artifacts/
|
| 49 |
)
|
| 50 |
)
|
| 51 |
splits = [
|
|
|
|
| 45 |
artifact_path = Path(
|
| 46 |
os.environ.get(
|
| 47 |
"BATTERYSWAP_PLANNER_PATH",
|
| 48 |
+
"submission_artifacts/hierarchical_fpt_planner.joblib",
|
| 49 |
)
|
| 50 |
)
|
| 51 |
splits = [
|
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen hierarchical Wiener first-passage residual for the public V05 planner.
|
| 2 |
+
|
| 3 |
+
This module is inference-only. It estimates a battery's terminal drift from
|
| 4 |
+
twelve non-overlapping weekly increments of the evaluator-exact smoothed median
|
| 5 |
+
voltage, shrinks that drift toward a leave-target-building pool, and uses the
|
| 6 |
+
analytic Wiener first-passage probability as a 15% rank residual. It never
|
| 7 |
+
changes V05's probability multiset, RUL heads, quota, timing policy, or planner.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
from scipy.stats import norm, rankdata
|
| 18 |
+
|
| 19 |
+
from .competition_planner import CompetitionPlanner
|
| 20 |
+
from .identity_ensemble import (
|
| 21 |
+
CausalHistoryCache,
|
| 22 |
+
CausalHistoryView,
|
| 23 |
+
planner_freshness_factors,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
SCHEMA_VERSION = 1
|
| 28 |
+
HORIZON_DAYS = 42.0
|
| 29 |
+
EOL_VOLTAGE = 2.40
|
| 30 |
+
TERMINAL_WEEKS = 12
|
| 31 |
+
MIN_WEEKLY_INCREMENTS = 4
|
| 32 |
+
MAX_STATE_STALENESS_DAYS = 7.0
|
| 33 |
+
RANK_RESIDUAL_WEIGHT = 0.15
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class FPTSnapshotFeatures:
|
| 38 |
+
last_smooth_voltage: np.ndarray
|
| 39 |
+
smooth_staleness_days: np.ndarray
|
| 40 |
+
weekly_increment_count: np.ndarray
|
| 41 |
+
eb_drift_v_per_day: np.ndarray
|
| 42 |
+
pooled_diffusion_v_per_sqrt_day: np.ndarray
|
| 43 |
+
eb_device_weight: np.ndarray
|
| 44 |
+
hit_probability_42d: np.ndarray
|
| 45 |
+
reliable: np.ndarray
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def terminal_weekly_increments(
|
| 49 |
+
series: pd.Series,
|
| 50 |
+
cutoff: pd.Timestamp | str,
|
| 51 |
+
) -> tuple[float, float, np.ndarray]:
|
| 52 |
+
"""Return the last pre-cut state and twelve disjoint seven-day changes.
|
| 53 |
+
|
| 54 |
+
The strict calendar-day cut matters because official scenarios start at
|
| 55 |
+
midnight. A cutoff-day aggregate could otherwise include later readings
|
| 56 |
+
from that same day when a full series is used for an offline audit.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
cutoff_day = pd.Timestamp(cutoff).normalize()
|
| 60 |
+
if series.empty:
|
| 61 |
+
return math.nan, math.inf, np.asarray([], dtype=float)
|
| 62 |
+
work = pd.Series(
|
| 63 |
+
pd.to_numeric(series, errors="coerce").to_numpy(float),
|
| 64 |
+
index=pd.DatetimeIndex(series.index).normalize(),
|
| 65 |
+
).sort_index(kind="stable")
|
| 66 |
+
if work.index.has_duplicates:
|
| 67 |
+
raise ValueError("smoothed voltage series has duplicate calendar days")
|
| 68 |
+
past = work.loc[work.index < cutoff_day].dropna()
|
| 69 |
+
if past.empty:
|
| 70 |
+
return math.nan, math.inf, np.asarray([], dtype=float)
|
| 71 |
+
last_day = pd.Timestamp(past.index[-1])
|
| 72 |
+
anchors = pd.DatetimeIndex(
|
| 73 |
+
[
|
| 74 |
+
last_day - pd.Timedelta(days=7 * offset)
|
| 75 |
+
for offset in range(TERMINAL_WEEKS, -1, -1)
|
| 76 |
+
]
|
| 77 |
+
)
|
| 78 |
+
values = work.reindex(anchors).to_numpy(float)
|
| 79 |
+
increments = np.diff(values)
|
| 80 |
+
increments = increments[np.isfinite(increments)]
|
| 81 |
+
staleness = float((cutoff_day - last_day) / pd.Timedelta(days=1))
|
| 82 |
+
return float(past.iloc[-1]), staleness, increments
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _robust_scale(values: np.ndarray) -> float:
|
| 86 |
+
values = np.asarray(values, dtype=float)
|
| 87 |
+
center = float(np.median(values))
|
| 88 |
+
return float(1.4826 * np.median(np.abs(values - center)))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def wiener_hit_probability(
|
| 92 |
+
initial_voltage: float,
|
| 93 |
+
drift_v_per_day: float,
|
| 94 |
+
diffusion_v_per_sqrt_day: float,
|
| 95 |
+
horizon_days: float = HORIZON_DAYS,
|
| 96 |
+
) -> float:
|
| 97 |
+
"""Analytic probability that drifted Brownian voltage hits 2.40 V."""
|
| 98 |
+
|
| 99 |
+
distance = float(initial_voltage - EOL_VOLTAGE)
|
| 100 |
+
if distance <= 0.0:
|
| 101 |
+
return 1.0
|
| 102 |
+
diffusion = max(float(diffusion_v_per_sqrt_day), 1e-6)
|
| 103 |
+
horizon = float(horizon_days)
|
| 104 |
+
scale = diffusion * math.sqrt(horizon)
|
| 105 |
+
drift = float(drift_v_per_day)
|
| 106 |
+
first = norm.cdf((-drift * horizon - distance) / scale)
|
| 107 |
+
log_second = (
|
| 108 |
+
-2.0 * drift * distance / (diffusion * diffusion)
|
| 109 |
+
+ norm.logcdf((drift * horizon - distance) / scale)
|
| 110 |
+
)
|
| 111 |
+
second = math.exp(min(float(log_second), 0.0))
|
| 112 |
+
return float(np.clip(first + second, 0.0, 1.0))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def hierarchical_fpt_features(
|
| 116 |
+
history: CausalHistoryView,
|
| 117 |
+
batteries: np.ndarray,
|
| 118 |
+
buildings: np.ndarray,
|
| 119 |
+
cutoff: pd.Timestamp | str,
|
| 120 |
+
) -> FPTSnapshotFeatures:
|
| 121 |
+
"""Compute the exact frozen residual features for one causal landmark."""
|
| 122 |
+
|
| 123 |
+
batteries = np.asarray(batteries, dtype=object)
|
| 124 |
+
buildings = np.asarray(buildings, dtype=object).astype(str)
|
| 125 |
+
if len(batteries) != len(buildings):
|
| 126 |
+
raise ValueError("battery and building arrays have different lengths")
|
| 127 |
+
state = np.full(len(batteries), np.nan)
|
| 128 |
+
staleness = np.full(len(batteries), np.inf)
|
| 129 |
+
changes: list[np.ndarray] = []
|
| 130 |
+
for position, battery in enumerate(batteries):
|
| 131 |
+
x0, gap, increments = terminal_weekly_increments(
|
| 132 |
+
history.smooth_lookup.get(str(battery), pd.Series(dtype=float)), cutoff
|
| 133 |
+
)
|
| 134 |
+
state[position], staleness[position] = x0, gap
|
| 135 |
+
changes.append(increments)
|
| 136 |
+
|
| 137 |
+
hit = np.full(len(batteries), np.nan)
|
| 138 |
+
drift_hat = np.full(len(batteries), np.nan)
|
| 139 |
+
diffusion_hat = np.full(len(batteries), np.nan)
|
| 140 |
+
shrinkage = np.full(len(batteries), np.nan)
|
| 141 |
+
for building in np.unique(buildings):
|
| 142 |
+
target = buildings == building
|
| 143 |
+
outer = ~target
|
| 144 |
+
pooled_parts = [
|
| 145 |
+
changes[position]
|
| 146 |
+
for position in np.flatnonzero(outer)
|
| 147 |
+
if len(changes[position])
|
| 148 |
+
]
|
| 149 |
+
if not pooled_parts:
|
| 150 |
+
continue
|
| 151 |
+
pooled = np.concatenate(pooled_parts)
|
| 152 |
+
pooled_drift = float(np.median(pooled) / 7.0)
|
| 153 |
+
diffusion = max(
|
| 154 |
+
_robust_scale(pooled - 7.0 * pooled_drift) / math.sqrt(7.0),
|
| 155 |
+
1e-6,
|
| 156 |
+
)
|
| 157 |
+
outer_devices = np.asarray(
|
| 158 |
+
[
|
| 159 |
+
position
|
| 160 |
+
for position in np.flatnonzero(outer)
|
| 161 |
+
if len(changes[position]) >= MIN_WEEKLY_INCREMENTS
|
| 162 |
+
],
|
| 163 |
+
dtype=int,
|
| 164 |
+
)
|
| 165 |
+
if outer_devices.size:
|
| 166 |
+
raw_drifts = np.asarray(
|
| 167 |
+
[np.median(changes[position]) / 7.0 for position in outer_devices]
|
| 168 |
+
)
|
| 169 |
+
between_variance = _robust_scale(raw_drifts) ** 2
|
| 170 |
+
noise_variance = np.asarray(
|
| 171 |
+
[
|
| 172 |
+
diffusion**2 / (7.0 * len(changes[position]))
|
| 173 |
+
for position in outer_devices
|
| 174 |
+
]
|
| 175 |
+
)
|
| 176 |
+
prior_variance = max(
|
| 177 |
+
float(between_variance - np.median(noise_variance)), 0.0
|
| 178 |
+
)
|
| 179 |
+
else:
|
| 180 |
+
prior_variance = 0.0
|
| 181 |
+
|
| 182 |
+
for position in np.flatnonzero(target):
|
| 183 |
+
count = len(changes[position])
|
| 184 |
+
if (
|
| 185 |
+
count < MIN_WEEKLY_INCREMENTS
|
| 186 |
+
or not np.isfinite(state[position])
|
| 187 |
+
or staleness[position] > MAX_STATE_STALENESS_DAYS
|
| 188 |
+
):
|
| 189 |
+
continue
|
| 190 |
+
raw_drift = float(np.median(changes[position]) / 7.0)
|
| 191 |
+
observation_variance = diffusion**2 / (7.0 * count)
|
| 192 |
+
weight = (
|
| 193 |
+
prior_variance / (prior_variance + observation_variance)
|
| 194 |
+
if prior_variance > 0.0
|
| 195 |
+
else 0.0
|
| 196 |
+
)
|
| 197 |
+
drift = weight * raw_drift + (1.0 - weight) * pooled_drift
|
| 198 |
+
hit[position] = wiener_hit_probability(state[position], drift, diffusion)
|
| 199 |
+
drift_hat[position] = drift
|
| 200 |
+
diffusion_hat[position] = diffusion
|
| 201 |
+
shrinkage[position] = weight
|
| 202 |
+
|
| 203 |
+
reliable = np.isfinite(hit)
|
| 204 |
+
return FPTSnapshotFeatures(
|
| 205 |
+
last_smooth_voltage=state,
|
| 206 |
+
smooth_staleness_days=staleness,
|
| 207 |
+
weekly_increment_count=np.asarray([len(value) for value in changes], dtype=int),
|
| 208 |
+
eb_drift_v_per_day=drift_hat,
|
| 209 |
+
pooled_diffusion_v_per_sqrt_day=diffusion_hat,
|
| 210 |
+
eb_device_weight=shrinkage,
|
| 211 |
+
hit_probability_42d=hit,
|
| 212 |
+
reliable=reliable,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def rerank_fpt_risk(
|
| 217 |
+
baseline: np.ndarray,
|
| 218 |
+
hit_probability: np.ndarray,
|
| 219 |
+
reliable: np.ndarray,
|
| 220 |
+
freshness: np.ndarray,
|
| 221 |
+
batteries: np.ndarray,
|
| 222 |
+
) -> np.ndarray:
|
| 223 |
+
"""Apply the frozen 15% residual without changing either risk multiset."""
|
| 224 |
+
|
| 225 |
+
baseline = np.asarray(baseline, dtype=float)
|
| 226 |
+
hit_probability = np.asarray(hit_probability, dtype=float)
|
| 227 |
+
reliable = np.asarray(reliable, dtype=bool)
|
| 228 |
+
freshness = np.asarray(freshness, dtype=float)
|
| 229 |
+
batteries = np.asarray(batteries, dtype=object)
|
| 230 |
+
lengths = {len(baseline), len(hit_probability), len(reliable), len(freshness), len(batteries)}
|
| 231 |
+
if len(lengths) != 1:
|
| 232 |
+
raise ValueError("FPT rerank inputs have different lengths")
|
| 233 |
+
if not np.isfinite(baseline).all() or not np.isfinite(freshness).all():
|
| 234 |
+
raise ValueError("base risks and freshness factors must be finite")
|
| 235 |
+
if not np.isfinite(hit_probability[reliable]).all():
|
| 236 |
+
raise ValueError("reliable FPT probabilities must be finite")
|
| 237 |
+
|
| 238 |
+
treatment = baseline.copy()
|
| 239 |
+
for factor in np.sort(np.unique(freshness)):
|
| 240 |
+
positions = np.flatnonzero((freshness == factor) & reliable)
|
| 241 |
+
if len(positions) < 2:
|
| 242 |
+
continue
|
| 243 |
+
base_rank = rankdata(baseline[positions], method="average") / len(positions)
|
| 244 |
+
fpt_rank = rankdata(hit_probability[positions], method="average") / len(positions)
|
| 245 |
+
score = (
|
| 246 |
+
(1.0 - RANK_RESIDUAL_WEIGHT) * base_rank
|
| 247 |
+
+ RANK_RESIDUAL_WEIGHT * fpt_rank
|
| 248 |
+
)
|
| 249 |
+
order = np.lexsort((batteries[positions].astype(str), score))
|
| 250 |
+
treatment[positions[order]] = np.sort(baseline[positions])
|
| 251 |
+
|
| 252 |
+
if not np.array_equal(np.sort(treatment), np.sort(baseline)):
|
| 253 |
+
raise AssertionError("FPT residual changed the raw risk multiset")
|
| 254 |
+
if not np.array_equal(
|
| 255 |
+
np.sort(treatment * freshness), np.sort(baseline * freshness)
|
| 256 |
+
):
|
| 257 |
+
raise AssertionError("FPT residual changed planner-effective risk multiset")
|
| 258 |
+
return treatment
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@dataclass
|
| 262 |
+
class HierarchicalFPTPlanner:
|
| 263 |
+
"""Serializable wrapper around the exact public V05 planner artifact."""
|
| 264 |
+
|
| 265 |
+
base_planner: CompetitionPlanner
|
| 266 |
+
base_artifact_sha256: str
|
| 267 |
+
schema_version: int = SCHEMA_VERSION
|
| 268 |
+
_history_cache: CausalHistoryCache | None = field(
|
| 269 |
+
default=None, init=False, repr=False, compare=False
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
def reset_split(self, split_id: str | None = None) -> None:
|
| 273 |
+
self._history_cache = CausalHistoryCache(split_id=split_id)
|
| 274 |
+
|
| 275 |
+
def plan_scenario(
|
| 276 |
+
self,
|
| 277 |
+
visible_history: pd.DataFrame,
|
| 278 |
+
snapshot: pd.DataFrame,
|
| 279 |
+
locations: pd.DataFrame,
|
| 280 |
+
travel_costs: pd.DataFrame,
|
| 281 |
+
settings,
|
| 282 |
+
start_time: pd.Timestamp | str,
|
| 283 |
+
) -> pd.DataFrame:
|
| 284 |
+
if self.schema_version != SCHEMA_VERSION:
|
| 285 |
+
raise RuntimeError(
|
| 286 |
+
f"hierarchical FPT schema {self.schema_version} != runtime {SCHEMA_VERSION}"
|
| 287 |
+
)
|
| 288 |
+
if self._history_cache is None:
|
| 289 |
+
self.reset_split(None)
|
| 290 |
+
assert self._history_cache is not None
|
| 291 |
+
start = pd.Timestamp(start_time)
|
| 292 |
+
history = self._history_cache.update(visible_history, start)
|
| 293 |
+
batteries = snapshot["battery"].astype(str).to_numpy(object)
|
| 294 |
+
if not np.array_equal(
|
| 295 |
+
batteries, locations["battery"].astype(str).to_numpy(object)
|
| 296 |
+
):
|
| 297 |
+
raise AssertionError("snapshot and locations battery order differs")
|
| 298 |
+
|
| 299 |
+
base_risk = self.base_planner.model.predict_event_risk(snapshot)
|
| 300 |
+
predicted_rul = self.base_planner.model.predict_rul(snapshot)
|
| 301 |
+
predicted_survivor = self.base_planner.model.predict_survivor_rul(snapshot)
|
| 302 |
+
if predicted_survivor is None:
|
| 303 |
+
predicted_survivor = np.full(
|
| 304 |
+
len(snapshot), float(settings.planning_window_days)
|
| 305 |
+
)
|
| 306 |
+
freshness = planner_freshness_factors(
|
| 307 |
+
snapshot["data_gap_days"].to_numpy(float), self.base_planner.policy
|
| 308 |
+
)
|
| 309 |
+
features = hierarchical_fpt_features(
|
| 310 |
+
history,
|
| 311 |
+
batteries,
|
| 312 |
+
snapshot["building"].astype(str).to_numpy(object),
|
| 313 |
+
start,
|
| 314 |
+
)
|
| 315 |
+
treatment_risk = rerank_fpt_risk(
|
| 316 |
+
base_risk,
|
| 317 |
+
features.hit_probability_42d,
|
| 318 |
+
features.reliable,
|
| 319 |
+
freshness,
|
| 320 |
+
batteries,
|
| 321 |
+
)
|
| 322 |
+
return self.base_planner.plan_snapshot(
|
| 323 |
+
snapshot,
|
| 324 |
+
locations,
|
| 325 |
+
travel_costs,
|
| 326 |
+
settings,
|
| 327 |
+
start,
|
| 328 |
+
predicted_rul=predicted_rul,
|
| 329 |
+
predicted_risk=treatment_risk,
|
| 330 |
+
predicted_survivor_rul=predicted_survivor,
|
| 331 |
+
)
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6e9262359591119c4979e1c21c13b02d842d7c04474a182fda5c2bcdcbc9a8a1
|
| 3 |
+
size 16262133
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"artifact": "submission_artifacts/hierarchical_fpt_planner.joblib",
|
| 3 |
+
"artifact_sha256": "6e9262359591119c4979e1c21c13b02d842d7c04474a182fda5c2bcdcbc9a8a1",
|
| 4 |
+
"schema_version": 1,
|
| 5 |
+
"base_artifact": "public/v05:submission_artifacts/planner.joblib",
|
| 6 |
+
"base_artifact_sha256": "63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3",
|
| 7 |
+
"configuration": {
|
| 8 |
+
"eol_voltage": 2.4,
|
| 9 |
+
"horizon_days": 42.0,
|
| 10 |
+
"terminal_nonoverlap_weeks": 12,
|
| 11 |
+
"minimum_weekly_increments": 4,
|
| 12 |
+
"maximum_state_staleness_days": 7.0,
|
| 13 |
+
"rank_residual_weight": 0.15,
|
| 14 |
+
"capacity_lookback_days": 42,
|
| 15 |
+
"emergency_operational_scale": 0.5,
|
| 16 |
+
"outer_pool": "leave current target building out",
|
| 17 |
+
"smoothing": "strict 10<T<30; daily median count>=5; rolling seven-calendar-day median min_periods=3; terminal calendar day strictly before cutoff"
|
| 18 |
+
},
|
| 19 |
+
"promotion_evidence": {
|
| 20 |
+
"official_48": "artifacts/hierarchical_fpt_official.json",
|
| 21 |
+
"robust_48x27": "artifacts/hierarchical_fpt_grid.cases.json"
|
| 22 |
+
},
|
| 23 |
+
"source_sha256": {
|
| 24 |
+
"src/batteryswapai/hierarchical_fpt.py": "51874f5fde70a2c868e5300cce3648d778279cfdb1d64ee3af5f6a30683eca0b",
|
| 25 |
+
"src/batteryswapai/identity_ensemble.py": "fa1f9aeaa89fba1bc5fc437c478c876544f2973960db18ef2c83f0042cc9df4a",
|
| 26 |
+
"src/batteryswapai/competition_planner.py": "a56bfe549a3d6fcfe59217f7bd8a647533a4484fdbb26fe3ef8f1d6c84035a98",
|
| 27 |
+
"scripts/experiment_hierarchical_fpt.py": "1ce854654813752930194413bcd5dff44984d105493a36c42e10945e23fa6b60",
|
| 28 |
+
"scripts/evaluate_hierarchical_fpt_grid.py": "90f764da5df050e37af15fc47524c629cce2b021de34b0eab39095383639a51e",
|
| 29 |
+
"scripts/build_hierarchical_fpt_submission.py": "39d4cc094642b6dbf0a03a9a5abff84c2639eb622ac117de32f04e5b6f0e8f78",
|
| 30 |
+
"script.py": "81d4ee862ef14125bd4e6f0c77ec4582a89a4deb95c50dd4c78b84124979628c",
|
| 31 |
+
"Dockerfile": "84a3f71b2ba664f91ceb8a2980dd5397a002eed02af9fdd264325caf89158573",
|
| 32 |
+
"requirements.txt": "53d9c582b98b392c0517dc87c78949cd13b99b3f09ebfce601d0c8eadd4f2db3"
|
| 33 |
+
},
|
| 34 |
+
"runtime_versions": {
|
| 35 |
+
"batteryswap_public": "0.3.4",
|
| 36 |
+
"joblib": "1.5.3",
|
| 37 |
+
"numpy": "2.2.6",
|
| 38 |
+
"pandas": "2.3.3",
|
| 39 |
+
"scipy": "1.14.1",
|
| 40 |
+
"scikit-learn": "1.7.2"
|
| 41 |
+
}
|
| 42 |
+
}
|