Logistic RUL model with adaptive top-sum planner (walk-forward CV selected)
Browse files- features.py: cached (device, origin) feature table; leak-free per-origin slicing
- models_cv.py: walk-forward CV harness; logistic/GBM/AFT/rank models,
threshold/topsum/batching planners; dev s_8-s_23, test s_24-s_41
- planner.py: final TopSumPlanner (logistic p42, N = 0.8 * sum(p42),
q05 day placement, building batching); refit on all closed windows
- harness.py: baseline comparisons (defer-all, lowest-voltage-k, oracle)
- best.pickle trained inside the competition Docker image (sklearn 1.7.2)
to avoid version skew; verified via script.py + metric in the image
Test-block walk-forward mean total_cost 1368 (voltage-15 2034, defer 2512,
oracle 186); train split via official metric: 2150 vs 8673 for the example.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- batteryswap_example/features.py +117 -0
- batteryswap_example/harness.py +247 -0
- batteryswap_example/models_cv.py +399 -0
- batteryswap_example/planner.py +102 -0
- batteryswap_example/planners/best.pickle +2 -2
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Precompute the (device, origin) feature/label table used by every model.
|
| 3 |
+
|
| 4 |
+
One row per (alive device, scenario origin). Features use only data at or before
|
| 5 |
+
the origin; no fleet-wide statistics are computed here, so slicing at an origin
|
| 6 |
+
cannot leak the future. Labels use the full eol_times and are for
|
| 7 |
+
training/evaluation only - the planner never reads them.
|
| 8 |
+
|
| 9 |
+
Cached to dataset/train/features.parquet (rebuild with --force).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
from batteryswap_public.utils import load_dataset
|
| 20 |
+
|
| 21 |
+
MIN_TEMP = 10
|
| 22 |
+
MAX_TEMP = 30
|
| 23 |
+
WINDOW_DAYS = 42
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def daily_voltage(timeseries: pd.DataFrame) -> pd.DataFrame:
|
| 27 |
+
"""In-band daily median voltage per device: (device_id, day, v)."""
|
| 28 |
+
df = timeseries.reset_index()
|
| 29 |
+
# device_id is categorical in the parquet; observed=False groupbys would emit
|
| 30 |
+
# the full device x day cartesian product with NaN voltages
|
| 31 |
+
df['device_id'] = df['device_id'].astype(str)
|
| 32 |
+
df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
|
| 33 |
+
df['day'] = df['end_time'].dt.normalize()
|
| 34 |
+
out = df.groupby(['device_id', 'day'])['voltage'].median().rename('v').reset_index()
|
| 35 |
+
return out.dropna(subset=['v']).sort_values('day')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def slope(days: np.ndarray, values: np.ndarray) -> float:
|
| 39 |
+
"""Per-day slope; NaN when under 2 points."""
|
| 40 |
+
if len(values) < 2:
|
| 41 |
+
return np.nan
|
| 42 |
+
x = (days - days[-1]) / np.timedelta64(1, 'D')
|
| 43 |
+
return float(np.polyfit(x.astype(float), values, 1)[0])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
|
| 47 |
+
origin: pd.Timestamp) -> pd.DataFrame:
|
| 48 |
+
"""Feature rows for every device with any reading at or before origin."""
|
| 49 |
+
h = daily[daily['day'] <= origin]
|
| 50 |
+
|
| 51 |
+
g = h.groupby('device_id')
|
| 52 |
+
last = g.tail(1).set_index('device_id')
|
| 53 |
+
feats = pd.DataFrame({'v_last': last['v']})
|
| 54 |
+
feats['staleness'] = (origin - last['day']).dt.days
|
| 55 |
+
|
| 56 |
+
# per-device historical reference: median over first 90 observed days
|
| 57 |
+
first_day = g['day'].min()
|
| 58 |
+
early = h[h['day'] <= h['device_id'].map(first_day) + pd.Timedelta(days=90)]
|
| 59 |
+
feats['v_ref'] = early.groupby('device_id')['v'].median()
|
| 60 |
+
feats['v_drop'] = feats['v_ref'] - feats['v_last']
|
| 61 |
+
|
| 62 |
+
for w in (30, 90):
|
| 63 |
+
hw = h[h['day'] > origin - pd.Timedelta(days=w)]
|
| 64 |
+
gw = hw.groupby('device_id')
|
| 65 |
+
feats[f'slope_{w}'] = gw.apply(
|
| 66 |
+
lambda u: slope(u['day'].values, u['v'].values), include_groups=False)
|
| 67 |
+
feats[f'v_min_{w}'] = gw['v'].min()
|
| 68 |
+
feats[f'n_{w}'] = gw['v'].size()
|
| 69 |
+
|
| 70 |
+
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
|
| 71 |
+
feats['age_days'] = (origin - install.reindex(feats.index)).dt.days
|
| 72 |
+
|
| 73 |
+
return feats
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
|
| 77 |
+
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
|
| 78 |
+
daily = daily_voltage(timeseries)
|
| 79 |
+
|
| 80 |
+
rows = []
|
| 81 |
+
for idx, sc in enumerate(scenarios):
|
| 82 |
+
origin = pd.Timestamp(sc['start_time'])
|
| 83 |
+
alive = eol_times.isna() | (eol_times > origin)
|
| 84 |
+
alive_ids = eol_times.index[alive]
|
| 85 |
+
|
| 86 |
+
f = features_at_origin(daily, locations, origin)
|
| 87 |
+
f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
|
| 88 |
+
f['origin_idx'] = idx
|
| 89 |
+
f['origin'] = origin
|
| 90 |
+
|
| 91 |
+
eol = eol_times.reindex(alive_ids)
|
| 92 |
+
f['eol'] = eol.values
|
| 93 |
+
f['dies42'] = ((eol.notna()) & (eol <= origin + pd.Timedelta(days=WINDOW_DAYS))).astype(int).values
|
| 94 |
+
f['days_to_eol'] = ((eol - origin).dt.total_seconds() / 86400).values
|
| 95 |
+
f['window_closed'] = origin + pd.Timedelta(days=WINDOW_DAYS) <= pd.Timestamp('2026-07-31')
|
| 96 |
+
|
| 97 |
+
rows.append(f.reset_index().rename(columns={'index': 'device_id'}))
|
| 98 |
+
print(f'{sc["name"]}: {len(f)} alive, {f["dies42"].sum()} die in window', file=sys.stderr)
|
| 99 |
+
|
| 100 |
+
table = pd.concat(rows, ignore_index=True)
|
| 101 |
+
table.to_parquet(out_path)
|
| 102 |
+
return table
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load(dataset_path: Path = Path('dataset'), force: bool = False) -> pd.DataFrame:
|
| 106 |
+
out_path = dataset_path / 'train' / 'features.parquet'
|
| 107 |
+
if out_path.exists() and not force:
|
| 108 |
+
return pd.read_parquet(out_path)
|
| 109 |
+
return build(dataset_path, out_path)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == '__main__':
|
| 113 |
+
force = '--force' in sys.argv
|
| 114 |
+
dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
|
| 115 |
+
t = load(dataset_path, force=force)
|
| 116 |
+
print(t.groupby('origin_idx')['dies42'].agg(['size', 'sum']).T)
|
| 117 |
+
print(t.describe().round(3).to_string())
|
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline planners and a forward-split evaluation harness.
|
| 3 |
+
|
| 4 |
+
The harness fits nothing (these baselines have no parameters), but it establishes
|
| 5 |
+
the reference points any real model has to beat:
|
| 6 |
+
|
| 7 |
+
current - the shipped OrderedPlanner
|
| 8 |
+
defer - park every battery past the horizon (costs nothing but emergencies)
|
| 9 |
+
voltage-k - swap the k lowest-voltage batteries
|
| 10 |
+
oracle - knows the true EOL, swaps on the exact day
|
| 11 |
+
|
| 12 |
+
Scored on origins s_24..s_41, which sit after the training block (origins whose
|
| 13 |
+
42-day window closes before s_24) and before the origins whose windows run past
|
| 14 |
+
the end of the data.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
import numpy
|
| 22 |
+
import pandas
|
| 23 |
+
import structlog
|
| 24 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 25 |
+
|
| 26 |
+
from batteryswap_public.interfaces import Planner
|
| 27 |
+
from batteryswap_public.utils import load_dataset, iterate_scenarios
|
| 28 |
+
from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
|
| 29 |
+
|
| 30 |
+
log = structlog.get_logger()
|
| 31 |
+
|
| 32 |
+
# Origins whose 42-day window closes before s_24 are the training block.
|
| 33 |
+
TEST_ORIGINS = range(24, 42)
|
| 34 |
+
|
| 35 |
+
# Temperature band for trusting a voltage reading, as in utils.smooth_series
|
| 36 |
+
MIN_TEMP = 10
|
| 37 |
+
MAX_TEMP = 30
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def infer_start_time(battery_data: pandas.DataFrame) -> pandas.Timestamp:
|
| 41 |
+
"""
|
| 42 |
+
Recover the planning start from the truncated timeseries.
|
| 43 |
+
|
| 44 |
+
The Planner interface does not receive start_time - iterate_scenarios cuts the
|
| 45 |
+
series at it, so the last observation is the best available estimate.
|
| 46 |
+
"""
|
| 47 |
+
return battery_data.reset_index()['end_time'].max().normalize()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def voltage_feature(battery_data: pandas.DataFrame) -> pandas.Series:
|
| 51 |
+
"""
|
| 52 |
+
Last daily-median voltage per device, using only in-band readings.
|
| 53 |
+
|
| 54 |
+
Daily median rejects within-day noise; the temperature band rejects the part of
|
| 55 |
+
the voltage swing that reflects ambient rather than state-of-charge. No recency
|
| 56 |
+
limit - a stale low reading still predicts EOL far better than no reading, and
|
| 57 |
+
the 95th percentile of staleness is ~148 days.
|
| 58 |
+
|
| 59 |
+
Deliberately uses no fleet-wide statistic, so slicing history at an origin can
|
| 60 |
+
never leak information from after it.
|
| 61 |
+
"""
|
| 62 |
+
df = battery_data.reset_index()
|
| 63 |
+
df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)]
|
| 64 |
+
if df.empty:
|
| 65 |
+
return pandas.Series(dtype=float, name='voltage')
|
| 66 |
+
|
| 67 |
+
df['day'] = df['end_time'].dt.normalize()
|
| 68 |
+
last_day = df.groupby('device_id')['day'].transform('max')
|
| 69 |
+
latest = df[df['day'] == last_day]
|
| 70 |
+
return latest.groupby('device_id')['voltage'].median().rename('voltage')
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _assemble(selected_days: pandas.Series, locations, start_time, window_days):
|
| 74 |
+
"""
|
| 75 |
+
Build a valid plan: scheduled batteries on their days, everything else parked
|
| 76 |
+
one day past the horizon where evaluate_plan discards it for free.
|
| 77 |
+
"""
|
| 78 |
+
parked_day = start_time + pandas.Timedelta(days=window_days + 1)
|
| 79 |
+
scheduled = set(selected_days.index)
|
| 80 |
+
parked = [b for b in locations['battery'] if b not in scheduled]
|
| 81 |
+
|
| 82 |
+
plan = pandas.concat([
|
| 83 |
+
pandas.DataFrame({'day': selected_days.values, 'battery': selected_days.index}),
|
| 84 |
+
pandas.DataFrame({'day': parked_day, 'battery': parked}),
|
| 85 |
+
], ignore_index=True)
|
| 86 |
+
plan = plan.sort_values('day', kind='stable').reset_index(drop=True)
|
| 87 |
+
|
| 88 |
+
check_plan_valid(plan, locations, start_time=start_time)
|
| 89 |
+
return plan
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class DeferAllPlanner(Planner):
|
| 93 |
+
"""Park everything. Pays only forced emergency visits."""
|
| 94 |
+
|
| 95 |
+
def plan(self, battery_data, locations, travel_costs, settings):
|
| 96 |
+
start_time = infer_start_time(battery_data)
|
| 97 |
+
empty = pandas.Series(dtype='datetime64[ns]')
|
| 98 |
+
return _assemble(empty, locations, start_time, settings.planning_window_days)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class LowestVoltagePlanner(Planner):
|
| 102 |
+
"""
|
| 103 |
+
Swap the k batteries with the lowest last in-band voltage, worst first, one per
|
| 104 |
+
day from the start of the window.
|
| 105 |
+
|
| 106 |
+
k is deliberately allowed to exceed the expected number of deaths: missing a
|
| 107 |
+
battery that dies costs an emergency visit, while including a healthy one costs
|
| 108 |
+
only early_replacement_penalty_daily per day.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
def __init__(self, k: int = 10, feature_cache: Optional[dict] = None):
|
| 112 |
+
self.k = k
|
| 113 |
+
self.feature_cache = feature_cache if feature_cache is not None else {}
|
| 114 |
+
|
| 115 |
+
def _voltage(self, battery_data, start_time):
|
| 116 |
+
key = pandas.Timestamp(start_time)
|
| 117 |
+
if key not in self.feature_cache:
|
| 118 |
+
self.feature_cache[key] = voltage_feature(battery_data)
|
| 119 |
+
return self.feature_cache[key]
|
| 120 |
+
|
| 121 |
+
def plan(self, battery_data, locations, travel_costs, settings):
|
| 122 |
+
start_time = infer_start_time(battery_data)
|
| 123 |
+
voltage = self._voltage(battery_data, start_time)
|
| 124 |
+
|
| 125 |
+
# Restrict to batteries still in the fleet; unmeasured ones sort last.
|
| 126 |
+
v = voltage.reindex(locations['battery']).fillna(numpy.inf)
|
| 127 |
+
worst = v.nsmallest(self.k, keep='first')
|
| 128 |
+
|
| 129 |
+
days = start_time + pandas.to_timedelta(numpy.arange(len(worst)), unit='D')
|
| 130 |
+
selected = pandas.Series(days, index=worst.index)
|
| 131 |
+
return _assemble(selected, locations, start_time, settings.planning_window_days)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class OraclePlanner(Planner):
|
| 135 |
+
"""Upper bound: knows the true EOL and swaps on exactly that day."""
|
| 136 |
+
|
| 137 |
+
def __init__(self, eol_times: pandas.Series):
|
| 138 |
+
self.eol_times = eol_times
|
| 139 |
+
|
| 140 |
+
def plan(self, battery_data, locations, travel_costs, settings):
|
| 141 |
+
start_time = infer_start_time(battery_data)
|
| 142 |
+
window = pandas.Timedelta(days=settings.planning_window_days)
|
| 143 |
+
eol = self.eol_times.reindex(locations['battery']).dropna()
|
| 144 |
+
doomed = eol[(eol > start_time) & (eol <= start_time + window)].sort_values()
|
| 145 |
+
return _assemble(doomed, locations, start_time, settings.planning_window_days)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def check_feature_parity(battery_data, cache_key, feature_cache):
|
| 149 |
+
"""
|
| 150 |
+
Guard against train/serve skew: the harness prefills the same cache the planner
|
| 151 |
+
reads, so assert the cached value matches a fresh online computation.
|
| 152 |
+
"""
|
| 153 |
+
cached = feature_cache[cache_key]
|
| 154 |
+
online = voltage_feature(battery_data)
|
| 155 |
+
pandas.testing.assert_series_equal(cached.sort_index(), online.sort_index())
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def load_shipped_planner(path='batteryswap_example/planners/best.pickle'):
|
| 159 |
+
"""
|
| 160 |
+
best.pickle was written by running train.py as a script, so its classes are
|
| 161 |
+
recorded under __main__. Register them there before unpickling.
|
| 162 |
+
"""
|
| 163 |
+
import pickle
|
| 164 |
+
import __main__
|
| 165 |
+
from batteryswap_example import train as train_module
|
| 166 |
+
for name in ('OrderedPlanner', 'DummyRULModel'):
|
| 167 |
+
setattr(__main__, name, getattr(train_module, name))
|
| 168 |
+
with open(path, 'rb') as f:
|
| 169 |
+
return pickle.load(f)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def run(dataset_path: Path, origins=TEST_ORIGINS, k_values=(5, 10, 15, 20, 30)):
|
| 173 |
+
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
|
| 174 |
+
log.info('harness-loaded', devices=len(locations), origins=list(origins))
|
| 175 |
+
|
| 176 |
+
shipped = load_shipped_planner()
|
| 177 |
+
|
| 178 |
+
feature_cache: dict = {}
|
| 179 |
+
policies = {'current': shipped, 'defer': DeferAllPlanner(),
|
| 180 |
+
'oracle': OraclePlanner(eol_times)}
|
| 181 |
+
for k in k_values:
|
| 182 |
+
policies[f'voltage-{k}'] = LowestVoltagePlanner(k, feature_cache=feature_cache)
|
| 183 |
+
|
| 184 |
+
records = []
|
| 185 |
+
parity_checked = False
|
| 186 |
+
gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
|
| 187 |
+
for index, (scenario, locs, cut, eol) in enumerate(gen):
|
| 188 |
+
if index not in origins:
|
| 189 |
+
continue
|
| 190 |
+
start_time = pandas.Timestamp(scenario['start_time'])
|
| 191 |
+
inferred = infer_start_time(cut)
|
| 192 |
+
assert inferred == start_time, (scenario['name'], inferred, start_time)
|
| 193 |
+
|
| 194 |
+
for name, planner in policies.items():
|
| 195 |
+
plan = planner.plan(cut, locs, scenario['travel_costs'], scenario['settings'])
|
| 196 |
+
_, _, overall = evaluate_plan(
|
| 197 |
+
plan, locs, scenario['travel_costs'], scenario['settings'],
|
| 198 |
+
eol_times=eol, start_time=start_time, verbose=0)
|
| 199 |
+
records.append(dict(overall, policy=name, origin=scenario['name']))
|
| 200 |
+
|
| 201 |
+
if not parity_checked:
|
| 202 |
+
check_feature_parity(cut, start_time, feature_cache)
|
| 203 |
+
parity_checked = True
|
| 204 |
+
log.info('feature-parity-ok', origin=scenario['name'])
|
| 205 |
+
|
| 206 |
+
log.info('origin-done', origin=scenario['name'])
|
| 207 |
+
|
| 208 |
+
return pandas.DataFrame(records)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def report(df: pandas.DataFrame):
|
| 212 |
+
order = ['total_cost', 'late_swap', 'early_swap', 'daily_limit', 'weekly_limit',
|
| 213 |
+
'overtime', 'travel', 'battery_swap', 'building_change', 'room_change']
|
| 214 |
+
mean = df.groupby('policy')[order].mean().sort_values('total_cost')
|
| 215 |
+
|
| 216 |
+
defer = mean.loc['defer', 'total_cost']
|
| 217 |
+
per_origin = df.pivot(index='origin', columns='policy', values='total_cost')
|
| 218 |
+
beats = (per_origin.lt(per_origin['defer'], axis=0)).sum()
|
| 219 |
+
spread = per_origin.std()
|
| 220 |
+
|
| 221 |
+
summary = mean.copy()
|
| 222 |
+
summary['vs_defer_%'] = (100 * (mean['total_cost'] / defer - 1)).round(1)
|
| 223 |
+
summary['origins_beating_defer'] = beats.reindex(summary.index).astype(str) + f'/{len(per_origin)}'
|
| 224 |
+
summary['std_across_origins'] = spread.reindex(summary.index).round(1)
|
| 225 |
+
|
| 226 |
+
print(f'\nForward split: scored on {len(per_origin)} origins '
|
| 227 |
+
f'({per_origin.index[0]}..{per_origin.index[-1]})\n')
|
| 228 |
+
print(summary.round(2).to_string())
|
| 229 |
+
return summary
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class Config(BaseSettings):
|
| 233 |
+
model_config = SettingsConfigDict(env_prefix='', cli_parse_args=True,
|
| 234 |
+
cli_ignore_unknown_args=True)
|
| 235 |
+
dataset_path: Optional[Path] = None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def main():
|
| 239 |
+
cfg = Config()
|
| 240 |
+
dataset_path = cfg.dataset_path or Path(
|
| 241 |
+
os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
|
| 242 |
+
df = run(Path(dataset_path))
|
| 243 |
+
report(df)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if __name__ == '__main__':
|
| 247 |
+
main()
|
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model comparison under walk-forward CV.
|
| 3 |
+
|
| 4 |
+
Protocol
|
| 5 |
+
--------
|
| 6 |
+
- Training pairs at origin T: all (device, origin') rows whose 42-day window closed
|
| 7 |
+
before T (origin' <= T - 42d). Window closed => label fully observed, and there is
|
| 8 |
+
a built-in embargo: no training window overlaps the scored window.
|
| 9 |
+
- Walk-forward: the model is refit at every scored origin on exactly the pairs that
|
| 10 |
+
would have been observable then.
|
| 11 |
+
- DEV block s_8..s_23: hyperparameters (p_star, day quantile, planner) are tuned here.
|
| 12 |
+
- TEST block s_24..s_41: scored once with dev-chosen settings.
|
| 13 |
+
|
| 14 |
+
Models predict, per alive device:
|
| 15 |
+
p42 - probability of EOL within the 42-day window
|
| 16 |
+
day_q - quantile of days-to-EOL conditional on dying in the window
|
| 17 |
+
|
| 18 |
+
Planners turn (p42, day_q) into a plan:
|
| 19 |
+
onepd - worst-first, one per day (control, shape of the shipped planner)
|
| 20 |
+
sched - swap each included battery on its predicted day
|
| 21 |
+
batch - sched + move each building's swaps to the group's earliest day and
|
| 22 |
+
cap building-groups per day (travel is expensive, early days are cheap)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
import itertools
|
| 27 |
+
import os
|
| 28 |
+
import sys
|
| 29 |
+
import warnings
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import pandas as pd
|
| 33 |
+
|
| 34 |
+
from sklearn.impute import SimpleImputer
|
| 35 |
+
from sklearn.preprocessing import StandardScaler
|
| 36 |
+
from sklearn.pipeline import make_pipeline
|
| 37 |
+
from sklearn.linear_model import LogisticRegression
|
| 38 |
+
from sklearn.ensemble import HistGradientBoostingClassifier, GradientBoostingRegressor
|
| 39 |
+
from sklearn.metrics import roc_auc_score
|
| 40 |
+
|
| 41 |
+
from batteryswap_public.utils import load_dataset, iterate_scenarios
|
| 42 |
+
from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
|
| 43 |
+
|
| 44 |
+
from batteryswap_example.features import load as load_features, WINDOW_DAYS
|
| 45 |
+
|
| 46 |
+
FEATURES = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30',
|
| 47 |
+
'staleness', 'age_days']
|
| 48 |
+
DEV_ORIGINS = range(8, 24)
|
| 49 |
+
TEST_ORIGINS = range(24, 42)
|
| 50 |
+
DAY_QUANTILES = (0.05, 0.20, 0.50)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------------- models
|
| 54 |
+
|
| 55 |
+
class DayQuantiles:
|
| 56 |
+
"""Quantiles of days-to-EOL, fit on training pairs that died in-window."""
|
| 57 |
+
|
| 58 |
+
def __init__(self):
|
| 59 |
+
self.models = {}
|
| 60 |
+
self.fallback = {}
|
| 61 |
+
|
| 62 |
+
def fit(self, X, days):
|
| 63 |
+
for q in DAY_QUANTILES:
|
| 64 |
+
self.fallback[q] = float(np.quantile(days, q))
|
| 65 |
+
if len(days) >= 40:
|
| 66 |
+
m = make_pipeline(
|
| 67 |
+
SimpleImputer(strategy='median'),
|
| 68 |
+
GradientBoostingRegressor(loss='quantile', alpha=q,
|
| 69 |
+
n_estimators=100, max_depth=2,
|
| 70 |
+
random_state=0))
|
| 71 |
+
m.fit(X, days)
|
| 72 |
+
self.models[q] = m
|
| 73 |
+
return self
|
| 74 |
+
|
| 75 |
+
def predict(self, X):
|
| 76 |
+
out = {}
|
| 77 |
+
for q in DAY_QUANTILES:
|
| 78 |
+
if q in self.models:
|
| 79 |
+
out[f'day_q{q}'] = np.clip(self.models[q].predict(X), 0, WINDOW_DAYS)
|
| 80 |
+
else:
|
| 81 |
+
out[f'day_q{q}'] = np.full(len(X), self.fallback[q])
|
| 82 |
+
return pd.DataFrame(out, index=X.index)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class SklearnRUL:
|
| 86 |
+
"""p42 from a sklearn classifier + DayQuantiles for timing."""
|
| 87 |
+
|
| 88 |
+
def __init__(self, kind):
|
| 89 |
+
self.kind = kind
|
| 90 |
+
|
| 91 |
+
def fit(self, train):
|
| 92 |
+
X, y = train[FEATURES], train['dies42']
|
| 93 |
+
if self.kind == 'logistic':
|
| 94 |
+
self.clf = make_pipeline(SimpleImputer(strategy='median'),
|
| 95 |
+
StandardScaler(),
|
| 96 |
+
LogisticRegression(max_iter=2000, C=1.0))
|
| 97 |
+
elif self.kind == 'gbm':
|
| 98 |
+
self.clf = HistGradientBoostingClassifier(
|
| 99 |
+
max_iter=200, max_depth=3, learning_rate=0.1,
|
| 100 |
+
min_samples_leaf=20, random_state=0)
|
| 101 |
+
self.clf.fit(X, y)
|
| 102 |
+
|
| 103 |
+
dying = train[train['dies42'] == 1]
|
| 104 |
+
self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
|
| 105 |
+
return self
|
| 106 |
+
|
| 107 |
+
def predict(self, rows):
|
| 108 |
+
X = rows[FEATURES]
|
| 109 |
+
out = self.days.predict(X)
|
| 110 |
+
out['p42'] = self.clf.predict_proba(X)[:, 1]
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class RankVoltage:
|
| 115 |
+
"""Control: score by v_last only; p42 is a rank in [0,1], day from DayQuantiles."""
|
| 116 |
+
|
| 117 |
+
def fit(self, train):
|
| 118 |
+
dying = train[train['dies42'] == 1]
|
| 119 |
+
self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
|
| 120 |
+
return self
|
| 121 |
+
|
| 122 |
+
def predict(self, rows):
|
| 123 |
+
out = self.days.predict(rows[FEATURES])
|
| 124 |
+
v = rows['v_last'].fillna(np.inf)
|
| 125 |
+
out['p42'] = 1.0 - v.rank(pct=True).values # lowest voltage -> highest score
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class WeibullAFT:
|
| 130 |
+
"""lifelines Weibull AFT on censored pairs; survival curve gives p42 and timing."""
|
| 131 |
+
|
| 132 |
+
def fit(self, train_censored):
|
| 133 |
+
from lifelines import WeibullAFTFitter
|
| 134 |
+
df = train_censored[FEATURES + ['duration', 'event']].copy()
|
| 135 |
+
self.imputer = SimpleImputer(strategy='median').fit(df[FEATURES])
|
| 136 |
+
df[FEATURES] = self.imputer.transform(df[FEATURES])
|
| 137 |
+
self.aft = WeibullAFTFitter(penalizer=0.01)
|
| 138 |
+
with warnings.catch_warnings():
|
| 139 |
+
warnings.simplefilter('ignore')
|
| 140 |
+
self.aft.fit(df, duration_col='duration', event_col='event')
|
| 141 |
+
return self
|
| 142 |
+
|
| 143 |
+
def predict(self, rows):
|
| 144 |
+
X = rows[FEATURES].copy()
|
| 145 |
+
X[FEATURES] = self.imputer.transform(X[FEATURES])
|
| 146 |
+
times = np.arange(1, WINDOW_DAYS + 1)
|
| 147 |
+
surv = self.aft.predict_survival_function(X, times=times) # times x devices
|
| 148 |
+
cdf = 1.0 - surv.T # devices x times
|
| 149 |
+
p42 = cdf.iloc[:, -1]
|
| 150 |
+
out = pd.DataFrame({'p42': p42.values}, index=rows.index)
|
| 151 |
+
|
| 152 |
+
# per-day death mass and expected residual life given surviving the window,
|
| 153 |
+
# for the expected-cost planner
|
| 154 |
+
pmf = np.diff(np.column_stack([np.zeros(len(cdf)), cdf.values]), axis=1)
|
| 155 |
+
for t in times:
|
| 156 |
+
out[f'pd_{t}'] = pmf[:, t - 1]
|
| 157 |
+
far = np.arange(WINDOW_DAYS, 400)
|
| 158 |
+
surv_far = self.aft.predict_survival_function(X, times=far).T.values
|
| 159 |
+
s42 = np.maximum(surv_far[:, 0], 1e-12)
|
| 160 |
+
out['e_far'] = WINDOW_DAYS + np.trapezoid(surv_far, far, axis=1) / s42
|
| 161 |
+
# quantiles of death time conditional on dying within the window
|
| 162 |
+
cond = cdf.div(p42.replace(0, np.nan), axis=0).clip(upper=1.0)
|
| 163 |
+
for q in DAY_QUANTILES:
|
| 164 |
+
day = (cond.values >= q).argmax(axis=1) + 1.0
|
| 165 |
+
day[~(cond.values[:, -1] >= q)] = WINDOW_DAYS
|
| 166 |
+
out[f'day_q{q}'] = np.where(p42.values > 0, day, WINDOW_DAYS)
|
| 167 |
+
return out
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
MODELS = {
|
| 171 |
+
'rank-v': RankVoltage,
|
| 172 |
+
'logistic': lambda: SklearnRUL('logistic'),
|
| 173 |
+
'gbm': lambda: SklearnRUL('gbm'),
|
| 174 |
+
'aft': WeibullAFT,
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------------------- planners
|
| 179 |
+
|
| 180 |
+
def assemble(selected_days, locations, start_time):
|
| 181 |
+
parked_day = start_time + pd.Timedelta(days=WINDOW_DAYS + 1)
|
| 182 |
+
scheduled = set(selected_days.index)
|
| 183 |
+
parked = [b for b in locations['battery'] if b not in scheduled]
|
| 184 |
+
plan = pd.concat([
|
| 185 |
+
pd.DataFrame({'day': selected_days.values, 'battery': selected_days.index}),
|
| 186 |
+
pd.DataFrame({'day': parked_day, 'battery': parked}),
|
| 187 |
+
], ignore_index=True).sort_values('day', kind='stable').reset_index(drop=True)
|
| 188 |
+
check_plan_valid(plan, locations, start_time=start_time)
|
| 189 |
+
return plan
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def plan_onepd(pred, included, locations, start_time, day_q):
|
| 193 |
+
order = pred.loc[included, 'p42'].sort_values(ascending=False)
|
| 194 |
+
days = start_time + pd.to_timedelta(np.arange(len(order)), unit='D')
|
| 195 |
+
return assemble(pd.Series(days, index=order.index), locations, start_time)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def plan_sched(pred, included, locations, start_time, day_q):
|
| 199 |
+
offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
|
| 200 |
+
days = start_time + pd.to_timedelta(offs.values, unit='D')
|
| 201 |
+
return assemble(pd.Series(days, index=offs.index), locations, start_time)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def plan_batch(pred, included, locations, start_time, day_q, max_groups_per_day=2):
|
| 205 |
+
offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
|
| 206 |
+
bldg = locations.set_index('battery')['building'].reindex(offs.index)
|
| 207 |
+
|
| 208 |
+
# one visit per building at that group's earliest predicted day
|
| 209 |
+
group_day = offs.groupby(bldg).min().sort_values()
|
| 210 |
+
|
| 211 |
+
# cap building-groups per day; push overflow to the next free day
|
| 212 |
+
used = {}
|
| 213 |
+
final = {}
|
| 214 |
+
for building, day in group_day.items():
|
| 215 |
+
d = int(day)
|
| 216 |
+
while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS:
|
| 217 |
+
d += 1
|
| 218 |
+
used[d] = used.get(d, 0) + 1
|
| 219 |
+
final[building] = d
|
| 220 |
+
|
| 221 |
+
offs = bldg.map(final).astype(float)
|
| 222 |
+
days = start_time + pd.to_timedelta(offs.values, unit='D')
|
| 223 |
+
return assemble(pd.Series(days, index=offs.index), locations, start_time)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _batch_days(offs, locations, start_time, max_groups_per_day=2):
|
| 227 |
+
"""Shared batching pass: one visit per building at the group's earliest day."""
|
| 228 |
+
bldg = locations.set_index('battery')['building'].reindex(offs.index)
|
| 229 |
+
group_day = offs.groupby(bldg).min().sort_values()
|
| 230 |
+
used, final = {}, {}
|
| 231 |
+
for building, day in group_day.items():
|
| 232 |
+
d = int(day)
|
| 233 |
+
while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS:
|
| 234 |
+
d += 1
|
| 235 |
+
used[d] = used.get(d, 0) + 1
|
| 236 |
+
final[building] = d
|
| 237 |
+
offs = bldg.map(final).astype(float)
|
| 238 |
+
days = start_time + pd.to_timedelta(offs.values, unit='D')
|
| 239 |
+
return assemble(pd.Series(days, index=offs.index), locations, start_time)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
EARLY, LATE = 0.5, 10.0
|
| 243 |
+
EMERGENCY_DAY = WINDOW_DAYS + 3 # emergencies run just after the plan ends
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def plan_expcost(pred, included, locations, start_time, day_q):
|
| 247 |
+
"""
|
| 248 |
+
Include/park and swap-day both chosen by expected cost under the model's
|
| 249 |
+
death distribution. `included` and `day_q` are ignored.
|
| 250 |
+
"""
|
| 251 |
+
t = np.arange(1, WINDOW_DAYS + 1)
|
| 252 |
+
pmf = pred[[f'pd_{i}' for i in t]].values # devices x 42
|
| 253 |
+
p_surv = np.clip(1.0 - pmf.sum(axis=1), 0.0, 1.0)
|
| 254 |
+
e_far = pred['e_far'].values
|
| 255 |
+
|
| 256 |
+
d = np.arange(0, WINDOW_DAYS + 1)
|
| 257 |
+
delta = d[None, :, None] - t[None, None, :] # 1 x 43 x 42
|
| 258 |
+
swap_penalty = np.where(delta >= 0, LATE * delta, EARLY * (-delta))
|
| 259 |
+
cost = (pmf[:, None, :] * swap_penalty).sum(axis=2) # devices x 43
|
| 260 |
+
cost += p_surv[:, None] * EARLY * np.maximum(e_far[:, None] - d[None, :], 0)
|
| 261 |
+
|
| 262 |
+
cost_park = (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1)
|
| 263 |
+
|
| 264 |
+
best_day = cost.argmin(axis=1)
|
| 265 |
+
include = cost[np.arange(len(cost)), best_day] < cost_park
|
| 266 |
+
offs = pd.Series(best_day[include].astype(float), index=pred.index[include])
|
| 267 |
+
if offs.empty:
|
| 268 |
+
return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
|
| 269 |
+
return _batch_days(offs, locations, start_time)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def plan_topsum(pred, included, locations, start_time, day_q):
|
| 273 |
+
"""
|
| 274 |
+
Adaptive inclusion: take the top-N batteries by p42 with N = alpha * sum(p42).
|
| 275 |
+
sum(p42) tracks the expected death count even when the probability scale
|
| 276 |
+
drifts between refits, unlike a fixed threshold. alpha arrives via p_star.
|
| 277 |
+
"""
|
| 278 |
+
alpha = plan_topsum.alpha
|
| 279 |
+
n = int(round(alpha * pred['p42'].sum()))
|
| 280 |
+
chosen = pred['p42'].nlargest(n).index
|
| 281 |
+
offs = pred.loc[chosen, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS)
|
| 282 |
+
if offs.empty:
|
| 283 |
+
return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time)
|
| 284 |
+
return _batch_days(offs, locations, start_time)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
plan_topsum.alpha = 1.2
|
| 288 |
+
|
| 289 |
+
PLANNERS = {'onepd': plan_onepd, 'sched': plan_sched, 'batch': plan_batch,
|
| 290 |
+
'expcost': plan_expcost, 'topsum': plan_topsum}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ------------------------------------------------------------------------ harness
|
| 294 |
+
|
| 295 |
+
def training_pairs(table, origin_time):
|
| 296 |
+
closed = table['origin'] <= origin_time - pd.Timedelta(days=WINDOW_DAYS)
|
| 297 |
+
return table[closed]
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def censored_pairs(table, origin_time):
|
| 301 |
+
"""All prior pairs with duration censored at origin_time (for AFT)."""
|
| 302 |
+
prior = table[table['origin'] < origin_time].copy()
|
| 303 |
+
eol = prior['eol']
|
| 304 |
+
end = eol.where(eol.notna() & (eol <= origin_time), origin_time)
|
| 305 |
+
prior['duration'] = ((end - prior['origin']).dt.total_seconds() / 86400).clip(lower=0.5)
|
| 306 |
+
prior['event'] = (eol.notna() & (eol <= origin_time)).astype(int)
|
| 307 |
+
return prior
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def walk_forward(table, scenario_ctx, origins, configs):
|
| 311 |
+
"""configs: list of dicts(model, planner, p_star, day_q, name)."""
|
| 312 |
+
fitted = {}
|
| 313 |
+
records = []
|
| 314 |
+
for idx in origins:
|
| 315 |
+
ctx = scenario_ctx[idx]
|
| 316 |
+
origin_time = ctx['start_time']
|
| 317 |
+
rows = table[table['origin_idx'] == idx].set_index('device_id')
|
| 318 |
+
|
| 319 |
+
preds = {}
|
| 320 |
+
for model_name in {c['model'] for c in configs}:
|
| 321 |
+
if (model_name, idx) not in fitted:
|
| 322 |
+
factory = MODELS[model_name]
|
| 323 |
+
if model_name == 'aft':
|
| 324 |
+
train = censored_pairs(table, origin_time)
|
| 325 |
+
else:
|
| 326 |
+
train = training_pairs(table, origin_time)
|
| 327 |
+
fitted[(model_name, idx)] = factory().fit(train)
|
| 328 |
+
preds[model_name] = fitted[(model_name, idx)].predict(rows)
|
| 329 |
+
|
| 330 |
+
for cfg in configs:
|
| 331 |
+
pred = preds[cfg['model']]
|
| 332 |
+
if cfg['planner'] == 'topsum':
|
| 333 |
+
PLANNERS['topsum'].alpha = cfg['p_star'] # p_star carries alpha
|
| 334 |
+
included = pred.index[pred['p42'] >= cfg['p_star']]
|
| 335 |
+
plan = PLANNERS[cfg['planner']](pred, included, ctx['locs'],
|
| 336 |
+
origin_time, cfg['day_q'])
|
| 337 |
+
_, _, overall = evaluate_plan(
|
| 338 |
+
plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
|
| 339 |
+
eol_times=ctx['eol'], start_time=origin_time, verbose=0)
|
| 340 |
+
records.append(dict(overall, origin=idx, **cfg))
|
| 341 |
+
|
| 342 |
+
auc = {m: roc_auc_score(rows['dies42'], p['p42'])
|
| 343 |
+
for m, p in preds.items() if rows['dies42'].sum() > 0}
|
| 344 |
+
print(f"s_{idx}: " + ' '.join(f'{m}={a:.3f}' for m, a in auc.items()),
|
| 345 |
+
file=sys.stderr)
|
| 346 |
+
return pd.DataFrame(records)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def load_context(dataset_path):
|
| 350 |
+
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
|
| 351 |
+
ctx = {}
|
| 352 |
+
gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
|
| 353 |
+
for idx, (scenario, locs, cut, eol) in enumerate(gen):
|
| 354 |
+
ctx[idx] = dict(start_time=pd.Timestamp(scenario['start_time']),
|
| 355 |
+
locs=locs, eol=eol,
|
| 356 |
+
travel_costs=scenario['travel_costs'],
|
| 357 |
+
settings=scenario['settings'])
|
| 358 |
+
return ctx
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def summarize(df, label):
|
| 362 |
+
df['name'] = (df['model'] + '/' + df['planner'] + '/p' +
|
| 363 |
+
df['p_star'].astype(str) + '/q' + df['day_q'].astype(str))
|
| 364 |
+
mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean')
|
| 365 |
+
print(f'\n=== {label} ===')
|
| 366 |
+
print(mean.round(1).to_string())
|
| 367 |
+
return mean
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def main():
|
| 371 |
+
dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
|
| 372 |
+
table = load_features(dataset_path)
|
| 373 |
+
ctx = load_context(dataset_path)
|
| 374 |
+
|
| 375 |
+
phase = sys.argv[1] if len(sys.argv) > 1 else 'dev'
|
| 376 |
+
|
| 377 |
+
if phase == 'dev':
|
| 378 |
+
configs = []
|
| 379 |
+
for model in MODELS:
|
| 380 |
+
for planner in PLANNERS:
|
| 381 |
+
for p_star in (0.1, 0.2, 0.35):
|
| 382 |
+
for day_q in (0.05, 0.20):
|
| 383 |
+
configs.append(dict(model=model, planner=planner,
|
| 384 |
+
p_star=p_star, day_q=day_q))
|
| 385 |
+
df = walk_forward(table, ctx, DEV_ORIGINS, configs)
|
| 386 |
+
df.to_csv('/tmp/dev_results.csv', index=False)
|
| 387 |
+
summarize(df, f'DEV s_8..s_23 ({len(configs)} configs)')
|
| 388 |
+
else:
|
| 389 |
+
# phase 'dev2' or 'test': configs from /tmp/<phase>_configs.json
|
| 390 |
+
import json
|
| 391 |
+
configs = json.loads(Path(f'/tmp/{phase}_configs.json').read_text())
|
| 392 |
+
origins = DEV_ORIGINS if phase.startswith('dev') else TEST_ORIGINS
|
| 393 |
+
df = walk_forward(table, ctx, origins, configs)
|
| 394 |
+
df.to_csv(f'/tmp/{phase}_results.csv', index=False)
|
| 395 |
+
summarize(df, f'{phase.upper()} origins {list(origins)[0]}..{list(origins)[-1]}')
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
if __name__ == '__main__':
|
| 399 |
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Final planner: logistic RUL model + adaptive top-sum inclusion + building batching.
|
| 3 |
+
|
| 4 |
+
Selected by walk-forward CV (see models_cv.py):
|
| 5 |
+
dev block s_8..s_23 - hyperparameters chosen (alpha=0.8, day_q=0.05, batching)
|
| 6 |
+
test block s_24..s_41 - mean total_cost 1368 vs voltage-15 2034, defer-all 2512,
|
| 7 |
+
oracle 186
|
| 8 |
+
|
| 9 |
+
Everything the planner needs at plan() time is computed from the arguments the
|
| 10 |
+
competition passes it: the truncated timeseries, the locations table (which carries
|
| 11 |
+
each device's install date), and the scenario settings.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import pickle
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
from batteryswap_public.interfaces import Planner
|
| 20 |
+
|
| 21 |
+
from batteryswap_example.features import daily_voltage, features_at_origin, load as load_features
|
| 22 |
+
from batteryswap_example.models_cv import SklearnRUL, FEATURES
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TopSumPlanner(Planner):
|
| 26 |
+
|
| 27 |
+
def __init__(self, model: SklearnRUL, alpha: float = 0.8, day_q: float = 0.05,
|
| 28 |
+
max_groups_per_day: int = 2):
|
| 29 |
+
self.model = model
|
| 30 |
+
self.alpha = alpha
|
| 31 |
+
self.day_q = day_q
|
| 32 |
+
self.max_groups_per_day = max_groups_per_day
|
| 33 |
+
|
| 34 |
+
def plan(self, battery_data, locations, travel_costs, settings):
|
| 35 |
+
window = int(settings.planning_window_days)
|
| 36 |
+
origin = battery_data.reset_index()['end_time'].max().normalize()
|
| 37 |
+
|
| 38 |
+
daily = daily_voltage(battery_data)
|
| 39 |
+
feats = features_at_origin(daily, locations, origin)
|
| 40 |
+
feats = feats.reindex(locations['battery'])
|
| 41 |
+
|
| 42 |
+
pred = self.model.predict(feats)
|
| 43 |
+
|
| 44 |
+
# adaptive inclusion: expected death count tracks prevalence even when the
|
| 45 |
+
# probability scale drifts between refits, unlike a fixed threshold
|
| 46 |
+
n = int(round(self.alpha * pred['p42'].sum()))
|
| 47 |
+
chosen = pred['p42'].nlargest(n).index
|
| 48 |
+
|
| 49 |
+
offs = pred.loc[chosen, f'day_q{self.day_q}'].round().clip(0, window)
|
| 50 |
+
|
| 51 |
+
# batching: one visit per building at the group's earliest predicted day,
|
| 52 |
+
# capped groups per day (travel is expensive, early days are cheap)
|
| 53 |
+
if len(offs):
|
| 54 |
+
bldg = locations.set_index('battery')['building'].reindex(offs.index)
|
| 55 |
+
group_day = offs.groupby(bldg).min().sort_values()
|
| 56 |
+
used, final = {}, {}
|
| 57 |
+
for building, day in group_day.items():
|
| 58 |
+
d = int(day)
|
| 59 |
+
while used.get(d, 0) >= self.max_groups_per_day and d < window:
|
| 60 |
+
d += 1
|
| 61 |
+
used[d] = used.get(d, 0) + 1
|
| 62 |
+
final[building] = d
|
| 63 |
+
offs = bldg.map(final).astype(float)
|
| 64 |
+
|
| 65 |
+
parked_day = origin + pd.Timedelta(days=window + 1)
|
| 66 |
+
scheduled = set(offs.index)
|
| 67 |
+
parked = [b for b in locations['battery'] if b not in scheduled]
|
| 68 |
+
plan = pd.concat([
|
| 69 |
+
pd.DataFrame({'day': origin + pd.to_timedelta(offs.values, unit='D'),
|
| 70 |
+
'battery': offs.index}),
|
| 71 |
+
pd.DataFrame({'day': parked_day, 'battery': parked}),
|
| 72 |
+
], ignore_index=True)
|
| 73 |
+
return plan.sort_values('day', kind='stable').reset_index(drop=True)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def train_full(dataset_path='dataset'):
|
| 77 |
+
"""Refit the selected model on every fully observed (device, origin) pair."""
|
| 78 |
+
from pathlib import Path
|
| 79 |
+
table = load_features(Path(dataset_path))
|
| 80 |
+
train = table[table['window_closed']]
|
| 81 |
+
model = SklearnRUL('logistic').fit(train)
|
| 82 |
+
print(f'refit on {len(train)} pairs, {train.dies42.sum()} positive, '
|
| 83 |
+
f'origins s_0..s_{train.origin_idx.max()}')
|
| 84 |
+
return TopSumPlanner(model)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main():
|
| 88 |
+
# When run as a script/-m, classes defined here would pickle under __main__ and
|
| 89 |
+
# be unloadable by script.py. Rebuild the instance from the imported module so
|
| 90 |
+
# the pickle records the importable path.
|
| 91 |
+
import os
|
| 92 |
+
import batteryswap_example.planner as this_module
|
| 93 |
+
dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')
|
| 94 |
+
planner = this_module.train_full(dataset_path)
|
| 95 |
+
path = 'batteryswap_example/planners/best.pickle'
|
| 96 |
+
with open(path, 'wb') as f:
|
| 97 |
+
pickle.dump(planner, f)
|
| 98 |
+
print('planner-save', path)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == '__main__':
|
| 102 |
+
main()
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba4a9792d820d91eab6cf328c8c8329dc3921d837dc864c23bdd7dbae0b1bc59
|
| 3 |
+
size 237194
|