web: surface personal training findings — cadence, fueling
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
round-trip + sync button all run on garminconnect; verified headlessly via
|
||||||
streamlit AppTest (login restore, button click, full sync, no exceptions).
|
streamlit AppTest (login restore, button click, full sync, no exceptions).
|
||||||
`openrun.ingest.auth` (garth) remains only for `--backend=garth`.
|
`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
|
- **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
|
openrun web app against their own Garmin account. Known gaps: per-user
|
||||||
token store + DB (everything is cwd-relative single-user today), 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
|
`openrun-sync`, fall back to `../garmin-pgc/` python-garminconnect backend if
|
||||||
garth hits Cloudflare 429), then populate `race_plan` through 2026-09-12
|
garth hits Cloudflare 429), then populate `race_plan` through 2026-09-12
|
||||||
using `banister_forecast` + `calibrate_tl_per_km`; log off-watch work.
|
using `banister_forecast` + `calibrate_tl_per_km`; log off-watch work.
|
||||||
- **P2 — data quality**: fix the 18 mixed-format timestamps, utcnow warnings,
|
- **P2 — data quality**: the 18 epoch-ms timestamps were migrated to ISO via
|
||||||
add `schema_version` + tiny migration runner before any public release.
|
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
|
- **P3 — merge sync fork**: fold `../garmin-pgc/` into openrun as
|
||||||
`openrun-sync --backend=garminconnect` instead of a sibling project.
|
`openrun-sync --backend=garminconnect` instead of a sibling project.
|
||||||
- **P4 — roadmap items reordered for the ultra**: TrainingReadinessDTO +
|
- **P4 — roadmap items reordered for the ultra**: TrainingReadinessDTO +
|
||||||
|
|||||||
107
NatesNotes.md
107
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
|
- **170 spm is ideal**; natural cadence is **161** — body falls back to it
|
||||||
- knee forward
|
when not consciously cued.
|
||||||
- stepping too far forward
|
- Holding ~170 has made HR consistently maintainable **< 145** (top of Z2 is
|
||||||
- lose momentum, hard strike, pulling
|
143) — at the cost of running slower. That trade is the point.
|
||||||
- instead, run on ice
|
- Watch datafield: big HR + cadence only — the only fields needed in training.
|
||||||
- feet land underneath, push backward
|
- Current block (week of Jun 5–12): mostly low-HR walking, deliberate.
|
||||||
- this has revealed weakness and asymmetry in feet/ankles/legs
|
|
||||||
- in particular, left foot is very difficult to load medial side during pushoff
|
|
||||||
|
|
||||||
- 170 bpm is ideal
|
## Breathing
|
||||||
- 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
|
|
||||||
|
|
||||||
- feeling of winding/turning my feet sup on lateral, inf on medial
|
- **3 strides in / 2 out.**
|
||||||
- aligns with medial MTP avoidance, good queue
|
- 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.
|
||||||
|
|
||||||
## Anatomy Observations
|
## Nutrition
|
||||||
- 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
|
|
||||||
|
|
||||||
## Other PT Findings
|
- **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.
|
||||||
|
|
||||||
### Connor Harris
|
## Gait / form cues (Eisley)
|
||||||
- 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
|
|
||||||
|
|
||||||
## Race Logistics
|
- Problem: knee forward / stepping too far out front → lose momentum, hard
|
||||||
- I have undernourished
|
strike, pulling instead of pushing.
|
||||||
- 80g carb/hr goal
|
- Cue: **"run on ice"** — feet land underneath, push backward.
|
||||||
- 3 strides in / 2 out breathing
|
- This revealed weakness and asymmetry in feet/ankles/legs; in particular
|
||||||
- noticed diaphragm very often in same place on same foot
|
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 |
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ def load_activities(conn: sqlite3.Connection, *, type: str | None = None) -> pd.
|
|||||||
distance_m, duration_s, moving_duration_s,
|
distance_m, duration_s, moving_duration_s,
|
||||||
avg_speed_mps, max_speed_mps, avg_hr, max_hr, calories,
|
avg_speed_mps, max_speed_mps, avg_hr, max_hr, calories,
|
||||||
elevation_gain_m, elevation_loss_m,
|
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
|
FROM activities
|
||||||
"""
|
"""
|
||||||
if type:
|
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["heart_rate"] = df.get("heart_rate")
|
||||||
out["speed_mps"] = df.get("enhanced_speed", df.get("speed"))
|
out["speed_mps"] = df.get("enhanced_speed", df.get("speed"))
|
||||||
out["distance_m"] = df.get("distance")
|
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["altitude_m"] = df.get("enhanced_altitude", df.get("altitude"))
|
||||||
out["power_w"] = df.get("power")
|
out["power_w"] = df.get("power")
|
||||||
out["vertical_oscillation_mm"] = df.get("vertical_oscillation")
|
out["vertical_oscillation_mm"] = df.get("vertical_oscillation")
|
||||||
|
|||||||
@@ -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")
|
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 "—"
|
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["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[[
|
display = recent_runs[[
|
||||||
"start_time_local", "activity_name", "distance", "pace",
|
"start_time_local", "activity_name", "distance", "pace",
|
||||||
"avg_hr", "training_load", "vo2_max",
|
"avg_hr", "cadence", "training_load", "vo2_max",
|
||||||
]].rename(columns={
|
]].rename(columns={
|
||||||
"start_time_local": "When",
|
"start_time_local": "When",
|
||||||
"activity_name": "Name",
|
"activity_name": "Name",
|
||||||
"distance": "Distance",
|
"distance": "Distance",
|
||||||
"pace": "Pace",
|
"pace": "Pace",
|
||||||
"avg_hr": "Avg HR",
|
"avg_hr": "Avg HR",
|
||||||
|
"cadence": "Cad",
|
||||||
"training_load": "TL",
|
"training_load": "TL",
|
||||||
"vo2_max": "VO₂",
|
"vo2_max": "VO₂",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Workflow:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
@@ -265,3 +266,47 @@ else:
|
|||||||
if race_rows:
|
if race_rows:
|
||||||
st.subheader("Race-day projection")
|
st.subheader("Race-day projection")
|
||||||
st.dataframe(pd.DataFrame(race_rows), hide_index=True, width="stretch")
|
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.")
|
||||||
|
|||||||
@@ -187,6 +187,32 @@ if fit_linked:
|
|||||||
else:
|
else:
|
||||||
ax = plot_fit_decoupling(records, segments=segments)
|
ax = plot_fit_decoupling(records, segments=segments)
|
||||||
st.pyplot(ax.figure, clear_figure=True)
|
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:
|
except FileNotFoundError as exc:
|
||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user