Thibaut Forest Claude Fable 5 commited on
Commit
34c30a6
·
1 Parent(s): 4d3b32e

Phase 62: synthetic scaling study (1x/2x/4x, official-mechanism EOLs); count rule scales cleanly; fixed-ref vmin7 dial (default off); alpha-1.0 edge holds at every scale

Browse files
EXPERIMENTS.md CHANGED
@@ -3305,3 +3305,17 @@ zero catches) was on the OLD ordering; whether ft2's ordering converts
3305
  extra appetite publicly is the one open question a public trigger can
3306
  answer. PREPARED: commit 296f8f2 = alpha 1.0 on ft2 (single variable,
3307
  selectable for public scoring); head 7d7632c keeps the safe 0.8.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3305
  extra appetite publicly is the one open question a public trigger can
3306
  answer. PREPARED: commit 296f8f2 = alpha 1.0 on ft2 (single variable,
3307
  selectable for public scoring); head 7d7632c keeps the safe 0.8.
3308
+
3309
+ ## Phase 62 - synthetic scaling study (user directive): CV tested on fleet-size scaling; fixed-reference vmin7 dial shipped
3310
+
3311
+ Synthetic fleets at 1x/2x/4x (461/922/1844 devices) from time-shifted
3312
+ building clones with OFFICIAL-mechanism-derived EOLs (0 mismatches on the
3313
+ checkable set at every scale); deployment-faithful static-path runs;
3314
+ anchor k=1 champ = 1762.5 ~= bgkf 1760.2 (same recall .471). Verdicts:
3315
+ count rule scales cleanly (swaps/death 0.98-1.07); live-rank vmin7_rank
3316
+ decays and inverts at 4x while a train-ECDF fixed-reference form is
3317
+ scale-neutral (shipped as BATTERYSWAP_FT2_FIXEDREF, default 0 - the Docker
3318
+ rehearsal measured 922-device splits = the safe 2x regime); alpha 1.0 best
3319
+ at EVERY scale with recall +7pts held (third independent support for the
3320
+ 296f8f2 public A/B); runtime 3.4s/scenario at 4x - no budget risk. Full
3321
+ tables: agent_reports_bgkf/c3_findings.md.
agent_reports_bgkf/artifacts/scale_run.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scaling evaluation: run an arm on a synthetic fleet at scale K.
2
+
3
+ Deployment-faithful: ONE static fit on the real packaged train table
4
+ (submit.py's held-out branch), then the 48 real scenario times planned on
5
+ the synthetic fleet and costed by the real evaluator with derived EOLs.
6
+
7
+ Arms: champ (plain logbag, alpha .8) | ft2 (frontier, alpha .8)
8
+ | ft2a10 (frontier, alpha 1.0)
9
+ Usage: scale_run.py K ARM
10
+ """
11
+ import sys, time, pickle
12
+ from pathlib import Path
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ OURS = Path('/Users/THIF/repos/BatterySwapAI2026-Example')
17
+ sys.path.insert(0, str(OURS))
18
+ from batteryswap_public.utils import load_dataset, iterate_scenarios
19
+ from batteryswap_public.evaluate import evaluate_plan
20
+ from batteryswap_example.features import (
21
+ daily_voltage, daily_all, weekly_betas, master_curve, physics_features,
22
+ features_at_origin, WINDOW_DAYS, BETA_DEFAULT)
23
+ from batteryswap_example.fht_features import daily_corrected, wiener_at_origin
24
+ import batteryswap_example.models_cv as mc
25
+ import batteryswap_example.submit as sub
26
+
27
+ K = int(sys.argv[1])
28
+ ARM = sys.argv[2]
29
+ ALPHA = 1.0 if ARM.endswith('a10') else 0.8
30
+ if ARM == 'ft2fix':
31
+ ALPHA = 0.8
32
+
33
+ with open(f'/tmp/c5/fleet_k{K}.pkl', 'rb') as f:
34
+ FL = pickle.load(f)
35
+ locations = FL['locations']
36
+ for _c in ('start_time', 'end_time'):
37
+ if getattr(locations[_c].dt, 'tz', None) is not None:
38
+ locations[_c] = locations[_c].dt.tz_localize(None)
39
+ eol = FL['eol']
40
+ if getattr(eol.dt, 'tz', None) is not None:
41
+ eol = eol.dt.tz_localize(None)
42
+ travel = FL['travel']
43
+ ts = FL['ts'].set_index(['device_id', 'end_time']).sort_index()
44
+
45
+ _, _, eol_real, scenarios = load_dataset(OURS / 'dataset/train')
46
+ # fleet-wide EOL series shaped like load_dataset's (NaT for survivors);
47
+ # clone-0 devices take the OFFICIAL values (exact), shifted clones the
48
+ # telemetry-derived ones (0 mismatches on the checkable set). The 6 real
49
+ # deaths the derivation misses become permanent low-voltage "ghosts" in
50
+ # shifted clones - noted, and shared by every arm equally.
51
+ eol_syn = eol.reindex(locations['battery'])
52
+ eol_syn.index = locations['battery']
53
+ er = eol_real.copy()
54
+ if getattr(er.dt, 'tz', None) is not None:
55
+ er = er.dt.tz_localize(None)
56
+ c0 = eol_syn.index.intersection(er.index)
57
+ eol_syn.loc[c0] = er.reindex(c0)
58
+ # scenarios with the synthetic travel matrix
59
+ scenarios = [dict(sc, travel_costs=travel) for sc in scenarios]
60
+ GEN = list(iterate_scenarios(locations, ts, eol_syn, scenarios))
61
+
62
+ # ---- one static foreign fit (the deployment path) ----
63
+ base = pd.read_parquet(OURS / 'batteryswap_example/data/train_features.parquet')
64
+ base = base[base['window_closed']]
65
+ model = (mc.SklearnRUL('logbag') if ARM == 'champ' else sub.Ft2RUL()).fit(base)
66
+ if ARM == 'ft2fix':
67
+ # scale hardening: vmin7_rank as a FIXED-reference percentile (train
68
+ # ECDF of v_min_7) instead of a live-fleet rank - fleet-size invariant
69
+ _q = np.sort(base['v_min_7'].dropna().to_numpy())
70
+ _inner = model
71
+ class _Fix:
72
+ def predict(self, rows):
73
+ r = rows.copy()
74
+ v = r['v_min_7'].to_numpy(dtype=float)
75
+ pct = np.searchsorted(_q, v, side='right') / len(_q)
76
+ r['vmin7_rank'] = np.where(np.isfinite(v), pct, np.nan)
77
+ return _inner.predict(r)
78
+ model = _Fix()
79
+
80
+ # ---- per-fleet caches ----
81
+ t0 = time.time()
82
+ daily = daily_voltage(ts)
83
+ dall = daily_all(ts)
84
+ betas = weekly_betas(dall)
85
+ fd = daily_corrected(ts)
86
+ print(f'fleet caches {time.time()-t0:.0f}s', file=sys.stderr, flush=True)
87
+
88
+ p = mc.plan_tsroute
89
+ p.alpha = ALPHA
90
+ p.ec_margin, p.ec_pmax = 0.8, 25.0
91
+ p.split_days = 7
92
+ p.floor_scale, p.recent_deaths = 2.5, 0.0
93
+
94
+ W = pd.Timedelta(days=WINDOW_DAYS)
95
+ recs = []
96
+ for i, (sc, fleet, cut, eol_f) in enumerate(GEN):
97
+ T = pd.Timestamp(sc['start_time'])
98
+ tt = time.time()
99
+ feats = features_at_origin(daily, fleet, T)
100
+ past = betas[betas['week_end'] <= T]['beta']
101
+ beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
102
+ dead = eol[eol.notna() & (eol <= T)]
103
+ curve = master_curve(dall[dall['day'] <= T], dead, beta)
104
+ feats = feats.join(physics_features(dall, T, beta, curve, match=False))
105
+ if ARM != 'champ':
106
+ feats = feats.join(wiener_at_origin(
107
+ fd, T.tz_localize(None) if T.tz is not None else T))
108
+ feats = feats.reindex(fleet['battery'])
109
+ t_feat = time.time() - tt
110
+
111
+ pred = model.predict(feats)
112
+ t_pred = time.time() - tt - t_feat
113
+ plan = mc.plan_tsroute(pred, None, fleet, T, 0.05,
114
+ travel_costs=travel, settings=sc['settings'])
115
+ t_plan = time.time() - tt - t_feat - t_pred
116
+ _, _, overall = evaluate_plan(plan, fleet, travel, sc['settings'],
117
+ eol_times=eol_f, start_time=T)
118
+ pday = plan['day']
119
+ if getattr(pday.dt, 'tz', None) is not None:
120
+ pday = pday.dt.tz_localize(None)
121
+ Tn = T.tz_localize(None) if T.tz is not None else T
122
+ inwin = plan[(pday <= Tn + W).values]
123
+ swap_day = inwin.groupby('battery')['day'].min()
124
+ dies = eol[(eol > Tn) & (eol <= Tn + W)
125
+ & eol.index.isin(set(fleet['battery']))]
126
+ caught = int(dies.index.isin(swap_day.index).sum())
127
+ recs.append(dict(overall, origin=i, n_swaps=int(inwin['battery'].nunique()),
128
+ deaths=len(dies), caught=caught,
129
+ t_feat=t_feat, t_pred=t_pred, t_plan=t_plan))
130
+ print(f's_{i}: {overall["total_cost"]:8.1f} deaths {len(dies):3d} '
131
+ f'caught {caught:3d} swaps {recs[-1]["n_swaps"]:3d} '
132
+ f'[f{t_feat:.1f} p{t_pred:.1f} pl{t_plan:.1f}s]',
133
+ file=sys.stderr, flush=True)
134
+
135
+ df = pd.DataFrame(recs)
136
+ df.to_csv(f'/tmp/c5/scale_k{K}_{ARM}.csv', index=False)
137
+ n_dev = locations['battery'].nunique()
138
+ print(f"\nK={K} ARM={ARM} devices={n_dev}")
139
+ print(f" mean total/scenario {df['total_cost'].mean():9.1f} "
140
+ f"per-DEVICE {df['total_cost'].mean()/n_dev:7.3f}")
141
+ print(f" swaps/scen {df['n_swaps'].mean():6.1f} deaths/scen "
142
+ f"{df['deaths'].mean():5.1f} recall {df['caught'].sum()/max(df['deaths'].sum(),1):.3f} "
143
+ f"swaps/death {df['n_swaps'].sum()/max(df['deaths'].sum(),1):.2f}")
144
+ print(f" late/scen {df['late_swap'].mean():7.1f} early/scen "
145
+ f"{df['early_swap'].mean():7.1f}")
146
+ print(f" sec/scenario: feat {df['t_feat'].mean():.1f} pred "
147
+ f"{df['t_pred'].mean():.1f} plan {df['t_plan'].mean():.1f}")
agent_reports_bgkf/artifacts/synth.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic fleet generator for the SCALING evaluation.
2
+
3
+ Fleet at scale k = k time-shifted clones of each real building. Each clone
4
+ building gets ONE shift (preserves within-building death correlation,
5
+ decorrelates clone sets). Ground-truth EOL is DERIVED from the shifted
6
+ telemetry with the official mechanism (7d-median < 2.40, validated 81/81
7
+ by the team repo) - labels stay mechanically consistent with the data.
8
+ Travel: distance between clones = distance between base buildings;
9
+ same-base clone pairs get the median off-diagonal hop.
10
+
11
+ Writes /tmp/c5/fleet_k{K}.pkl with: timeseries, locations, eol, travel fn.
12
+ """
13
+ import sys, pickle, time
14
+ from pathlib import Path
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ TEAM = Path('/tmp/teamrepo')
19
+ OURS = Path('/Users/THIF/repos/BatterySwapAI2026-Example')
20
+ sys.path.insert(0, str(TEAM))
21
+ sys.path.insert(0, str(OURS))
22
+ from batteryswap_public.utils import load_dataset
23
+ from features import daily_panel, eol_from_panel
24
+
25
+ K = int(sys.argv[1]) if len(sys.argv) > 1 else 2
26
+ SEED = 7
27
+
28
+ locations, ts, eol_times, scenarios = load_dataset(OURS / 'dataset/train')
29
+ df = ts.reset_index()
30
+ df['device_id'] = df['device_id'].astype(str)
31
+ dev = pd.read_csv(OURS / 'dataset/train/devices.csv').drop_duplicates('device_id')
32
+ span_lo = df['end_time'].min()
33
+ span_hi = df['end_time'].max()
34
+
35
+ rng = np.random.default_rng(SEED)
36
+ frames, loc_rows = [], []
37
+ for c in range(K):
38
+ for b, sub in dev.groupby('building_id'):
39
+ # clone 0 of each building = the untouched original (scale anchor)
40
+ shift = 0 if c == 0 else int(rng.integers(-120, 121))
41
+ sb = b if c == 0 else f'{b}_c{c}'
42
+ for _, r in sub.iterrows():
43
+ sd = r['device_id'] if c == 0 else f"{r['device_id']}_c{c}"
44
+ d = df[df['device_id'] == r['device_id']].copy()
45
+ d['device_id'] = sd
46
+ d['end_time'] = d['end_time'] + pd.Timedelta(days=shift)
47
+ d = d[(d['end_time'] >= span_lo) & (d['end_time'] <= span_hi)]
48
+ if len(d) < 50:
49
+ continue
50
+ frames.append(d)
51
+ loc_rows.append(dict(battery=sd, building=sb,
52
+ room=(r['room_id'] if c == 0
53
+ else f"{r['room_id']}_c{c}"),
54
+ base_building=b,
55
+ start_time=pd.Timestamp(r['start_time'])
56
+ + pd.Timedelta(days=shift),
57
+ end_time=pd.Timestamp(r['end_time'])
58
+ + pd.Timedelta(days=shift)))
59
+
60
+ sts = pd.concat(frames, ignore_index=True)
61
+ locs = pd.DataFrame(loc_rows)
62
+ for col in ('start_time', 'end_time'):
63
+ locs[col] = pd.to_datetime(locs[col], utc=True)
64
+
65
+ t0 = time.time()
66
+ panel = daily_panel(sts.set_index(['device_id', 'end_time']))
67
+ eol = eol_from_panel(panel)
68
+ print(f'k={K}: {locs.battery.nunique()} devices, '
69
+ f'{locs.building.nunique()} buildings, {len(eol)} EOLs '
70
+ f'(panel {time.time()-t0:.0f}s)')
71
+
72
+ # sanity: clone-0 EOLs must match the official file exactly
73
+ official = pd.read_csv(OURS / 'dataset/train/eol_times.csv')
74
+ col = [c for c in official.columns if c != 'device_id'][0]
75
+ truth = pd.to_datetime(official.set_index('device_id')[col]).dt.tz_localize(None)
76
+ c0 = eol[~eol.index.str.contains('_c')]
77
+ both = truth.dropna().index.intersection(c0.index)
78
+ mism = int((pd.to_datetime(c0.reindex(both)).dt.normalize()
79
+ != truth.reindex(both).dt.normalize()).sum())
80
+ print(f'clone-0 EOL check: {len(both)} deaths, {mism} mismatches')
81
+
82
+ # travel-cost template: base 24x24 matrix from scenario 0
83
+ tc0 = scenarios[0]['travel_costs']
84
+ base_h = tc0.set_index(['from', 'to'])['hours']
85
+ med_hop = tc0.loc[tc0['from'] != tc0['to'], 'hours'].median()
86
+ blds = sorted(locs['building'].unique())
87
+ base_of = locs.drop_duplicates('building').set_index('building')['base_building']
88
+ rows = []
89
+ for i in blds:
90
+ for j in blds:
91
+ if i == j:
92
+ h = 0.0
93
+ else:
94
+ bi, bj = base_of[i], base_of[j]
95
+ h = med_hop if bi == bj else float(base_h.get((bi, bj), med_hop))
96
+ rows.append(dict(**{'from': i, 'to': j}, hours=h))
97
+ travel = pd.DataFrame(rows)
98
+
99
+ with open(f'/tmp/c5/fleet_k{K}.pkl', 'wb') as f:
100
+ pickle.dump(dict(ts=sts, locations=locs, eol=pd.to_datetime(eol),
101
+ travel=travel), f)
102
+ print(f'wrote /tmp/c5/fleet_k{K}.pkl')
agent_reports_bgkf/c3_findings.md CHANGED
@@ -167,3 +167,44 @@ trigger can answer it. Prepared as a one-click experiment:
167
  **commit 296f8f2 = alpha 1.0 on the ft2 frontier** (single variable);
168
  head 7d7632c stays at the safe 0.8. Pushes are scoring-inert - 296f8f2
169
  only runs if the user triggers it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  **commit 296f8f2 = alpha 1.0 on the ft2 frontier** (single variable);
168
  head 7d7632c stays at the safe 0.8. Pushes are scoring-inert - 296f8f2
169
  only runs if the user triggers it.
170
+
171
+ ## SCALING EVALUATION (user directive, phase 62): synthetic fleets 1x/2x/4x
172
+
173
+ Construction: fleets of 461/922/1844 devices (24/48/96 buildings) = time-
174
+ shifted clones of real buildings (one shift per clone building; clone-0 =
175
+ the untouched real fleet); ground-truth EOLs DERIVED from the shifted
176
+ telemetry with the official label mechanism (clone-0 check: 0 mismatches
177
+ at every scale). Deployment-faithful runs: one static fit on the shipped
178
+ train table, submit.py's per-scenario feature path, real evaluator,
179
+ synthetic travel matrix. ANCHOR: k=1 champ = 1762.5/scenario ~= the bgkf
180
+ baseline 1760.2 with the same recall (.471) - the instrument calibrates.
181
+ Clone bias affects levels, cancels across scales and arms.
182
+
183
+ | per-device cost | k=1 | k=2 | k=4 | recall k1->k4 |
184
+ |---|---|---|---|---|
185
+ | champ (logbag, a.8) | 3.823 | 3.891 | 4.438 | .471 -> .453 |
186
+ | ft2 (live-rank) | 3.713 | 3.874 | 4.506 | .489 -> .448 |
187
+ | ft2 FIXED-REF vmin7 | 3.736 | 3.848 | 4.443 | .491 -> .457 |
188
+ | ft2 + alpha 1.0 | 3.523 | 3.569 | 4.020 | .545 -> .522 |
189
+
190
+ FINDINGS:
191
+ 1. COUNT RULE SCALES CLEANLY: swaps/death 0.98-1.07 at every scale for the
192
+ alpha-.8 arms - sum(p42) tracks prevalence linearly. No calibration
193
+ risk at large fleets.
194
+ 2. LIVE-RANK vmin7 DECAYS with fleet size and inverts at 4x. Fixed-
195
+ reference percentile (train ECDF of v_min_7) removes the decay; on the
196
+ standard bgkf it costs +2.1 +/- 14.4 (free at the mean, 8W/27L slightly
197
+ sign-worse on small fleets). SHIPPED AS A DIAL:
198
+ BATTERYSWAP_FT2_FIXEDREF (default 0). The Docker rehearsal MEASURED the
199
+ real splits at 922 devices (= k=2, where live-rank is still fine), so
200
+ the dial stays off; flip it if fleets turn out much larger. (The
201
+ "4000 total" figure is inconsistent with the rehearsal's observation.)
202
+ 3. ALPHA 1.0 IS THE BEST ARM AT EVERY SCALE and its edge GROWS absolutely
203
+ with fleet size (recall +7pts held at 4x; swaps/death stable 1.15-1.22).
204
+ Third independent line (bgkf mean, podium operating points, synthetic
205
+ scaling) behind the 296f8f2 public A/B.
206
+ 4. RUNTIME IS A NON-ISSUE: 3.4s/scenario at 1844 devices (feat 0.7 + plan
207
+ 2.0-2.7) ~= 3 min/split even at 4x fleets, far inside the 20-min budget.
208
+
209
+ Artifacts: /tmp/c5/ (generator synth.py, runner scale_run.py, fleets,
210
+ per-arm CSVs, /tmp/cv_bgkfcand_ft2fix.csv).
batteryswap_example/submit.py CHANGED
@@ -212,6 +212,16 @@ class FrontierRUL:
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:
@@ -239,9 +249,16 @@ class Ft2RUL:
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)
 
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
+ # Scale hardening (phase 62 synthetic scaling study): vmin7_rank as a
216
+ # FIXED-reference percentile (train ECDF of v_min_7) instead of the scored
217
+ # fleet's live rank. Live-rank ft2's edge decays with fleet size and
218
+ # inverts at 4x (per-device 4.506 vs champ 4.438 at 1844 devices); the
219
+ # fixed form is scale-neutral (4.443) and best at 2x (3.848). Cost on the
220
+ # standard bgkf instrument: +2.1 +/- 14.4 (free at the mean, slightly
221
+ # sign-worse on small fleets). Docker rehearsal measured the real splits
222
+ # at 922 devices (= the 2x regime, where live-rank is fine), so this ships
223
+ # OFF - flip to 1 if the scored fleets turn out much larger than 922.
224
+ FT2_FIXEDREF = os.environ.get('BATTERYSWAP_FT2_FIXEDREF', '0') != '0'
225
 
226
 
227
  class Ft2RUL:
 
249
  self.primary = SklearnRUL(MODEL_KIND).fit(train)
250
  self.wiener = (models_cv.WienerFHT().fit(train)
251
  if FT2_W > 0 and 'wnr_n' in train.columns else None)
252
+ self._ecdf = (np.sort(train['v_min_7'].dropna().to_numpy())
253
+ if FT2_FIXEDREF and 'v_min_7' in train.columns else None)
254
  return self
255
 
256
  def predict(self, rows):
257
+ if self._ecdf is not None and 'v_min_7' in rows.columns:
258
+ rows = rows.copy()
259
+ v = rows['v_min_7'].to_numpy(dtype=float)
260
+ pct = np.searchsorted(self._ecdf, v, side='right') / len(self._ecdf)
261
+ rows['vmin7_rank'] = np.where(np.isfinite(v), pct, np.nan)
262
  if 'vmin7_rank' not in rows.columns:
263
  rows = rows.copy()
264
  rows['vmin7_rank'] = (rows['v_min_7'].rank(pct=True)