feature_extractor
View feature_extractor.py on GitHub →
feature_extractor.py — swirl-phase feature extraction for the feels cup-content study.
Consumes one telemetry CSV from repeatable_grasp.py (single continuous pick-up → swirl → place macro) and outputs the explicit 10-D feature vector z plus its L2-normalized embedding e in S^9, ready for MongoDB Atlas $vectorSearch (index: cup_dynamics_index, 10 dims, cosine).
10-D feature vector z:
- mass_j2 Δτ2 at pre-swirl peak-Z vs INIT tare (N·m)
- mass_j3 Δτ3 at pre-swirl peak-Z vs INIT tare (N·m)
- swirl_var_j2 variance of detrended τ2 across SWIRL (N·m²)
- swirl_var_j3 variance of detrended τ3 across SWIRL (N·m²)
- swirl_ptp_j3 peak-to-peak τ3 across SWIRL (N·m)
- phase_lag_s xcorr lag: TCP speed vs τ3 residual (s;
- = torque lags the commanded motion)
- rattle_e spectral energy of τ3 residual in the observable high band [1.5 Hz, Nyquist]. NOTE: at 4-5 Hz cloud sampling Nyquist ≈ 2.5 Hz, so the true 5-25 Hz rattle band is NOT observable — see diagnostics ["rattle_band_limited"]. Needs the 100 Hz on-robot module (control_service.py) for the real band.
- grip_comp std of gripper_pos across SWIRL (encoder units)
- lift_dyn_j3 std of τ3 in LIFT_TO_TRAVEL (N·m)
- swirl_speed mean TCP speed across SWIRL (mm/s)
Usage: python feature_extractor.py recordings/recording_20261029_180332_empty_cup.csv
1#!/usr/bin/env python3 2""" 3feature_extractor.py — swirl-phase feature extraction for the feels cup-content study. 4 5Consumes one telemetry CSV from repeatable_grasp.py (single continuous 6pick-up → swirl → place macro) and outputs the explicit 10-D feature vector z 7plus its L2-normalized embedding e in S^9, ready for MongoDB Atlas 8$vectorSearch (index: cup_dynamics_index, 10 dims, cosine). 9 1010-D feature vector z: 11 1. mass_j2 Δτ2 at pre-swirl peak-Z vs INIT tare (N·m) 12 2. mass_j3 Δτ3 at pre-swirl peak-Z vs INIT tare (N·m) 13 3. swirl_var_j2 variance of detrended τ2 across SWIRL (N·m²) 14 4. swirl_var_j3 variance of detrended τ3 across SWIRL (N·m²) 15 5. swirl_ptp_j3 peak-to-peak τ3 across SWIRL (N·m) 16 6. phase_lag_s xcorr lag: TCP speed vs τ3 residual (s; 17 + = torque lags the commanded motion) 18 7. rattle_e spectral energy of τ3 residual in the observable 19 high band [1.5 Hz, Nyquist]. NOTE: at 4-5 Hz cloud 20 sampling Nyquist ≈ 2.5 Hz, so the true 5-25 Hz rattle 21 band is NOT observable — see diagnostics 22 ["rattle_band_limited"]. Needs the 100 Hz on-robot 23 module (control_service.py) for the real band. 24 8. grip_comp std of gripper_pos across SWIRL (encoder units) 25 9. lift_dyn_j3 std of τ3 in LIFT_TO_TRAVEL (N·m) 26 10. swirl_speed mean TCP speed across SWIRL (mm/s) 27 28Usage: 29 python feature_extractor.py recordings/recording_20261029_180332_empty_cup.csv 30""" 31import argparse 32import json 33import os 34import sys 35 36import numpy as np 37import pandas as pd 38from scipy.signal import butter, filtfilt 39 40JOINTS = list(range(1, 7)) 41SWIRL_PHASE = "SWIRL" 42FALLBACK_DYN_PHASES = ["LIFT_TO_TRAVEL", "DESCEND_PLACE", "TRAVEL_SETTLING"] 43FEATURE_NAMES = [ 44 "mass_j2", "mass_j3", 45 "swirl_var_j2", "swirl_var_j3", "swirl_ptp_j3", 46 "phase_lag_s", "rattle_e", 47 "grip_comp", "lift_dyn_j3", "swirl_speed", 48] 49 50# Plain-language meaning of each feature, shown in the dashboard. 51FEATURE_DESCRIPTIONS = { 52 "mass_j2": "Static gravity torque offset at shoulder (J2) — payload mass estimate [excluded from matching: tare noise exceeds payload signal]", 53 "mass_j3": "Static gravity torque offset at elbow (J3) — payload mass estimate [excluded from matching: tare noise exceeds payload signal]", 54 "swirl_var_j2": "Torque variance at shoulder during swirl — slosh / loose-content energy", 55 "swirl_var_j3": "Torque variance at elbow during swirl", 56 "swirl_ptp_j3": "Peak-to-peak torque swing at elbow during swirl — shifting mass", 57 "phase_lag_s": "Lag between TCP motion and torque response — viscous / fluid damping", 58 "rattle_e": "High-frequency torque energy — rattle (band-limited by 4-5 Hz sampling)", 59 "grip_comp": "Gripper compliance change under load — squishy vs rigid contents", 60 "lift_dyn_j3": "Dynamic torque at elbow during lift — inertial mass cue", 61 "swirl_speed": "Mean TCP speed during swirl — excitation normalization [excluded from matching: measures the fixed macro's drive, not contents]", 62} 63 64# Per-feature scales so the L2-normalized embedding isn't dominated by the 65# largest-magnitude raw feature (swirl_speed ~160 mm/s vs variances ~1-5). 66# Calibrated on swirl-protocol data ONLY (--calibrate --require-swirl); 67# mixing protocols confounds motion with contents. 68# 69# Per-feature weights: 0.0 excludes a feature from the embedding (it stays in 70# z for display/diagnostics). mass_j2/mass_j3 are proven dead — the audit 71# found static hold torques identical across contents to 3 decimals, i.e. 72# payload signal (~1 N-m) is below tare noise (~10 N-m). swirl_speed is a 73# nuisance by construction: it measures how hard the fixed macro drives, 74# which is ~constant across contents, so it only compresses cosine 75# similarities toward 1. The embedding is effectively 7-D (index stays 10-D 76# with zeros in the excluded dims — no migration needed). 77FEATURE_WEIGHTS = { 78 "mass_j2": 0.0, 79 "mass_j3": 0.0, 80 "swirl_var_j2": 1.0, 81 "swirl_var_j3": 1.0, 82 "swirl_ptp_j3": 1.0, 83 "phase_lag_s": 1.0, 84 "rattle_e": 1.0, 85 "grip_comp": 1.0, 86 "lift_dyn_j3": 1.0, 87 "swirl_speed": 0.0, 88} 89# Calibrated as the 90th percentile of |z| over the recordings available on 90# 2026-10-29 (n=8, only one true SWIRL recording). RECALIBRATE on the P0 91# swirl dataset, then freeze — rescaling invalidates old Atlas signatures. 92# Regenerate with: python feature_extractor.py --calibrate 93FEATURE_SCALES = { 94 # Calibrated 2026-10-29 on swirl-only train recordings (n=33, 90th pct of |z|). 95 # The previous values were fallback-protocol calibrated and collapsed every 96 # swirl embedding to ~0.97 cosine (98%-confidence artifact). Reintroduced 97 # to the feat branch after the main-branch rollback (was 84080f0). 98 "mass_j2": 8.978, 99 "mass_j3": 3.244, 100 "swirl_var_j2": 19.866, 101 "swirl_var_j3": 4.883, 102 "swirl_ptp_j3": 16.479, 103 "phase_lag_s": 1.455, 104 "rattle_e": 179.594, 105 "grip_comp": 0.632, 106 "lift_dyn_j3": 4.141, 107 "swirl_speed": 182.422, 108} 109 110 111# ── signal helpers ────────────────────────────────────────────────────── 112def _fs(df): 113 diffs = df["elapsed_sec"].diff().median() 114 return 1.0 / diffs if diffs and diffs > 0 else 5.0 115 116 117def _lowpass(x, fs, cutoff_hz=0.8, order=2): 118 nyq = fs / 2.0 119 wc = min(cutoff_hz, 0.9 * nyq) / nyq 120 b, a = butter(order, wc, btype="low") 121 padlen = 3 * max(len(a), len(b)) 122 if len(x) <= padlen: 123 return x.astype(float) 124 return filtfilt(b, a, x) 125 126 127def _tare_with_qc(df): 128 """Per-joint INIT tare + quality flags (see BASELINE_FINDINGS.md).""" 129 tares, flags = {}, [] 130 for j in JOINTS: 131 col = f"tau{j}_nm" 132 init = df.loc[df["phase"] == "INIT", col] 133 tares[j] = init.median() if len(init) else df[col].iloc[:5].median() 134 if len(init) >= 2: 135 d2 = df.loc[df["phase"] == "INIT", "tau2_nm"] 136 d3 = df.loc[df["phase"] == "INIT", "tau3_nm"] 137 if d2.max() - d2.min() > 3.0 or d3.max() - d3.min() > 1.0: 138 flags.append("INIT_DRIFT") 139 q2 = df.loc[df["phase"] == "INIT", "q2_deg"] 140 if q2.max() - q2.min() > 5.0: 141 flags.append("INIT_MOTION") 142 return tares, flags 143 144 145def _tcp_speed(df): 146 t = df["elapsed_sec"].values 147 x = df["tcp_x_mm"].values 148 y = df["tcp_y_mm"].values 149 dt = np.diff(t) 150 dt[dt <= 0] = np.nan 151 return np.concatenate([[0.0], np.hypot(np.diff(x), np.diff(y)) / dt]) 152 153 154def _phase_lag(x, y, fs, max_lag_s=2.0): 155 """Lag of max |cross-correlation| between x and y, in seconds.""" 156 a = np.asarray(x, dtype=float) 157 b = np.asarray(y, dtype=float) 158 a = a - a.mean() 159 b = b - b.mean() 160 if np.std(a) < 1e-9 or np.std(b) < 1e-9: 161 return 0.0 162 corr = np.correlate(a / (np.std(a) + 1e-12), b / (np.std(b) + 1e-12), mode="full") 163 lags = (np.arange(len(corr)) - (len(a) - 1)) / fs 164 m = np.abs(lags) <= max_lag_s 165 return float(lags[m][np.argmax(np.abs(corr[m]))]) if m.any() else 0.0 166 167 168# ── main extraction ───────────────────────────────────────────────────── 169def extract_features(df): 170 """Compute the 10-D swirl signature and normalized embedding from a recording. 171 172 Reads the SWIRL phase (or falls back to dynamic phases), detrends joint 173 torques against the INIT baseline, and computes the ten physical features 174 (static lift, swirl dynamics, grip/excitation groups). 175 176 Args: 177 df: recording DataFrame with phase, elapsed_sec, tcp_*_mm, tau*_nm columns. 178 Returns: 179 Dict with z (10-D raw feature list), e (L2-normalized embedding list), 180 diagnostics (tare flags, phase source, sample count, sample rate). 181 Raises: 182 ValueError: when required columns are missing. 183 """ 184 if not {"phase", "elapsed_sec", "tcp_x_mm", "tcp_y_mm", "tau2_nm", "tau3_nm"}.issubset(df.columns): 185 raise ValueError("CSV missing required columns (phase, elapsed_sec, tcp_*, tau2/3_nm)") 186 fs = _fs(df) 187 tares, tare_flags = _tare_with_qc(df) 188 dtau2 = df["tau2_nm"].values - tares[2] 189 dtau3 = df["tau3_nm"].values - tares[3] 190 speed = _tcp_speed(df) 191 192 sw = df[df["phase"] == SWIRL_PHASE] 193 phase_source = SWIRL_PHASE 194 if len(sw) < 4: 195 sw = df[df["phase"].isin(FALLBACK_DYN_PHASES)] 196 phase_source = "fallback:" + ",".join(FALLBACK_DYN_PHASES) 197 if len(sw) < 4: 198 raise ValueError("No SWIRL (or fallback dynamic) phase samples in recording") 199 200 idx = sw.index.values 201 r2 = dtau2[idx] - _lowpass(dtau2[idx], fs) # detrended residuals 202 r3 = dtau3[idx] - _lowpass(dtau3[idx], fs) 203 v = speed[idx] 204 205 # 1-2. mass offset: pre-swirl peak-Z mean vs INIT tare 206 pre = df.iloc[: idx[0]] 207 peak = pre[pre["tcp_z_mm"] == pre["tcp_z_mm"].max()] 208 mass_j2 = float(peak["tau2_nm"].mean() - tares[2]) if len(peak) else 0.0 209 mass_j3 = float(peak["tau3_nm"].mean() - tares[3]) if len(peak) else 0.0 210 211 # 3-5. swirl variance / peak-to-peak 212 swirl_var_j2 = float(np.var(r2)) 213 swirl_var_j3 = float(np.var(r3)) 214 swirl_ptp_j3 = float(np.ptp(dtau3[idx])) 215 216 # 6. phase lag: xcorr(TCP speed, τ3 residual), lag of max |corr| within ±2 s 217 phase_lag_s = _phase_lag(v, r3, fs) 218 219 # 7. rattle energy: observable high band only (honest about Nyquist) 220 nyq = fs / 2.0 221 lo, hi = 1.5, nyq 222 rattle_band_limited = nyq < 5.0 223 if len(r3) >= 8 and hi > lo: 224 freqs = np.fft.rfftfreq(len(r3), d=1.0 / fs) 225 psd = np.abs(np.fft.rfft(r3)) ** 2 226 band = (freqs >= lo) & (freqs <= hi) 227 rattle_e = float(np.sum(psd[band])) 228 else: 229 rattle_e = 0.0 230 231 # 8. grip compliance 232 grip_comp = float(sw["gripper_pos"].std()) if "gripper_pos" in sw else 0.0 233 234 # 9. lift transient 235 lift = df[df["phase"] == "LIFT_TO_TRAVEL"] 236 lift_dyn_j3 = float(lift["tau3_nm"].std()) if len(lift) >= 2 else 0.0 237 238 # 10. mean swirl speed 239 swirl_speed = float(np.nanmean(v)) if len(v) else 0.0 240 241 z = np.array([ 242 mass_j2, mass_j3, 243 swirl_var_j2, swirl_var_j3, swirl_ptp_j3, 244 phase_lag_s, rattle_e, 245 grip_comp, lift_dyn_j3, swirl_speed, 246 ], dtype=float) 247 z = np.nan_to_num(z, nan=0.0, posinf=0.0, neginf=0.0) 248 scales = np.array([FEATURE_SCALES[n] for n in FEATURE_NAMES], dtype=float) 249 weights = np.array([FEATURE_WEIGHTS[n] for n in FEATURE_NAMES], dtype=float) 250 zs = z / np.where(scales > 0, scales, 1.0) # balanced, unit-ish features 251 zw = zs * weights # drop dead/nuisance dims 252 norm = np.linalg.norm(zw) 253 e = (zw / norm).tolist() if norm > 1e-12 else [0.0] * len(zw) 254 255 diagnostics = { 256 "fs_hz": round(float(fs), 2), 257 "nyquist_hz": round(float(nyq), 2), 258 "n_samples": int(len(df)), 259 "phase_source": phase_source, 260 "n_swirl_samples": int(len(sw)), 261 "tare_flags": tare_flags, 262 "rattle_band_hz": [float(lo), round(float(hi), 2)], 263 "rattle_band_limited": bool(rattle_band_limited), 264 "label": str(df["object_label"].iloc[0]) if "object_label" in df else None, 265 } 266 return {"z": z.tolist(), "e": e, "diagnostics": diagnostics} 267 268 269def swirl_window_series(df): 270 """Detrended torque residuals over the dynamic (swirl) window, for the 271 dashboard 'why' charts. Returns a JSON-safe dict.""" 272 if not {"phase", "elapsed_sec", "tau2_nm", "tau3_nm"}.issubset(df.columns): 273 raise ValueError("CSV missing required columns (phase, elapsed_sec, tau2/3_nm)") 274 fs = _fs(df) 275 tares, _ = _tare_with_qc(df) 276 dtau2 = df["tau2_nm"].values - tares[2] 277 dtau3 = df["tau3_nm"].values - tares[3] 278 speed = _tcp_speed(df) if {"tcp_x_mm", "tcp_y_mm"}.issubset(df.columns) else np.zeros(len(df)) 279 sw = df[df["phase"] == SWIRL_PHASE] 280 if len(sw) < 4: 281 sw = df[df["phase"].isin(FALLBACK_DYN_PHASES)] 282 if len(sw) < 4: 283 raise ValueError("No SWIRL (or fallback dynamic) phase samples in recording") 284 idx = sw.index.values 285 t0 = float(df["elapsed_sec"].values[idx[0]]) 286 r2 = dtau2[idx] - _lowpass(dtau2[idx], fs) 287 r3 = dtau3[idx] - _lowpass(dtau3[idx], fs) 288 return { 289 "t": [round(float(x) - t0, 3) for x in df["elapsed_sec"].values[idx]], 290 "r2": [round(float(x), 4) for x in r2], 291 "r3": [round(float(x), 4) for x in r3], 292 "phase_lag_s": round(_phase_lag(speed[idx], r3, fs), 3), 293 "fs_hz": round(float(fs), 2), 294 } 295 296 297def _calibrate(): 298 """Recompute FEATURE_SCALES from all real recordings; print the dict.""" 299 import glob 300 rows = [] 301 for f in sorted(glob.glob("recordings/**/*.csv", recursive=True)): 302 d = pd.read_csv(f) 303 if not {"phase", "tau3_nm"}.issubset(d.columns): 304 continue 305 try: 306 rows.append(np.abs(extract_features(d)["z"])) 307 except Exception as ex: 308 print(f"skip {f}: {ex}", file=sys.stderr) 309 if not rows: 310 sys.exit("no usable recordings under recordings/") 311 A = np.array(rows) 312 print(f"# calibrated on {len(rows)} recordings") 313 print("FEATURE_SCALES = {") 314 for n, s in zip(FEATURE_NAMES, np.percentile(A, 90, axis=0)): 315 print(f' "{n}": {s:.4g},') 316 print("}") 317 318 319def main(): 320 """CLI entry: extract features from a CSV or recalibrate FEATURE_SCALES.""" 321 ap = argparse.ArgumentParser( 322 description="Extract 10-D swirl features + normalized embedding.", 323 epilog="""examples: 324 python feature_extractor.py recordings/foo.csv # print z, e, diagnostics 325 python feature_extractor.py --calibrate --require-swirl # recompute FEATURE_SCALES 326 # from swirl recordings, then 327 # paste the output into FEATURE_SCALES 328 329The 5-25 Hz rattle band is NOT observable at 4-5 Hz cloud sampling; 330diagnostics flags rattle_band_limited honestly.""", 331 formatter_class=argparse.RawDescriptionHelpFormatter) 332 ap.add_argument("csv", nargs="?", help="Telemetry CSV from repeatable_grasp.py --record") 333 ap.add_argument("--calibrate", action="store_true", 334 help="recompute FEATURE_SCALES from recordings/ and print them") 335 args = ap.parse_args() 336 if args.calibrate: 337 _calibrate() 338 return 339 if not args.csv: 340 ap.error("pass a CSV path (or --calibrate)") 341 if not os.path.isfile(args.csv): 342 sys.exit(f"file not found: {args.csv}") 343 out = extract_features(pd.read_csv(args.csv)) 344 out["feature_names"] = FEATURE_NAMES 345 print(json.dumps(out, indent=2)) 346 347 348if __name__ == "__main__": 349 main()
170def extract_features(df): 171 """Compute the 10-D swirl signature and normalized embedding from a recording. 172 173 Reads the SWIRL phase (or falls back to dynamic phases), detrends joint 174 torques against the INIT baseline, and computes the ten physical features 175 (static lift, swirl dynamics, grip/excitation groups). 176 177 Args: 178 df: recording DataFrame with phase, elapsed_sec, tcp_*_mm, tau*_nm columns. 179 Returns: 180 Dict with z (10-D raw feature list), e (L2-normalized embedding list), 181 diagnostics (tare flags, phase source, sample count, sample rate). 182 Raises: 183 ValueError: when required columns are missing. 184 """ 185 if not {"phase", "elapsed_sec", "tcp_x_mm", "tcp_y_mm", "tau2_nm", "tau3_nm"}.issubset(df.columns): 186 raise ValueError("CSV missing required columns (phase, elapsed_sec, tcp_*, tau2/3_nm)") 187 fs = _fs(df) 188 tares, tare_flags = _tare_with_qc(df) 189 dtau2 = df["tau2_nm"].values - tares[2] 190 dtau3 = df["tau3_nm"].values - tares[3] 191 speed = _tcp_speed(df) 192 193 sw = df[df["phase"] == SWIRL_PHASE] 194 phase_source = SWIRL_PHASE 195 if len(sw) < 4: 196 sw = df[df["phase"].isin(FALLBACK_DYN_PHASES)] 197 phase_source = "fallback:" + ",".join(FALLBACK_DYN_PHASES) 198 if len(sw) < 4: 199 raise ValueError("No SWIRL (or fallback dynamic) phase samples in recording") 200 201 idx = sw.index.values 202 r2 = dtau2[idx] - _lowpass(dtau2[idx], fs) # detrended residuals 203 r3 = dtau3[idx] - _lowpass(dtau3[idx], fs) 204 v = speed[idx] 205 206 # 1-2. mass offset: pre-swirl peak-Z mean vs INIT tare 207 pre = df.iloc[: idx[0]] 208 peak = pre[pre["tcp_z_mm"] == pre["tcp_z_mm"].max()] 209 mass_j2 = float(peak["tau2_nm"].mean() - tares[2]) if len(peak) else 0.0 210 mass_j3 = float(peak["tau3_nm"].mean() - tares[3]) if len(peak) else 0.0 211 212 # 3-5. swirl variance / peak-to-peak 213 swirl_var_j2 = float(np.var(r2)) 214 swirl_var_j3 = float(np.var(r3)) 215 swirl_ptp_j3 = float(np.ptp(dtau3[idx])) 216 217 # 6. phase lag: xcorr(TCP speed, τ3 residual), lag of max |corr| within ±2 s 218 phase_lag_s = _phase_lag(v, r3, fs) 219 220 # 7. rattle energy: observable high band only (honest about Nyquist) 221 nyq = fs / 2.0 222 lo, hi = 1.5, nyq 223 rattle_band_limited = nyq < 5.0 224 if len(r3) >= 8 and hi > lo: 225 freqs = np.fft.rfftfreq(len(r3), d=1.0 / fs) 226 psd = np.abs(np.fft.rfft(r3)) ** 2 227 band = (freqs >= lo) & (freqs <= hi) 228 rattle_e = float(np.sum(psd[band])) 229 else: 230 rattle_e = 0.0 231 232 # 8. grip compliance 233 grip_comp = float(sw["gripper_pos"].std()) if "gripper_pos" in sw else 0.0 234 235 # 9. lift transient 236 lift = df[df["phase"] == "LIFT_TO_TRAVEL"] 237 lift_dyn_j3 = float(lift["tau3_nm"].std()) if len(lift) >= 2 else 0.0 238 239 # 10. mean swirl speed 240 swirl_speed = float(np.nanmean(v)) if len(v) else 0.0 241 242 z = np.array([ 243 mass_j2, mass_j3, 244 swirl_var_j2, swirl_var_j3, swirl_ptp_j3, 245 phase_lag_s, rattle_e, 246 grip_comp, lift_dyn_j3, swirl_speed, 247 ], dtype=float) 248 z = np.nan_to_num(z, nan=0.0, posinf=0.0, neginf=0.0) 249 scales = np.array([FEATURE_SCALES[n] for n in FEATURE_NAMES], dtype=float) 250 weights = np.array([FEATURE_WEIGHTS[n] for n in FEATURE_NAMES], dtype=float) 251 zs = z / np.where(scales > 0, scales, 1.0) # balanced, unit-ish features 252 zw = zs * weights # drop dead/nuisance dims 253 norm = np.linalg.norm(zw) 254 e = (zw / norm).tolist() if norm > 1e-12 else [0.0] * len(zw) 255 256 diagnostics = { 257 "fs_hz": round(float(fs), 2), 258 "nyquist_hz": round(float(nyq), 2), 259 "n_samples": int(len(df)), 260 "phase_source": phase_source, 261 "n_swirl_samples": int(len(sw)), 262 "tare_flags": tare_flags, 263 "rattle_band_hz": [float(lo), round(float(hi), 2)], 264 "rattle_band_limited": bool(rattle_band_limited), 265 "label": str(df["object_label"].iloc[0]) if "object_label" in df else None, 266 } 267 return {"z": z.tolist(), "e": e, "diagnostics": diagnostics}
Compute the 10-D swirl signature and normalized embedding from a recording.
Reads the SWIRL phase (or falls back to dynamic phases), detrends joint torques against the INIT baseline, and computes the ten physical features (static lift, swirl dynamics, grip/excitation groups).
Args: df: recording DataFrame with phase, elapsed_sec, tcp__mm, tau_nm columns. Returns: Dict with z (10-D raw feature list), e (L2-normalized embedding list), diagnostics (tare flags, phase source, sample count, sample rate). Raises: ValueError: when required columns are missing.
270def swirl_window_series(df): 271 """Detrended torque residuals over the dynamic (swirl) window, for the 272 dashboard 'why' charts. Returns a JSON-safe dict.""" 273 if not {"phase", "elapsed_sec", "tau2_nm", "tau3_nm"}.issubset(df.columns): 274 raise ValueError("CSV missing required columns (phase, elapsed_sec, tau2/3_nm)") 275 fs = _fs(df) 276 tares, _ = _tare_with_qc(df) 277 dtau2 = df["tau2_nm"].values - tares[2] 278 dtau3 = df["tau3_nm"].values - tares[3] 279 speed = _tcp_speed(df) if {"tcp_x_mm", "tcp_y_mm"}.issubset(df.columns) else np.zeros(len(df)) 280 sw = df[df["phase"] == SWIRL_PHASE] 281 if len(sw) < 4: 282 sw = df[df["phase"].isin(FALLBACK_DYN_PHASES)] 283 if len(sw) < 4: 284 raise ValueError("No SWIRL (or fallback dynamic) phase samples in recording") 285 idx = sw.index.values 286 t0 = float(df["elapsed_sec"].values[idx[0]]) 287 r2 = dtau2[idx] - _lowpass(dtau2[idx], fs) 288 r3 = dtau3[idx] - _lowpass(dtau3[idx], fs) 289 return { 290 "t": [round(float(x) - t0, 3) for x in df["elapsed_sec"].values[idx]], 291 "r2": [round(float(x), 4) for x in r2], 292 "r3": [round(float(x), 4) for x in r3], 293 "phase_lag_s": round(_phase_lag(speed[idx], r3, fs), 3), 294 "fs_hz": round(float(fs), 2), 295 }
Detrended torque residuals over the dynamic (swirl) window, for the dashboard 'why' charts. Returns a JSON-safe dict.
320def main(): 321 """CLI entry: extract features from a CSV or recalibrate FEATURE_SCALES.""" 322 ap = argparse.ArgumentParser( 323 description="Extract 10-D swirl features + normalized embedding.", 324 epilog="""examples: 325 python feature_extractor.py recordings/foo.csv # print z, e, diagnostics 326 python feature_extractor.py --calibrate --require-swirl # recompute FEATURE_SCALES 327 # from swirl recordings, then 328 # paste the output into FEATURE_SCALES 329 330The 5-25 Hz rattle band is NOT observable at 4-5 Hz cloud sampling; 331diagnostics flags rattle_band_limited honestly.""", 332 formatter_class=argparse.RawDescriptionHelpFormatter) 333 ap.add_argument("csv", nargs="?", help="Telemetry CSV from repeatable_grasp.py --record") 334 ap.add_argument("--calibrate", action="store_true", 335 help="recompute FEATURE_SCALES from recordings/ and print them") 336 args = ap.parse_args() 337 if args.calibrate: 338 _calibrate() 339 return 340 if not args.csv: 341 ap.error("pass a CSV path (or --calibrate)") 342 if not os.path.isfile(args.csv): 343 sys.exit(f"file not found: {args.csv}") 344 out = extract_features(pd.read_csv(args.csv)) 345 out["feature_names"] = FEATURE_NAMES 346 print(json.dumps(out, indent=2))
CLI entry: extract features from a CSV or recalibrate FEATURE_SCALES.