| """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 |
| WCB_GC_DURATION = 1.0 |
| WCB_GC_VGRF_PEAK_FRAC = 0.05 |
| WCB_GC_VGRF_FLOOR = 20.0 |
| WCB_GC_MIN_DURATION = 0.4 |
|
|
|
|
| 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) |
| df = df.loc[:, ~df.columns.str.contains('^Unnamed')] |
| return meta, df |
|
|
|
|
| |
| WCB_COLOR_LO = ('steelblue', [0.27, 0.51, 0.71]) |
| WCB_COLOR_HI = ('tomato', [1.00, 0.39, 0.28]) |
|
|