Thibaut Forest Claude Fable 5 commited on
Commit
3f9bf12
·
1 Parent(s): 6936910

ADOPT campaign-2 frontier ft_vmin7r_wnr20: BATTERYSWAP_FT2 default ON

Browse files

Ft2RUL = logbag on FEATURES+vmin7_rank, 0.20 value-preserving WienerFHT
order blend (order remapped onto primary's own sorted p42 values; count/
pricing/timing untouched). bgkf v5: 1688.4 vs 1760.2 (-71.8 +/- 23.5,
3.05 sigma, Wilcoxon .037, swaps flat, count-clean; reproduced exactly by
3 lanes). Worst-case within-test-fleet vmin7 recompute still -60.8 (2.3
sigma). Deployment parity vs the CV frontier: bit-exact (max cell diff 0).
wnr_m/s2/n merged into the shipped train table; split-path wiener params
computed past-only from the scored split's own telemetry (0.1s/scenario).
Soft degradation: missing wnr -> pure vm7 order; fit error -> legacy
champion. BATTERYSWAP_FT2=0 reverts to the measured-1661.45 config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

agent_reports_bgkf/artifacts/compose.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """General composer on cv.run_bgkf: registers vm7 variant + custom aux kinds
2
+ in-process, then runs any (kind, variant, blend) combo. No repo edits.
3
+ Usage: compose.py <label> kind=<k> [variant=base] [blend=<k2>:<w>]"""
4
+ import sys, time
5
+ sys.path.insert(0, '/Users/THIF/repos/BatterySwapAI2026-Example')
6
+ import os
7
+ os.chdir('/Users/THIF/repos/BatterySwapAI2026-Example')
8
+ import numpy as np, pandas as pd
9
+ from batteryswap_example import models_cv as mc
10
+ from batteryswap_example import cv
11
+
12
+ label = sys.argv[1]
13
+ kind, variant, blend = 'logbag', 'base', None
14
+ for a in sys.argv[2:]:
15
+ k, v = a.split('=', 1)
16
+ if k == 'kind':
17
+ kind = v
18
+ elif k == 'variant':
19
+ variant = v
20
+ elif k == 'blend':
21
+ b, w = v.split(':')
22
+ blend = (b, float(w))
23
+
24
+ cv.VARIANTS['vm7'] = lambda t: cv.BASE_FEATURES + ['vmin7_rank']
25
+ mc.MODELS['aux_ho'] = lambda: mc.LogBagFeats(['hot_90', 'oob_90'])
26
+ mc.MODELS['aux_hot'] = lambda: mc.LogBagFeats(['hot_90'])
27
+ mc.MODELS['aux_oob'] = lambda: mc.LogBagFeats(['oob_90'])
28
+ mc.MODELS['aux_ho30'] = lambda: mc.LogBagFeats(['hot_90', 'oob_90'],
29
+ n_estimators=30)
30
+ mc.MODELS['aux_ho50'] = lambda: mc.LogBagFeats(['hot_90', 'oob_90'],
31
+ n_estimators=50)
32
+
33
+ t0 = time.time()
34
+ df = cv.run_bgkf(kind=kind, variant=variant, blend=blend)
35
+ df['cand'] = label
36
+ df.to_csv(f'/tmp/cv_bgkfcand_{label}.csv', index=False)
37
+ base = pd.read_csv('/tmp/cv_bgkf.csv').groupby('origin')[
38
+ ['total_cost', 'n_swaps']].sum()
39
+ d = df.groupby('origin')[['total_cost', 'n_swaps']].sum()
40
+ delta = (d['total_cost'] - base['total_cost']).dropna()
41
+ se = delta.std() / np.sqrt(len(delta))
42
+ print(f'{label}: SCORE {d["total_cost"].mean():.1f} '
43
+ f'delta {delta.mean():+.1f} SE {se:.1f} '
44
+ f'{int((delta<0).sum())}W/{int((delta>0).sum())}L '
45
+ f'swaps {d["n_swaps"].mean():.1f} [{time.time()-t0:.0f}s]')
agent_reports_bgkf/artifacts/mdmodels.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """md-agent candidate models (rebuilt post-outage). Registered by run.py."""
2
+ import numpy as np
3
+ import pandas as pd
4
+ from sklearn.impute import SimpleImputer
5
+ from sklearn.preprocessing import StandardScaler, RobustScaler
6
+ from sklearn.pipeline import make_pipeline
7
+ from sklearn.linear_model import LogisticRegression
8
+ from sklearn.ensemble import BaggingClassifier, HistGradientBoostingClassifier
9
+
10
+ import batteryswap_example.models_cv as mc
11
+
12
+ # risk-direction of p42 in each feature (0 = unconstrained)
13
+ MONO = {
14
+ 'v_last': -1, 'v_drop': +1, 'slope_30': -1, 'slope_90': -1,
15
+ 'v_min_30': -1, 'staleness': +1, 'age_days': +1, 'slope_lastk': -1,
16
+ 'drop_recent': +1, 'below_frac': +1, 'accel': -1, 'n_30': 0,
17
+ 'tau_projected': -1,
18
+ 'vrel': -1, 'vmin7_rank': -1, 'r_vlast': -1, 'r_tau': -1,
19
+ }
20
+
21
+
22
+ class _DaysMixin:
23
+ def _fit_days(self, train):
24
+ dying = train[train['dies42'] == 1]
25
+ self.days = mc.DayQuantiles().fit(dying[mc.FEATURES], dying['days_to_eol'])
26
+
27
+ def _out(self, rows, p42):
28
+ out = self.days.predict(rows[mc.FEATURES])
29
+ out['p42'] = p42
30
+ return out
31
+
32
+
33
+ class HGBMono(_DaysMixin):
34
+ """HistGradientBoosting with monotonic constraints (risk-direction by name)."""
35
+
36
+ def __init__(self, **kw):
37
+ self.kw = dict(max_iter=200, max_depth=3, learning_rate=0.1,
38
+ min_samples_leaf=20, random_state=0)
39
+ self.kw.update(kw)
40
+
41
+ def fit(self, train):
42
+ feats = list(mc.FEATURES)
43
+ cst = [MONO.get(f, 0) for f in feats]
44
+ self.feats = feats
45
+ self.clf = HistGradientBoostingClassifier(monotonic_cst=cst, **self.kw)
46
+ self.clf.fit(train[feats], train['dies42'])
47
+ self._fit_days(train)
48
+ return self
49
+
50
+ def predict(self, rows):
51
+ return self._out(rows, self.clf.predict_proba(rows[self.feats])[:, 1])
52
+
53
+
54
+ class MeanBlend(_DaysMixin):
55
+ """Mean of logbag p42 and a partner model's p42."""
56
+
57
+ def __init__(self, partner, w=0.5):
58
+ self.partner_factory = partner
59
+ self.w = w
60
+
61
+ def fit(self, train):
62
+ self.a = mc.SklearnRUL('logbag').fit(train)
63
+ self.b = self.partner_factory().fit(train)
64
+ return self
65
+
66
+ def predict(self, rows):
67
+ out = self.a.predict(rows)
68
+ out['p42'] = ((1 - self.w) * out['p42']
69
+ + self.w * self.b.predict(rows)['p42'])
70
+ return out
71
+
72
+
73
+ class MeanBlendCM(MeanBlend):
74
+ """Mean blend, count-matched: p42 rescaled so sum(p42) equals logbag's."""
75
+
76
+ def predict(self, rows):
77
+ pa = self.a.predict(rows)
78
+ pb = self.b.predict(rows)['p42']
79
+ blend = (1 - self.w) * pa['p42'] + self.w * pb
80
+ s = blend.sum()
81
+ if s > 0:
82
+ blend = (blend * (pa['p42'].sum() / s)).clip(upper=1.0)
83
+ out = pa
84
+ out['p42'] = blend
85
+ return out
86
+
87
+
88
+ class OrderBlend3(_DaysMixin):
89
+ """Value-preserving 3-way ORDER blend: logbag p42 values kept (count
90
+ machinery untouched), ordering = weighted mean of pct-ranks of
91
+ logbag / partner2 / partner3."""
92
+
93
+ def __init__(self, p2, w2, p3=None, w3=0.0):
94
+ self.p2f, self.w2 = p2, w2
95
+ self.p3f, self.w3 = p3, w3
96
+
97
+ def fit(self, train):
98
+ self.a = mc.SklearnRUL('logbag').fit(train)
99
+ self.b = self.p2f().fit(train)
100
+ self.c = self.p3f().fit(train) if self.p3f is not None else None
101
+ return self
102
+
103
+ def predict(self, rows):
104
+ out = self.a.predict(rows)
105
+ w1 = 1.0 - self.w2 - self.w3
106
+ r = (w1 * out['p42'].rank(pct=True)
107
+ + self.w2 * self.b.predict(rows)['p42'].rank(pct=True))
108
+ if self.c is not None:
109
+ r = r + self.w3 * self.c.predict(rows)['p42'].rank(pct=True)
110
+ vals = np.sort(out['p42'].values)[::-1]
111
+ out['p42'] = pd.Series(
112
+ vals, index=r.sort_values(ascending=False).index).reindex(out.index)
113
+ return out
114
+
115
+
116
+ def register():
117
+ M = mc.MODELS
118
+ M['md_hgb_d2'] = lambda: HGBMono(max_depth=2, l2_regularization=1.0)
119
+ M['md_hgb_d2l2'] = lambda: HGBMono(max_depth=2, l2_regularization=2.0)
120
+ M['md_hgb_d2l10'] = lambda: HGBMono(max_depth=2, l2_regularization=10.0)
121
+ M['md_hgb_d1'] = lambda: HGBMono(max_depth=1, l2_regularization=1.0,
122
+ learning_rate=0.05, max_iter=400)
123
+ M['md_hgb_d1l25'] = lambda: HGBMono(max_depth=1, l2_regularization=5.0,
124
+ learning_rate=0.05, max_iter=400)
125
+ M['md_bl_hgbd2'] = lambda: MeanBlend(M['md_hgb_d2'], 0.5)
126
+ M['md_blcm_hgbd2'] = lambda: MeanBlendCM(M['md_hgb_d2'], 0.5)
127
+ M['md_blcm_hgbd1'] = lambda: MeanBlendCM(M['md_hgb_d1'], 0.5)
128
+ # 3-way order blend on the frontier recipe: logbag .55 / hgb .25 / wiener .20
129
+ M['md_ord3_d2'] = lambda: OrderBlend3(M['md_hgb_d2'], 0.25, mc.WienerFHT, 0.20)
130
+ M['md_ord3_d1l25'] = lambda: OrderBlend3(M['md_hgb_d1l25'], 0.25,
131
+ mc.WienerFHT, 0.20)
132
+ M['md_ord3_d2w15'] = lambda: OrderBlend3(M['md_hgb_d2'], 0.15, mc.WienerFHT, 0.20)
agent_reports_bgkf/artifacts/rankers.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Learning-to-rank partners for the bgkf value-preserving blend API.
2
+
3
+ All rankers: .fit(train_df)->self, .predict(rows)->DataFrame['p42'] where
4
+ p42 is any monotone score (used as rank source only).
5
+
6
+ Pair schemes (groups are the comparison sets deployment makes):
7
+ 'building' : pairs within (origin_idx, building) -- REFUTED pre-crash: worst
8
+ 'origin' : pairs within origin_idx (cross-building comparisons included)
9
+ Weighting:
10
+ 'none' : all pairs weight 1
11
+ 'days' : pair weight ~ urgency of the dying device,
12
+ 1 + (WINDOW-days_to_eol)/WINDOW (sooner death = costlier miss)
13
+ """
14
+ import numpy as np
15
+ import pandas as pd
16
+ from pathlib import Path
17
+ from sklearn.impute import SimpleImputer
18
+ from sklearn.preprocessing import StandardScaler
19
+ from sklearn.linear_model import LogisticRegression
20
+
21
+ DATASET = Path('/Users/THIF/repos/BatterySwapAI2026-Example/dataset')
22
+ WINDOW = 42.0
23
+
24
+ F13 = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30',
25
+ 'staleness', 'age_days',
26
+ 'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
27
+ 'tau_projected']
28
+ FV = F13 + ['vrel', 'vmin7_rank']
29
+
30
+ _DEV_B = None
31
+ def dev_building():
32
+ global _DEV_B
33
+ if _DEV_B is None:
34
+ _DEV_B = (pd.read_csv(DATASET / 'train' / 'devices.csv')
35
+ .drop_duplicates('device_id')
36
+ .set_index('device_id')['building_id'])
37
+ return _DEV_B
38
+
39
+
40
+ def add_group_ranks(df, feats, group):
41
+ out = df.copy()
42
+ out['g_rvlast'] = df.groupby(group)['v_last'].rank(pct=True)
43
+ out['g_rtau'] = df.groupby(group)['tau_projected'].rank(pct=True)
44
+ return out, feats + ['g_rvlast', 'g_rtau']
45
+
46
+
47
+ def build_pairs(df, mode, rng, max_neg_per_pos=50):
48
+ """Return (i_idx, j_idx, days_i) positional indices into df where i is the
49
+ dying device, j the surviving one, within the comparison group."""
50
+ b = df['device_id'].map(dev_building())
51
+ if mode == 'building':
52
+ keys = list(zip(df['origin_idx'].values, b.values))
53
+ else:
54
+ keys = list(df['origin_idx'].values)
55
+ gmap = {}
56
+ for p, k in enumerate(keys):
57
+ gmap.setdefault(k, []).append(p)
58
+ y = df['dies42'].values
59
+ days = df['days_to_eol'].values
60
+ ii, jj, dd = [], [], []
61
+ for k, idxs in gmap.items():
62
+ idxs = np.asarray(idxs)
63
+ p_ = idxs[y[idxs] == 1]
64
+ n_ = idxs[y[idxs] == 0]
65
+ if len(p_) == 0 or len(n_) == 0:
66
+ continue
67
+ for i in p_:
68
+ n_sel = n_
69
+ if len(n_) > max_neg_per_pos:
70
+ n_sel = rng.choice(n_, size=max_neg_per_pos, replace=False)
71
+ ii.extend([i] * len(n_sel))
72
+ jj.extend(n_sel)
73
+ dd.extend([days[i]] * len(n_sel))
74
+ return np.asarray(ii), np.asarray(jj), np.asarray(dd)
75
+
76
+
77
+ def pair_weights(dd, weighting):
78
+ if weighting == 'days':
79
+ w = 1.0 + np.clip((WINDOW - dd) / WINDOW, 0, 1)
80
+ return w / w.mean()
81
+ return np.ones(len(dd))
82
+
83
+
84
+ class _Prep:
85
+ def fit(self, X):
86
+ self.imp = SimpleImputer(strategy='median').fit(X)
87
+ self.sc = StandardScaler().fit(self.imp.transform(X))
88
+ return self
89
+ def transform(self, X):
90
+ return self.sc.transform(self.imp.transform(X))
91
+
92
+
93
+ class PairwiseLogistic:
94
+ """Linear scorer fit by logistic regression on standardized pair
95
+ differences (symmetric +/- augmentation, no intercept)."""
96
+
97
+ def __init__(self, feats=F13, mode='building', weighting='none',
98
+ use_group_ranks=False, C=1.0, seed=0, max_neg_per_pos=50):
99
+ self.feats0 = list(feats)
100
+ self.mode, self.weighting = mode, weighting
101
+ self.ugr = use_group_ranks
102
+ self.C, self.seed, self.mnp = C, seed, max_neg_per_pos
103
+
104
+ def _table(self, df, group):
105
+ feats = list(self.feats0)
106
+ if self.ugr:
107
+ df, feats = add_group_ranks(df, feats, group)
108
+ return df, feats
109
+
110
+ def fit(self, train):
111
+ rng = np.random.default_rng(self.seed)
112
+ b = train['device_id'].map(dev_building())
113
+ grp = ([train['origin_idx'], b] if self.mode == 'building'
114
+ else train['origin_idx'])
115
+ df, feats = self._table(train, grp)
116
+ self.feats = feats
117
+ self.prep = _Prep().fit(df[feats])
118
+ X = self.prep.transform(df[feats])
119
+ ii, jj, dd = build_pairs(df, self.mode, rng, self.mnp)
120
+ D = X[ii] - X[jj]
121
+ w = pair_weights(dd, self.weighting)
122
+ Xp = np.vstack([D, -D])
123
+ yp = np.r_[np.ones(len(D)), np.zeros(len(D))]
124
+ wp = np.r_[w, w]
125
+ self.clf = LogisticRegression(fit_intercept=False, C=self.C,
126
+ max_iter=2000)
127
+ self.clf.fit(Xp, yp, sample_weight=wp)
128
+ return self
129
+
130
+ def predict(self, rows):
131
+ df = rows.copy()
132
+ feats = list(self.feats0)
133
+ if self.ugr:
134
+ df['g_rvlast'] = df['v_last'].rank(pct=True)
135
+ df['g_rtau'] = df['tau_projected'].rank(pct=True)
136
+ feats = feats + ['g_rvlast', 'g_rtau']
137
+ X = self.prep.transform(df[feats])
138
+ s = X @ self.clf.coef_.ravel()
139
+ return pd.DataFrame({'p42': s}, index=rows.index)
140
+
141
+
142
+ class RankNet:
143
+ """Small MLP RankNet, N-seed bag, pairwise logistic loss on within-group
144
+ pairs. Deterministic per seed; torch threads pinned to 2."""
145
+
146
+ def __init__(self, feats=F13, mode='building', weighting='none',
147
+ use_group_ranks=False, seeds=(0, 1, 2), hidden=(32, 16),
148
+ epochs=300, lr=3e-3, wd=1e-3, max_neg_per_pos=50):
149
+ self.feats0 = list(feats)
150
+ self.mode, self.weighting = mode, weighting
151
+ self.ugr = use_group_ranks
152
+ self.seeds, self.hidden = seeds, hidden
153
+ self.epochs, self.lr, self.wd = epochs, lr, wd
154
+ self.mnp = max_neg_per_pos
155
+
156
+ def fit(self, train):
157
+ import torch
158
+ torch.set_num_threads(2)
159
+ b = train['device_id'].map(dev_building())
160
+ grp = ([train['origin_idx'], b] if self.mode == 'building'
161
+ else train['origin_idx'])
162
+ df = train
163
+ feats = list(self.feats0)
164
+ if self.ugr:
165
+ df, feats = add_group_ranks(df, feats, grp)
166
+ self.feats = feats
167
+ self.prep = _Prep().fit(df[feats])
168
+ X = self.prep.transform(df[feats]).astype(np.float32)
169
+ rng = np.random.default_rng(0)
170
+ ii, jj, dd = build_pairs(df, self.mode, rng, self.mnp)
171
+ w = pair_weights(dd, self.weighting).astype(np.float32)
172
+ Xi = torch.tensor(X[ii]); Xj = torch.tensor(X[jj])
173
+ wt = torch.tensor(w)
174
+ self.nets = []
175
+ d_in = X.shape[1]
176
+ for seed in self.seeds:
177
+ torch.manual_seed(seed)
178
+ layers, prev = [], d_in
179
+ for h in self.hidden:
180
+ layers += [torch.nn.Linear(prev, h), torch.nn.ReLU()]
181
+ prev = h
182
+ layers += [torch.nn.Linear(prev, 1)]
183
+ net = torch.nn.Sequential(*layers)
184
+ opt = torch.optim.Adam(net.parameters(), lr=self.lr,
185
+ weight_decay=self.wd)
186
+ lossf = torch.nn.BCEWithLogitsLoss(weight=wt)
187
+ target = torch.ones(len(ii))
188
+ for _ in range(self.epochs):
189
+ opt.zero_grad()
190
+ diff = (net(Xi) - net(Xj)).squeeze(-1)
191
+ loss = lossf(diff, target)
192
+ loss.backward()
193
+ opt.step()
194
+ net.eval()
195
+ self.nets.append(net)
196
+ return self
197
+
198
+ def predict(self, rows):
199
+ import torch
200
+ torch.set_num_threads(2)
201
+ df = rows.copy()
202
+ feats = list(self.feats0)
203
+ if self.ugr:
204
+ df['g_rvlast'] = df['v_last'].rank(pct=True)
205
+ df['g_rtau'] = df['tau_projected'].rank(pct=True)
206
+ feats = feats + ['g_rvlast', 'g_rtau']
207
+ X = self.prep.transform(df[feats]).astype(np.float32)
208
+ with torch.no_grad():
209
+ ss = [net(torch.tensor(X)).squeeze(-1).numpy()
210
+ for net in self.nets]
211
+ s = np.mean([pd.Series(s_).rank(pct=True).values for s_ in ss], axis=0)
212
+ return pd.DataFrame({'p42': s}, index=rows.index)
agent_reports_bgkf/artifacts/run3w.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """3-way value-preserving order injection on the vm7+wiener frontier base.
2
+ Usage: run3w.py LABEL A B C [NSEEDS SEED0 FEATS]
3
+ final order = A*logbag(vm7)_rank + B*wiener_rank + C*ranknet_rank,
4
+ injected at harness weight 1.0 so values (and counts) stay the primary
5
+ logbag(vm7)'s. (A,B,C)=(0.8,0.2,0) reproduces ft_vmin7r_wnr20 exactly.
6
+ RankNet: within-origin pairs, days weighting (the champion-base survivor),
7
+ FEATS f13|fvm7 (fvm7 = F13 + vmin7_rank, per coordinator).
8
+ """
9
+ import sys, time, os
10
+ sys.path.insert(0, '/tmp/rk2')
11
+ sys.path.insert(0, '/Users/THIF/repos/BatterySwapAI2026-Example')
12
+ import numpy as np
13
+ import pandas as pd
14
+ import rankers as rk
15
+ import batteryswap_example.cv as cv
16
+ import batteryswap_example.models_cv as mc
17
+
18
+ label = sys.argv[1]
19
+ A, B, C = (float(x) for x in sys.argv[2:5])
20
+ nseeds = int(sys.argv[5]) if len(sys.argv) > 5 else 7
21
+ seed0 = int(sys.argv[6]) if len(sys.argv) > 6 else 0
22
+ feats_name = sys.argv[7] if len(sys.argv) > 7 else 'fvm7'
23
+ D = float(sys.argv[8]) if len(sys.argv) > 8 else 0.0
24
+ feats = {'f13': rk.F13,
25
+ 'fvm7': rk.F13 + ['vmin7_rank'],
26
+ 'fvm7h': rk.F13 + ['vmin7_rank', 'hot_90', 'oob_90']}[feats_name]
27
+
28
+ cv.VARIANTS['vm7'] = lambda t: cv.BASE_FEATURES + ['vmin7_rank']
29
+
30
+
31
+ class ThreeWay:
32
+ def fit(self, train):
33
+ self.lb = mc.SklearnRUL('logbag').fit(train)
34
+ self.wn = mc.MODELS['wiener']().fit(train) if B > 0 else None
35
+ self.rk = (rk.RankNet(feats=feats, mode='origin', weighting='days',
36
+ seeds=tuple(range(seed0, seed0 + nseeds)))
37
+ .fit(train) if C > 0 else None)
38
+ self.ho = (mc.LogBagFeats(['hot_90', 'oob_90']).fit(train)
39
+ if D > 0 else None)
40
+ return self
41
+
42
+ def predict(self, rows):
43
+ s = A * self.lb.predict(rows)['p42'].rank(pct=True)
44
+ if self.wn is not None:
45
+ s = s + B * self.wn.predict(rows)['p42'].rank(pct=True)
46
+ if self.rk is not None:
47
+ s = s + C * self.rk.predict(rows)['p42'].rank(pct=True)
48
+ if self.ho is not None:
49
+ s = s + D * self.ho.predict(rows)['p42'].rank(pct=True)
50
+ return pd.DataFrame({'p42': s.values}, index=rows.index)
51
+
52
+
53
+ mc.MODELS[label] = ThreeWay
54
+ t0 = time.time()
55
+ df = cv.run_bgkf(kind='logbag', variant='vm7', blend=(label, 1.0))
56
+ df['cand'] = label
57
+ out = f'/tmp/cv_bgkfcand_{label}.csv'
58
+ df.to_csv(out, index=False)
59
+
60
+ d = df.groupby('origin')[['total_cost', 'n_swaps']].sum()
61
+ line = (f"{label} (A={A} B={B} C={C} D={D} s{nseeds}+{seed0} {feats_name}): "
62
+ f"SCORE {d['total_cost'].mean():.2f} swaps {d['n_swaps'].mean():.2f}")
63
+ if os.path.exists('/tmp/cv_bgkf.csv'):
64
+ base = pd.read_csv('/tmp/cv_bgkf.csv').groupby('origin')[
65
+ ['total_cost', 'n_swaps']].sum()
66
+ delta = (d['total_cost'] - base['total_cost']).dropna()
67
+ se = delta.std() / np.sqrt(len(delta))
68
+ line += (f" delta {delta.mean():+.2f} SE {se:.2f} "
69
+ f"W/L {int((delta<0).sum())}W/{int((delta>0).sum())}L")
70
+ else:
71
+ line += " [baseline missing - pair later]"
72
+ print(f"\n{line} [{time.time()-t0:.0f}s] -> {out}")
batteryswap_example/data/train_features.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:4d7e31ddec4d82eec9e062848ed0c019811068947a3bd6ccf7e42d6f7d8e7376
3
- size 6159081
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5be48b744effea696e1443bff9b00b0ebab496920e83a3b823737e67563a16f
3
+ size 7434713
batteryswap_example/submit.py CHANGED
@@ -194,7 +194,82 @@ class FrontierRUL:
194
  return out
195
 
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  def _fit_model(train):
 
 
 
 
 
 
 
198
  return (FrontierRUL() if FRONTIER else SklearnRUL(MODEL_KIND)).fit(train)
199
  # Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
200
  # minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
@@ -387,6 +462,18 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
387
  locations, timeseries, eol_times, scenarios = load_best_effort(split_path, log)
388
  scen_starts = [pd.Timestamp(s['start_time']) for s in scenarios]
389
  daily = daily_voltage(timeseries)
 
 
 
 
 
 
 
 
 
 
 
 
390
  cert_days = cert_confirm_days(daily) if CERT_FIRST else pd.Series(dtype='datetime64[ns]')
391
  dall = daily_all(timeseries)
392
  betas = weekly_betas(dall)
@@ -529,6 +616,12 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
529
  dead = eol_times[eol_times.notna() & (eol_times <= T)]
530
  curve = master_curve(dall[dall['day'] <= T], dead, beta)
531
  feats = feats.join(physics_features(dall, T, beta, curve, match=False))
 
 
 
 
 
 
532
  feats = feats.reindex(locs['battery'])
533
 
534
  pred = model.predict(feats)
 
194
  return out
195
 
196
 
197
+ # CAMPAIGN-2 FRONTIER (2026-08-23, building-GroupKFold v5 instrument):
198
+ # ft_vmin7r_wnr20 = logbag on FEATURES + vmin7_rank (fleet pct-rank of the
199
+ # 7d-min voltage, computed by features_at_origin over whatever fleet it
200
+ # ranks - own-fleet semantics in deployment) with a 0.20 value-preserving
201
+ # WienerFHT order blend. THE SCORE 1688.4 vs champion 1760.2 (-71.8 +/-
202
+ # 23.5, 3.05 sigma, Wilcoxon p=.037, swaps 9.58 flat, count-clean by every
203
+ # control; components additive: wiener -50.3, vmin7_rank -23.2; reproduced
204
+ # independently by three lanes). Worst-case reference-shift bound: with
205
+ # vmin7_rank recomputed WITHIN each ~5-building test fleet the stack still
206
+ # reads -60.8 +/- 26.3 (2.31 sigma); the scored fleets are full ~922-device
207
+ # populations, much closer to the shared-reference case. Value-preserving
208
+ # means the blended rank ORDER is mapped back onto the primary's own sorted
209
+ # p42 values: count/pricing/timing machinery see the primary's exact p42
210
+ # distribution (the phase-56/58 shipping bar). Both components degrade
211
+ # softly: missing wnr_* columns drop the wiener source (pure vm7 logbag),
212
+ # any fit error falls back to the legacy champion via _fit_model.
213
+ FT2 = os.environ.get('BATTERYSWAP_FT2', '1') != '0'
214
+ FT2_W = float(os.environ.get('BATTERYSWAP_FT2_W', '0.20'))
215
+
216
+
217
+ class Ft2RUL:
218
+ """Faithful deployment of cv.run_bgkf(kind='logbag', variant='vm7',
219
+ blend=('wiener', 0.20)): primary = SklearnRUL('logbag') fit with
220
+ FEATURES + vmin7_rank (the CV sets mc.FEATURES globally; here the swap
221
+ is scoped to this class's fits and predicts), partner = WienerFHT
222
+ (fit under the same feature list, exactly as the CV's global swap did),
223
+ blended rank order mapped onto the primary's own sorted p42 values,
224
+ timing columns staying with their device."""
225
+
226
+ def _swap(self):
227
+ class _S:
228
+ def __enter__(s):
229
+ s.old = models_cv.FEATURES
230
+ if 'vmin7_rank' not in models_cv.FEATURES:
231
+ models_cv.FEATURES = models_cv.FEATURES + ['vmin7_rank']
232
+ return s
233
+ def __exit__(s, *a):
234
+ models_cv.FEATURES = s.old
235
+ return _S()
236
+
237
+ def fit(self, train):
238
+ with self._swap():
239
+ self.primary = SklearnRUL(MODEL_KIND).fit(train)
240
+ self.wiener = (models_cv.WienerFHT().fit(train)
241
+ if FT2_W > 0 and 'wnr_n' in train.columns else None)
242
+ return self
243
+
244
+ def predict(self, rows):
245
+ if 'vmin7_rank' not in rows.columns:
246
+ rows = rows.copy()
247
+ rows['vmin7_rank'] = (rows['v_min_7'].rank(pct=True)
248
+ if 'v_min_7' in rows.columns else np.nan)
249
+ with self._swap():
250
+ pred = self.primary.predict(rows)
251
+ rk = pred['p42'].rank(pct=True)
252
+ if self.wiener is not None and 'wnr_n' in rows.columns:
253
+ # inside the swap: the wiener's DayQuantiles was fit under
254
+ # the same swapped feature list (exactly the CV's global set)
255
+ p2 = self.wiener.predict(rows)
256
+ rk = (1 - FT2_W) * rk + FT2_W * p2['p42'].rank(pct=True)
257
+ vals = np.sort(pred['p42'].values)[::-1]
258
+ out = pred.copy()
259
+ out['p42'] = pd.Series(
260
+ vals, index=rk.sort_values(ascending=False).index
261
+ ).reindex(out.index)
262
+ return out
263
+
264
+
265
  def _fit_model(train):
266
+ if FT2:
267
+ try:
268
+ return Ft2RUL().fit(train)
269
+ except Exception:
270
+ # deadline-day discipline: a broken frontier fit degrades to the
271
+ # measured champion, never to a crash
272
+ traceback.print_exc()
273
  return (FrontierRUL() if FRONTIER else SklearnRUL(MODEL_KIND)).fit(train)
274
  # Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
275
  # minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
 
462
  locations, timeseries, eol_times, scenarios = load_best_effort(split_path, log)
463
  scen_starts = [pd.Timestamp(s['start_time']) for s in scenarios]
464
  daily = daily_voltage(timeseries)
465
+ ft2_daily = None
466
+ if FT2:
467
+ # per-scenario Wiener (drift, variance) for the ft2 order blend -
468
+ # computed from THIS split's own past-only telemetry, same estimator
469
+ # the training cache used. Any failure just drops the wiener source.
470
+ try:
471
+ from batteryswap_example.fht_features import (
472
+ daily_corrected, wiener_at_origin)
473
+ ft2_daily = daily_corrected(timeseries)
474
+ except Exception as e:
475
+ log(f' WARNING ft2 wiener daily build failed ({e}); '
476
+ f'order blend drops the wiener source')
477
  cert_days = cert_confirm_days(daily) if CERT_FIRST else pd.Series(dtype='datetime64[ns]')
478
  dall = daily_all(timeseries)
479
  betas = weekly_betas(dall)
 
616
  dead = eol_times[eol_times.notna() & (eol_times <= T)]
617
  curve = master_curve(dall[dall['day'] <= T], dead, beta)
618
  feats = feats.join(physics_features(dall, T, beta, curve, match=False))
619
+ if ft2_daily is not None:
620
+ try:
621
+ feats = feats.join(wiener_at_origin(ft2_daily, T))
622
+ except Exception as e:
623
+ log(f' WARNING {scenario["name"]}: ft2 wiener params failed '
624
+ f'({e}); wiener source dropped this scenario')
625
  feats = feats.reindex(locs['battery'])
626
 
627
  pred = model.predict(feats)