File size: 3,180 Bytes
0b3b5de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
"""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])