File size: 17,760 Bytes
b89e779 e3fa964 b89e779 e3fa964 b89e779 e3fa964 b89e779 fc9c286 41a82cd c7d5d98 41a82cd c7d5d98 b89e779 e3fa964 b89e779 fc9c286 e3fa964 fc9c286 e3fa964 fc9c286 cedc5cb e3fa964 cedc5cb fc9c286 b636dfd b89e779 fc9c286 b89e779 fc9c286 b89e779 e3fa964 b89e779 fc9c286 b636dfd fc9c286 b89e779 fc9c286 b89e779 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 | """
Precompute the (device, origin) feature/label table used by every model.
One row per (alive device, scenario origin). Features use only data at or before
the origin; no fleet-wide statistics are computed here, so slicing at an origin
cannot leak the future. Labels use the full eol_times and are for
training/evaluation only - the planner never reads them.
Cached to dataset/train/features.parquet (rebuild with --force).
"""
from pathlib import Path
import os
import sys
import numpy as np
import pandas as pd
from batteryswap_public.utils import load_dataset
MIN_TEMP = 10
MAX_TEMP = 30
WINDOW_DAYS = 42
def daily_voltage(timeseries: pd.DataFrame) -> pd.DataFrame:
"""In-band daily median voltage per device: (device_id, day, v)."""
df = timeseries.reset_index()
# device_id is categorical in the parquet; observed=False groupbys would emit
# the full device x day cartesian product with NaN voltages
df['device_id'] = df['device_id'].astype(str)
df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
df['day'] = df['end_time'].dt.normalize()
out = df.groupby(['device_id', 'day'])['voltage'].median().rename('v').reset_index()
return out.dropna(subset=['v']).sort_values('day')
def slope(days: np.ndarray, values: np.ndarray) -> float:
"""Per-day slope; NaN when under 2 points."""
if len(values) < 2:
return np.nan
x = (days - days[-1]) / np.timedelta64(1, 'D')
return float(np.polyfit(x.astype(float), values, 1)[0])
def detect_swaps(dall: pd.DataFrame, beta: float) -> pd.Series:
"""
Last UNDOCUMENTED battery-swap day per device, from daily corrected
voltage <= whatever the caller masked to (leak-free if dall is masked to
the origin). A swap = consecutive daily readings jumping up by >= 0.15 V
from a degraded level (<= 2.85) onto the fresh plateau (>= 3.00). The
landing gate is load-bearing: the detected set is identical for jump
thresholds 0.10-0.25 (real swaps jump >= 0.28 V, nearest noise landing
2.989), so the rule sits in a wide dead zone. Devices without a detected
swap are absent from the result.
"""
d = dall.sort_values(['device_id', 'day'])
vc = d['v'] - beta * (d['t'] - 20.0)
same = d['device_id'].eq(d['device_id'].shift())
jump = (vc - vc.shift() >= 0.15) & (vc.shift() <= 2.85) & (vc >= 3.00) & same
return d.loc[jump].groupby('device_id')['day'].max()
def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
origin: pd.Timestamp, swaps: pd.Series = None) -> pd.DataFrame:
"""Feature rows for every device with any reading at or before origin.
swaps: optional device -> last-swap-day map (detect_swaps). History before
a device's swap belongs to the PREVIOUS battery: it is dropped so slopes,
baselines and windows describe the battery actually in the slot, and
age_days restarts at the swap.
"""
h = daily[daily['day'] <= origin]
if swaps is not None and len(swaps):
cut = h['device_id'].map(swaps)
h = h[cut.isna() | (h['day'] >= cut)]
g = h.groupby('device_id')
last = g.tail(1).set_index('device_id')
feats = pd.DataFrame({'v_last': last['v']})
feats['staleness'] = (origin - last['day']).dt.days
# per-device historical reference: median over first 90 observed days
first_day = g['day'].min()
early = h[h['day'] <= h['device_id'].map(first_day) + pd.Timedelta(days=90)]
feats['v_ref'] = early.groupby('device_id')['v'].median()
feats['v_drop'] = feats['v_ref'] - feats['v_last']
for w in (30, 90):
hw = h[h['day'] > origin - pd.Timedelta(days=w)]
gw = hw.groupby('device_id')
feats[f'slope_{w}'] = gw.apply(
lambda u: slope(u['day'].values, u['v'].values), include_groups=False)
feats[f'v_min_{w}'] = gw['v'].min()
feats[f'n_{w}'] = gw['v'].size()
# window-free slope over the last K observations: sparse traces (the worst
# recall misses) have <2 points in any fixed window but still have history
lastk = g.tail(10)
feats['slope_lastk'] = lastk.groupby('device_id').apply(
lambda u: slope(u['day'].values, u['v'].values), include_groups=False)
# recent drop: median of days -28..-7 minus the latest value (trend, not level)
prev = h[(h['day'] > origin - pd.Timedelta(days=28)) &
(h['day'] <= origin - pd.Timedelta(days=7))]
feats['drop_recent'] = prev.groupby('device_id')['v'].median() - feats['v_last']
# fraction of the last 30 observations below the personal baseline - 0.15
last30 = g.tail(30).set_index('device_id')
below = last30['v'] < (feats['v_ref'].reindex(last30.index) - 0.15)
feats['below_frac'] = below.groupby(level=0).mean()
feats['accel'] = feats['slope_30'] - feats['slope_90']
# ---- fleet-relative encoding (REJECTED - phase 41) ----------------------
# Ported from the blind-harness champion, where vrel and vmin7_rank were
# the two largest single-feature wins. Both are computed at PLAN time over
# the fleet actually in front of the planner, so they absorb a fleet-level
# voltage shift that the absolute features (v_last, v_min_30, ...) cannot.
# The motivation was TRANSFER to a held-out fleet.
#
# NOT IN models_cv.FEATURES, and they must not be added. Re-ablated on
# recall at FIXED N (the phase-39 directive), which is the only way to see
# a feature's effect on ordering separately from its effect on the topsum
# count. Deaths caught, paired per origin, bootstrap 95% CI:
#
# native all48 static gate (5 splits)
# vrel+vmin7 N=12 -7 [-15,+0] N=12 +4 [-2,+10]
# N=20 +5 [ -1,+11] N=20 -1 [-9, +7]
#
# The sign flips with N on BOTH instruments and every CI straddles zero.
# The ordering is untouched at 36-41 of 48 native origins and 228 of 240
# static ones - these features barely enter the ranking at all. The one CI
# that excludes zero is vrel alone, native, N=12: -6 [-12,-1], negative.
# Cost confirms it: all48 1800.4 vs 1661.7 (+138.7).
#
# Left computed rather than deleted only to avoid regenerating the
# packaged parquet on submission day; the two columns cost ~1s inside
# build_split_table's 360s budget. Delete them when there is slack.
#
# Deliberately NOT filled here. The classifier pipeline median-imputes and
# then scales, which parks a stale/dark device at "unremarkable" so it is
# never selected; three independent measurements found that making those
# devices visible instead is a monotone loss. Do not add fillna() or
# na_option= to the rank.
#
# Population note: the denominator is whatever has history at or before the
# origin, dead-but-not-yet-dropped devices included. That is identical in
# the training-table build and in the runtime path, which is what matters -
# both sides see the same population definition.
h7 = h[h['day'] > origin - pd.Timedelta(days=7)]
feats['v_min_7'] = h7.groupby('device_id')['v'].min()
feats['vrel'] = feats['v_last'] - feats['v_last'].median()
feats['vmin7_rank'] = feats['v_min_7'].rank(pct=True)
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
eff_install = install.reindex(feats.index)
if swaps is not None and len(swaps):
sw = swaps.reindex(feats.index)
eff_install = eff_install.where(sw.isna() | (eff_install >= sw), sw)
feats['age_days'] = (origin - eff_install).dt.days
return feats
# ------------------------------------------------------------- physics model
# Empirically validated on the train split: temperature moves voltage by
# ~+5 mV/degC within a device-week, and the 82 observed deaths all cross
# ~2.40 V, approaching it along a shared discharge curve (sd 0.034 V at EOL).
TAU_MAX = 300 # curve support, days before EOL
FIT_WINDOW = 120 # days of history used for trajectory matching
BETA_DEFAULT = 0.005
def daily_all(timeseries: pd.DataFrame) -> pd.DataFrame:
"""Daily median voltage AND temperature per device, all readings kept."""
df = timeseries.reset_index()
df['device_id'] = df['device_id'].astype(str)
df['day'] = df['end_time'].dt.normalize()
out = df.groupby(['device_id', 'day']).agg(
v=('voltage', 'median'), t=('temperature', 'median')).reset_index()
return out.dropna(subset=['v']).sort_values('day')
def weekly_betas(daily: pd.DataFrame) -> pd.DataFrame:
"""Per (device, week) voltage-vs-temperature slope; 'week_end' for cutoffs."""
d = daily.copy()
d['week'] = d['day'].dt.to_period('W')
def beta_of(g):
if len(g) < 5 or g['t'].std() < 1:
return np.nan
return np.polyfit(g['t'], g['v'], 1)[0]
b = d.groupby(['device_id', 'week']).apply(beta_of, include_groups=False).dropna()
b = b.rename('beta').reset_index()
b['week_end'] = b['week'].dt.end_time.dt.normalize()
return b
def master_curve(daily: pd.DataFrame, dead_eol: pd.Series, beta: float,
min_dead: int = 5) -> 'np.ndarray | None':
"""Median temp-corrected voltage at each days-before-EOL in 0..TAU_MAX."""
if len(dead_eol) < min_dead:
return None
d = daily[daily['device_id'].isin(dead_eol.index)].copy()
d['v_corr'] = d['v'] - beta * (d['t'] - 20.0)
d['tau'] = (d['device_id'].map(dead_eol).dt.normalize() - d['day']).dt.days
d = d[(d['tau'] >= 0) & (d['tau'] <= TAU_MAX)]
med = d.groupby('tau')['v_corr'].median()
grid = np.arange(0, TAU_MAX + 1)
curve = np.interp(grid, med.index.values, med.values)
return np.maximum.accumulate(curve) # enforce monotone non-decreasing in tau
def physics_features(daily: pd.DataFrame, origin: pd.Timestamp,
beta: float, curve, match: bool = True,
swaps: pd.Series = None) -> pd.DataFrame:
"""
Physics-model features.
v_corr_last - latest temp-corrected voltage (full history, no window)
thresh_dist - v_corr_last minus the curve's EOL voltage
tau_projected - C^-1(v_corr_last) minus staleness: days-to-EOL projected
forward through the silence. The key feature for devices
that go dark and keep discharging - a stale healthy-ish
reading minus months of silence can mean "due now".
tau_hat/fit_err - trajectory registration (match=True only; expensive)
"""
all_h = daily[daily['day'] <= origin]
if swaps is not None and len(swaps):
cut = all_h['device_id'].map(swaps)
all_h = all_h[cut.isna() | (all_h['day'] >= cut)]
last = all_h.sort_values('day').groupby('device_id').tail(1).set_index('device_id')
v_corr = last['v'] - beta * (last['t'] - 20.0)
stale = (origin - last['day']).dt.days
out = pd.DataFrame({'v_corr_last': v_corr})
# ghost marker: days since the corrected voltage first crossed below 2.45
# (post-swap history only). A real recorded death follows the crossing
# within days; a crossing >42d old with no EOL on file means the recording
# process abandoned this device - it will never be scored as a death.
vc_all = all_h['v'] - beta * (all_h['t'] - 20.0)
below = all_h.loc[vc_all < 2.45]
first_below = below.groupby('device_id')['day'].min()
out['days_below_245'] = (origin - first_below.reindex(out.index)).dt.days
# plunge precursors: volatility of the corrected voltage rises BEFORE the
# level falls (AUC 0.83/0.81 on the voltage-silent dying cohort, where the
# level itself is blind). These are the fast-plunge early-warning features.
h30 = all_h[all_h['day'] > origin - pd.Timedelta(days=30)].copy()
h30['v_corr'] = h30['v'] - beta * (h30['t'] - 20.0)
g30 = h30.groupby('device_id')['v_corr']
out['vstd_30'] = g30.std()
out['rng_30'] = g30.max() - g30.min()
if curve is not None:
grid = np.arange(0, TAU_MAX + 1)
tau_at_last = np.interp(v_corr.values, curve, grid)
out['thresh_dist'] = v_corr - curve[0]
out['tau_projected'] = tau_at_last - stale.values
else:
out['thresh_dist'] = np.nan
out['tau_projected'] = np.nan
out['tau_hat'] = np.nan
out['fit_err'] = np.nan
if match and curve is not None:
h = all_h[all_h['day'] > origin - pd.Timedelta(days=FIT_WINDOW)].copy()
h['v_corr'] = h['v'] - beta * (h['t'] - 20.0)
h['delta'] = (origin - h['day']).dt.days
taus = np.arange(1, TAU_MAX - FIT_WINDOW)
for dev, g in h.groupby('device_id'):
if len(g) >= 5:
idx = taus[:, None] + g['delta'].values[None, :]
err = np.abs(g['v_corr'].values[None, :] - curve[idx]).mean(axis=1)
best = err.argmin()
out.at[dev, 'tau_hat'] = float(taus[best])
out.at[dev, 'fit_err'] = float(err[best])
return out
def cohort_features(devices: pd.DataFrame, eol_times: pd.Series,
origin: pd.Timestamp) -> pd.DataFrame:
"""
Install-cohort calibrated clock. Batteries from the same install batch die at
nearly the same age (batch explains ~90% of lifetime variance), so
age - median(batch lifetimes observed so far) predicts death even when the
voltage has not moved yet (AUC 0.91 on voltage-silent pairs).
Uses only deaths <= origin.
"""
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
dead = eol_times[eol_times.notna() & (eol_times <= origin)]
life = (dead - install.reindex(dead.index)).dt.days.rename('life')
out = pd.DataFrame(index=install.index)
age = (origin - install).dt.days
for col, key in [('cohort_rel_age', install),
('cohort_rel_age_m', install.dt.to_period('M'))]:
batch_life = life.groupby(key.reindex(life.index)).agg(['median', 'size'])
med = key.map(batch_life['median'].where(batch_life['size'] >= 2))
out[col] = age - med
# neutral-when-unknown encoding: fires only past the batch's observed
# lifetime, exactly 0 otherwise - no imputation noise for the majority
out[col.replace('rel_age', 'overdue')] = (age - med).clip(lower=0).fillna(0)
out.index.name = 'device_id'
return out
def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
daily = daily_voltage(timeseries)
dall = daily_all(timeseries)
betas = weekly_betas(dall)
# real scenario origins, plus synthetic training-only origins: a denser grid
# (every 4 days) extended back before s_0, so every observed death lands in
# training windows and positive pairs are ~3x more numerous. Synthetic rows are
# never scored - they only feed training_pairs().
real_starts = [pd.Timestamp(sc['start_time']) for sc in scenarios]
synth = pd.date_range('2025-02-15', max(real_starts), freq='4D')
synth = [t for t in synth if t not in set(real_starts)]
origins = ([(idx, t, False) for idx, t in enumerate(real_starts)] +
[(1000 + i, t, True) for i, t in enumerate(synth)])
rows = []
for idx, origin, is_synth in origins:
alive = eol_times.isna() | (eol_times > origin)
alive_ids = eol_times.index[alive]
# NOTE (phase 15): detect_swaps truncation was tested here and NOT
# adopted - panel 1684.5 vs 1679.2. The undocumented-swap age error
# touches only age_days (weak, linear); the machinery stays available
# via the swaps= parameter but production builds run without it.
past = betas[betas['week_end'] <= origin]['beta']
beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
f = features_at_origin(daily, locations, origin)
f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
# physics features: beta and curve from information <= origin only.
# Synthetic origins get the light variant (no trajectory matching).
dead = eol_times[eol_times.notna() & (eol_times <= origin)]
curve = master_curve(dall[dall['day'] <= origin], dead, beta)
f = f.join(physics_features(dall, origin, beta, curve, match=not is_synth))
f = f.join(cohort_features(locations, eol_times, origin))
f['synthetic'] = is_synth
f['origin_idx'] = idx
f['origin'] = origin
eol = eol_times.reindex(alive_ids)
f['eol'] = eol.values
f['dies42'] = ((eol.notna()) & (eol <= origin + pd.Timedelta(days=WINDOW_DAYS))).astype(int).values
f['days_to_eol'] = ((eol - origin).dt.total_seconds() / 86400).values
f['window_closed'] = origin + pd.Timedelta(days=WINDOW_DAYS) <= pd.Timestamp('2026-07-31')
rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
print(f'origin {idx} {origin.date()}: {len(f)} alive, '
f'{f["dies42"].sum()} die in window', file=sys.stderr)
table = pd.concat(rows, ignore_index=True)
table.to_parquet(out_path)
return table
def load(dataset_path: Path = Path('dataset'), force: bool = False) -> pd.DataFrame:
out_path = dataset_path / 'train' / 'features.parquet'
if out_path.exists() and not force:
return pd.read_parquet(out_path)
return build(dataset_path, out_path)
if __name__ == '__main__':
force = '--force' in sys.argv
dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
t = load(dataset_path, force=force)
print(t.groupby('origin_idx')['dies42'].agg(['size', 'sum']).T)
print(t.describe().round(3).to_string())
|