Compare commits
9 Commits
dbae49dce2
...
b3eef65906
| Author | SHA1 | Date | |
|---|---|---|---|
| b3eef65906 | |||
| c51befb497 | |||
| 875a54f0db | |||
| 2547f3a601 | |||
| 5b21f4d4bc | |||
| 1b6ad40897 | |||
| 87f73ad36b | |||
| dd2b8ef1bd | |||
| 9bb4b98491 |
11
.gitea/workflows/test.yml
Normal file
11
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
name: tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- run: uv sync --frozen
|
||||
- run: uv run pytest
|
||||
@@ -1,13 +0,0 @@
|
||||
test:
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
variables:
|
||||
UV_CACHE_DIR: .uv-cache
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- uv.lock
|
||||
paths:
|
||||
- .uv-cache
|
||||
script:
|
||||
- uv sync --frozen
|
||||
- uv run pytest
|
||||
@@ -10,9 +10,10 @@ architecture and conventions. Delete this file when the list is done.
|
||||
- **P0 DONE** (commit 6df25bcd, NOT yet pushed — push denied to agent, run
|
||||
`git push origin main`): .gitignore fixed and Takeout dump (23,673 files,
|
||||
personal health data) untracked; note it is still in remote history from
|
||||
earlier pushes — rewrite history if that ever matters. `.gitlab-ci.yml`
|
||||
added (assumes GitLab at g.o00.io; swap if Gitea/Forgejo). `gitlab` remote
|
||||
URL typo (ttps://) fixed. `scripts/backup_db.sh` snapshots the DB, keeps 14.
|
||||
earlier pushes — rewrite history if that ever matters. CI added at
|
||||
`.gitea/workflows/test.yml` (remote g.o00.io is Gitea — Actions must be
|
||||
enabled repo-side with a registered runner). `gitlab` remote URL typo
|
||||
(ttps://) fixed. `scripts/backup_db.sh` snapshots the DB, keeps 14.
|
||||
- DB: `garmin/data/garmin.db` is canonical — 378 activities (→ 2026-05-10),
|
||||
349 FITs linked, wellness through 2026-05-17.
|
||||
- The stray `../data/garmin.db` (vault root) was a Takeout-ZIP ingest run from
|
||||
@@ -20,10 +21,36 @@ architecture and conventions. Delete this file when the list is done.
|
||||
timestamps, no FITs). Copied to
|
||||
`data/backups/vault-root-takeout-ingest-2026-06-08.db`; the original at
|
||||
`../data/` is redundant once live sync runs — user should delete it.
|
||||
- **Sync is blocked on auth**: `.secrets/` is empty. User runs
|
||||
`uv run openrun-auth` (password + MFA), then
|
||||
`uv run openrun-sync --days 35` (covers the wellness gap since May 17;
|
||||
activities + FITs are incremental automatically).
|
||||
- **SYNC DONE (2026-06-12)** via new garminconnect backend. garth's SSO login
|
||||
is Cloudflare-429-blocked AND garth is deprecated (matin/garth#222), so
|
||||
`src/openrun/ingest/garminconnect_backend.py` now authenticates with
|
||||
python-garminconnect 0.3.5 DI tokens (`.secrets/garmin_tokens.json`) and
|
||||
shims garth's client surface; `openrun-sync --backend=auto` prefers it.
|
||||
This is P3-lite, pulled forward. Login (once/year-ish):
|
||||
`uv run python -m openrun.ingest.garminconnect_backend`.
|
||||
Gotchas learned: usersummary endpoint needs UUID displayName, not userName
|
||||
(403 otherwise); a parseable-but-dead tokenstore blocks the password
|
||||
fallback in gc.login (main() parks it as .bak first).
|
||||
- DB after sync: 385 activities (→ 2026-06-02), all ISO timestamps on new
|
||||
rows, 356 FITs linked, 364 TIZ rows, wellness through 2026-06-12.
|
||||
The 18 legacy epoch-ms rows remain (P2).
|
||||
- **Web Sync page ported to the gc backend** (same session): login + MFA
|
||||
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
|
||||
openrun.toml (zones/races — openrun-init helps), and deploy story
|
||||
(`streamlit run` is single-process; secrets must stay server-side).
|
||||
- `race_plan` table is EMPTY and `manual_activities` empty, but openrun.toml
|
||||
has races: 30K 2026-06-13, 50K 2026-07-25, 50 MILE 2026-09-12.
|
||||
- Known bugs: 18 activities have epoch-ms floats in `start_time_local`
|
||||
@@ -38,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 +
|
||||
|
||||
48
Nate Personal Notes.md
Normal file
48
Nate Personal Notes.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Nate Personal Notes
|
||||
|
||||
## Eisley
|
||||
|
||||
### Exercises
|
||||
|
||||
### 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 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
|
||||
|
||||
- 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
|
||||
|
||||
## PT youtube - 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
|
||||
|
||||
## 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
|
||||
|
||||
## 50M Training
|
||||
- 30k June, 50k July, 50M September
|
||||
- minimize Z3 junk miles
|
||||
117
NatesNotes.md
117
NatesNotes.md
@@ -1,47 +1,86 @@
|
||||
# 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
|
||||
-
|
||||
## 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.
|
||||
|
||||
## Race fueling
|
||||
|
||||
Plans, product reference, and the carb science live in
|
||||
[RaceLogistics.md](RaceLogistics.md). Personal takeaways: target 80 g/h
|
||||
(history says I under-fuel), both Tailwind and Hammer Gel are
|
||||
glucose-dominant — pushing past ~85 g/h with this stack needs more
|
||||
fructose, not more gels.
|
||||
|
||||
## 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 |
|
||||
|
||||
126
RaceLogistics.md
Normal file
126
RaceLogistics.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Race Logistics — fueling reference & plans
|
||||
|
||||
Reference doc: nutrition science, product cheat-sheet, race plans, watch
|
||||
setup. Personal findings/cues stay in [NatesNotes.md](NatesNotes.md).
|
||||
|
||||
## Carb science in five bullets
|
||||
|
||||
- The gut absorbs glucose and fructose through **separate transporters**:
|
||||
SGLT1 (glucose) saturates around **~60 g/h**; GLUT5 (fructose) adds another
|
||||
**~30–50 g/h** on top. That's the entire reason dual-source fueling exists.
|
||||
- Maltodextrin and dextrose are pure glucose for this purpose; **sucrose is
|
||||
half glucose, half fructose**.
|
||||
- Targets by effort: >2.5 h → **60–90 g/h**; the current elite trend is
|
||||
**90–120 g/h at ~1:0.8 glucose:fructose** (Maurten/SiS Beta/Nduranz-style
|
||||
products) — measurably less fatigue in trail-marathon studies, but only
|
||||
with a **trained gut**. Build in long runs, never debut on race day.
|
||||
- Hydration **450–750 ml/h** (up to ~1 L/h hot). Sodium **~500–700 mg/h**
|
||||
typical; true range is individual (200–1,500). Most "gel" GI distress is
|
||||
actually dehydration/sodium imbalance concentrating sugars in the gut.
|
||||
- Pre-race: 100–150 g familiar carbs 2.5–3 h out. Start fueling at min 25–30.
|
||||
Post: 80–100 g carbs + protein within the hour.
|
||||
|
||||
## Product reference (what's in the cabinet)
|
||||
|
||||
| Product | Per unit | Carb sources | ~glu:fru |
|
||||
| --- | --- | --- | --- |
|
||||
| Tailwind Endurance Fuel | 25 g carb, ~310 mg sodium / scoop | dextrose + cane sugar (sucrose) | ~3–4 : 1 |
|
||||
| Hammer Gel | ~21–25 g carb / serving | ~80% maltodextrin + ~20% fructose | ~4 : 1 |
|
||||
| Endurolytes Extreme | ~300 mg salt ≈ **~120 mg sodium**, 75 mg K / cap | — | — |
|
||||
|
||||
Both fuels are **glucose-dominant**. Stacked at ~78 g/h that's ~60–63 g/h
|
||||
glucose — right at the SGLT1 ceiling, fine. Pushing this stack to 90+ g/h
|
||||
overloads the glucose pathway (GI risk) because the fructose share is too
|
||||
small; going higher means *more Tailwind relative to gels* or a 1:0.8
|
||||
product for the 50K/50M.
|
||||
|
||||
## Fructose sources (for raising the ratio past ~85 g/h)
|
||||
|
||||
- **1:0.8 products**: Maurten 320, SiS Beta Fuel, Nduranz, Precision Fuel.
|
||||
- **Cheap**: table sugar (sucrose = 50% fructose) in a bottle; DIY
|
||||
maltodextrin + crystalline fructose at ~1:0.8 + pinch of salt.
|
||||
- **Real food / aid station**: honey (~40% fru), maple syrup, gummy candy,
|
||||
flat Coke (~55% fru), dates/raisins/figs (test fiber tolerance), bananas.
|
||||
- **Avoid as primary**: agave (~85% fructose), apple juice — fructose solo
|
||||
absorbs slower and causes its own GI trouble; it only works as the co-fuel.
|
||||
|
||||
## 30K prep timeline — gun at 09:00, 2026-06-13
|
||||
|
||||
**Tonight (Jun 12)**
|
||||
- Mix the bladder: 6 scoops Tailwind in 2 L, into the fridge.
|
||||
- Lay out kit: vest, bladder, 8 gels + 2 spare, ~10 Mamba chews, 4 E-lyte
|
||||
caps, baggie of 2 spare scoops; shoes, socks, watch, HR strap, hat.
|
||||
- Charge the watch + phone. Set the **0:15 recurring Run alert** now.
|
||||
- Check weather → decide hot-day salt (1 cap/h from hr 2) vs hold.
|
||||
- Dinner: familiar carb-forward meal, go easy on fiber/fat/alcohol.
|
||||
- Set alarms (see below). Sleep target lights-out ~21:30.
|
||||
|
||||
**Race morning**
|
||||
| Time | Do |
|
||||
| --- | --- |
|
||||
| 06:00 | Wake. Coffee if habitual. |
|
||||
| 06:00–06:15 | **Breakfast: 120–150 g familiar carbs** (e.g. bagel + banana + honey/juice). 3 h pre-gun = time to digest + a bathroom window. |
|
||||
| ~07:00 | Bathroom #1. Dress, sunscreen, body-glide. |
|
||||
| 07:15 | Leave for venue (adjust to travel time — arrive by 08:00). |
|
||||
| 08:00 | Pick up bib if needed. Bladder in vest, gels/chews/caps in pockets. |
|
||||
| 08:00–08:45 | **Sip ~500 ml** (water or light Tailwind). Bathroom #2. |
|
||||
| 08:30 | Easy 8–10 min warm-up jog + strides; settle cadence toward 170. |
|
||||
| 08:45 | Stop drinking large volumes (avoid a sloshing start). |
|
||||
| 08:50 | **1 gel.** Final bathroom if the line allows. |
|
||||
| 08:55 | Corral. Start the watch GPS so it has a lock. |
|
||||
| 09:00 | **Gun.** First fuel buzz: eat at min 25–30, not before. |
|
||||
|
||||
**Alarms to set tonight:** 06:00 wake · 07:10 "leave" · 08:45 "first gel".
|
||||
|
||||
## 30K plan — 2026-06-13 (assumes ~4 h)
|
||||
|
||||
Targets: **75–80 g carb/h · 500–750 ml fluid/h · ~500–700 mg sodium/h**
|
||||
|
||||
- **Bladder: 6 scoops Tailwind in 2 L** → 150 g carb, ~1,860 mg sodium.
|
||||
~500 ml/h = 37 g carb + 465 mg sodium per hour; lasts exactly 4 h.
|
||||
- **Every 15-min watch buzz = eat + sip**:
|
||||
**:00 gel · :15 Mamba chew · :30 gel · :45 Mamba chew** —
|
||||
40 g (gels) + ~7.5 g (2 chews @ ~3.7 g) + 37 g (Tailwind) ≈ **85 g/h**.
|
||||
Mamba chews (glucose syrup + sugar + fruit juice, trace fat) double as the
|
||||
stomach-settler — proven in training.
|
||||
- Stomach turns → **drop the :30 gel, keep the chews** (≈65 g/h floor).
|
||||
Never go to zero carbs. Don't add a 3rd gel on top of this — the glucose
|
||||
pathway is already at its ~60 g/h ceiling.
|
||||
- **Endurolytes**: carry 4; 1/h from hour 2 only if hot / salty sweater /
|
||||
cramp twinges. Tailwind covers baseline sodium.
|
||||
- **Carry**: 8 gels + 2 spare · ~10 Mamba chews · mixed bladder · 4 caps ·
|
||||
2 spare scoops in a baggie for refill points.
|
||||
- **Pre**: 120–150 g carbs 2.5–3 h before · ~500 ml in the last hour ·
|
||||
1 gel 10 min pre-gun.
|
||||
|
||||
## FR255 fueling reminders
|
||||
|
||||
Native (race-day choice): Run profile → hold **UP** → Run Settings → Alerts
|
||||
→ Add New → **Time → Recurring → 0:15**. Mapping: every buzz = sip ·
|
||||
every 2nd (:30s) = gel · every 4th (hourly) = salt check.
|
||||
Connect IQ alternative for labeled eat/drink intervals:
|
||||
[Eat! Drink! Reminder!](https://apps.garmin.com/en-US/apps/7449127c-65ba-470b-954c-3a5fa93376c3)
|
||||
— test on a training run first; costs a data-field slot.
|
||||
|
||||
## Before the 50K (2026-07-25)
|
||||
|
||||
- Gut-train toward 90 g/h on long runs (raise Tailwind share, or trial a
|
||||
1:0.8 glucose:fructose fuel).
|
||||
- Re-derive sodium needs from how the 30K went (cramps? salt crust? nausea?).
|
||||
- Log the 30K outcome below.
|
||||
|
||||
## Race log
|
||||
|
||||
- **2026-06-13 30K** — (fill in: finish time, what was consumed, GI notes,
|
||||
cramps, what to change)
|
||||
|
||||
## Sources
|
||||
|
||||
[120 vs 90 vs 60 g/h trail marathon](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7400827/) ·
|
||||
[CHO & GI problems in ultra-trail review](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC8197833/) ·
|
||||
[Cadence ultramarathon guide](https://us.usecadence.com/blogs/science/ultramarathon-nutrition-guide) ·
|
||||
[Precision Hydration ultra fueling](https://www.precisionhydration.com/performance-advice/nutrition/preparing-fueling-nutrition-plan-for-ultra-racing/) ·
|
||||
[Tailwind nutrition](https://tailwindnutrition.com/pages/nutrition) ·
|
||||
[Hammer Gel](https://hammernutrition.com/products/hammer-gel) ·
|
||||
[Endurolytes Extreme](https://hammernutrition.com/products/endurolytes-extreme) ·
|
||||
[FR255 alerts manual](https://www8.garmin.com/manuals/webhelp/GUID-676967A0-1B23-4384-9BC9-76F3D643F1C8/EN-US/GUID-E9B4413D-28EB-4054-8622-5CCD3F4571D2.html)
|
||||
@@ -7,6 +7,7 @@ license = { text = "MIT" }
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fitparse>=1.2.0",
|
||||
"garminconnect>=0.3.5",
|
||||
"garth>=0.8.0",
|
||||
"ipykernel>=7.2.0",
|
||||
"jinja2>=3.1.6",
|
||||
|
||||
@@ -595,13 +595,27 @@ def main() -> None:
|
||||
help="Cap how many activities --fit-backfill pulls (newest first)")
|
||||
parser.add_argument("--skip-activities", action="store_true",
|
||||
help="Wellness only, no activity sync")
|
||||
parser.add_argument("--backend", choices=("auto", "garth", "garminconnect"),
|
||||
default="auto",
|
||||
help="Auth backend. 'auto' picks garminconnect when "
|
||||
".secrets/garmin_tokens.json exists, else garth.")
|
||||
args = parser.parse_args()
|
||||
|
||||
tok = _token_dir()
|
||||
if not tok.exists() or not any(tok.iterdir()):
|
||||
print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
garth.resume(tok)
|
||||
from . import garminconnect_backend
|
||||
|
||||
backend = args.backend
|
||||
if backend == "auto":
|
||||
backend = "garminconnect" if garminconnect_backend.has_tokens(tok) else "garth"
|
||||
|
||||
if backend == "garminconnect":
|
||||
user = garminconnect_backend.activate(tok)
|
||||
print(f"auth: garminconnect backend (user: {user})")
|
||||
else:
|
||||
if not tok.exists() or not any(tok.iterdir()):
|
||||
print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
garth.resume(tok)
|
||||
|
||||
conn = connect()
|
||||
try:
|
||||
|
||||
160
src/openrun/ingest/garminconnect_backend.py
Normal file
160
src/openrun/ingest/garminconnect_backend.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""garminconnect-backed auth for openrun-sync.
|
||||
|
||||
garth's password login is Cloudflare-rate-limited (429 on sso.garmin.com) and
|
||||
garth itself is deprecated. python-garminconnect 0.3.x authenticates with
|
||||
Garmin's newer DI Bearer tokens (`.secrets/garmin_tokens.json`) and refreshes
|
||||
them against connectapi.garmin.com — no SSO involved after the first login.
|
||||
|
||||
`activate()` logs in via garminconnect, then routes garth's API surface
|
||||
(module-level `garth.connectapi`/`garth.download`, the `garth.http.client`
|
||||
singleton used by `garth.stats`, and the `username` property) through the
|
||||
garminconnect client, so all of `openrun.ingest.garmin_api` runs unchanged.
|
||||
|
||||
This is the slim version of ROADMAP P3 (`openrun-sync --backend=garminconnect`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def token_dir() -> Path:
|
||||
"""Token store: `.secrets/` relative to the current working directory."""
|
||||
return Path.cwd() / ".secrets"
|
||||
|
||||
|
||||
def has_tokens(token_dir: Path) -> bool:
|
||||
return (token_dir / "garmin_tokens.json").exists()
|
||||
|
||||
|
||||
def resume(td: Path | None = None) -> Any | None:
|
||||
"""Restore an authenticated Garmin client from saved tokens, else None.
|
||||
|
||||
Validates against the API (refreshing the DI token if needed), so a dead
|
||||
tokenstore returns None rather than failing later mid-sync.
|
||||
"""
|
||||
from garminconnect import Garmin
|
||||
|
||||
td = td or token_dir()
|
||||
if not has_tokens(td):
|
||||
return None
|
||||
try:
|
||||
gc = Garmin()
|
||||
gc.login(tokenstore=str(td))
|
||||
return gc
|
||||
except Exception: # noqa: BLE001 — expired/rejected tokens → logged out
|
||||
return None
|
||||
|
||||
|
||||
def begin_login(email: str, password: str, td: Path | None = None) -> tuple[str, Any]:
|
||||
"""Start a fresh credential login (web flow, no input()). Returns:
|
||||
|
||||
* ``("ok", gc)`` — logged in; tokens saved.
|
||||
* ``("needs_mfa", gc)`` — hold ``gc`` (e.g. in st.session_state) and pass
|
||||
it with the emailed/app code to ``complete_mfa``.
|
||||
|
||||
Raises garminconnect's typed errors on bad credentials / rate limits.
|
||||
"""
|
||||
from garminconnect import Garmin
|
||||
|
||||
gc = Garmin(email=email, password=password, return_on_mfa=True)
|
||||
status, _ = gc.login() # cascading strategies; early-returns pre-dump
|
||||
if status == "needs_mfa":
|
||||
return "needs_mfa", gc
|
||||
return "ok", _finish_login(gc, td)
|
||||
|
||||
|
||||
def complete_mfa(gc: Any, code: str, td: Path | None = None) -> Any:
|
||||
"""Finish an MFA login started by ``begin_login``. Returns the client."""
|
||||
gc.client.resume_login(None, code.strip())
|
||||
return _finish_login(gc, td)
|
||||
|
||||
|
||||
def _finish_login(gc: Any, td: Path | None = None) -> Any:
|
||||
"""Persist tokens and load the profile (skipped in return_on_mfa mode)."""
|
||||
td = td or token_dir()
|
||||
td.mkdir(parents=True, exist_ok=True)
|
||||
gc.client.dump(str(td))
|
||||
gc._load_profile_and_settings() # noqa: SLF001 — wrapper has no public hook
|
||||
return gc
|
||||
|
||||
|
||||
def user_label(gc: Any) -> str:
|
||||
"""Human-readable account label for UI display."""
|
||||
return gc.full_name or gc.username or gc.display_name or "Garmin user"
|
||||
|
||||
|
||||
def activate(token_dir: Path) -> str:
|
||||
"""Authenticate from `token_dir` and patch garth. Returns the username."""
|
||||
from garminconnect import Garmin
|
||||
|
||||
gc = Garmin()
|
||||
gc.login(tokenstore=str(token_dir)) # loads DI tokens; auto-refreshes + re-saves
|
||||
return patch_garth(gc)
|
||||
|
||||
|
||||
def patch_garth(gc: Any) -> str:
|
||||
"""Route garth's API surface through an authenticated garminconnect client."""
|
||||
import garth
|
||||
from garth.http import Client as GarthClient
|
||||
from garth.http import client as garth_singleton
|
||||
|
||||
def connectapi(path: str, **kwargs: Any) -> Any:
|
||||
return gc.connectapi(path, **kwargs)
|
||||
|
||||
def download(path: str, **kwargs: Any) -> bytes:
|
||||
return gc.download(path, **kwargs)
|
||||
|
||||
# Call-time lookups on the singleton (garth.stats.* resolve `http.client`).
|
||||
garth_singleton.connectapi = connectapi # type: ignore[method-assign]
|
||||
garth_singleton.download = download # type: ignore[method-assign]
|
||||
# Module-level names were bound to the original methods at import time.
|
||||
garth.connectapi = connectapi # type: ignore[assignment]
|
||||
garth.download = download # type: ignore[assignment]
|
||||
|
||||
# `Client.username` is a read-only property; replace it on the class.
|
||||
# sync_resting_hr interpolates it into /usersummary-service/.../{username},
|
||||
# which requires the UUID displayName — userName ("o@...") gets a 403.
|
||||
profile = gc.connectapi("userprofile-service/socialProfile") or {}
|
||||
username = gc.display_name or profile.get("displayName") or profile.get("userName") or ""
|
||||
GarthClient.username = username # type: ignore[assignment]
|
||||
return profile.get("userName") or username
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""One-time interactive login; saves DI tokens to ./.secrets/garmin_tokens.json.
|
||||
|
||||
Uses garminconnect's cascading login strategies (mobile+cffi first, which
|
||||
impersonates a browser TLS fingerprint) — gets past the Cloudflare 429
|
||||
that blocks garth's plain SSO login.
|
||||
|
||||
uv run python -m openrun.ingest.garminconnect_backend
|
||||
"""
|
||||
import getpass
|
||||
import os
|
||||
|
||||
from garminconnect import Garmin
|
||||
|
||||
td = Path.cwd() / ".secrets"
|
||||
td.mkdir(exist_ok=True)
|
||||
|
||||
# A parseable-but-dead tokenstore short-circuits the password fallback in
|
||||
# garminconnect's login(); park any existing file so we get a fresh login.
|
||||
stale = td / "garmin_tokens.json"
|
||||
if stale.exists():
|
||||
stale.rename(td / "garmin_tokens.json.bak")
|
||||
print(f"(moved old tokens to {stale.name}.bak)")
|
||||
|
||||
email = os.environ.get("GARMIN_EMAIL") or input("Garmin email: ").strip()
|
||||
password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
|
||||
|
||||
gc = Garmin(email=email, password=password,
|
||||
prompt_mfa=lambda: input("MFA code: ").strip())
|
||||
gc.login(tokenstore=str(td)) # no tokens on disk now → password login, then dump
|
||||
print(f"Logged in as {gc.display_name or email}. Tokens saved to {td}.")
|
||||
print("Now run: uv run openrun-sync --days 35")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.")
|
||||
|
||||
@@ -19,7 +19,7 @@ import streamlit as st
|
||||
|
||||
from openrun.config import default_config
|
||||
from openrun.db import get_state
|
||||
from openrun.ingest import auth as gauth
|
||||
from openrun.ingest import garminconnect_backend as gauth
|
||||
from openrun.ingest.garmin_api import run_sync
|
||||
from openrun.ingest.garmin_export import ingest_path
|
||||
from openrun.web._helpers import get_conn, show_sidebar
|
||||
@@ -45,10 +45,14 @@ st.write(
|
||||
st.divider()
|
||||
st.subheader("Live sync (Garmin Connect)")
|
||||
|
||||
_user = gauth.current_user()
|
||||
# Resume validates tokens against the API (one round-trip), so cache the
|
||||
# result for the browser session instead of re-checking on every rerun.
|
||||
if "garmin_client" not in st.session_state:
|
||||
st.session_state["garmin_client"] = gauth.resume()
|
||||
_gc = st.session_state["garmin_client"]
|
||||
|
||||
if _user:
|
||||
st.success(f"Logged in as **{_user}**.")
|
||||
if _gc is not None:
|
||||
st.success(f"Logged in as **{gauth.user_label(_gc)}**.")
|
||||
|
||||
c1, c2 = st.columns(2)
|
||||
full = c1.checkbox(
|
||||
@@ -77,34 +81,33 @@ if _user:
|
||||
fit_limit = int(_lim) or None
|
||||
|
||||
if st.button("🔄 Sync now from Garmin", type="primary"):
|
||||
if not gauth.resume():
|
||||
st.error("Session expired — log in again below.")
|
||||
else:
|
||||
summary = None
|
||||
with st.status("Syncing from Garmin…", expanded=True) as status:
|
||||
try:
|
||||
summary = run_sync(
|
||||
get_conn(),
|
||||
full=full,
|
||||
fetch_fit=want_fit,
|
||||
fit_backfill=fit_backfill,
|
||||
fit_type=fit_type,
|
||||
fit_limit=fit_limit,
|
||||
progress=status.write,
|
||||
)
|
||||
status.update(label="Sync complete", state="complete")
|
||||
except Exception as exc: # noqa: BLE001 — surface any garth/HTTP error
|
||||
status.update(label="Sync failed", state="error")
|
||||
st.error(str(exc))
|
||||
if summary is not None:
|
||||
st.cache_data.clear()
|
||||
st.success("Synced from Garmin Connect.")
|
||||
st.json(summary)
|
||||
summary = None
|
||||
with st.status("Syncing from Garmin…", expanded=True) as status:
|
||||
try:
|
||||
gauth.patch_garth(_gc)
|
||||
summary = run_sync(
|
||||
get_conn(),
|
||||
full=full,
|
||||
fetch_fit=want_fit,
|
||||
fit_backfill=fit_backfill,
|
||||
fit_type=fit_type,
|
||||
fit_limit=fit_limit,
|
||||
progress=status.write,
|
||||
)
|
||||
status.update(label="Sync complete", state="complete")
|
||||
except Exception as exc: # noqa: BLE001 — surface any API/HTTP error
|
||||
status.update(label="Sync failed", state="error")
|
||||
st.error(str(exc))
|
||||
if summary is not None:
|
||||
st.cache_data.clear()
|
||||
st.success("Synced from Garmin Connect.")
|
||||
st.json(summary)
|
||||
|
||||
with st.expander("Log out"):
|
||||
st.caption("Removes the saved OAuth tokens in `.secrets/`. You'll need to log in again to sync.")
|
||||
if st.button("Forget saved tokens"):
|
||||
shutil.rmtree(gauth.token_dir(), ignore_errors=True)
|
||||
st.session_state.pop("garmin_client", None)
|
||||
st.rerun()
|
||||
|
||||
else:
|
||||
@@ -123,7 +126,8 @@ else:
|
||||
st.warning("Enter both email and password.")
|
||||
else:
|
||||
try:
|
||||
kind, payload = gauth.begin_login(email, password)
|
||||
with st.spinner("Logging in to Garmin…"):
|
||||
kind, payload = gauth.begin_login(email, password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
st.error(f"Login failed: {exc}")
|
||||
else:
|
||||
@@ -131,7 +135,7 @@ else:
|
||||
st.session_state["garmin_mfa_state"] = payload
|
||||
st.rerun()
|
||||
else:
|
||||
st.success(f"Logged in as {payload}.")
|
||||
st.session_state["garmin_client"] = payload
|
||||
st.rerun()
|
||||
|
||||
if "garmin_mfa_state" in st.session_state:
|
||||
@@ -141,12 +145,12 @@ else:
|
||||
mfa_submitted = st.form_submit_button("Verify", type="primary")
|
||||
if mfa_submitted:
|
||||
try:
|
||||
user = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code)
|
||||
gc = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
st.error(f"Verification failed: {exc}")
|
||||
else:
|
||||
del st.session_state["garmin_mfa_state"]
|
||||
st.success(f"Logged in as {user}.")
|
||||
st.session_state["garmin_client"] = gc
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
@@ -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:
|
||||
|
||||
58
uv.lock
generated
58
uv.lock
generated
@@ -356,6 +356,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl-cffi"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "cffi" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cycler"
|
||||
version = "0.12.1"
|
||||
@@ -439,6 +472,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "garminconnect"
|
||||
version = "0.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "requests" },
|
||||
{ name = "ua-generator" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/d8/df118bbbf9a4634bf335cba7baf66f0618babbbf6982c7c43560f9d96d32/garminconnect-0.3.5.tar.gz", hash = "sha256:b4de13fa5e6c581348cbd8110afb9095dc68273d6cfb1284ed13cae7df0f6b02", size = 61692 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bb/c35ed445156311b9408aa2a10b6c9199392ea1fa26144de947d544c5601a/garminconnect-0.3.5-py3-none-any.whl", hash = "sha256:7a34c408ab4b94a69f67baeda96e8c8aef674447389bbb39beea0e5e49c2a29d", size = 57331 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "garth"
|
||||
version = "0.8.0"
|
||||
@@ -1003,6 +1050,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fitparse" },
|
||||
{ name = "garminconnect" },
|
||||
{ name = "garth" },
|
||||
{ name = "ipykernel" },
|
||||
{ name = "jinja2" },
|
||||
@@ -1020,6 +1068,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fitparse", specifier = ">=1.2.0" },
|
||||
{ name = "garminconnect", specifier = ">=0.3.5" },
|
||||
{ name = "garth", specifier = ">=0.8.0" },
|
||||
{ name = "ipykernel", specifier = ">=7.2.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
@@ -1899,6 +1948,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ua-generator"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/4a/46aa28f8eb83969ef8e1e8ae521147de966916de85ae6729d2d97480be15/ua_generator-2.1.1.tar.gz", hash = "sha256:c01984c1055da4900c8a1f4c419f229fa454caf42d8642267aec1904110d1c8d", size = 29642 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/8c/a9f0161f2a34752987199425fec7f0068b012a02c98551615a9b82cbd70a/ua_generator-2.1.1-py3-none-any.whl", hash = "sha256:d8fd6e39e44dc057dd232d5f808fe0f61dab4a3163903cfe24446777ecdd2ec4", size = 32769 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
|
||||
Reference in New Issue
Block a user