From 1b6ad4089766e1ffc4e5008752911aa7d681164a Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 12 Jun 2026 08:35:01 -0400 Subject: [PATCH] =?UTF-8?q?web:=20surface=20personal=20training=20findings?= =?UTF-8?q?=20=E2=80=94=20cadence,=20fueling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NatesNotes.md restructured into the personal-findings doc (cadence 170 target / 161 natural, 80 g carb/h, 3/2 breathing, gait cues, injury log) - load_activities exposes avg_cadence_spm (summaryDTO.averageRunCadence, falling back to Takeout's avgDoubleCadence — full coverage, 342/342 runs) - load_fit_records: FIT running cadence is single-leg rev/min; now returns true steps/min ((cadence + fractional_cadence) * 2) - Dashboard: cadence trend chart (per-run avg, 28-day mean, 170/161 lines) + Cad column in recent activities - Activity Detail: per-second cadence trace with %time >= 170 - Race Plan: fueling section (carb g/h x est. finish time per race) - one-time migration: 18 epoch-ms-as-text timestamps -> ISO (P2 item; backup at data/backups/garmin-20260612-082025.db) Co-Authored-By: Claude Fable 5 --- NEXT_SESSION.md | 15 ++- NatesNotes.md | 109 +++++++++++++-------- src/openrun/model.py | 17 +++- src/openrun/web/pages/1_Dashboard.py | 48 ++++++++- src/openrun/web/pages/3_Race_Plan.py | 45 +++++++++ src/openrun/web/pages/8_Activity_Detail.py | 26 +++++ 6 files changed, 215 insertions(+), 45 deletions(-) diff --git a/NEXT_SESSION.md b/NEXT_SESSION.md index a84a89e3..3e80d06c 100644 --- a/NEXT_SESSION.md +++ b/NEXT_SESSION.md @@ -38,6 +38,14 @@ architecture and conventions. Delete this file when the list is done. round-trip + sync button all run on garminconnect; verified headlessly via streamlit AppTest (login restore, button click, full sync, no exceptions). `openrun.ingest.auth` (garth) remains only for `--backend=garth`. +- **Personal findings surfaced in UI (2026-06-12)**: NatesNotes.md is the + findings doc (cadence 170/161, 80 g carb/h, 3/2 breathing, gait cues). + Dashboard gained a cadence trend chart + table column; Activity Detail a + per-second cadence trace (FIT cadence is single-leg — load_fit_records now + doubles it); Race Plan a fueling calculator. Targets 170/161 are constants + in the pages — move to openrun.toml when config grows a slot. Still unsurfaced: + walking/time-on-feet (Dashboard filters type='running'; off-watch walking + only exists in daily_steps). - **New goal (user, 2026-06-12): shareable web UI** — let others run the openrun web app against their own Garmin account. Known gaps: per-user token store + DB (everything is cwd-relative single-user today), per-user @@ -57,8 +65,11 @@ architecture and conventions. Delete this file when the list is done. `openrun-sync`, fall back to `../garmin-pgc/` python-garminconnect backend if garth hits Cloudflare 429), then populate `race_plan` through 2026-09-12 using `banister_forecast` + `calibrate_tl_per_km`; log off-watch work. -- **P2 — data quality**: fix the 18 mixed-format timestamps, utcnow warnings, - add `schema_version` + tiny migration runner before any public release. +- **P2 — data quality**: the 18 epoch-ms timestamps were migrated to ISO via + one-time UPDATE on 2026-06-12 (they were stored as TEXT like + "1658951176000.0"; backup at data/backups/garmin-20260612-082025.db). + Still to do: normalize on ingest so Takeout re-ingest can't reintroduce + them, utcnow warnings, `schema_version` + tiny migration runner. - **P3 — merge sync fork**: fold `../garmin-pgc/` into openrun as `openrun-sync --backend=garminconnect` instead of a sibling project. - **P4 — roadmap items reordered for the ultra**: TrainingReadinessDTO + diff --git a/NatesNotes.md b/NatesNotes.md index 41191c24..5171d517 100644 --- a/NatesNotes.md +++ b/NatesNotes.md @@ -1,47 +1,78 @@ -# Nate Personal Notes +# Nate — Personal Findings -## Eisley +Working notes from PT (Eisley), self-observation, and race experience. +Last recap: 2026-06-12 (day before the 30K). -### Exercises +## Cadence & HR — the two numbers that matter -### Gait / posture -- knee forward - - stepping too far forward - - lose momentum, hard strike, pulling - - instead, run on ice - - feet land underneath, push backward - - this has revealed weakness and asymmetry in feet/ankles/legs - - in particular, left foot is very difficult to load medial side during pushoff +- **170 spm is ideal**; natural cadence is **161** — body falls back to it + when not consciously cued. +- Holding ~170 has made HR consistently maintainable **< 145** (top of Z2 is + 143) — at the cost of running slower. That trade is the point. +- Watch datafield: big HR + cadence only — the only fields needed in training. +- Current block (week of Jun 5–12): mostly low-HR walking, deliberate. -- 170 bpm is ideal - - This has made HR consistently maintainable < 145 - - I end up running slower to maintain HR - - datafield on watch showing big HR/Cadence - only important fields for training +## Breathing -- feeling of winding/turning my feet sup on lateral, inf on medial - - aligns with medial MTP avoidance, good queue - -## Anatomy Observations -- Natural cadence is 161. Body falls back to this if I don't think about it -- Feeling of legs out of socket, or tailbone out of place, or sacrum twisted(?) -- asymettery in motion of each leg -- tightness and weakness where collarbones join - - feel of trying to pull that area down seems right -- likewise in shoulders, feel out of socket or twisted/tight +- **3 strides in / 2 out.** +- Noticed the diaphragm very often lands in the same position on the same + foot — odd-count breathing (3/2) rotates which foot takes the exhale impact. -## Other PT Findings +## Nutrition -### Connor Harris -- Big toe ball exercise - - side faces wall - - forearm against wall, opposite leg slightly lateral - - raise wall-side leg marcher - - lift body w/far leg, focus on pressing into big toe ball - - imagine trying to lift toes from ground +- **I have undernourished in races.** +- Goal: **80 g carbs/hour** during long efforts. + - At 30K (~3.5 h?) that's ~280 g total — plan bottles/gels ahead, don't wing it. -## Race Logistics -- I have undernourished - - 80g carb/hr goal -- 3 strides in / 2 out breathing - - noticed diaphragm very often in same place on same foot -- \ No newline at end of file +## Gait / form cues (Eisley) + +- Problem: knee forward / stepping too far out front → lose momentum, hard + strike, pulling instead of pushing. +- Cue: **"run on ice"** — feet land underneath, push backward. + - This revealed weakness and asymmetry in feet/ankles/legs; in particular + the **left foot struggles to load the medial side during pushoff**. +- Cue: feeling of winding/turning the feet — sup on lateral, inf on medial — + aligns with the medial-MTP avoidance; good cue. +- Ice-walk stride note from injury rehab (2026-03-08): shorter, less + out-in-front steps; cue of rotating musculature from inner to outer + (pull meat back from inside inner ankle bone, push out behind outer + ankle bone) felt good. + +## Anatomy observations + +- Asymmetry in the motion of each leg. +- Feeling of legs out of socket / tailbone out of place / sacrum twisted(?). +- Tightness + weakness where the collarbones join — the feel of pulling that + area down seems right; likewise shoulders feel out of socket or twisted. + +## Injury history + +- **2026-03-07, right leg**: right inner leg felt "short", tending to limp, + unable to pull the right leg back far; legs heavy, cardio fine. + Rehabbed via the ice-walk stride work above (see ../RunningLogs.md). + +## PT exercises (Connor Harris, YouTube) + +- **Big toe ball exercise**: side faces wall; forearm against wall, opposite + leg slightly lateral; raise wall-side leg (marcher); lift body with the far + leg, focus on pressing into the big-toe ball; imagine lifting the toes + from the ground. + +## 50M training strategy + +- Races: 30K **2026-06-13**, 50K **2026-07-25**, 50M **2026-09-12**. +- **Minimize Z3 junk miles** — polarize: Z2 volume + deliberate quality. +- Low-HR walking counts as training (time on feet) even though the dashboard + currently ignores non-running activities. + +## Where this lives in the data / web UI + +| Finding | Surfaced today? | +| --- | --- | +| HR < 145 / zone discipline | ✅ Time-in-zone (Activity Detail), polarized split (Dashboard) | +| Minimize Z3 junk | ✅ Dashboard "Polarized split (last 12 weeks)" | +| Aerobic base quality | ✅ Pa:HR decoupling (Activity Detail) | +| **Cadence 170 vs 161** | ❌ not shown anywhere, though per-second cadence is in every FIT | +| **80 g carb/hr fueling** | ❌ nothing — Race Plan page could compute totals from projected duration | +| **Walking / time-on-feet** | ❌ Dashboard filters to `type="running"` only | +| 3/2 breathing, form cues | n/a — not watch-measurable; lives in this file | diff --git a/src/openrun/model.py b/src/openrun/model.py index 930597d3..a7efeff8 100644 --- a/src/openrun/model.py +++ b/src/openrun/model.py @@ -37,7 +37,11 @@ def load_activities(conn: sqlite3.Connection, *, type: str | None = None) -> pd. distance_m, duration_s, moving_duration_s, avg_speed_mps, max_speed_mps, avg_hr, max_hr, calories, elevation_gain_m, elevation_loss_m, - training_load, aerobic_te, anaerobic_te, vo2_max + training_load, aerobic_te, anaerobic_te, vo2_max, + COALESCE( + json_extract(raw, '$.summaryDTO.averageRunCadence'), + json_extract(raw, '$.avgDoubleCadence') + ) AS avg_cadence_spm FROM activities """ if type: @@ -760,7 +764,16 @@ def load_fit_records(conn: sqlite3.Connection, activity_id: int) -> pd.DataFrame out["heart_rate"] = df.get("heart_rate") out["speed_mps"] = df.get("enhanced_speed", df.get("speed")) out["distance_m"] = df.get("distance") - out["cadence_spm"] = df.get("cadence") + # FIT "cadence" for running is single-leg rev/min (~80s); true steps/min + # is (cadence + fractional_cadence) * 2. + cad = df.get("cadence") + if cad is not None: + cad = cad.astype("float64") + frac = df.get("fractional_cadence") + if frac is not None: + cad = cad + frac.astype("float64").fillna(0.0) + cad = cad * 2.0 + out["cadence_spm"] = cad out["altitude_m"] = df.get("enhanced_altitude", df.get("altitude")) out["power_w"] = df.get("power") out["vertical_oscillation_mm"] = df.get("vertical_oscillation") diff --git a/src/openrun/web/pages/1_Dashboard.py b/src/openrun/web/pages/1_Dashboard.py index dcecd309..b39cba84 100644 --- a/src/openrun/web/pages/1_Dashboard.py +++ b/src/openrun/web/pages/1_Dashboard.py @@ -172,7 +172,49 @@ with right: # --------------------------------------------------------------------------- -# Row 4 — Recent activities +# Row 4 — Cadence trend +# --------------------------------------------------------------------------- + +# Training cue (see NatesNotes.md): hold ~170 spm; body falls back to ~161 +# when uncued. Per-run average cadence vs those two reference lines. +CADENCE_TARGET_SPM = 170 +CADENCE_NATURAL_SPM = 161 + +st.subheader("Cadence") +cad_runs = ( + runs.dropna(subset=["start_time_local", "avg_cadence_spm"]) + if "avg_cadence_spm" in runs + else pd.DataFrame() +) +if cad_runs.empty: + empty_state("No cadence data yet — it arrives with synced activity details.") +else: + cad = cad_runs.tail(60).copy() # runs are sorted ascending by start time + latest = cad["avg_cadence_spm"].iloc[-1] + st.caption( + f"Latest run: **{latest:.0f} spm** · target **{CADENCE_TARGET_SPM}** · " + f"natural fallback **{CADENCE_NATURAL_SPM}** (last {len(cad)} runs)" + ) + fig3, ax3 = plt.subplots(figsize=(12, 2.8)) + ax3.scatter(cad["start_time_local"], cad["avg_cadence_spm"], + s=16, color="#264653", alpha=0.65, label="run avg") + roll = ( + cad.set_index("start_time_local")["avg_cadence_spm"] + .rolling("28D").mean() + ) + ax3.plot(roll.index, roll.values, color="#2a9d8f", lw=2, label="28-day mean") + ax3.axhline(CADENCE_TARGET_SPM, color="#2a9d8f", ls="--", lw=1, label=f"target {CADENCE_TARGET_SPM}") + ax3.axhline(CADENCE_NATURAL_SPM, color="#e76f51", ls=":", lw=1, label=f"natural {CADENCE_NATURAL_SPM}") + ax3.set_ylabel("avg cadence (spm)") + ax3.legend(fontsize=8, loc="lower right", ncol=4) + for s in ("top", "right"): + ax3.spines[s].set_visible(False) + fig3.tight_layout() + st.pyplot(fig3, clear_figure=True) + + +# --------------------------------------------------------------------------- +# Row 5 — Recent activities # --------------------------------------------------------------------------- st.subheader("Recent activities") @@ -189,15 +231,17 @@ else: lambda p: f"{int(p)}:{int((p - int(p)) * 60):02d}/km" if pd.notna(p) else "—" ) recent_runs["distance"] = recent_runs["distance_km"].round(2).astype(str) + " km" + recent_runs["cadence"] = recent_runs.get("avg_cadence_spm", pd.Series(dtype=float)).round(0) display = recent_runs[[ "start_time_local", "activity_name", "distance", "pace", - "avg_hr", "training_load", "vo2_max", + "avg_hr", "cadence", "training_load", "vo2_max", ]].rename(columns={ "start_time_local": "When", "activity_name": "Name", "distance": "Distance", "pace": "Pace", "avg_hr": "Avg HR", + "cadence": "Cad", "training_load": "TL", "vo2_max": "VO₂", }) diff --git a/src/openrun/web/pages/3_Race_Plan.py b/src/openrun/web/pages/3_Race_Plan.py index eeb7c434..fdc0281b 100644 --- a/src/openrun/web/pages/3_Race_Plan.py +++ b/src/openrun/web/pages/3_Race_Plan.py @@ -12,6 +12,7 @@ Workflow: from __future__ import annotations +import re from datetime import date, timedelta import matplotlib.pyplot as plt @@ -265,3 +266,47 @@ else: if race_rows: st.subheader("Race-day projection") st.dataframe(pd.DataFrame(race_rows), hide_index=True, width="stretch") + + +# --------------------------------------------------------------------------- +# Fueling plan +# --------------------------------------------------------------------------- + +st.subheader("Fueling") +st.caption( + "Race-experience finding (NatesNotes.md): historically undernourished — " + "plan carbs ahead, don't wing it. Default target **80 g/h**." +) + + +def _race_km(label: str) -> float | None: + """Pull a distance out of a race label like 'wk 4 — 30K' or '50 MILE'.""" + m = re.search(r"(\d+(?:\.\d+)?)\s*(mile|mi\b|k\b)", label, re.IGNORECASE) + if not m: + return None + val = float(m.group(1)) + return val * 1.60934 if m.group(2).lower().startswith("m") else val + + +if cfg.races: + carb_rate = st.number_input( + "Carb target (g/h)", min_value=30, max_value=120, value=80, step=5, + help="60–90 g/h is the trained-gut range for ultras; 80 is the plan.", + ) + fuel_cols = st.columns(len(cfg.races)) + for i, (label, d) in enumerate(cfg.races): + km = _race_km(label) + # Conservative trail-ultra default pace (7.5 km/h); edit per race. + default_h = round((km / 7.5) * 2) / 2 if km else 4.0 + with fuel_cols[i]: + hours = st.number_input( + f"{label} ({d}) — est. finish (h)", + min_value=1.0, max_value=24.0, value=float(default_h), step=0.5, + key=f"fuel_hours_{i}", + ) + total = carb_rate * hours + st.metric(label, f"{total:.0f} g carbs", + f"≈ {total / 25:.0f} × 25 g gels over {hours:.1f} h", + delta_color="off") +else: + st.info("Add races to openrun.toml to plan fueling.") diff --git a/src/openrun/web/pages/8_Activity_Detail.py b/src/openrun/web/pages/8_Activity_Detail.py index 5a6f183c..7987cf15 100644 --- a/src/openrun/web/pages/8_Activity_Detail.py +++ b/src/openrun/web/pages/8_Activity_Detail.py @@ -187,6 +187,32 @@ if fit_linked: else: ax = plot_fit_decoupling(records, segments=segments) st.pyplot(ax.figure, clear_figure=True) + + # Cadence trace — training cue is ~170 spm, natural fallback ~161 + # (see NatesNotes.md). 30 s rolling mean to cut sensor noise. + cad = records.dropna(subset=["cadence_spm"]) + cad = cad[cad["cadence_spm"] > 0] # drop standing/paused samples + if not cad.empty: + st.subheader("Cadence (per-second from FIT)") + smooth = ( + cad.set_index("elapsed_s")["cadence_spm"] + .rolling(30, min_periods=5, center=True).mean() + ) + fig, axc = plt.subplots(figsize=(8, 2.4)) + axc.plot(smooth.index / 60, smooth.values, color="#264653", lw=1.2) + axc.axhline(170, color="#2a9d8f", ls="--", lw=1, label="target 170") + axc.axhline(161, color="#e76f51", ls=":", lw=1, label="natural 161") + axc.set_xlabel("elapsed (min)") + axc.set_ylabel("spm") + axc.legend(fontsize=8, loc="lower right") + for s in ("top", "right"): + axc.spines[s].set_visible(False) + fig.tight_layout() + st.pyplot(fig, clear_figure=True) + st.caption( + f"Moving average cadence: **{cad['cadence_spm'].mean():.0f} spm** · " + f"time ≥170: **{(cad['cadence_spm'] >= 170).mean() * 100:.0f}%**" + ) except FileNotFoundError as exc: st.error(str(exc)) else: