Thibaut Forest
Claude Fable 5
Phase 34-35: retire cross-fleet CV, re-screen 72 configs, champion unchanged
7d93fb8
Raw History Blame Contribute Delete
6.95 kB
"""
Static pickled planner: logistic RUL model + tsroute planning (topsum inclusion,
expected-cost day placement, building-group day search).
train_full() fits it on every closed-window (device, origin) pair and main()
writes planners/best.pickle - the per-split fallback submit.py uses when the
adaptive path fails. Planner knobs mirror the phase-11/14 champion settings
(alpha=0.8, ec_margin=0.8, ec_pmax=25, split_days=7; see models_cv.py and
EXPERIMENTS.md). TopSumPlanner below is the retired v1 champion, kept for
reference and so older pickles that recorded it stay loadable.
Everything the planner needs at plan() time is computed from the arguments the
competition passes it: the truncated timeseries, the locations table (which carries
each device's install date), and the scenario settings.
"""
import pickle
import pandas as pd
from batteryswap_public.interfaces import Planner
from batteryswap_example.features import (
daily_voltage, features_at_origin, load as load_features,
daily_all, weekly_betas, master_curve, physics_features, BETA_DEFAULT)
from batteryswap_example.models_cv import SklearnRUL
class TopSumPlanner(Planner):
"""
Retired v1 champion (phase 1): adaptive top-sum inclusion + building
batching. Walk-forward CV at selection time: test block s_24..s_41 mean
total_cost 1368 vs voltage-15 2034, defer-all 2512, oracle 186.
Superseded by ECRoutePlanner (tsroute); see EXPERIMENTS.md.
"""
def __init__(self, model: SklearnRUL, alpha: float = 0.8, day_q: float = 0.05,
max_groups_per_day: int = 2):
self.model = model
self.alpha = alpha
self.day_q = day_q
self.max_groups_per_day = max_groups_per_day
def plan(self, battery_data, locations, travel_costs, settings):
window = int(settings.planning_window_days)
origin = battery_data.reset_index()['end_time'].max().normalize()
daily = daily_voltage(battery_data)
feats = features_at_origin(daily, locations, origin)
feats = feats.reindex(locations['battery'])
pred = self.model.predict(feats)
# adaptive inclusion: expected death count tracks prevalence even when the
# probability scale drifts between refits, unlike a fixed threshold
n = int(round(self.alpha * pred['p42'].sum()))
chosen = pred['p42'].nlargest(n).index
offs = pred.loc[chosen, f'day_q{self.day_q}'].round().clip(0, window)
# batching: one visit per building at the group's earliest predicted day,
# capped groups per day (travel is expensive, early days are cheap)
if len(offs):
bldg = locations.set_index('battery')['building'].reindex(offs.index)
group_day = offs.groupby(bldg).min().sort_values()
used, final = {}, {}
for building, day in group_day.items():
d = int(day)
while used.get(d, 0) >= self.max_groups_per_day and d < window:
d += 1
used[d] = used.get(d, 0) + 1
final[building] = d
offs = bldg.map(final).astype(float)
parked_day = origin + pd.Timedelta(days=window + 1)
scheduled = set(offs.index)
parked = [b for b in locations['battery'] if b not in scheduled]
plan = pd.concat([
pd.DataFrame({'day': origin + pd.to_timedelta(offs.values, unit='D'),
'battery': offs.index}),
pd.DataFrame({'day': parked_day, 'battery': parked}),
], ignore_index=True)
return plan.sort_values('day', kind='stable').reset_index(drop=True)
class ECRoutePlanner(Planner):
"""
The pickled fallback planner. Despite the legacy name it calls plan_tsroute:
topsum inclusion (N = alpha * sum(p42)) plus the phase-11 break-even top-up,
expected-cost day placement, and local search over building-group days
against an exact replica of the evaluator's travel/overtime/limit mechanics.
The name dates from the pure expected-cost include/park selection
(plan_ecroute, combined dev+test mean 1816 vs previous champion 1901),
superseded by tsroute in phases 6-14; kept so existing pickles load.
"""
def __init__(self, model: SklearnRUL, beta: float = BETA_DEFAULT, curve=None):
self.model = model
# physics context baked in at fit time: the planner receives no EOL data,
# so the master discharge curve and temperature coefficient travel with it
self.beta = beta
self.curve = curve
def plan(self, battery_data, locations, travel_costs, settings):
from batteryswap_example.models_cv import plan_tsroute
origin = battery_data.reset_index()['end_time'].max().normalize()
daily = daily_voltage(battery_data)
feats = features_at_origin(daily, locations, origin)
phys = physics_features(daily_all(battery_data), origin, self.beta, self.curve)
feats = feats.join(phys).reindex(locations['battery'])
pred = self.model.predict(feats)
plan_tsroute.alpha = 0.8
plan_tsroute.ec_margin = 0.8 # phase-11 break-even top-up
plan_tsroute.ec_pmax = 25.0
plan_tsroute.split_days = 7 # phase-14 building day-splitting
return plan_tsroute(pred, None, locations, origin, 0.05,
travel_costs=travel_costs, settings=settings)
def train_full(dataset_path='dataset'):
"""Refit the selected model on every fully observed (device, origin) pair."""
from pathlib import Path
from batteryswap_public.utils import load_dataset
table = load_features(Path(dataset_path))
train = table[table['window_closed']]
model = SklearnRUL('logistic').fit(train)
# physics context from the full train split
_, timeseries, eol_times, _ = load_dataset(Path(dataset_path) / 'train')
dall = daily_all(timeseries)
betas = weekly_betas(dall)['beta']
beta = float(betas.median()) if len(betas) >= 20 else BETA_DEFAULT
curve = master_curve(dall, eol_times.dropna(), beta)
print(f'refit on {len(train)} pairs, {train.dies42.sum()} positive, '
f'origins s_0..s_{train.origin_idx.max()}, beta={beta:.4f}, '
f'curve={"yes" if curve is not None else "no"}')
return ECRoutePlanner(model, beta=beta, curve=curve)
def main():
# When run as a script/-m, classes defined here would pickle under __main__ and
# be unloadable by script.py. Rebuild the instance from the imported module so
# the pickle records the importable path.
import os
import batteryswap_example.planner as this_module
dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')
planner = this_module.train_full(dataset_path)
path = 'batteryswap_example/planners/best.pickle'
with open(path, 'wb') as f:
pickle.dump(planner, f)
print('planner-save', path)
if __name__ == '__main__':
main()