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