Thibaut Forest
Claude Fable 5
Phase 34-35: retire cross-fleet CV, re-screen 72 configs, champion unchanged
7d93fb8
Raw History Blame Contribute Delete
4.19 kB
"""
Auxiliary (device, origin) features from raw hourly readings.
Everything in features.py is built on daily *medians*; this module mines the
within-day and meta-signal structure instead - discharge depth (daily voltage
range/std), telemetry intensity, thermal exposure, and reporting-gap dynamics.
Motivation: 27 of 60 remaining test-block misses had p42 < 0.10 because their
median voltage had not moved by the origin; usage stress may lead the droop.
Cached to dataset/train/aux_features.parquet, keyed (device_id, origin_idx),
aligned with features.parquet's origins. Only data <= origin is used.
"""
from pathlib import Path
import os
import sys
import numpy as np
import pandas as pd
MIN_TEMP, MAX_TEMP = 10, 30
AUX_FEATURES = ['rng_30', 'rng_90', 'rng_ratio', 'vstd_30', 'hot_90',
't_med_90', 'reads_30', 'reads_90', 'oob_90',
'gap_max_60', 'v_min_life']
def day_stats(timeseries: pd.DataFrame) -> pd.DataFrame:
df = timeseries.reset_index()
df['device_id'] = df['device_id'].astype(str)
df['day'] = df['end_time'].dt.normalize()
inband = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
g = inband.groupby(['device_id', 'day'])['voltage']
stats = pd.DataFrame({
'v_rng': g.max() - g.min(),
'v_std': g.std(),
'v_med': g.median(),
})
ga = df.groupby(['device_id', 'day'])
stats['n_reads'] = ga.size()
stats['t_med'] = ga['temperature'].median()
stats['hot'] = ga['temperature'].apply(lambda t: (t > 28).mean())
stats['oob'] = ga['temperature'].apply(
lambda t: ((t <= MIN_TEMP) | (t >= MAX_TEMP)).mean())
return stats.reset_index().sort_values('day')
def aux_at_origin(stats: pd.DataFrame, origin: pd.Timestamp) -> pd.DataFrame:
h = stats[stats['day'] <= origin]
out = {}
for w in (30, 90):
hw = h[h['day'] > origin - pd.Timedelta(days=w)]
gw = hw.groupby('device_id')
if w == 30:
out['rng_30'] = gw['v_rng'].mean()
out['vstd_30'] = gw['v_std'].mean()
out['reads_30'] = gw['n_reads'].mean()
else:
out['rng_90'] = gw['v_rng'].mean()
out['reads_90'] = gw['n_reads'].mean()
out['hot_90'] = gw['hot'].mean()
out['t_med_90'] = gw['t_med'].mean()
out['oob_90'] = gw['oob'].mean()
h60 = h[h['day'] > origin - pd.Timedelta(days=60)]
def max_gap(days):
d = np.sort(days.values)
edges = np.concatenate([d, [np.datetime64(origin)]])
return np.diff(edges).max() / np.timedelta64(1, 'D') if len(d) else np.nan
out['gap_max_60'] = h60.groupby('device_id')['day'].apply(max_gap)
out['v_min_life'] = h.groupby('device_id')['v_med'].min()
f = pd.DataFrame(out)
f['rng_ratio'] = f['rng_30'] / f['rng_90'].replace(0, np.nan)
return f[AUX_FEATURES]
def build(dataset_path: Path) -> pd.DataFrame:
from batteryswap_public.utils import load_dataset
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
table = pd.read_parquet(dataset_path / 'train' / 'features.parquet')
origins = (table[['origin_idx', 'origin']].drop_duplicates()
.sort_values('origin_idx'))
stats = day_stats(timeseries)
rows = []
for _, r in origins.iterrows():
f = aux_at_origin(stats, r['origin'])
f['origin_idx'] = r['origin_idx']
rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
print(f"aux origin {r['origin_idx']} {r['origin'].date()}: {len(f)}",
file=sys.stderr)
out = pd.concat(rows, ignore_index=True)
out.to_parquet(dataset_path / 'train' / 'aux_features.parquet')
return out
def load(dataset_path: Path = Path('dataset'), force: bool = False) -> pd.DataFrame:
p = dataset_path / 'train' / 'aux_features.parquet'
if p.exists() and not force:
return pd.read_parquet(p)
return build(dataset_path)
if __name__ == '__main__':
dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
t = load(dataset_path, force='--force' in sys.argv)
print(t.describe().round(3).to_string())