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())