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:
2026-06-12 08:35:01 -04:00
parent 87f73ad36b
commit 1b6ad40897
6 changed files with 215 additions and 45 deletions

View File

@@ -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 +

View File

@@ -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 512): 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
- 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 ## Nutrition
### Connor Harris - **I have undernourished in races.**
- Big toe ball exercise - Goal: **80 g carbs/hour** during long efforts.
- side faces wall - At 30K (~3.5 h?) that's ~280 g total — plan bottles/gels ahead, don't wing it.
- 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 ## Gait / form cues (Eisley)
- I have undernourished
- 80g carb/hr goal - Problem: knee forward / stepping too far out front → lose momentum, hard
- 3 strides in / 2 out breathing strike, pulling instead of pushing.
- noticed diaphragm very often in same place on same foot - 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 |

View 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")

View File

@@ -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₂",
}) })

View File

@@ -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="6090 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.")

View File

@@ -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: