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
|
||||
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 +
|
||||
|
||||
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
|
||||
- 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
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
## Nutrition
|
||||
|
||||
## 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
|
||||
- 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
|
||||
## Gait / form cues (Eisley)
|
||||
|
||||
## 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
|
||||
-
|
||||
- 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 |
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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₂",
|
||||
})
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user