"""Shared helpers (no app/viewer imports → safe for both app.py and viewer/builder.py to import). One source of truth for the things the 2D gait-cycle plots (app.py) and the 3D skeleton viewer (viewer/builder.py) must agree on: left heel-strike detection, .mot file reading, and the low/high-cadence condition colors. """ import io import numpy as np import pandas as pd WCB_GC_FRAMES = 101 # 0-100% gait cycle, inclusive WCB_GC_DURATION = 1.0 # seconds of animation per gait cycle (shared by all skeletons) WCB_GC_VGRF_PEAK_FRAC = 0.05 # heel-strike threshold = max(FLOOR, frac * peak vGRF) WCB_GC_VGRF_FLOOR = 20.0 # N; lower bound on the heel-strike threshold WCB_GC_MIN_DURATION = 0.4 # s; refractory guard rejecting sub-stride crossings def heel_strike_threshold(vgrf): """Stance-level rising-edge threshold: ~5% of the peak vGRF, floored at 20 N. Not ~0 N, because the generated/resampled vGRF has small swing-phase bumps a 1 N threshold would mistake for heel strikes (truncating the cycle to a sub-stride blip).""" return max(WCB_GC_VGRF_FLOOR, WCB_GC_VGRF_PEAK_FRAC * float(np.nanmax(vgrf))) def first_gait_cycle_indices(vgrf, time=None, dt=None, min_duration=WCB_GC_MIN_DURATION): """First left heel-strike-to-heel-strike index pair (i0, i1) spanning >= min_duration seconds, or None. Heel strikes are rising-edge crossings of heel_strike_threshold(); the min_duration guard skips sub-stride crossings. Pass either per-sample `time` or uniform `dt`.""" thd = heel_strike_threshold(vgrf) hs = np.where((vgrf[:-1] < thd) & (vgrf[1:] >= thd))[0] for k in range(len(hs) - 1): a, b = hs[k], hs[k + 1] gap = (time[b] - time[a]) if time is not None else (b - a) * dt if gap >= min_duration: return a, b return None def left_vy_column(cols): """Left-foot (calcn_l) vertical-GRF column name in a .mot column list, or None. 'force_l_vy' (generated, convertDfToGRFMot) or 'ground_force_2_vy' (experimental).""" for c in cols: if c.endswith('_l_vy') or 'ground_force_2' in c: return c return None def read_mot(path): """Read a .mot file -> (header lines before the column row, DataFrame). Returns (None, None) if path is None or no 'time' column row is found. Drops pandas 'Unnamed' overflow columns.""" if path is None: return None, None with open(path) as f: lines = f.readlines() hdr_idx = next((i for i, l in enumerate(lines) if l.strip().startswith('time')), None) if hdr_idx is None: return None, None meta = lines[:hdr_idx] data = ''.join(lines[hdr_idx:]) try: df = pd.read_csv(io.StringIO(data), sep='\t') except Exception: df = pd.read_csv(io.StringIO(data), delim_whitespace=True) # whitespace-delimited fallback df = df.loc[:, ~df.columns.str.contains('^Unnamed')] return meta, df # Condition colors shared verbatim by the 2D plots, the 3D overlay, and the HTML legend. WCB_COLOR_LO = ('steelblue', [0.27, 0.51, 0.71]) # (matplotlib/CSS name, glTF RGB 0-1) WCB_COLOR_HI = ('tomato', [1.00, 0.39, 0.28])