This commit is contained in:
2026-06-12 05:48:30 -04:00
parent 9d91ac8ebc
commit 64a5ab4b7f
37 changed files with 4530 additions and 407 deletions

204
QUICKSTART.md Normal file
View File

@@ -0,0 +1,204 @@
# QUICKSTART
You're ~5 minutes from a running dashboard. Two ways in: **web** (recommended, almost zero terminal) or **CLI** (for scripting).
---
## What you'll need
- **A Mac or Linux machine** with a terminal. Windows works under WSL but isn't tested.
- **[uv](https://github.com/astral-sh/uv)** — Python's package manager. Install:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
- **A Garmin export** — see *Getting your Garmin data* below. You can skip this for now and use the manual logger.
That's it. No accounts, no cloud, no Python knowledge required.
---
## Web app (recommended)
### 1. Get the code & install deps
```bash
git clone <repo-url> openrun
cd openrun
uv sync
```
`uv sync` creates a `.venv/` and installs everything. ~30 seconds on first run.
### 2. Launch the app
```bash
uv run openrun-web
```
Your browser opens at `http://localhost:8501`. (If it doesn't, copy that URL manually.)
### 3. Run the setup wizard
The first time you load the app, a yellow banner says **"Looks like a fresh install"** with a **Start setup →** button. Click it.
The wizard has five steps, each ~15 seconds:
1. **Intro** — read it, click *Start*.
2. **Your profile** — name, max HR, lactate-threshold HR, resting HR. If you don't know your max HR, leave the default at 200 and we'll auto-derive zones. You can always come back.
3. **HR zones** — auto-filled from your max HR. If you've already configured custom zones in Garmin Connect, paste those bpm boundaries here instead.
4. **Race calendar** — optional. Add one row per upcoming race (label + ISO date). Skip-able.
5. **Get your data** — upload a Garmin export zip, or skip and start logging by hand.
When the wizard finishes, you land on the **Dashboard**.
### 4. Look around
- **📊 Dashboard** — CTL/ATL/TSB tiles, PMC chart, weekly volume, polarized split, recent activities.
- **📋 Activities** — filter by type/date/distance/name. Click any row to drill into splits, time-in-zone, decoupling.
- **📈 Race plan** — edit your weekly plan in the table; the projected PMC redraws automatically. Race-day TSB is shown for each race in your calendar.
- **📝 Manual log** — quick form to log strength/hike/unrecorded-run sessions. These feed the PMC.
- **🛌 Recovery** — sleep stages, HRV + RHR trends, training→next-morning HRV correlation.
- **🫀 Efficiency** — m/beat with rolling median, distance-bucket × year heatmap.
- **🔄 Sync** — DB status + a place to upload more exports later.
That's the whole app.
### 5. Stop / restart
```
Ctrl-C in the terminal that's running it.
uv run openrun-web # restart any time
```
Your data is in `data/garmin.db`. Back that file up periodically.
---
## Getting your Garmin data
The web app's Sync page accepts a Garmin Connect **data export zip**. You request one from Garmin:
1. Sign in at <https://www.garmin.com/account/datamanagement/exportdata>.
2. Click **Request Data Export**. Pick the categories you want — `Activities`, `Wellness`, and `Connect Settings` are the useful ones.
3. Garmin emails a download link within 2472 hours.
4. Download the zip (several hundred MB to a few GB depending on your history).
5. Drag-and-drop it into the **Sync** page, click **Ingest**.
You'll see a progress log; takes 15 minutes depending on how many years of data.
### Two zip flavours
Garmin confusingly has two export formats:
- **Garmin Connect data export** (`connect.zip`) — what you get from the link above. Filenames are clean `<activity_id>_<name>.fit`. The wizard / Sync page handles this directly.
- **Garmin Takeout dump** — a separate, UUID-named folder you can request via Google's data tools (less common). FITs use upload-IDs in filenames so they need an extra by-content linking step. If you have one of these, see the **CLI** section below for the 4-step sequence.
### Live sync (skip on first install)
You can also pull incrementally from Garmin Connect's live API instead of waiting on the email export. Currently this is **CLI-only** because Garmin's MFA flow needs interactive prompts:
```bash
uv run openrun-auth # email + password + MFA prompt; saves OAuth to .secrets/
uv run openrun-sync # incremental top-up (last 14 days by default)
uv run openrun-sync --full # 365-day backfill
```
Browser-driven live sync is on the roadmap.
---
## CLI quickstart (power-user path)
If you'd rather skip the web app entirely:
```bash
uv sync
uv run openrun-init # creates data/garmin.db, prints status
uv run openrun-ingest path/to/export/zip
uv run openrun-link-fit path/to/export/ # only if it's a Takeout dump
uv run openrun-time-in-zone # precompute the TIZ cache
```
Then open one of the notebooks under `examples/notebooks/`, pick the project's `.venv` as the kernel, and run-all.
### Manual activity import via CSV
```bash
echo 'activity_date,activity_type,distance_km,duration_min,training_load,notes,external_id
2026-05-19,strength,,45,30,upper body,
2026-05-18,hike,8.0,120,40,morning hike,' > workouts.csv
uv run openrun-import-manual workouts.csv
```
`external_id` makes re-imports idempotent (upsert on conflict). Leave blank if you don't care about dedupe.
---
## Troubleshooting
### "Port 8501 already in use"
Another Streamlit is running. Stop it (`Ctrl-C` in the terminal that started it) or pick another port:
```bash
uv run openrun-web -- --server.port 8800
```
### "FIT file not at recorded path"
You moved your export folder after linking. Run:
```bash
uv run openrun-link-fit /new/path/to/export --relink
```
This rewrites every stored FIT path to the new location by basename match.
### Dashboard is empty
Either you haven't ingested any data yet (use the Sync page or `openrun-ingest`), or the activities are filtered out by the default `running` / `trail_running` types — check the **Activities** page with the type filter cleared.
### Garmin live sync fails with 429
The endpoint rate-limits aggressively. Wait 3060 minutes, switch networks, or use the Path A export instead.
### TSB looks way off
Most likely your training_load column is missing on a chunk of activities (Garmin only reports it for activities recorded by a compatible device). Check on the **Activities** page; the dashboard's PMC only counts rows where `training_load IS NOT NULL`.
---
## What next?
Once you have data in:
- **Race-plan page** — set up your goal race in `openrun.toml` (or via the wizard), then iterate on the weekly km / long-run km until projected race-day TSB lands in **+10 to +25**.
- **Activity-detail page** — pick a recent race or hard workout, scroll to the **Pa:Hr decoupling** section, and look for where the bar chart crosses Friel's 10 % "unsustainable" line. That's where the wheels came off.
- **Recovery page** — after a few weeks of data, check whether the Pearson r between yesterday's training load and tonight's HRV is meaningful for you. Often it's not, which is itself useful information.
- **Manual log** — every strength session you log lifts CTL the next day. The Dashboard updates automatically.
---
## Where things live
| What | Where |
|---|---|
| Your config | `./openrun.toml` (edit by hand any time) |
| Your data | `./data/garmin.db` (single SQLite file — back this up) |
| Garmin auth tokens | `./.secrets/` (gitignored) |
| Logs / errors | Wherever you ran `openrun-web` — stderr in that terminal |
| Notebooks (reference) | `./examples/notebooks/` |
---
## Resetting
Want to start over?
```bash
rm -rf data/ openrun.toml .secrets/
uv run openrun-web # the wizard reappears
```
Nothing on this list touches anything outside the project directory.

422
README.md
View File

@@ -1,117 +1,154 @@
# openrun — endurance running analytics
A local-first, open-source endurance-training analysis pipeline. Garmin data in (live API or official export), SQLite on disk, pandas notebooks out. Banister CTL/ATL/TSB, Pa:HR decoupling (per-mile and per-second from FIT), Garmin-configured HR-zone time-in-zone, route clustering, race-plan projection with forward CTL/ATL/TSB.
A local-first, open-source endurance-training analysis tool. Garmin data in (live API or official export), SQLite on disk, an in-browser dashboard out — plus a Python API and Jupyter notebooks for power users. No accounts, no cloud, no telemetry. Your data lives in one file on your machine.
This README focuses on what's in the data, what gets computed from it, and how each derived metric is defined. The package name `openrun` is provisional; the local repo can be renamed without code changes.
**What it gives you:** Banister CTL/ATL/TSB (fitness/fatigue/form), Pa:HR decoupling per-mile and per-second from FIT, FIT-aware HR zone time-in-zone, GPS route clustering, race-plan projection with forward CTL/ATL/TSB, off-watch activity logging, and a first-run web wizard so non-CLI users can get going in a few minutes.
```
openrun/
├── pyproject.toml # packaged as `openrun` (src layout, hatchling)
├── openrun.toml # user config (HR zones, weight, LTHR, race calendar)
├── pyproject.toml # packaged as `openrun` (src layout, hatchling)
├── openrun.toml # your config — created by the setup wizard
├── data/garmin.db # SQLite, created on first run
├── src/openrun/
│ ├── __init__.py # re-exports the common API
│ ├── config.py # UserProfile, HRZones, BanisterParams, TOML loader
│ ├── db.py # SQLite schema + connection helper
│ ├── model.py # loaders + derived metrics (was analysis.py)
── ingest/
├── auth.py # Garmin OAuth login
├── garmin_api.py # Path B: incremental sync via garth
├── garmin_export.py # Path A: parse Connect/Takeout JSON + index FITs
├── fit_linker.py # match Takeout FITs to activities by session.start_time
── time_in_zone.py # cache per-activity HR-zone breakdowns
├── examples/notebooks/
├── 01_overview.ipynb # data coverage, recent activity
── 02_running.ipynb # volume, pace, PMC (Banister), PRs
├── 03_recovery.ipynb # sleep, HRV, RHR, body battery
├── 04_efficiency.ipynb # m/beat trends year over year
├── 05_intra_run.ipynb # decoupling, cadence, routes, HR zones
│ └── 06_race_plan.ipynb # periodised plan + tracker + projected PMC
├── data/garmin.db # SQLite (created on first run)
└── {auth,sync,ingest_export,link_fit_files,compute_time_in_zone,db,analysis}.py
# thin top-level shims for back-compat
│ ├── config.py # UserProfile, HRZones, BanisterParams + TOML reader/writer
│ ├── db.py # schema + connect() helper
│ ├── model.py # loaders + derived metrics
│ ├── plots.py # matplotlib plot helpers
── setup.py # idempotent workspace bootstrap + status
├── ingest/
├── auth.py # Garmin OAuth login
├── garmin_api.py # Path B: incremental sync via garth
├── garmin_export.py # Path A: parse Connect / Takeout export
── fit_linker.py # match Takeout FITs by session.start_time
│ │ ├── manual.py # CSV → manual_activities (off-watch sessions)
│ └── time_in_zone.py # per-activity TIZ cache
── web/ # Streamlit app
├── app.py # controller — st.navigation builds the sidebar
├── _helpers.py # cached loaders + sidebar chrome
└── pages/ # one file per page
└── examples/notebooks/ # original analysis notebooks (kept as reference)
```
Most callers want:
## TL;DR — install and use
```bash
uv sync
uv run openrun-web # opens http://localhost:8501
```
A first-launch wizard walks you through profile + HR zones + race calendar, then either ingests a Garmin export zip or takes you to manual logging.
See [QUICKSTART.md](QUICKSTART.md) for the step-by-step.
---
## The two interfaces
### Web app (recommended for everyone)
```bash
uv run openrun-web
```
Eight pages, all wired to the same SQLite DB:
| Page | What's on it |
|---|---|
| 📊 Dashboard | CTL/ATL/TSB tiles + chart, weekly km + ACWR, polarized split, recent runs |
| 📋 Activities | Filter by type / date / distance / name → click a row to drill in |
| 🏃 Activity detail | Splits + pace-vs-HR scatter, time-in-zone bars, FIT decoupling chart |
| 📈 Race plan | Editable plan rows, projected PMC through race day, race-day TSB table |
| 📝 Manual log | Form-based logging of off-watch sessions (strength, hikes, unrecorded runs) |
| 🛌 Recovery | Sleep stages, HRV + RHR overlay, training→next-morning-HRV correlation |
| 🫀 Efficiency | Metres per heartbeat with 30-run rolling median, distance × year heatmap |
| 🔄 Sync | Status + in-browser zip ingest with live progress |
### CLI (for scripting / power users)
```bash
openrun-init # bootstrap + status dashboard
openrun-auth # Garmin OAuth login (live sync)
openrun-sync [--full] # incremental Garmin Connect sync (downloads per-second FIT for new activities)
openrun-sync --fit-backfill --fit-type running # pull per-second FITs for past runs, no website export
openrun-sync --no-fit # sync without the per-second FIT download
openrun-ingest <path|zip> # parse a Garmin export
openrun-link-fit <export> # match Takeout FITs by start time
openrun-link-fit <root> --relink # rewrite paths after moving the export
openrun-time-in-zone # populate the TIZ cache
openrun-import-manual <csv> # bulk off-watch import
openrun-web # launch the Streamlit app
```
### Python API (for notebooks / one-offs)
```python
from openrun import open_conn, load_activities, banister, daily_training_load_series
conn = open_conn()
runs = load_activities(conn, type='running')
pmc = banister(daily_training_load_series(conn))
runs = load_activities(conn, type="running")
pmc = banister(daily_training_load_series(conn, include_manual=True))
```
User-specific values (HR zones, LTHR, weight, race calendar) live in `openrun.toml` — config discovery walks up from cwd looking for it. See [openrun.toml](openrun.toml) for the schema.
CLI entry points (after `uv sync`):
```bash
openrun-auth # OAuth login (Path B)
openrun-sync [--full] # incremental Garmin Connect sync
openrun-ingest <export> # parse a Connect/Takeout export
openrun-link-fit <export> # match Takeout FITs to activities by content
openrun-time-in-zone # populate the activity_time_in_zone cache
```
Top-level shims (`uv run sync.py`, `uv run ingest_export.py`, etc.) still work — they re-export `main()` from the corresponding `openrun.ingest.*` module.
---
## 1 — Source data
### Two ways to get data into the DB
### Two paths in, intentionally overlapping
| Path | Script | What you get | When to use |
| Path | Tool | What you get | When to use |
|---|---|---|---|
| **A. Official data export** (recommended) | `ingest_export.py` | Complete history, FIT files, multi-year wellness | First load; periodic refreshes |
| **B. Live API sync** | `sync.py` (after `auth.py`) | Last N days, lap-level splits, no FIT files | Incremental top-ups between exports |
| **A. Official data export** (recommended for first load) | `openrun-ingest` | Complete history, FIT files, multi-year wellness | First load; periodic refreshes |
| **B. Live API sync** | `openrun-auth` then `openrun-sync`*or the web app's Sync page* | Last N days, lap-level splits, **per-second FIT files** (downloaded via the API) | Incremental top-ups between exports |
The two paths overlap intentionally: the export is the source of truth, the live API fills the gap between exports. The upsert logic only overwrites the raw-JSON column and `fetched_at` on conflict, so Path B values aren't clobbered by Path A re-ingests.
The upsert logic only overwrites the raw-JSON column and `fetched_at` on conflict, so Path B values aren't clobbered by Path A re-ingests.
### Garmin's export formats
**No terminal required.** The web app's **Sync** page now logs in to Garmin (email/password, plus an MFA code when the account needs one) and runs the same pull in-process behind a **🔄 Sync now** button — with checkboxes for full backfill, per-second FIT download, and FIT backfill. Credentials go straight to Garmin; only OAuth tokens are cached in `.secrets/`. The CLI (`openrun-auth` + `openrun-sync`) does the identical thing for scripting.
Garmin actually has *two* export formats, both confusingly called "the export":
**Per-second from the API.** `openrun-sync` downloads each new activity's original FIT (`/download-service/files/activity/{id}`) into `data/fit/<id>.fit` and links it in `activity_fit_files` — the same table the export-based linker writes, so decoupling, FIT-based time-in-zone, and the route map all work without a website export. Skip it with `--no-fit`. To pull per-second history for activities synced before this existed, run `openrun-sync --fit-backfill [--fit-type running] [--fit-limit N]`, then `openrun-time-in-zone` to refresh the per-second TIZ cache. Downloads are idempotent — skipped when the FIT is already on disk and linked.
1. **Garmin Connect data export** (`connect.zip`) — request via Account Management → Export Your Data. Filenames are `<activity_id>_<activity_name>.fit`, distances/durations in SI units (m, s). `ingest_export.py` was built for this.
2. **Garmin Takeout dump** — UUID-named folder (`<uuid>_N/`) with many unrelated domains (aviation, Tacx, InReach…). The running data lives under `DI_CONNECT/`. **Crucially different conventions:**
### Garmin's two export formats
Garmin has two unrelated formats both called "the export":
1. **Garmin Connect data export** (`connect.zip`) — request via Account Management → Export Your Data. Filenames are `<activity_id>_<activity_name>.fit`, SI units throughout. The web app's Sync page accepts this zip directly.
2. **Garmin Takeout dump** — UUID-named folder (`<uuid>_N/`) with many domains (aviation, Tacx, InReach…). Running data lives under `DI_CONNECT/`. **Different conventions:**
- FIT filenames: `<email>_<upload_id>.fit` — upload IDs ≠ activity IDs
- `summarizedActivities` uses scaled-integer units: distance in **cm**, duration in **ms**, elevation in **cm**, speed in **m/s ÷ 10**
- `ingest_export.py` now detects and converts these; `link_fit_files.py` links FITs by content (parses `session.start_time` and matches activities by ±60 s) since the filename-based approach doesn't work
- `summarizedActivities` uses scaled-integer units: distance **cm**, duration **ms**, elevation **cm**, speed **m/s × 0.1**
- `openrun-ingest` detects and converts these; `openrun-link-fit` links FITs by content (parses `session.start_time` and matches activities by ±60 s) since the filename trick doesn't work.
For a Takeout dump, the full sequence is:
For Takeout dumps, the full sequence is:
```bash
# 1. Extract the FIT archives in place
mkdir -p <export>/DI_CONNECT/DI-Connect-Uploaded-Files/fit
unzip -d <export>/DI_CONNECT/DI-Connect-Uploaded-Files/fit \
<export>/DI_CONNECT/DI-Connect-Uploaded-Files/UploadedFiles_0-_Part*.zip
# 2. Ingest JSON + index FITs that have parseable activity IDs
uv run ingest_export.py <export>/
# 3. Link Takeout FITs to activities by start-time match
uv run link_fit_files.py <export>/
# 4. (Optional) precompute time-in-zone cache
uv run compute_time_in_zone.py
uv run openrun-ingest <export>/
uv run openrun-link-fit <export>/
uv run openrun-time-in-zone
```
### Garmin's units & quirks worth knowing
### Off-watch activities
| Field | Live API | Takeout summarizedActivities | Convert by |
Recorded Garmin activity isn't the whole picture — strength work, hikes, runs you forgot to record, group runs on someone else's watch. The web app's **Manual log** writes to a separate `manual_activities` table; loaders union it into the PMC when you pass `include_manual=True` (the Dashboard does this by default).
For bulk import, drop a CSV with `activity_date,activity_type,distance_km,duration_min,training_load,notes[,external_id]` and run `uv run openrun-import-manual workouts.csv`. The optional `external_id` makes re-imports idempotent.
### Units worth knowing (Connect vs Takeout)
| Field | Live API / Connect | Takeout `summarizedActivities` | Convert by |
|---|---|---|---|
| distance | m | cm | × 0.01 |
| duration / movingDuration | s | ms | × 0.001 |
| elevationGain / Loss | m | cm | × 0.01 |
| averageSpeed / maxSpeed | m/s | m/s ÷ 10 | × 10 |
| HR (avg, max) | bpm | bpm | — |
| calories | kcal | kcal | — |
| training_effect, vo2_max | scalar | scalar | — |
| averageSpeed / maxSpeed | m/s | m/s × 0.1 | × 10 |
| HR (avg, max), calories, training_effect, vo2_max | bpm / kcal / scalar | — | — |
| cadence (`averageRunCadence`) | both-legs SPM | both-legs SPM | — (do **not** double) |
| strideLength | cm | cm | × 0.01 for m |
| FIT `position_lat/long` | semicircles | semicircles | × (180 / 2³¹) for degrees |
| FIT `enhanced_speed` | m/s | m/s | — |
The cadence trap bit me: `averageRunCadence` on this account's exports is already both-legs (~150180 spm). Doubling it as if it were single-leg yields 300+ which trips any sane filter, then nothing makes it through.
The cadence trap is real: `averageRunCadence` is already both-legs (~150180 spm). Doubling it as if it were single-leg yields 300+, which trips any sane filter.
---
@@ -121,18 +158,20 @@ The cadence trap bit me: `averageRunCadence` on this account's exports is alread
| Table | Grain | Origin | Notes |
|---|---|---|---|
| `activities` | one row per activity | both paths | `raw` JSON has every Garmin field; the parsed columns are a curated subset |
| `activity_splits` | one row per lap | Path B only | Garmin defaults to auto-laps (~1 km or 1 mi per split). `raw` has cadence, stride, vert osc, GPS bounds |
| `activity_fit_files` | one row per linked FIT | Path A | `fit_path` is **relative** to whichever export root was passed to `link_fit_files.py` — keep the export folder in place |
| `activity_time_in_zone` | one row per activity | precomputed | Cached by `compute_time_in_zone.py`; `source` is `'fit'` or `'lap'` |
| `daily_steps` / `daily_sleep` / `daily_stress` / `daily_hrv` / `daily_body_battery` / `daily_intensity_minutes` / `daily_resting_hr` | one row per calendar date | both paths | Each has a `raw` JSON column with the full source payload |
| `sync_state` | key-value | both paths | Records last-sync / last-ingest timestamps |
| `activities` | one row per activity | both paths | `raw` JSON has every Garmin field; parsed columns are a curated subset |
| `activity_splits` | one row per lap | Path B only | Auto-laps (~1 km or 1 mi). `raw` has cadence, stride, GPS bounds |
| `activity_fit_files` | one row per linked FIT | linker | `fit_path` is stored **absolute** at link time. Move the export → run `openrun-link-fit --relink` |
| `activity_time_in_zone` | one row per activity | precomputed | `source = 'fit'` (per-second) or `'lap'` (split-average) |
| `manual_activities` | one row per logged session | web form / CSV | Parallel to `activities`, never clobbered by Garmin re-ingest |
| `race_plan` | one row per ISO-Monday week | web editor | Feeds `banister_forecast` for projected PMC |
| `daily_*` (steps / sleep / stress / hrv / body_battery / intensity_minutes / resting_hr) | one row per date | both paths | Each has a `raw` JSON column with the full payload |
| `sync_state` | key/value | both paths | last-sync / last-ingest timestamps |
The `raw` column on every table is the full upstream JSON. If you need a field the schema doesn't surface (sleep stage breakdowns, GPS bounding box, swimming-specific metrics, sport sub-types), unpack it from `raw` rather than re-ingesting:
The `raw` column on every table is the full upstream JSON. If you need a field the schema doesn't surface, unpack it from `raw`:
```python
from analysis import expand_raw, load_activities
acts = load_activities(open_conn(), type='running')
from openrun import open_conn, load_activities, expand_raw
acts = load_activities(open_conn(), type="running")
fields = expand_raw(acts) # pd.json_normalize'd companion frame
```
@@ -140,7 +179,7 @@ fields = expand_raw(acts) # pd.json_normalize'd companion frame
## 3 — Metrics & how they're calculated
All loaders and helpers live in [analysis.py](analysis.py). The key derivations:
All loaders and helpers live in [src/openrun/model.py](src/openrun/model.py). Highlights:
### Pace
@@ -148,19 +187,17 @@ All loaders and helpers live in [analysis.py](analysis.py). The key derivations:
pace_min_per_km = (moving_duration_s / 60) / (distance_m / 1000)
```
`moving_duration_s` excludes paused time. Activities with implausible paces (sub-3 min/km or over 30 min/km — covers world-record marathon pace on the fast side and walks-with-stops on the slow side) get masked to NaN in `load_activities` rather than dropped, so the row count is preserved.
Implausible paces (sub-3 min/km or over 30 min/km — covers world-record marathon pace and walks-with-stops) get masked to NaN in `load_activities` rather than dropped, so row count is preserved.
### Acute:Chronic Workload Ratio (ACWR)
Daily training-load sum, then:
```
acute = sum(training_load) over the last 7 days
chronic = sum(training_load) over the last 28 days, divided by 4 for week-equivalence
ACWR = acute / chronic
```
Sweet spot ~0.81.3, > 1.5 = aggressive ramp / injury risk. Implemented inline in [02_running.ipynb](notebooks/02_running.ipynb).
Sweet spot ~0.81.3, > 1.5 = aggressive ramp / injury risk. Surfaced on the Dashboard.
### Aerobic efficiency — "m/beat"
@@ -168,24 +205,22 @@ Sweet spot ~0.81.3, > 1.5 = aggressive ramp / injury risk. Implemented inline
m_per_beat = distance_m / (duration_s * avg_hr / 60)
```
Higher = more metres covered per heartbeat = fitter aerobic system at the run's effort. Best compared YoY within a tight distance bucket so terrain and intent don't dominate. Used as the headline view in [04_efficiency.ipynb](notebooks/04_efficiency.ipynb).
Higher = more metres per heartbeat = fitter aerobic system. Best compared YoY within a tight distance bucket so terrain and intent don't dominate. The Efficiency page does this with a 30-run rolling median plus a distance-bucket × year heatmap and an "easy runs only" headline view (HR < 75 % LTHR).
### Decoupling (Pa:Hr drift)
Friel's method, in two resolutions. *Positive* = pace/HR fell off in the second half (cardiac drift, possibly fueling deficit). *Negative* = improved (negative split).
Friel's method, two resolutions. *Positive* = pace/HR fell off in the second half (cardiac drift, possibly fuelling deficit). *Negative* = improved (negative split).
**Per-mile (lap level)**`analysis.decoupling(splits, min_splits=6)`:
**Per-mile (lap level)**`decoupling(splits, min_splits=6)`:
```
For each activity with ≥ min_splits laps:
halves = first half / second half by lap count
halves = first / second by lap count
eff_half = duration-weighted mean(speed_mps) / mean(heart_rate)
decoupling_pct = (eff_first / eff_second 1) × 100
```
Cheap, but smears across aid-station stops and rounds at lap boundaries.
**Per-second (FIT)**`analysis.fit_decoupling(records, segments=N, warmup_min=5, cooldown_min=2, min_speed_mps=0.5)`:
**Per-second (FIT)**`fit_decoupling(records, segments=N, warmup_min=5, cooldown_min=2, min_speed_mps=0.5)`:
```
records = per-second FIT messages
@@ -197,97 +232,44 @@ for each chunk:
decoupling_pct = (efficiency[0] / efficiency[i] 1) × 100
```
Friel's published thresholds (interpret on **steady aerobic** efforts only):
- **< 5%** — aerobically developed
- **510%** — sustainable
- **> 10%** — pacing unsustainable for the distance, OR fueling shortfall, OR heat
Friel's thresholds (interpret on **steady aerobic** efforts only):
- **< 5 %** — aerobically developed
- **510 %** — sustainable
- **> 10 %** — pacing unsustainable for the distance, OR fueling shortfall, OR heat
In this dataset the per-second number is consistently **715 percentage points lower** than the per-mile number for the same run — the gap is aid-station noise.
The Activity-detail page plots this with `plot_fit_decoupling`.
### HR zone time-in-zone
**Zone boundaries come from Garmin's `heartRateZones.json`** (training method `HR_MAX`), not estimated from observed HR. The constants for this user live in `analysis.HR_ZONES_USER`:
Zone boundaries come from `[user.hr_zones]` in your `openrun.toml` (matching what Garmin's `heartRateZones.json` advertises). Two implementations; `openrun-time-in-zone` picks the better one per activity and caches it.
```python
Z1: 102122 (recovery)
Z2: 123143 (easy aerobic long-run target)
Z3: 144164 (tempo / "junk-miles middle")
Z4: 165185 (threshold LTHR=182 sits inside Z4)
Z5: 186209 (VO₂ max)
```
**From FIT (`source='fit'`)**`time_in_zone_from_fit(records)`: per-second; large gaps (>30 s, paused recording) are clipped to 30 s.
Time-in-zone has two implementations; `compute_time_in_zone.py` picks the better one per activity and caches the result in `activity_time_in_zone`.
**From FIT (`source='fit'`)**`analysis.time_in_zone_from_fit(records)`:
```
records = per-second FIT messages
for each consecutive record pair:
dt = elapsed_seconds delta (clipped to [0, 30] so paused recording doesn't dump hours into one zone)
zone = bucket(this record's heart_rate)
accumulate dt into zone
```
**From splits (`source='lap'`)** — fallback when no FIT is linked:
```
for each split:
zone = bucket(split's avg_hr)
accumulate split.duration_s into that single zone
```
The FIT version is the right number; the lap version is **biased toward the middle zone**: when a lap's HR average is in Z3 but the actual HR oscillated into Z2 and Z4, the lap method dumps the whole lap into Z3. On a sample 8-hour race in this dataset, the lap method over-counted Z3 by 67 minutes and under-counted Z2 and Z4 by ~32 minutes each.
**From splits (`source='lap'`)** — fallback when no FIT is linked: assigns each split's avg HR to a single zone. Biased toward the middle zone (smooths over within-lap variation); the FIT version is ground truth where available.
### Polarized split
A three-bucket simplification used in [05_intra_run.ipynb §4](notebooks/05_intra_run.ipynb):
```
easy = Z1 + Z2 (HR ≤ 143)
moderate = Z3 (144164, the "junk-miles middle")
hard = Z4 + Z5 (≥ 165, threshold and up)
easy = Z1 + Z2 (recovery + easy aerobic)
moderate = Z3 (tempo)
hard = Z4 + Z5 (threshold + VO₂)
```
Seiler's polarized target is ~80% easy, < 10% moderate, ~20% hard. Pyramidal training is high easy, modest moderate, low hard. Threshold-heavy training is the opposite — most time in moderate.
Seiler's polarised target is ~80 % easy, < 10 % moderate, ~20 % hard. The Dashboard shows your last-12-week split as tiles plus a stacked bar.
### Cadence & stride
### Banister fitness / fatigue / form (PMC)
Both come directly from Garmin's lap data (`averageRunCadence`, `strideLength`) and from FIT records (`cadence`, `step_length`). Two gotchas:
1. **Cadence is already both-legs SPM** for this user's exports — do not double. Sanity range for running: 140200 spm.
2. **Stride length is in cm** in lap data (`strideLength: 78` → 78 cm → 0.78 m) and in **mm** in FIT records (`step_length: 1041` → 104 cm). Divide accordingly.
The notebook restricts form analysis to splits with cadence > 0 (drops walks/standing intervals) and to a controlled pace band (5:306:30 min/km) when comparing YoY so fitness changes don't contaminate the form signal.
### GPS route clustering
`analysis.cluster_routes(lats, lons, radius_km=0.25)`:
The standard endurance lens (TrainingPeaks PMC), in `banister(daily_load, ctl_tau=42, atl_tau=7)`:
```
Greedy haversine clustering:
For each unassigned point i:
distances = haversine(point_i, all_other_points)
neighbours = points within radius_km
if len(neighbours) >= 2:
assign all to next_label
next_label += 1
CTL_today = CTL_yesterday · exp(1/τ_CTL) + load_today · (1 exp(1/τ_CTL)) τ_CTL = 42d
ATL_today = ATL_yesterday · exp(1/τ_ATL) + load_today · (1 exp(1/τ_ATL)) τ_ATL = 7d
TSB_today = CTL_yesterday ATL_yesterday # yesterday's values (TP convention)
```
Per-route pace trends control for terrain — the same loop run in 2023 vs 2025 says way more about fitness than raw monthly pace. Adequate for a few hundred starts; switch to `sklearn.cluster.DBSCAN(metric='haversine')` for thousands.
`daily_training_load_series(conn, *, include_manual=True)` is the canonical input — it unions Garmin-recorded TL with `manual_activities`. Rest days are filled with 0; both EWMAs still update.
### Banister fitness / fatigue / form (Performance Management Chart)
The standard endurance-training lens (TrainingPeaks PMC), implemented in `analysis.banister(daily_load, ctl_tau=42, atl_tau=7)`:
```
CTL_today = CTL_yesterday · exp(1/τ_CTL) + load_today · (1 exp(1/τ_CTL)) τ_CTL = 42d
ATL_today = ATL_yesterday · exp(1/τ_ATL) + load_today · (1 exp(1/τ_ATL)) τ_ATL = 7d
TSB_today = CTL_yesterday ATL_yesterday # yesterday's values (TP convention)
```
Helper: `daily_training_load_series(conn, activity_types=('running','trail_running'))` returns the per-day summed `training_load` Series that feeds it. Rest days are filled with 0 — both EWMAs still update, ATL drops faster than CTL, TSB rises.
TSB interpretation (used in both notebooks 02 and 06):
TSB interpretation (used across the app):
| TSB | meaning |
|---|---|
@@ -298,73 +280,103 @@ TSB interpretation (used in both notebooks 02 and 06):
| **+10 to +25** | **fresh / peaked — race-day target** |
| > +25 | detrained (taper too long) |
[02_running.ipynb](notebooks/02_running.ipynb) plots the historical PMC; [06_race_plan.ipynb](notebooks/06_race_plan.ipynb) projects it forward through race day by converting plan-week km into estimated daily loads (separate `RACE_DAY_TL_PER_KM ≈ 7` for the race itself vs `~11` for training runs — race-day TL/km is empirically lower because ultras spread load over more time).
`banister_forecast(history, future, *, today=None)` splices historical load with planned future load and runs the same recursion forward, so the Race-plan page can project CTL/ATL/TSB through race day. The planned-future series comes from `plan_to_daily_load(plan, *, tl_per_km, race_day_tl_per_km, race_dates)` — convert your weekly plan rows into per-day load using a long-run-Saturday-heavy distribution (Sat 40 %, Tue 20 %, Thu 20 %, Mon 10 %, Wed 10 %), with race weeks treating race day specially.
`calibrate_tl_per_km(conn)` reports the empirical median + IQR of `training_load / km` from your history — that's the number to feed into the plan, rather than a hard-coded constant.
### Personal records
`personal_records(activities, distance_bins_km=(5, 10, 21.0975, 42.195, 50), tolerance=0.05)` picks the fastest run within ±5 % of each bin distance.
### GPS route clustering
`cluster_routes(lats, lons, radius_km=0.25)` — greedy haversine-radius clustering of run starts. Per-route pace trends control for terrain (the same loop in 2023 vs 2025 says more about fitness than raw monthly pace). Adequate for hundreds of starts; the README's old TODO for DBSCAN at thousands is still open.
---
## 4 — Notebook tour
## 4 — Reference notebooks (kept around for power-user analysis)
Each notebook bootstraps the same way:
Each notebook under [examples/notebooks/](examples/notebooks/) bootstraps the same way:
```python
import sys; sys.path.insert(0, '..')
from analysis import open_conn, load_activities, load_wellness, ...
from openrun import open_conn, load_activities, ...
conn = open_conn()
```
| # | Notebook | Covers |
|---|---|---|
| 01 | Overview | Data coverage by table, recent activities, monthly mileage YoY, wellness completeness |
| 02 | Running | Weekly volume + rolling, pace-vs-HR scatter, aerobic-efficiency trend, ACWR, PRs by distance |
| 03 | Recovery | Sleep composition, after-run vs after-rest HRV/RHR, correlations between training and recovery, weekly km vs recovery indicators |
| 04 | Efficiency | YoY m/beat by distance bucket, HR/pace split, polynomial curve fits, easy-runs-only headline view |
| 05 | Intra-run | (1) per-mile decoupling, (1b) per-second decoupling on race FITs, (2) cadence & stride at controlled pace, (3) GPS route clustering, (4) HR-zone time-in-zone (FIT-based) + polarized split |
| 06 | Race plan | Periodised 17-week plan around 30K → 50K → 50-mile, with auto-tracking against actual recorded volume |
| # | Notebook | Covers | Web equivalent |
|---|---|---|---|
| 01 | Overview | Data coverage, recent activities | Dashboard |
| 02 | Running | Weekly volume, pace-HR, PMC, PRs | Dashboard + Activities |
| 03 | Recovery | Sleep composition, HRV, RHR, body battery | Recovery |
| 04 | Efficiency | YoY m/beat | Efficiency |
| 05 | Intra-run | Decoupling, cadence, route clusters, TIZ | Activity detail |
| 06 | Race plan | Periodised plan + projected PMC | Race plan |
Notebooks 05 and 06 are generated from `_build_05.py` / `_build_06.py` build scripts. Edit those, then `uv run python notebooks/_build_NN.py` regenerates the .ipynb. Easier than editing 30 KB of JSON.
Notebooks 05 and 06 are generated from `_build_05.py` / `_build_06.py` build scripts — edit those, then `uv run python examples/notebooks/_build_NN.py` regenerates the .ipynb. They're kept as a reference for the math the web app implements.
---
## 5 — Setup
```bash
uv sync # creates .venv from pyproject.toml
uv sync # creates .venv from pyproject.toml
uv run openrun-web # launch the web app
```
Kernel: in VSCode, pick the project's `.venv` (top-right of any notebook). If it's not discoverable:
The first time you open the app, a setup wizard collects your profile + HR zones, optionally takes a race calendar, and offers to ingest a Garmin export zip in-browser. Your config is written to `openrun.toml` in the current directory — edit by hand any time.
If you'd rather work from the CLI directly:
```bash
uv run python -m ipykernel install --user --name garmin --display-name "garmin (.venv)"
uv run openrun-init # bootstrap (no input required)
uv run openrun-ingest <export> # or
uv run openrun-auth # OAuth login (Path B)
uv run openrun-sync --full # 365-day backfill
```
### Path A — official export
`garth` (Garmin's live-sync library) is deprecated upstream (<https://github.com/matin/garth/discussions/222>). If the live sync ever stops working, fall back to Path A.
1. Go to <https://www.garmin.com/account/datamanagement/exportdata> and request the export. Garmin emails the link within 2472 h.
2. Download the zip (several hundred MB to a few GB) or, for the Takeout dump, the UUID-named folder.
3. Ingest:
---
## 6 — Configuration
[openrun.toml](openrun.toml) — created by the setup wizard, hand-editable.
```toml
[user]
name = "Athlete"
hr_max = 200
lthr = 175
resting_hr = 50
[user.hr_zones]
Z1 = [100, 120]
Z2 = [121, 140]
Z3 = [141, 160]
Z4 = [161, 180]
Z5 = [181, 200]
[db]
path = "data/garmin.db"
[banister]
ctl_tau_days = 42.0
atl_tau_days = 7.0
race_day_tl_per_km = 7.0
[[races]]
label = "wk 4 — 30K"
date = "2026-06-13"
```
Discovery walks up from cwd looking for `openrun.toml`, falls back to `$OPENRUN_CONFIG`, then `~/.config/openrun/config.toml`, then built-in defaults.
---
## 7 — Tests
```bash
uv run ingest_export.py path/to/connect.zip
# or, if already unzipped:
uv run ingest_export.py path/to/<export>/
uv run pytest
```
Add `--dry-run` to preview without writing. The data is upserted on `activity_id` / `calendar_date`, so it's safe to re-ingest a refreshed export later.
For Takeout dumps specifically, follow the 4-step extract+ingest+link+precompute sequence in §1.
### Path B — live API sync
Garmin's SSO endpoint is rate-limited; you may hit 429s. If so, wait 3060 min, switch networks, or use Path A.
```bash
uv run auth.py # prompts for email / password / MFA, saves OAuth to .secrets/
uv run sync.py --full # 365-day backfill
uv run sync.py # incremental thereafter (last 14 days)
uv run sync.py --days 90 # custom backfill window
uv run sync.py --no-details # skip per-activity detail/splits (much faster)
uv run sync.py --skip-activities # wellness only
```
`garth` is deprecated upstream (<https://github.com/matin/garth/discussions/222>). If the live sync stops working, fall back to Path A.
[tests/unit/](tests/unit/) is the pure-function surface (loaders, derivations, helpers). [tests/integration/](tests/integration/) is ingest-path + cross-cutting (`tmp_conn` fixture in [tests/conftest.py](tests/conftest.py) builds an in-memory schema). The Streamlit pages are smoke-tested via `streamlit.testing.v1.AppTest` — see the test invocations in [ROADMAP.md](ROADMAP.md) sections.

View File

@@ -1,117 +1,131 @@
# openrun — roadmap
Living backlog. Items are grouped by phase; within a phase, top to bottom is rough priority. Each item names its **test plan** alongside the implementation so they ship together.
This isn't a contract. If something turns out to be harder, smaller, or pointless once we're in the code, edit it here rather than rationalise away.
Living backlog. Items are grouped by phase; within a phase, top to bottom is rough priority. Edit this file as we go — items removed because they turned out to be misguided are more valuable signal than items completed.
---
## Phase 0 — test scaffolding (prerequisite, one-time)
## Done — highlights from past sessions
The codebase has no `tests/` directory and no pytest in `pyproject.toml`. Everything below assumes this is in place first.
The major arcs that are already landed (see git history for per-commit detail):
- **Add pytest + a minimal `tests/` layout.** Add `pytest` and `pytest-cov` to `[dependency-groups.dev]` in [pyproject.toml](pyproject.toml). Create `tests/{unit,integration,fixtures}/`. A `tests/conftest.py` should expose a shared `tmp_conn` fixture that builds an in-memory SQLite via `openrun.db.connect(":memory:")` (so the live `data/garmin.db` is never touched).
- **Test plan:** N/A — this *is* the test plan.
- **DoD:** `uv run pytest` exits 0 with one trivial passing test.
**Project structure & test scaffolding**
- `src/openrun/` package split out: `config` (UserProfile / HRZones / BanisterParams + TOML reader & writer), `db`, `model`, `plots`, `setup`, and `ingest/*` (auth, garmin_api, garmin_export, fit_linker, manual, time_in_zone).
- `openrun.toml` is the single source of user-specific config — no athlete numbers anywhere in `src/openrun/`.
- pytest + pytest-cov in `[dependency-groups.dev]`, `tests/unit/` and `tests/integration/`, `tmp_conn` fixture for in-memory SQLite. **88 tests passing** at last count.
- **Pin a tiny fixture set.** One `.fit` (~5 MB workout), one Takeout `summarizedActivities` snippet (~3 activities), one Connect-format snippet, one of each daily wellness JSON. Anonymise; commit under `tests/fixtures/`. These back every test below.
- **DoD:** Each fixture has a one-line `tests/fixtures/README.md` entry recording its provenance + what it exercises.
**Ingest correctness & robustness**
- `fit_linker.record_link` + `relink` — absolute paths stored at link time; `openrun-link-fit <new_root> --relink` rewrites the table after an export moves. `_resolve_fit_path` is now an O(1) existence check with a clear "run --relink" hint on miss.
- `link()` accepts an injected `fit_iter` for unit testing without real FIT files; warn-and-skip on activity-id collision.
- `handle_fit` skips Takeout-style filenames cleanly and surfaces a "run openrun-link-fit" hint in the summary.
- Schema round-trip tests: ingest JSON → DB row → loader → DataFrame for `activities` + 7 wellness tables.
**Derived metrics & helpers**
- `banister_forecast(history, future, *, today=None)` — splices historical load with planned future load and runs the same EWMA; load-bearing splice invariant tested.
- `plan_to_daily_load(plan, *, tl_per_km, race_day_tl_per_km, race_dates)` — converts weekly km plan rows into daily load with race-week-aware distribution.
- `calibrate_tl_per_km(conn)` — empirical median/IQR of TL/km from history, replaces hard-coded constants in race-plan flows.
- `personal_records(activities, distance_bins_km, tolerance)` — fastest run within ±tolerance of each bin.
- `weekly_time_in_zone(conn)` — ISO-week pivot of the cached TIZ table.
- `load_sleep_stages(conn)` — deep/light/rem/awake seconds + percentages, with NaN-aware invariant (present-stage pcts sum to 1).
- `plot_fit_decoupling(records, *, segments)` — new `openrun.plots` submodule (lazy matplotlib import).
**Per-second data from the live API (Path B)**
- `openrun-sync` downloads each new activity's original FIT via `/download-service/files/activity/{id}`, stores it in `data/fit/<id>.fit`, and links it through `fit_linker.record_link` — so decoupling, FIT-based TIZ, and the route map work without a website export.
- `_extract_fit_bytes` handles both the zip-wrapped and bare-FIT responses; `download_fit` is idempotent (skips when on-disk + linked); `backfill_fits` pulls per-second history for past activities (`--fit-backfill [--fit-type] [--fit-limit]`).
- Tested at the `garth.download` boundary (`tests/unit/test_fit_download.py`); network call mocked per ROADMAP conventions.
**Off-watch volume integration**
- New `manual_activities` table + `openrun-import-manual <csv>` CLI + web form on the Manual log page.
- `daily_training_load_series(..., include_manual=True)` unions both sources via UNION ALL; same-day rows sum.
**Web app (Streamlit)**
- `openrun-web` launches a multipage browser UI on `localhost:8501`.
- Pages: Home, Dashboard, Activities (with row-click drill-in to Activity detail), Race plan (editable + projected PMC), Manual log, Recovery, Efficiency, Sync (in-browser zip ingest), Welcome wizard.
- `st.navigation`-driven sidebar: pages grouped into sections; **Welcome only appears when openrun.toml is missing or the DB is empty**.
- First-run wizard writes `openrun.toml`, runs `init_workspace`, and offers to ingest a zip in the same flow — no terminal needed.
---
## Phase 1 — code gaps (existing functionality)
### 1.1 Takeout export doesn't ingest splits — RETIRED (premise wrong for current account)
~~[ingest/garmin_export.py](src/openrun/ingest/garmin_export.py) only handles `summarizedActivities`. Lap detail in Takeout lives in `<activity_id>_<name>.json` under `DI-Connect-Fitness/`~~
**Finding (2026-05-18):** the actual Takeout dump for this account contains zero per-activity lap-detail JSONs under `DI-Connect-Fitness/` — only `summarizedActivities`, `personalRecord`, `trainingPlan`, `workout`, `gear`. Lap data is only retrievable from the FIT files via `link_fit_files.py` (Path B sync also pulls it directly from `/activity-service/activity/{aid}/splits`). If a future Takeout shape *does* include `<activity_id>.json` lap files, reopen this item; until then the assumption that drove it doesn't hold.
## Phase 1 — code gaps still open
### 1.2 Unhandled Takeout JSON categories
[project_takeout_export_quirks.md](../.claude/projects/-Users-noise-Documents-obsidian-RunningLog-garmin/memory/project_takeout_export_quirks.md) lists files we currently `unrecognized`: `HydrationLog`, `TrainingReadinessDTO`, `EnduranceScore`, `HillScore`, `RunRacePredictions`. Each is a new SQLite table + dispatch entry. Skip `HydrationLog` (low value), prioritise `TrainingReadinessDTO` (Garmin's own readiness score — useful for comparing against derived TSB) and `RunRacePredictions` (a free baseline for the projected-PMC plan).
- **Test plan:** per category, `test_garmin_export.py::test_handle_<category>` fixture-driven insert; one schema-roundtrip test (`SELECT *` returns the same values).
The Takeout dump includes several JSON categories we currently mark `unrecognized`: `TrainingReadinessDTO`, `EnduranceScore`, `HillScore`, `RunRacePredictions` (skip `HydrationLog` low value).
### 1.3 fit_linker can't disambiguate near-simultaneous activities — DONE
Implemented warn-and-skip on collision: `link()` tracks `linked_aids: dict[int, Path]`; the second FIT to match an already-linked activity is logged on stderr and skipped (count surfaced in summary). Pure `_match_activity` helper extracted for unit-testable tolerance/closest-pick behaviour, and `link()` accepts a `fit_iter=` injection point so tests avoid the FIT-parsing path. See [tests/unit/test_fit_linker.py](tests/unit/test_fit_linker.py).
- **Priority:** `TrainingReadinessDTO` first (Garmin's own readiness score — useful as a sanity check against derived TSB) and `RunRacePredictions` (a free baseline for the projected-PMC plan).
- Each needs a new SQLite table + dispatch entry + a fixture JSON pulled from a real Takeout dump.
- **Test plan:** per category, `test_garmin_export.py::test_handle_<category>` does fixture-driven insert; one schema-roundtrip test per table.
### 1.4 `handle_fit` silently drops FITs with no parseable activity-id chunk — DONE
Main loop now counts FITs whose stem has no 8+-digit chunk and prints a one-line `n FITs skipped … run openrun-link-fit <root> for Takeout-style exports` hint. The activity-id extraction is now `_activity_id_from_filename`, with five unit tests covering classic/Takeout/year-prefix/empty cases, plus a subprocess end-to-end test of `main()`.
### 1.5 `_resolve_fit_path` is fragile (decided: absolute paths) — DONE
Switched to absolute paths everywhere. `fit_linker.record_link` is a small shared helper used by both ingest paths and stores `str(p.resolve())`. `relink(conn, new_root)` walks a moved export by basename and rewrites the table; CLI flag is `openrun-link-fit <new_root> --relink`. `_resolve_fit_path` collapsed to a one-line existence check that raises with the relink hint on miss. The dead `fit_search_paths` config field (and matching `[ingest]` block in `openrun.toml`) was removed. Live DB migrated (349 paths rewritten, 0 unmatched).
### 1.6 Schema round-trip tests — DONE (wellness + activities; splits/fit_files/tiz open)
[tests/integration/test_loaders.py](tests/integration/test_loaders.py) covers 8 ingest-handler → DB → loader round-trips:
- `activities` (Takeout scaled-int units → `load_activities` derived columns)
- 7 wellness tables (steps / sleep / stress / hrv / resting_hr / intensity_minutes / body_battery), each through `load_wellness`. Sleep additionally rides through `load_sleep_stages` to lock the deep+light+rem=1.0 invariant.
**Caught a real bug in the test fixture:** my first draft used `averageSpeed: 27.78` thinking that was the SI value. Inspecting live Takeout data showed Garmin actually stores it as `m/s × 0.1` (e.g. a 3.247 m/s sprint stored as 0.3247), so the `× 10` conversion in the handler is correct — the README table can stay; the fixture was wrong.
**Still open:** `activity_splits`, `activity_fit_files`, `activity_time_in_zone` — these aren't reached through a single Takeout JSON handler (splits are sync-only, FIT files are linker-driven, TIZ is precomputed), so each needs a different fixture/path. Pulled forward to a follow-up; the existing helpers' tests (test_fit_linker.py, test_weekly_tiz.py) cover most of what these round-trips would.
### 1.6 Schema round-trip leftovers
`activity_splits`, `activity_fit_files`, and `activity_time_in_zone` aren't reached through a single Takeout JSON handler (splits are sync-only, FITs are linker-driven, TIZ is precomputed). Each needs a different fixture/path. The existing helper-level tests in `test_fit_linker.py` and `test_weekly_tiz.py` cover most of what these would, but a true round-trip test would close the gap.
---
## Phase 2 — analytical features hinted at in the README
## Phase 2 — analytical features still open
### 2.1 DBSCAN route clustering at scale
[README §3](README.md) explicitly: *"Adequate for a few hundred starts; switch to `sklearn.cluster.DBSCAN(metric='haversine')` for thousands."* Add `cluster_routes(..., method='dbscan')` alternate path. Don't replace the greedy version — it's a good correctness baseline.
- **Test plan:**
- `test_geo.py::test_haversine_known_pairs` — two cities, compare to a published value within 0.1 km.
- `test_geo.py::test_cluster_routes_greedy_vs_dbscan_agree` — on a synthetic 200-point dataset (3 well-separated clusters + noise), both methods produce the same cluster assignment up to label permutation.
The greedy haversine clusterer is fine for hundreds of starts; switch to `sklearn.cluster.DBSCAN(metric='haversine')` once a dataset gets to thousands. Add `cluster_routes(..., method='dbscan')` as an alternate path; keep greedy as the baseline.
### 2.2 Off-watch volume integration
[feedback_db_not_full_picture.md](../.claude/projects/-Users-noise-Documents-obsidian-RunningLog-garmin/memory/feedback_db_not_full_picture.md): recorded activity ≠ full training. Add a `manual_activities` table + a CSV/TOML loader so the user can log strength, hikes, unrecorded runs without faking Garmin uploads. `daily_training_load_series` learns an optional `include_manual=True`. Existing analyses pick this up via the same Banister pipeline.
- **Test plan:**
- `test_manual_activities.py::test_load_from_csv` — schema check.
- `test_loaders.py::test_daily_training_load_series_with_manual` — assert `include_manual=True` increases the total when synthetic manual rows are present, and that the Banister output is monotonically affected.
### 2.3 PR detection by distance bucket — DONE
`personal_records(activities, distance_bins_km=..., tolerance=0.05)` in [model.py](src/openrun/model.py); tested in [test_race_plan.py](tests/unit/test_race_plan.py) — fastest-in-band selection, tolerance enforcement, NaN-pace masking, empty input. On live data: PR table comes out 5K @ 4:49, 10K @ 5:38, half @ 6:18, 50 @ 8:04 (no marathon bin populated, correctly skipped).
### 2.4 Race-plan TL/km calibration from history — DONE
`calibrate_tl_per_km(conn, *, activity_types=..., min_distance_km=2.0, lookback_days=365)` returns `{median, q1, q3, n}`. Tests cover the median/IQR math, the short-distance and other-activity-type exclusions, and the empty-DB → NaN path. On live data the median came in at 11.4 with IQR [9.2, 16.4] — confirms the README's "training runs are ~11" claim and shows the spread is wide enough that hardcoded constants are a real weakness.
### 2.5 Banister projection helper — DONE
`banister_forecast(history, future, *, today=None)` lives in [model.py](src/openrun/model.py); the notebook's splice (`hist[hist.index <= TODAY]``forecast[forecast.index > TODAY]`) is now in one place. Tests in [test_banister.py](tests/unit/test_banister.py):
- `test_banister_forecast_matches_full_history` — splice invariant: PMC frame equals `banister(full)` when full is split (history, future).
- Plus a closed-form impulse-response check on the EWMA itself: with load=L on day 0 and zeros after, `CTL[i] = L·(1-decay)·decay^i` (the ROADMAP's original "CTL at day 42 ≈ L·(1-1/e)" was wrong — that's the step response after τ days, not impulse — corrected in the test).
- `test_banister_steady_state_approaches_input` covers the step response (constant input L converges to L; at i=τ it's L·(1-1/e)).
- Boundary-day overlap, default-today, and tail-extension tests round it out.
### 2.6 Sleep-stage breakdown surfacing — DONE
`load_sleep_stages(conn)` in [model.py](src/openrun/model.py) returns deep/light/rem/awake (seconds + percentages) keyed by date. Percentages of present stages sum to 1.0 by construction; awake_pct uses total-time-in-bed (not asleep-only). Tests in [test_sleep_stages.py](tests/unit/test_sleep_stages.py) cover the invariant + the zero-sleep / NaN-stage edge cases (the latter exposed by real 2026-05-17 row where `rem_s` is NULL).
### 2.7 Per-second decoupling: drop-in plotting helper — DONE
New `openrun.plots` submodule (matplotlib imported lazily inside the function so the rest of `openrun` stays import-light). `plot_fit_decoupling(records, *, segments=2, ax=None)` draws a per-segment bar chart with Friel's 5% / 10% reference lines. Tests in [test_plots.py](tests/unit/test_plots.py) cover bar count, caller-supplied Axes, and the "no usable records" sentinel path.
### 2.8 Time-in-zone weekly summary — DONE
`weekly_time_in_zone(conn, *, start=None, end=None, activity_types=...)` in [model.py](src/openrun/model.py) pivots the cached `activity_time_in_zone` table by ISO week (Monday-anchored). Tests in [test_weekly_tiz.py](tests/unit/test_weekly_tiz.py): within-week summation, week splitting, activity-type filter, date window, empty-frame shape. On live data, recent weeks show a Z3-heavy profile that matches the README's "junk-miles middle" warning.
- **Adds dependency:** `scikit-learn`. Defer until there's a concrete dataset that needs it.
- **Test plan:** `test_geo.py::test_haversine_known_pairs` (two cities vs published value within 0.1 km) and `test_cluster_routes_greedy_vs_dbscan_agree` on a synthetic 200-point dataset.
---
## Phase 3 — aspirational (not on the critical path)
## Phase 3 — web app polish (post-MVP)
These are README-shaped or memory-shaped *maybes*. Don't take them on without a concrete reason; listed so they stop showing up as recurring "we should…" thoughts.
The web app covers the full workflow but a few rough edges are worth picking off when there's time.
- **Heat & humidity adjustment of decoupling.** FIT messages carry `temperature` and sometimes `developer_data` from external sensors. We don't capture it. A weather-adjusted Pa:Hr would explain summer-vs-winter decoupling gaps. Real work: capture temp in `load_fit_records`, add a `decoupling_adjusted(...)` that conditions on it. Test plan: regression with vs without adjustment on a hot-run fixture should differ only when `temperature` is present.
- **Multi-athlete support.** The config already accepts a per-user profile. Promoting it to `openrun.toml` having multiple `[athletes.<name>]` blocks + a `--athlete` CLI flag is straightforward but premature for a single-user dataset. Test plan: profile-resolution unit tests would multiply by N.
- **Power & vertical-oscillation regression.** Lap raw has `averagePower`, `verticalOscillation`, `verticalRatio`. No analysis surfaces them. Would slot into [04_efficiency.ipynb](examples/notebooks/04_efficiency.ipynb). Worth a notebook section before becoming a `model.py` helper.
- **Race-day projected vs actual diff tracker.** After a race, compare the [06_race_plan.ipynb](examples/notebooks/06_race_plan.ipynb) projection to what actually happened (CTL on race day, predicted vs realised pace from `RunRacePredictions`). One-shot retrospective; doesn't need to be reusable.
### 3.1 Browser-driven Garmin live sync — **done**
Garmin's `garth` uses screen-scraped email/password/MFA login, not real OAuth. The Sync page now drives the full multi-step flow in the browser: email/password → (if required) MFA code → token store in `.secrets/`**🔄 Sync now** (activities + per-second FIT + wellness), with a log-out that forgets tokens.
- `openrun.ingest.auth` gained web-friendly, `input()`-free helpers: `has_tokens`, `resume`, `current_user`, `begin_login` (returns `("ok", user)` or `("needs_mfa", state)`), `complete_mfa`. The MFA `client_state` is held in `st.session_state` between the password and code steps.
- `openrun.ingest.garmin_api.run_sync` is the auth-free orchestrator shared by the CLI `main()` and the Sync page; it streams step progress via a `progress` callback.
- **Done in tests:** `test_auth.py` (token-store helpers + no-MFA and MFA-required login paths, mocked at `garth.sso`), `test_run_sync.py` (orchestration contract).
- **Possible follow-up:** a Streamlit `AppTest` smoke test for the page (the repo has none yet); auto-run `openrun-time-in-zone` after a sync that pulled new FITs so the TIZ cache never lags.
### 3.2 Route map on Activity detail
Each activity with linked FIT has per-second `position_lat/long` (semicircles). Render the route on a map (`pydeck` or `folium`), highlight HR-zone or pace via segment colouring. Pure visualisation; the data is already there.
- **Adds dependency:** `pydeck` or `folium`.
### 3.3 Activity-detail polish
- Heart rate over time (5-min rolling), with zone bands shaded.
- Splits sortable.
- "Compare against another activity" — overlay this run's pace/HR curve on a prior run of similar distance.
### 3.4 Race-plan UX
- Inline race-week badges in the data-editor table.
- "Suggest a plan" button that auto-generates a ramp given the race calendar + current CTL.
- Save vs Reset — currently the editor blows away the plan table on every save.
### 3.5 Multi-athlete support
The config already accepts a per-user profile. Promoting `openrun.toml` to allow multiple `[athletes.<name>]` blocks + a profile selector in the sidebar is straightforward but premature for the single-user case the README is written for.
---
## Phase 4 — distribution
When the tool is solid enough to share with non-Python folks:
- **One-command installer.** A `make install` / `./run.sh` that handles `uv sync` + first launch. The only terminal step left after the wizard.
- **Electron/Tauri desktop wrap.** Bundle a Python runtime + the wheel + a thin native shell that opens the Streamlit server in a webview. Mechanical; the HTTP server design is already wrap-ready.
- **Public release.** PyPI, GitHub releases, screenshots in README. Pick an actual name (`openrun` is provisional — squat-check before shipping).
---
## Test conventions
- **Layout:**
- `tests/unit/` — pure-function metrics ([model.py](src/openrun/model.py) public surface). One file per concept: `test_banister.py`, `test_decoupling.py`, `test_geo.py`, `test_zones.py`.
- `tests/integration/` — ingest pipelines + loaders. Use the in-memory SQLite fixture.
- `tests/fixtures/` — pinned JSON / FIT / CSV samples. Anonymised.
- **Granularity:** prefer one assertion per test (within reason). Friel's `< 5%` / `510%` thresholds make for natural assertions; the round-trip schema tests should be exact-equality on inserted rows.
- `tests/unit/` — pure-function metrics (model.py public surface). One file per concept: `test_banister.py`, `test_race_plan.py`, `test_sleep_stages.py`, etc.
- `tests/integration/` — ingest pipelines, loaders, cross-cutting flows.
- `tests/fixtures/` — pinned JSON / FIT / CSV samples. Anonymised, small.
- **Granularity:** one assertion per test when reasonable; closed-form math checks for derivations (impulse response, step response).
- **What we don't test:**
- `garth.connectapi` — network-dependent, fragile, and garth is upstream-deprecated. Mock at the `_safe_call` boundary if we ever need to.
- Notebook content. The build scripts ([examples/notebooks/_build_05.py](examples/notebooks/_build_05.py) etc.) are easier to read than the generated `.ipynb`; we test what they import from, not the notebook itself.
- Plotting beyond smoke tests (artist counts, axis labels). Visual regression isn't worth the maintenance.
- **Run:** `uv run pytest` (no markers needed at this scale). Add `pytest-cov` for an off-the-shelf coverage report; ignore the percentage as a target until we have meaningful coverage of the pure-function surface.
- `garth.connectapi` — network-dependent, fragile, upstream-deprecated. Mock at `_safe_call` if we ever need to.
- Notebook content. The build scripts are easier to read than the generated JSON; we test what they import.
- Streamlit visual output. AppTest framework smoke-tests page rendering (zero exceptions on a real DB); we don't visual-regress.
- **Run:** `uv run pytest`. `pytest-cov` is in dev deps; coverage isn't a target.
---
## Working agreement
When picking up an item: write the failing test first against the API the test plan describes, then make it pass. If the test plan turns out to be wrong (the function shouldn't behave that way after all), update this file in the same PR. Items removed because they turned out to be misguided are more valuable signal than items completed.
When picking up an item: write the failing test first against the API the test plan describes, then make it pass. If the test plan turns out to be wrong (the function shouldn't behave that way after all), update this file in the same PR. **Items removed because they turned out to be misguided are more valuable signal than items completed.**

View File

@@ -12,6 +12,7 @@ dependencies = [
"jinja2>=3.1.6",
"matplotlib>=3.10.9",
"pandas>=3.0.3",
"streamlit>=1.40",
]
[project.scripts]
@@ -21,6 +22,8 @@ openrun-sync = "openrun.ingest.garmin_api:main"
openrun-ingest = "openrun.ingest.garmin_export:main"
openrun-link-fit = "openrun.ingest.fit_linker:main"
openrun-time-in-zone = "openrun.ingest.time_in_zone:main"
openrun-import-manual = "openrun.ingest.manual:main"
openrun-web = "openrun.web.launcher:main"
[dependency-groups]
dev = [

Binary file not shown.

View File

@@ -21,6 +21,7 @@ from .config import (
default_config,
default_profile,
load_config,
write_config,
)
from .db import connect, get_state, set_state
from .model import (
@@ -51,6 +52,7 @@ from .model import (
# Race-plan helpers
personal_records,
calibrate_tl_per_km,
plan_to_daily_load,
# Geo
haversine_km,
cluster_routes,
@@ -60,7 +62,7 @@ from .model import (
__all__ = [
"Config", "HRZones", "UserProfile", "BanisterParams",
"default_config", "default_profile", "load_config",
"default_config", "default_profile", "load_config", "write_config",
"connect", "get_state", "set_state",
"open_conn",
"load_activities", "load_wellness", "load_sleep_stages", "load_splits",
@@ -70,6 +72,7 @@ __all__ = [
"assign_hr_zone", "hr_to_user_zone",
"time_in_zone_from_fit", "time_in_zone_from_splits", "weekly_time_in_zone",
"banister", "banister_forecast", "personal_records", "calibrate_tl_per_km",
"plan_to_daily_load",
"haversine_km", "cluster_routes", "decoupling",
"HR_ZONES_USER", # back-compat alias, resolved lazily
]

View File

@@ -180,6 +180,73 @@ def load_config(path: Path | str | None = None) -> Config:
return Config(user=user, db_path=db_path, banister=banister, races=races)
def _toml_quote(s: str) -> str:
"""Quote a TOML basic string. Escapes the few characters TOML cares about."""
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
def write_config(config: Config, path: Path | str) -> None:
"""Serialise a Config to an `openrun.toml` file.
Used by the first-run web wizard. The format is deliberately hand-rolled
(no `tomli_w` dependency) — the schema is small and stable, and the file
is human-edited often enough that we want full control over comments,
section ordering, and quoting.
"""
p = Path(path)
user = config.user
zones = user.hr_zones()
ban = config.banister
lines: list[str] = [
"# openrun project config. Discovered by openrun.config.load_config().",
"# Edit values here; nothing in src/openrun/ should be user-specific.",
"",
"[user]",
f"name = {_toml_quote(user.name)}",
]
if user.height_cm is not None:
lines.append(f"height_cm = {user.height_cm}")
if user.weight_kg is not None:
lines.append(f"weight_kg = {user.weight_kg}")
lines.append(f"hr_max = {user.hr_max}")
if user.lthr is not None:
lines.append(f"lthr = {user.lthr}")
if user.resting_hr is not None:
lines.append(f"resting_hr = {user.resting_hr}")
lines += [
"",
"[user.hr_zones]",
f"Z1 = [{zones.z1[0]}, {zones.z1[1]}]",
f"Z2 = [{zones.z2[0]}, {zones.z2[1]}]",
f"Z3 = [{zones.z3[0]}, {zones.z3[1]}]",
f"Z4 = [{zones.z4[0]}, {zones.z4[1]}]",
f"Z5 = [{zones.z5[0]}, {zones.z5[1]}]",
"",
"[db]",
f"path = {_toml_quote(str(config.db_path))}",
"",
"[banister]",
f"ctl_tau_days = {ban.ctl_tau_days}",
f"atl_tau_days = {ban.atl_tau_days}",
f"race_day_tl_per_km = {ban.race_day_tl_per_km}",
"",
]
if config.races:
for label, when in config.races:
lines += [
"[[races]]",
f"label = {_toml_quote(label)}",
f"date = {_toml_quote(when)}",
"",
]
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("\n".join(lines))
# Cached default — load once per process; explicit overrides go through load_config().
_default: Config | None = None
@@ -193,3 +260,10 @@ def default_config() -> Config:
def default_profile() -> UserProfile:
return default_config().user
def reset_default_config() -> None:
"""Invalidate the process-wide cache so the next `default_config()` re-reads
from disk. Called by the wizard after writing a fresh `openrun.toml`."""
global _default
_default = None

View File

@@ -141,14 +141,52 @@ CREATE TABLE IF NOT EXISTS sync_state (
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Periodised training plan. One row per ISO-Monday-anchored week. The web
-- Race-plan editor reads/writes these directly; `banister_forecast` consumes
-- them to project future CTL/ATL/TSB through the race calendar.
CREATE TABLE IF NOT EXISTS race_plan (
week_start TEXT PRIMARY KEY,
weekly_km REAL,
long_run_km REAL,
notes TEXT,
updated_at TEXT NOT NULL
);
-- Off-watch activities the user logs by hand (strength, hikes, unrecorded runs).
-- Distinct from `activities` (Garmin-sourced) so re-ingesting from Garmin never
-- clobbers them. `external_id` is a stable string the user provides for dedupe;
-- when present, re-importing the same row upserts instead of creating duplicates.
CREATE TABLE IF NOT EXISTS manual_activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_date TEXT NOT NULL,
activity_type TEXT NOT NULL,
distance_km REAL,
duration_min REAL,
training_load REAL,
notes TEXT,
external_id TEXT UNIQUE,
source TEXT,
imported_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_date ON manual_activities(activity_date);
"""
def connect(db_path: Path | str | None = None) -> sqlite3.Connection:
"""Open (and create-if-missing) the SQLite DB. Defaults to config-supplied path."""
def connect(
db_path: Path | str | None = None,
*,
check_same_thread: bool = True,
) -> sqlite3.Connection:
"""Open (and create-if-missing) the SQLite DB. Defaults to config-supplied path.
`check_same_thread=False` is needed when a single connection is reused
across threads (e.g. Streamlit's per-rerun script threads). SQLite itself
is thread-safe under WAL mode; the check is a Python-binding-only guard.
"""
path = Path(db_path) if db_path is not None else default_config().db_path
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn = sqlite3.connect(path, check_same_thread=check_same_thread)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row

View File

@@ -1,37 +1,112 @@
"""One-time Garmin Connect login; saves OAuth tokens to ./.secrets/.
"""Garmin Connect login + token management.
Usage:
Two entry points share one token store (`./.secrets/` relative to cwd):
* CLI — `main()` prompts for email/password (+ MFA) and saves tokens.
* Web app — the helper functions below drive the same flow without `input()`,
pausing between password submit and MFA code so a browser form can collect
the code across reruns.
Tokens are valid ~1 year and are refreshed automatically by
`openrun.ingest.garmin_api` on subsequent runs.
Usage (CLI):
python -m openrun.ingest.auth
# or, via shim, from the project root:
uv run auth.py
Prompts for email + password (and MFA if your account has 2FA). Tokens are
refreshed automatically by `openrun.ingest.garmin_api` on subsequent runs
(valid ~1 year).
Run from the project root — the token directory is `.secrets/` relative to cwd.
"""
from __future__ import annotations
import getpass
import os
from pathlib import Path
from typing import Any
import garth
def _token_dir() -> Path:
def token_dir() -> Path:
"""Token store: `.secrets/` relative to the current working directory."""
return Path.cwd() / ".secrets"
# Backwards-compatible private alias (other modules imported `_token_dir`).
def _token_dir() -> Path:
return token_dir()
def has_tokens(td: Path | None = None) -> bool:
"""True if a non-empty token store exists (i.e. a prior login was saved)."""
td = td or token_dir()
return td.exists() and any(td.iterdir())
def resume(td: Path | None = None) -> bool:
"""Load saved tokens into the global garth client. Returns False if none/invalid."""
td = td or token_dir()
if not has_tokens(td):
return False
try:
garth.resume(str(td))
return True
except Exception: # noqa: BLE001 — corrupt/expired tokens → treat as logged out
return False
def current_user(td: Path | None = None) -> str | None:
"""Username for the resumed session, or None if not logged in."""
if not resume(td):
return None
try:
return garth.client.username
except Exception: # noqa: BLE001
return None
def begin_login(email: str, password: str, td: Path | None = None) -> tuple[str, Any]:
"""Start a login. Returns one of:
* ``("ok", username)`` — logged in (no MFA); tokens already saved.
* ``("needs_mfa", state)`` — pass ``state`` + the emailed/app code to
``complete_mfa`` to finish.
Raises on bad credentials (garth surfaces an HTTP error).
"""
result = garth.sso.login(email, password, client=garth.client, return_on_mfa=True)
if isinstance(result, tuple) and result and result[0] == "needs_mfa":
return "needs_mfa", result[1]
# No MFA: result is (oauth1_token, oauth2_token).
garth.client.oauth1_token, garth.client.oauth2_token = result
return "ok", _save(td)
def complete_mfa(client_state: Any, code: str, td: Path | None = None) -> str:
"""Finish an MFA login with the user-supplied code. Returns the username."""
oauth1, oauth2 = garth.sso.resume_login(client_state, code.strip())
garth.client.oauth1_token, garth.client.oauth2_token = oauth1, oauth2
return _save(td)
def _save(td: Path | None = None) -> str:
td = td or token_dir()
td.mkdir(parents=True, exist_ok=True)
garth.save(str(td))
return garth.client.username
def main() -> None:
tok = _token_dir()
tok.mkdir(exist_ok=True)
td = token_dir()
td.mkdir(exist_ok=True)
email = os.environ.get("GARMIN_EMAIL") or input("Garmin email: ").strip()
password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
garth.login(email, password)
garth.save(tok)
profile = garth.client.username
print(f"Logged in as {profile}. Tokens saved to {tok}.")
status, payload = begin_login(email, password, td)
if status == "needs_mfa":
code = input("MFA code: ").strip()
user = complete_mfa(payload, code, td)
else:
user = payload
print(f"Logged in as {user}. Tokens saved to {td}.")
if __name__ == "__main__":

View File

@@ -5,6 +5,8 @@ Usage:
python -m openrun.ingest.garmin_api --full # full backfill (activities + 365 days wellness)
python -m openrun.ingest.garmin_api --days 90 # custom wellness backfill window
python -m openrun.ingest.garmin_api --no-details # skip per-activity detail/splits (faster)
python -m openrun.ingest.garmin_api --no-fit # skip per-second FIT download for new activities
python -m openrun.ingest.garmin_api --fit-backfill # download+link FITs for past activities missing them
python -m openrun.ingest.garmin_api --skip-activities # wellness only
Run from the project root. Run `openrun.ingest.auth` first to log in.
@@ -14,10 +16,12 @@ from __future__ import annotations
import argparse
import dataclasses
import io
import json
import sqlite3
import sys
import time
import zipfile
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Callable
@@ -25,13 +29,22 @@ from typing import Any, Callable
import garth
from garth.exc import GarthHTTPError
from ..config import default_config
from ..db import connect, get_state, set_state
from .fit_linker import record_link
def _token_dir() -> Path:
return Path.cwd() / ".secrets"
def _fit_dir() -> Path:
"""Managed directory for FITs pulled via the API, alongside the DB file."""
d = default_config().db_path.parent / "fit"
d.mkdir(parents=True, exist_ok=True)
return d
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
@@ -79,14 +92,24 @@ def _safe_call(fn: Callable[[], Any], description: str) -> Any:
# activity sync
# ---------------------------------------------------------------------------
def sync_activities(conn: sqlite3.Connection, *, full: bool, fetch_details: bool) -> int:
def sync_activities(
conn: sqlite3.Connection,
*,
full: bool,
fetch_details: bool,
fetch_fit: bool = False,
fit_dir: Path | None = None,
) -> int:
existing_ids: set[int] = {
row["activity_id"] for row in conn.execute("SELECT activity_id FROM activities")
}
if fetch_fit and fit_dir is None:
fit_dir = _fit_dir()
page_size = 100
offset = 0
inserted = 0
detail_count = 0
fit_count = 0
while True:
page = _safe_call(
@@ -156,6 +179,10 @@ def sync_activities(conn: sqlite3.Connection, *, full: bool, fetch_details: bool
if _fetch_activity_details(conn, aid):
detail_count += 1
if fetch_fit:
if download_fit(conn, aid, fit_dir=fit_dir):
fit_count += 1
conn.commit()
print(f" activities: page offset={offset}{new_in_page} new (total inserted: {inserted})")
@@ -167,6 +194,8 @@ def sync_activities(conn: sqlite3.Connection, *, full: bool, fetch_details: bool
if fetch_details:
print(f" fetched details/splits for {detail_count} activities")
if fetch_fit:
print(f" downloaded per-second FIT for {fit_count} activities")
return inserted
@@ -224,6 +253,112 @@ def _fetch_activity_details(conn: sqlite3.Connection, aid: int) -> bool:
return detail is not None
# ---------------------------------------------------------------------------
# per-second FIT download (Path B per-second, no website export needed)
# ---------------------------------------------------------------------------
def _extract_fit_bytes(blob: bytes | None) -> bytes | None:
"""Pull the FIT payload out of a download-service response.
Garmin's `/download-service/files/activity/{id}` returns a ZIP wrapping the
original FIT; occasionally it hands back a bare FIT. Return the FIT bytes, or
None if nothing FIT-like is present.
"""
if not blob:
return None
buf = io.BytesIO(blob)
if zipfile.is_zipfile(buf):
with zipfile.ZipFile(buf) as zf:
names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
if not names: # member sometimes lacks an extension — take the largest
names = sorted(
zf.namelist(), key=lambda n: zf.getinfo(n).file_size, reverse=True
)[:1]
return zf.read(names[0]) if names else None
# Bare FIT: the FIT header carries the literal b".FIT" at bytes 8..12.
if len(blob) > 12 and blob[8:12] == b".FIT":
return blob
return None
def download_fit(
conn: sqlite3.Connection,
activity_id: int,
*,
fit_dir: Path,
force: bool = False,
) -> bool:
"""Download one activity's original FIT from the API, store it under
`fit_dir/<activity_id>.fit`, and link it in `activity_fit_files`.
Returns True if a linked FIT is present afterward. Idempotent: skips the
network call when the FIT already exists on disk and is linked, unless
`force`. Everything downstream (decoupling, time-in-zone, route map) reads
these the same way it reads export-sourced FITs.
"""
dest = fit_dir / f"{activity_id}.fit"
linked = conn.execute(
"SELECT 1 FROM activity_fit_files WHERE activity_id = ? LIMIT 1", (activity_id,)
).fetchone() is not None
if dest.exists() and linked and not force:
return True
blob = _safe_call(
lambda: garth.download(f"/download-service/files/activity/{activity_id}"),
f"FIT download {activity_id}",
)
fit = _extract_fit_bytes(blob)
if fit is None:
return False
dest.write_bytes(fit)
record_link(conn, activity_id, dest)
return True
def backfill_fits(
conn: sqlite3.Connection,
*,
fit_dir: Path,
activity_type: str | None = None,
limit: int | None = None,
force: bool = False,
) -> int:
"""Download+link FITs for already-synced activities that lack one.
Lets you pull per-second history straight from the API instead of requesting
a full website export. Restrict with `activity_type` (e.g. "running") and
`limit` to keep the request volume sane.
"""
sql = "SELECT activity_id FROM activities a"
params: list = []
where: list[str] = []
if activity_type:
where.append("a.activity_type = ?")
params.append(activity_type)
if not force:
where.append("a.activity_id NOT IN (SELECT activity_id FROM activity_fit_files)")
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY a.start_time_local DESC"
if limit:
sql += f" LIMIT {int(limit)}"
targets = [r[0] for r in conn.execute(sql, params)]
print(f" {len(targets):,} activities need a FIT"
+ (f" (type={activity_type})" if activity_type else ""))
got = 0
t0 = time.time()
for i, aid in enumerate(targets, 1):
if download_fit(conn, aid, fit_dir=fit_dir, force=force):
got += 1
if i % 25 == 0:
conn.commit()
rate = i / max(time.time() - t0, 1e-6)
print(f" {i}/{len(targets)} linked={got} rate={rate:.1f}/s")
conn.commit()
return got
# ---------------------------------------------------------------------------
# wellness sync
# ---------------------------------------------------------------------------
@@ -379,32 +514,46 @@ def sync_resting_hr(conn: sqlite3.Connection, end: date, period: int) -> int:
# ---------------------------------------------------------------------------
# main
# orchestration (shared by the CLI and the web app)
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--full", action="store_true", help="Full backfill (365 days of wellness)")
parser.add_argument("--days", type=int, default=None, help="Days of wellness to backfill")
parser.add_argument("--no-details", action="store_true", help="Skip per-activity detail/splits")
parser.add_argument("--skip-activities", action="store_true",
help="Wellness only, no activity sync")
args = parser.parse_args()
def run_sync(
conn: sqlite3.Connection,
*,
full: bool = False,
days: int | None = None,
fetch_details: bool = True,
fetch_fit: bool = True,
skip_activities: bool = False,
fit_backfill: bool = False,
fit_type: str | None = None,
fit_limit: int | None = None,
progress: Callable[[str], None] | None = None,
) -> dict[str, int]:
"""Run a full incremental sync against an already-authenticated garth client.
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)
days = args.days if args.days is not None else (365 if args.full else 14)
Auth is the caller's responsibility — call `garth.resume(...)` (or complete a
login) first. Top-level step messages go to `progress` (defaults to `print`),
so the web app can stream them into a status panel. Returns a counts summary.
"""
say = progress or print
days = days if days is not None else (365 if full else 14)
end = date.today()
conn = connect()
summary: dict[str, int] = {}
if not args.skip_activities:
print(f"→ activities (full={args.full}, details={not args.no_details})")
n = sync_activities(conn, full=args.full, fetch_details=not args.no_details)
print(f" done: {n} activities upserted")
if not skip_activities:
say(f"→ activities (full={full}, details={fetch_details}, fit={fetch_fit})")
summary["activities"] = sync_activities(
conn, full=full, fetch_details=fetch_details, fetch_fit=fetch_fit
)
say(f" done: {summary['activities']} activities upserted")
if fit_backfill:
say(f"→ FIT backfill (type={fit_type or 'all'}, limit={fit_limit or 'none'})")
summary["fit_backfill"] = backfill_fits(
conn, fit_dir=_fit_dir(), activity_type=fit_type, limit=fit_limit
)
say(f" done: {summary['fit_backfill']} FITs downloaded/linked")
wellness = [
("steps", sync_steps),
@@ -416,14 +565,59 @@ def main() -> None:
("body battery", sync_body_battery),
]
for name, fn in wellness:
print(f"{name} (last {days} days)")
say(f"{name} (last {days} days)")
n = fn(conn, end, days)
print(f" done: {n} rows")
summary[name] = n
say(f" done: {n} rows")
set_state(conn, "last_sync_utc", datetime.utcnow().isoformat(timespec="seconds"))
conn.commit()
conn.close()
print("✓ sync complete")
say("✓ sync complete")
return summary
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--full", action="store_true", help="Full backfill (365 days of wellness)")
parser.add_argument("--days", type=int, default=None, help="Days of wellness to backfill")
parser.add_argument("--no-details", action="store_true", help="Skip per-activity detail/splits")
parser.add_argument("--no-fit", action="store_true",
help="Skip per-second FIT download for newly synced activities")
parser.add_argument("--fit-backfill", action="store_true",
help="Download+link FITs for past activities that lack one, then exit the FIT pass")
parser.add_argument("--fit-type", default=None,
help="Restrict --fit-backfill to one activity_type (e.g. running)")
parser.add_argument("--fit-limit", type=int, default=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")
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)
conn = connect()
try:
run_sync(
conn,
full=args.full,
days=args.days,
fetch_details=not args.no_details,
fetch_fit=not args.no_fit,
skip_activities=args.skip_activities,
fit_backfill=args.fit_backfill,
fit_type=args.fit_type,
fit_limit=args.fit_limit,
)
finally:
conn.close()
if __name__ == "__main__":

View File

@@ -27,7 +27,7 @@ import zipfile
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Iterable
from typing import Any, Callable, Iterable, Optional
from ..db import connect, set_state
from .fit_linker import record_link
@@ -425,6 +425,95 @@ def iter_files(root: Path) -> Iterable[Path]:
yield p
def ingest_path(
source: Path | str,
*,
conn: sqlite3.Connection | None = None,
dry_run: bool = False,
progress: Callable[[str], None] | None = None,
) -> dict:
"""Ingest a Garmin export (zip or unzipped folder) into the DB.
Returns a summary dict with `counts` (per-category row counts), `fits`,
`fits_skipped_no_id`, `unknown_files` (sample of paths), and `source`.
`progress`, if supplied, is called with one-line status updates so a web
UI can stream them (`st.status` / `st.toast` etc.). The CLI's `main()`
just prints them.
"""
log = progress or (lambda s: print(s))
src = Path(source).expanduser().resolve()
if not src.exists():
raise FileNotFoundError(f"path does not exist: {src}")
cleanup_dir: tempfile.TemporaryDirectory | None = None
if src.is_file() and src.suffix.lower() == ".zip":
cleanup_dir = tempfile.TemporaryDirectory(prefix="garmin_export_")
export_root = Path(cleanup_dir.name)
log(f"unzipping {src.name}{export_root}")
with zipfile.ZipFile(src) as zf:
zf.extractall(export_root)
elif src.is_dir():
export_root = src
else:
raise ValueError(f"unsupported source: {src}")
own_conn = conn is None
if conn is None:
conn = connect()
counts: dict[str, int] = defaultdict(int)
unknown: list[str] = []
fit_count = 0
fit_skipped_no_id = 0
try:
for path in iter_files(export_root):
if path.suffix.lower() == ".fit":
if _activity_id_from_filename(path.stem) is None:
fit_skipped_no_id += 1
continue
if not dry_run:
fit_count += handle_fit(conn, path)
else:
fit_count += 1
continue
if path.suffix.lower() != ".json":
continue
matched = classify(path.name)
if not matched:
unknown.append(str(path.relative_to(export_root)))
continue
label, fn = matched
if dry_run:
counts[label] += 1
continue
try:
counts[label] += fn(conn, path)
except Exception as exc: # noqa: BLE001
log(f" ! error in {label} handler for {path.name}: {exc}")
if not dry_run:
set_state(conn, "last_ingest_utc", datetime.utcnow().isoformat(timespec="seconds"))
set_state(conn, "last_ingest_source", str(src))
conn.commit()
finally:
if own_conn:
conn.close()
if cleanup_dir:
cleanup_dir.cleanup()
return {
"source": str(src),
"counts": dict(counts),
"fits": fit_count,
"fits_skipped_no_id": fit_skipped_no_id,
"unknown_files": unknown,
"dry_run": dry_run,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", help="Path to export.zip or unzipped export directory")
@@ -432,79 +521,26 @@ def main() -> None:
help="Show what would be ingested without writing to the DB")
args = parser.parse_args()
src = Path(args.source).expanduser().resolve()
if not src.exists():
sys.exit(f"path does not exist: {src}")
cleanup_dir: tempfile.TemporaryDirectory | None = None
if src.is_file() and src.suffix.lower() == ".zip":
cleanup_dir = tempfile.TemporaryDirectory(prefix="garmin_export_")
export_root = Path(cleanup_dir.name)
print(f"unzipping {src.name}{export_root}")
with zipfile.ZipFile(src) as zf:
zf.extractall(export_root)
elif src.is_dir():
export_root = src
else:
sys.exit(f"unsupported source: {src}")
conn = connect()
counts: dict[str, int] = defaultdict(int)
unknown: list[str] = []
fit_count = 0
fit_skipped_no_id = 0
for path in iter_files(export_root):
if path.suffix.lower() == ".fit":
if _activity_id_from_filename(path.stem) is None:
fit_skipped_no_id += 1
continue
if not args.dry_run:
fit_count += handle_fit(conn, path)
else:
fit_count += 1
continue
if path.suffix.lower() != ".json":
continue
matched = classify(path.name)
if not matched:
unknown.append(str(path.relative_to(export_root)))
continue
label, fn = matched
if args.dry_run:
counts[label] += 1
continue
try:
counts[label] += fn(conn, path)
except Exception as exc: # noqa: BLE001
print(f" ! error in {label} handler for {path.name}: {exc}", file=sys.stderr)
try:
summary = ingest_path(args.source, dry_run=args.dry_run)
except (FileNotFoundError, ValueError) as exc:
sys.exit(str(exc))
print("\n=== ingest summary ===")
for label, n in sorted(counts.items()):
for label, n in sorted(summary["counts"].items()):
print(f" {label:20s} {n:>6} rows" + (" (file count, dry run)" if args.dry_run else ""))
print(f" fit_files {fit_count:>6}")
if fit_skipped_no_id:
print(f" fit_files {summary['fits']:>6}")
if summary["fits_skipped_no_id"]:
print(
f" {fit_skipped_no_id:>6} FITs skipped (no activity-id in filename; "
f"run openrun-link-fit {export_root} for Takeout-style exports)"
f" {summary['fits_skipped_no_id']:>6} FITs skipped (no activity-id in filename; "
f"run openrun-link-fit <export-root> for Takeout-style exports)"
)
if unknown:
print(f"\n unrecognized JSON files ({len(unknown)}):")
for name in unknown[:25]:
if summary["unknown_files"]:
print(f"\n unrecognized JSON files ({len(summary['unknown_files'])}):")
for name in summary["unknown_files"][:25]:
print(f" {name}")
if len(unknown) > 25:
print(f" ... and {len(unknown) - 25} more")
if not args.dry_run:
set_state(conn, "last_ingest_utc", datetime.utcnow().isoformat(timespec="seconds"))
set_state(conn, "last_ingest_source", str(src))
conn.commit()
conn.close()
if cleanup_dir:
cleanup_dir.cleanup()
if len(summary["unknown_files"]) > 25:
print(f" ... and {len(summary['unknown_files']) - 25} more")
print("\n✓ done")

View File

@@ -0,0 +1,163 @@
"""Import manually-logged activities (strength, hikes, unrecorded runs) from CSV.
Garmin-sourced activities live in `activities`; this module writes to a
parallel `manual_activities` table so re-ingesting from Garmin can never
clobber hand-entered rows. The two tables are unioned at read time by
`daily_training_load_series(..., include_manual=True)`.
CSV schema (header row required):
activity_date,activity_type,distance_km,duration_min,training_load,notes,external_id
Only `activity_date` and `activity_type` are required; other columns are
optional and may be blank. `external_id`, when supplied, makes re-imports
idempotent (upsert on conflict).
Usage:
uv run openrun-import-manual workouts.csv
uv run openrun-import-manual workouts.csv --dry-run
"""
from __future__ import annotations
import argparse
import csv
import sqlite3
import sys
from dataclasses import dataclass
from datetime import date as Date, datetime
from pathlib import Path
from ..db import connect
REQUIRED_FIELDS = ("activity_date", "activity_type")
OPTIONAL_FIELDS = ("distance_km", "duration_min", "training_load", "notes", "external_id")
@dataclass(frozen=True)
class ImportResult:
inserted: int
updated: int
skipped: int
errors: list[str]
def _parse_float(s: str | None) -> float | None:
if s is None or s == "":
return None
return float(s)
def _parse_date(s: str) -> str:
"""Normalise to ISO YYYY-MM-DD. Accepts ISO date or ISO datetime."""
s = s.strip()
# Allow a YYYY-MM-DD or full ISO datetime; take only the date part.
parsed = datetime.fromisoformat(s) if "T" in s or " " in s else Date.fromisoformat(s)
if isinstance(parsed, datetime):
parsed = parsed.date()
return parsed.isoformat()
def import_csv(conn: sqlite3.Connection, csv_path: Path, *, source: str | None = None) -> ImportResult:
"""Read a CSV file and upsert each row into `manual_activities`.
Rows with a non-empty `external_id` are upserted on that key; rows without
one are always inserted (so the user can deliberately log a duplicate by
leaving external_id blank).
"""
inserted = updated = skipped = 0
errors: list[str] = []
src_label = source or str(csv_path)
with csv_path.open(newline="") as fh:
reader = csv.DictReader(fh)
missing = [f for f in REQUIRED_FIELDS if f not in (reader.fieldnames or [])]
if missing:
raise ValueError(
f"CSV is missing required columns: {missing}. "
f"Got: {reader.fieldnames}"
)
for line_no, row in enumerate(reader, start=2):
try:
date_iso = _parse_date(row["activity_date"])
atype = row["activity_type"].strip()
if not atype:
raise ValueError("activity_type is empty")
distance = _parse_float(row.get("distance_km"))
duration = _parse_float(row.get("duration_min"))
tl = _parse_float(row.get("training_load"))
notes = (row.get("notes") or "").strip() or None
ext = (row.get("external_id") or "").strip() or None
except (KeyError, ValueError) as exc:
errors.append(f"line {line_no}: {exc}")
skipped += 1
continue
if ext is not None:
existing = conn.execute(
"SELECT id FROM manual_activities WHERE external_id = ?", (ext,)
).fetchone()
if existing is not None:
conn.execute(
"""UPDATE manual_activities SET
activity_date=?, activity_type=?, distance_km=?,
duration_min=?, training_load=?, notes=?,
source=?, imported_at=datetime('now')
WHERE external_id=?""",
(date_iso, atype, distance, duration, tl, notes, src_label, ext),
)
updated += 1
continue
conn.execute(
"""INSERT INTO manual_activities
(activity_date, activity_type, distance_km, duration_min,
training_load, notes, external_id, source, imported_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))""",
(date_iso, atype, distance, duration, tl, notes, ext, src_label),
)
inserted += 1
conn.commit()
return ImportResult(inserted=inserted, updated=updated, skipped=skipped, errors=errors)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("csv_path", help="Path to a CSV file of manual activities")
parser.add_argument("--dry-run", action="store_true",
help="Parse the CSV but don't write to the DB")
args = parser.parse_args()
csv_path = Path(args.csv_path).expanduser().resolve()
if not csv_path.is_file():
sys.exit(f"file not found: {csv_path}")
if args.dry_run:
# Use an in-memory throwaway connection so we still exercise the schema.
conn = sqlite3.connect(":memory:")
from .. import db as _db
conn.executescript(_db.SCHEMA)
else:
conn = connect()
try:
result = import_csv(conn, csv_path)
finally:
conn.close()
print(f"manual import — {csv_path.name}")
print(f" inserted : {result.inserted}")
print(f" updated : {result.updated}")
print(f" skipped : {result.skipped}")
if result.errors:
print(" errors :")
for e in result.errors[:20]:
print(f" {e}")
if len(result.errors) > 20:
print(f" … and {len(result.errors) - 20} more")
if __name__ == "__main__":
main()

View File

@@ -612,23 +612,99 @@ def calibrate_tl_per_km(
}
def plan_to_daily_load(
plan: pd.DataFrame,
*,
tl_per_km: float,
race_day_tl_per_km: float = 7.0,
race_dates: tuple[str, ...] = (),
weekday_weights: dict[int, float] | None = None,
) -> pd.Series:
"""Distribute weekly km from a race plan into a daily training-load Series.
`plan` columns: `week_start` (date), `weekly_km`, `long_run_km`. Weekday
weights default to long-run-Saturday-heavy (Sat 40 %, Tue 20 %, Thu 20 %,
Mon 10 %, Wed 10 %); override `weekday_weights` if you train differently.
For race weeks (a `race_dates` entry falls inside the week): the race day
gets `long_run_km × race_day_tl_per_km`, remaining km distribute across
Mon/Wed/Fri shakeouts at the training `tl_per_km`. Empirically, race-day
TL/km is lower than training because long ultras spread load over more
time — see `calibrate_tl_per_km` for the data-driven number.
"""
if plan.empty:
return pd.Series(dtype=float)
weights = weekday_weights or {0: 0.10, 1: 0.20, 2: 0.10, 3: 0.20, 4: 0.0, 5: 0.40, 6: 0.0}
race_day_set = {pd.Timestamp(d).normalize() for d in race_dates}
rows: list[tuple[pd.Timestamp, float]] = []
for _, week in plan.iterrows():
week_start = pd.Timestamp(week["week_start"]).normalize()
week_days = [week_start + pd.Timedelta(days=i) for i in range(7)]
race_day = next((d for d in week_days if d in race_day_set), None)
weekly_km = float(week.get("weekly_km") or 0)
long_run_km = float(week.get("long_run_km") or 0)
if race_day is not None:
race_load = long_run_km * race_day_tl_per_km
rem_km = max(weekly_km - long_run_km, 0.0)
rem_load = rem_km * tl_per_km
for d in week_days:
if d == race_day:
rows.append((d, race_load))
elif d.weekday() in (0, 2, 4):
rows.append((d, rem_load / 3))
else:
rows.append((d, 0.0))
else:
total_load = weekly_km * tl_per_km
for d in week_days:
rows.append((d, total_load * weights.get(d.weekday(), 0.0)))
s = pd.Series(dict(rows)).sort_index()
s.index.name = "d"
return s
def daily_training_load_series(
conn: sqlite3.Connection,
*,
activity_types: tuple[str, ...] = ("running", "trail_running"),
include_manual: bool = False,
) -> pd.Series:
"""Daily-summed training_load across the given activity types."""
"""Daily-summed training_load across the given activity types.
When `include_manual=True`, also sums `manual_activities` rows whose
`activity_type` is in `activity_types`. Manual rows let users surface
off-watch training volume (strength, hikes, unrecorded runs) so the PMC
reflects total training load, not just what Garmin recorded.
"""
placeholders = ",".join(["?"] * len(activity_types))
df = pd.read_sql(
parts = [
f"""SELECT date(start_time_local) AS d, SUM(training_load) AS tl
FROM activities
WHERE activity_type IN ({placeholders}) AND training_load IS NOT NULL
GROUP BY d ORDER BY d""",
conn,
params=list(activity_types),
parse_dates=["d"],
)
return df.set_index("d")["tl"]
WHERE activity_type IN ({placeholders})
AND training_load IS NOT NULL
GROUP BY d"""
]
params: list = list(activity_types)
if include_manual:
parts.append(
f"""SELECT activity_date AS d, SUM(training_load) AS tl
FROM manual_activities
WHERE activity_type IN ({placeholders})
AND training_load IS NOT NULL
GROUP BY d"""
)
params.extend(activity_types)
sql = " UNION ALL ".join(parts)
df = pd.read_sql(sql, conn, params=params, parse_dates=["d"])
if df.empty:
return pd.Series(dtype=float)
return df.groupby("d")["tl"].sum().sort_index()
# ---------------------------------------------------------------------------

View File

@@ -34,6 +34,8 @@ EXPECTED_TABLES = (
"daily_body_battery",
"daily_intensity_minutes",
"daily_resting_hr",
"manual_activities",
"race_plan",
"sync_state",
)

View File

@@ -0,0 +1,6 @@
"""openrun.web — Streamlit app for browser-driven analysis and ingest.
Launch with `openrun-web` (wired in pyproject.toml). Pages live in
`openrun/web/pages/`; the entry point is `app.py`. The launcher hands off
to `streamlit.web.bootstrap` so we don't shell out.
"""

126
src/openrun/web/_helpers.py Normal file
View File

@@ -0,0 +1,126 @@
"""Shared helpers for the Streamlit pages.
Pages should depend on these wrappers rather than importing model loaders
directly so caching, error surfacing, and "no data yet" handling stay in one
place.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta
import pandas as pd
import streamlit as st
from openrun import (
banister,
calibrate_tl_per_km,
daily_training_load_series,
load_activities,
load_sleep_stages,
load_wellness,
open_conn,
personal_records,
weekly_time_in_zone,
)
from openrun.config import default_config
from openrun.db import get_state
@st.cache_resource
def get_conn() -> sqlite3.Connection:
"""Module-singleton SQLite connection — Streamlit reruns the script on
every interaction, so a fresh `connect()` each time would hammer the DB.
`check_same_thread=False` because Streamlit's AppTest framework and page
reruns each spin up their own thread; SQLite under WAL mode handles
concurrent reads safely, and we only write from one place at a time.
"""
from openrun.db import connect
return connect(check_same_thread=False)
@st.cache_data(ttl=60)
def cached_activities(type: str | None = "running") -> pd.DataFrame:
return load_activities(get_conn(), type=type)
@st.cache_data(ttl=60)
def cached_wellness() -> pd.DataFrame:
return load_wellness(get_conn())
@st.cache_data(ttl=60)
def cached_sleep_stages() -> pd.DataFrame:
return load_sleep_stages(get_conn())
@st.cache_data(ttl=60)
def cached_pmc(include_manual: bool = True) -> pd.DataFrame:
return banister(daily_training_load_series(get_conn(), include_manual=include_manual))
@st.cache_data(ttl=60)
def cached_weekly_tiz(weeks: int = 12) -> pd.DataFrame:
end = pd.Timestamp.utcnow().normalize()
start = end - pd.Timedelta(weeks=weeks)
return weekly_time_in_zone(get_conn(), start=start, end=end)
@st.cache_data(ttl=60)
def cached_personal_records() -> pd.DataFrame:
return personal_records(cached_activities(type="running"))
@st.cache_data(ttl=300)
def cached_tl_per_km() -> dict:
return calibrate_tl_per_km(get_conn())
def last_sync_label() -> str:
"""Human-readable last-sync / last-ingest line for the sidebar."""
conn = get_conn()
sync = get_state(conn, "last_sync_utc")
ingest = get_state(conn, "last_ingest_utc")
parts: list[str] = []
if sync:
parts.append(f"sync · {_relative(sync)}")
if ingest:
parts.append(f"ingest · {_relative(ingest)}")
return " | ".join(parts) if parts else "no sync yet"
def _relative(iso_ts: str) -> str:
try:
ts = datetime.fromisoformat(iso_ts.replace("Z", ""))
except ValueError:
return iso_ts
delta = datetime.utcnow() - ts
if delta < timedelta(minutes=1):
return "just now"
if delta < timedelta(hours=1):
return f"{int(delta.total_seconds() // 60)}m ago"
if delta < timedelta(days=1):
return f"{int(delta.total_seconds() // 3600)}h ago"
if delta < timedelta(days=30):
return f"{delta.days}d ago"
return ts.date().isoformat()
def show_sidebar() -> None:
"""Sidebar chrome rendered on every page — last sync + DB path + nav hint."""
cfg = default_config()
with st.sidebar:
st.markdown("### openrun")
st.caption("local-first endurance analytics")
st.markdown("---")
st.caption(f"**Last activity:** {last_sync_label()}")
st.caption(f"**DB:** `{cfg.db_path}`")
def empty_state(message: str, hint: str | None = None) -> None:
"""Standard 'no data yet' panel — use when a page would otherwise be blank."""
st.info(message)
if hint:
st.caption(hint)

70
src/openrun/web/app.py Normal file
View File

@@ -0,0 +1,70 @@
"""openrun — Streamlit controller.
Uses `st.navigation` to build the sidebar from an explicit page list. This
lets us hide Welcome after first-run setup completes — directory-based
auto-discovery (the older Streamlit pattern) would show every file in
`pages/` no matter what.
Pages are grouped into thematic sections so the sidebar stays scannable.
`Activity detail` is registered but tucked under `Activities` since it's
only reached via clickthrough from the Activities list.
"""
from __future__ import annotations
import streamlit as st
from openrun.config import _find_config
from openrun.web._helpers import get_conn
st.set_page_config(
page_title="openrun",
page_icon="🏃",
layout="wide",
initial_sidebar_state="expanded",
)
# ---------------------------------------------------------------------------
# First-run detection
# ---------------------------------------------------------------------------
def _is_first_run() -> bool:
if _find_config() is None:
return True
try:
n = get_conn().execute("SELECT COUNT(*) FROM activities").fetchone()[0]
except Exception: # noqa: BLE001
return True
return n == 0
needs_setup = _is_first_run() or st.session_state.get("show_welcome", False)
# ---------------------------------------------------------------------------
# Page registry — Streamlit resolves these paths relative to this entrypoint.
# ---------------------------------------------------------------------------
home = st.Page("pages/Home.py", title="Home", icon="🏠", default=True)
dashboard = st.Page("pages/1_Dashboard.py", title="Dashboard", icon="📊")
activities = st.Page("pages/2_Activities.py", title="Activities", icon="📋")
activity_detail = st.Page("pages/8_Activity_Detail.py", title="Activity detail", icon="🏃")
race_plan = st.Page("pages/3_Race_Plan.py", title="Race plan", icon="📈")
manual_log = st.Page("pages/4_Manual_Log.py", title="Manual log", icon="📝")
recovery = st.Page("pages/6_Recovery.py", title="Recovery", icon="🛌")
efficiency = st.Page("pages/7_Efficiency.py", title="Efficiency", icon="🫀")
sync_page = st.Page("pages/5_Sync.py", title="Sync", icon="🔄")
welcome = st.Page("pages/0_Welcome.py", title="Setup", icon="👋")
sections = {
"": [home, dashboard],
"Train": [activities, activity_detail, race_plan, manual_log],
"Insight": [recovery, efficiency],
"Data": [sync_page],
}
if needs_setup:
sections["Setup"] = [welcome]
st.navigation(sections).run()

View File

@@ -0,0 +1,24 @@
"""CLI entry for the Streamlit web app.
Usage:
openrun-web # boots on http://localhost:8501
openrun-web -- --server.port 8800 # forward args to streamlit
"""
from __future__ import annotations
import sys
from pathlib import Path
def main() -> None:
from streamlit.web import cli as stcli # imported lazily; streamlit is heavy
app_path = Path(__file__).parent / "app.py"
# Forward any user-supplied args after the entry; default to no extras.
sys.argv = ["streamlit", "run", str(app_path), *sys.argv[1:]]
sys.exit(stcli.main())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,362 @@
"""First-run wizard.
Five steps, driven by `st.session_state["wizard_step"]`:
intro → profile → zones → races → data → done
The wizard only **writes** at the end of `profile` (creates the DB on first
`get_conn`), at the end of `zones` (writes openrun.toml), and at the end of
`races` (re-writes openrun.toml). The data step is the optional bit — users
can skip and use the Manual log page instead.
Anyone can re-run the wizard from this page at any time; existing values
pre-fill every field, so it's safe to revisit.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import streamlit as st
from openrun import (
BanisterParams,
Config,
HRZones,
UserProfile,
default_config,
write_config,
)
from openrun.config import _find_config, reset_default_config
from openrun.ingest.garmin_export import ingest_path
from openrun.setup import init_workspace
from openrun.web._helpers import get_conn, show_sidebar
st.set_page_config(page_title="Welcome · openrun", page_icon="👋", layout="wide")
show_sidebar()
st.title("👋 Welcome to openrun")
# ---------------------------------------------------------------------------
# Setup-status detection — visible at every step.
# ---------------------------------------------------------------------------
def _setup_status() -> dict:
cfg_path = _find_config()
activity_count = 0
try:
activity_count = get_conn().execute("SELECT COUNT(*) FROM activities").fetchone()[0]
except Exception: # noqa: BLE001
pass
return {
"config_exists": cfg_path is not None,
"config_path": cfg_path,
"activity_count": activity_count,
}
status = _setup_status()
if status["config_exists"] and status["activity_count"] > 0:
st.success(
f"You're already set up — `{status['config_path'].name}` is in place and "
f"the DB has **{status['activity_count']:,}** activities. Use the wizard "
"below to tweak your profile, or jump straight to the Dashboard."
)
if st.button("Open Dashboard", type="primary"):
st.switch_page("pages/1_Dashboard.py")
st.divider()
# ---------------------------------------------------------------------------
# Wizard state machine
# ---------------------------------------------------------------------------
STEPS = ("intro", "profile", "zones", "races", "data", "done")
STEP_LABELS = {
"intro": "Intro",
"profile": "Your profile",
"zones": "HR zones",
"races": "Race calendar",
"data": "Get your data",
"done": "Done",
}
def _set_step(target: str) -> None:
st.session_state.wizard_step = target
def _build_config() -> Config:
"""Materialise a Config from the current draft."""
d = st.session_state.draft
profile = UserProfile(
name=d["name"],
height_cm=d["height_cm"],
weight_kg=d["weight_kg"],
hr_max=int(d["hr_max"]),
lthr=d["lthr"],
resting_hr=d["resting_hr"],
zones=d["zones"],
)
return Config(
user=profile,
db_path=default_config().db_path,
banister=d["banister"],
races=tuple(d["races"]),
)
if "wizard_step" not in st.session_state:
st.session_state.wizard_step = "intro"
if "draft" not in st.session_state:
# Seed from current config so revisits pre-fill every field.
cur = default_config()
st.session_state.draft = {
"name": cur.user.name,
"height_cm": cur.user.height_cm,
"weight_kg": cur.user.weight_kg,
"hr_max": cur.user.hr_max,
"lthr": cur.user.lthr,
"resting_hr": cur.user.resting_hr,
"zones": cur.user.hr_zones(),
"races": list(cur.races),
"banister": cur.banister,
}
step = st.session_state.wizard_step
idx = STEPS.index(step)
st.progress((idx + 1) / len(STEPS), text=f"Step {idx + 1} / {len(STEPS)}{STEP_LABELS[step]}")
# ---------------------------------------------------------------------------
# Step: intro
# ---------------------------------------------------------------------------
if step == "intro":
st.markdown(
"""
Five quick steps to get you running:
1. **Profile** — your max HR, threshold HR, weight (optional)
2. **HR zones** — auto-derived from your max HR; tweak if needed
3. **Race calendar** — upcoming races (optional)
4. **Get your data** — upload a Garmin export, or skip and log manually
5. **Done** — straight to the dashboard
No CLI required. You can come back and rerun this any time.
"""
)
if st.button("Start", type="primary"):
_set_step("profile")
st.rerun()
# ---------------------------------------------------------------------------
# Step: profile
# ---------------------------------------------------------------------------
elif step == "profile":
with st.form("profile_form"):
st.subheader("Your profile")
st.caption(
"These numbers personalise pace, zone, and load calculations. "
"If you don't know your max HR, leave the default and we'll auto-derive zones."
)
c1, c2 = st.columns(2)
name = c1.text_input("Display name", value=st.session_state.draft["name"])
hr_max = c2.number_input("Max HR (bpm)",
min_value=120, max_value=240,
value=int(st.session_state.draft["hr_max"]))
lthr = c1.number_input("Lactate-threshold HR (bpm)",
min_value=80, max_value=230,
value=int(st.session_state.draft["lthr"] or hr_max - 25))
rhr = c2.number_input("Resting HR (bpm)",
min_value=30, max_value=100,
value=int(st.session_state.draft["resting_hr"] or 60))
c3, c4 = st.columns(2)
height = c3.number_input("Height (cm, optional)",
min_value=0.0, max_value=250.0,
value=float(st.session_state.draft["height_cm"] or 0.0),
step=0.5)
weight = c4.number_input("Weight (kg, optional)",
min_value=0.0, max_value=200.0,
value=float(st.session_state.draft["weight_kg"] or 0.0),
step=0.1)
cols = st.columns([1, 1, 4])
back = cols[0].form_submit_button("← Back")
nxt = cols[1].form_submit_button("Next →", type="primary")
if back:
_set_step("intro")
st.rerun()
if nxt:
st.session_state.draft.update({
"name": name.strip() or "Athlete",
"hr_max": int(hr_max),
"lthr": int(lthr),
"resting_hr": int(rhr),
"height_cm": float(height) if height > 0 else None,
"weight_kg": float(weight) if weight > 0 else None,
"zones": HRZones.from_pct_hr_max(int(hr_max)), # reset zones to %HRmax derived
})
_set_step("zones")
st.rerun()
# ---------------------------------------------------------------------------
# Step: zones
# ---------------------------------------------------------------------------
elif step == "zones":
st.subheader("HR zones")
st.caption(
"Auto-derived from your max HR (Garmin's `HR_MAX` method). "
"If you've configured custom zones in Garmin Connect, paste those numbers here instead."
)
z = st.session_state.draft["zones"]
cols = st.columns(5)
z_inputs: list[tuple[int, int]] = []
for col, (label, lo, hi) in zip(
cols,
(("Z1", *z.z1), ("Z2", *z.z2), ("Z3", *z.z3), ("Z4", *z.z4), ("Z5", *z.z5)),
):
new_lo = col.number_input(f"{label} low", min_value=40, max_value=240, value=int(lo), key=f"{label}_lo")
new_hi = col.number_input(f"{label} high", min_value=40, max_value=240, value=int(hi), key=f"{label}_hi")
z_inputs.append((int(new_lo), int(new_hi)))
new_zones = HRZones(z1=z_inputs[0], z2=z_inputs[1], z3=z_inputs[2],
z4=z_inputs[3], z5=z_inputs[4])
c1, c2, _ = st.columns([1, 1, 4])
if c1.button("← Back"):
_set_step("profile")
st.rerun()
if c2.button("Next →", type="primary"):
st.session_state.draft["zones"] = new_zones
_set_step("races")
st.rerun()
# ---------------------------------------------------------------------------
# Step: races
# ---------------------------------------------------------------------------
elif step == "races":
import pandas as pd
st.subheader("Race calendar (optional)")
st.caption(
"Future races your training is pointed at. Used by the Race plan page "
"to land your taper. Empty is fine — you can add them later."
)
races_in = st.session_state.draft["races"]
races_df = pd.DataFrame(races_in, columns=["label", "date"])
if races_df.empty:
races_df = pd.DataFrame([{"label": "", "date": ""}])
edited = st.data_editor(
races_df,
num_rows="dynamic",
width="stretch",
column_config={
"label": st.column_config.TextColumn("Label",
help="e.g. 'wk 17 — Cascade Crest 50M'"),
"date": st.column_config.TextColumn("Date (YYYY-MM-DD)"),
},
hide_index=True,
key="races_editor",
)
c1, c2, _ = st.columns([1, 1, 4])
if c1.button("← Back"):
_set_step("zones")
st.rerun()
if c2.button("Next →", type="primary"):
keep = [
(str(r["label"]).strip(), str(r["date"]).strip())
for _, r in edited.iterrows()
if str(r["label"]).strip() and str(r["date"]).strip()
]
st.session_state.draft["races"] = keep
# Write the config now so the user has a real openrun.toml even if they bail.
config = _build_config()
cfg_target = Path.cwd() / "openrun.toml"
write_config(config, cfg_target)
# Init the workspace (idempotent) so the DB exists.
init_workspace(config=config)
# Reset the process-wide config cache so other pages see the new values.
reset_default_config()
st.cache_data.clear()
_set_step("data")
st.rerun()
# ---------------------------------------------------------------------------
# Step: data
# ---------------------------------------------------------------------------
elif step == "data":
st.subheader("Get your data")
st.caption(
"Optional but recommended — without data the dashboards are empty. "
"Upload a Garmin Connect export (`.zip`) and we'll ingest it now. "
"Or skip and start logging activities by hand from the Manual log page."
)
uploaded = st.file_uploader("Upload connect.zip", type=["zip"])
cols = st.columns([1, 1, 1, 3])
back = cols[0].button("← Back")
ingest = cols[1].button("Ingest", disabled=uploaded is None, type="primary")
skip = cols[2].button("Skip for now")
if back:
_set_step("races")
st.rerun()
if skip:
_set_step("done")
st.rerun()
if ingest and uploaded is not None:
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(uploaded.read())
tmp_path = Path(tmp.name)
try:
with st.status(f"Ingesting {uploaded.name}", expanded=True) as bar:
summary = ingest_path(tmp_path, conn=get_conn(), progress=bar.write)
bar.update(label="Ingest complete", state="complete")
finally:
tmp_path.unlink(missing_ok=True)
st.cache_data.clear()
st.success(
f"Ingested **{sum(summary['counts'].values()):,}** JSON rows and "
f"**{summary['fits']}** FIT files."
)
_set_step("done")
st.rerun()
# ---------------------------------------------------------------------------
# Step: done
# ---------------------------------------------------------------------------
elif step == "done":
st.subheader("You're set 🎉")
st.markdown(
f"""
Your config is at **`{Path.cwd() / 'openrun.toml'}`** — edit it directly any time.
The DB lives at **`{default_config().db_path}`**.
**Next:**
- 📊 **Dashboard** — your fitness/fatigue/form right now
- 📋 **Activities** — browse + drill into individual runs
- 📈 **Race plan** — periodised plan with projected race-day TSB
- 📝 **Manual log** — record off-watch sessions
"""
)
if st.button("Open Dashboard →", type="primary"):
st.switch_page("pages/1_Dashboard.py")
if st.button("Restart wizard"):
st.session_state.wizard_step = "intro"
st.rerun()

View File

@@ -0,0 +1,204 @@
"""Dashboard — at-a-glance training state.
Layout:
Row 1: 3 PMC tiles (CTL / ATL / TSB) with delta vs 7 days ago + form badge.
Row 2: PMC chart (CTL line, ATL line, TSB on twin axis).
Row 3: Weekly volume + ACWR (left), Polarized split (right).
Row 4: Recent activities table.
"""
from __future__ import annotations
import matplotlib.pyplot as plt
import pandas as pd
import streamlit as st
from openrun.web._helpers import (
cached_activities,
cached_pmc,
cached_weekly_tiz,
empty_state,
show_sidebar,
)
st.set_page_config(page_title="Dashboard · openrun", page_icon="📊", layout="wide")
show_sidebar()
st.title("📊 Dashboard")
# ---------------------------------------------------------------------------
# data
# ---------------------------------------------------------------------------
pmc = cached_pmc()
runs = cached_activities(type="running")
if pmc.empty:
empty_state(
"No training-load data yet.",
"Run `openrun-sync` from the terminal or import a Garmin export from the Sync page.",
)
st.stop()
# ---------------------------------------------------------------------------
# Row 1 — PMC tiles
# ---------------------------------------------------------------------------
today_row = pmc.iloc[-1]
prev_idx = max(0, len(pmc) - 8)
prev_row = pmc.iloc[prev_idx]
ctl, atl, tsb = today_row["CTL"], today_row["ATL"], today_row["TSB"]
d_ctl = ctl - prev_row["CTL"]
d_atl = atl - prev_row["ATL"]
d_tsb = tsb - prev_row["TSB"]
def _tsb_badge(tsb_value: float) -> tuple[str, str]:
"""Return (label, message) — read straight off the README's TSB table."""
if tsb_value < -30:
return "🔴 severely fatigued", "injury risk — back off"
if tsb_value <= -10:
return "🟠 productive overload", "heart of a build"
if tsb_value <= 0:
return "🟡 balanced building", ""
if tsb_value <= 10:
return "🟢 sharpening", ""
if tsb_value <= 25:
return "🟢 fresh / peaked", "race-day target window"
return "🔵 detrained", "taper has gone long — add stimulus"
tile1, tile2, tile3 = st.columns(3)
tile1.metric("CTL (fitness, 42d)", f"{ctl:.1f}", f"{d_ctl:+.1f} vs 7d")
tile2.metric("ATL (fatigue, 7d)", f"{atl:.1f}", f"{d_atl:+.1f} vs 7d")
tile3.metric("TSB (form)", f"{tsb:+.1f}", f"{d_tsb:+.1f} vs 7d")
label, hint = _tsb_badge(tsb)
st.caption(f"**Form:** {label}" + (f"{hint}" if hint else ""))
# ---------------------------------------------------------------------------
# Row 2 — PMC chart
# ---------------------------------------------------------------------------
st.subheader("Fitness / fatigue / form")
window_days = st.select_slider(
"Window",
options=[30, 60, 90, 180, 365, len(pmc)],
value=min(180, len(pmc)),
format_func=lambda d: f"all ({d}d)" if d == len(pmc) else f"{d}d",
key="pmc_window",
)
recent = pmc.tail(window_days)
fig, ax1 = plt.subplots(figsize=(11, 4.2))
ax1.plot(recent.index, recent["CTL"], color="#2a9d8f", lw=2.2, label="CTL")
ax1.plot(recent.index, recent["ATL"], color="#e76f51", lw=1.2, alpha=0.85, label="ATL")
ax1.set_ylabel("CTL / ATL (training load)")
ax1.legend(loc="upper left", fontsize=8)
ax2 = ax1.twinx()
tsb_series = recent["TSB"]
ax2.fill_between(recent.index, tsb_series, 0,
where=tsb_series >= 0, color="#2a9d8f", alpha=0.15, interpolate=True)
ax2.fill_between(recent.index, tsb_series, 0,
where=tsb_series < 0, color="#e76f51", alpha=0.15, interpolate=True)
ax2.axhline(0, color="black", lw=0.4)
ax2.axhline(10, color="#2a9d8f", ls=":", lw=0.8)
ax2.axhline(25, color="#2a9d8f", ls=":", lw=0.8)
ax2.set_ylabel("TSB (form)")
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Row 3 — Weekly volume + Polarized split
# ---------------------------------------------------------------------------
left, right = st.columns(2)
with left:
st.subheader("Weekly volume")
if not runs.empty and "distance_km" in runs:
runs2 = runs.dropna(subset=["start_time_local", "distance_km"]).copy()
runs2["week"] = runs2["start_time_local"].dt.to_period("W-SUN").dt.start_time
weekly = runs2.groupby("week")["distance_km"].sum().tail(12)
if len(weekly) >= 4:
acute = weekly.tail(1).iloc[0]
chronic = weekly.tail(4).mean()
acwr = acute / chronic if chronic else float("nan")
st.metric("This week", f"{acute:.1f} km",
f"4-wk avg {chronic:.1f}")
st.caption(f"ACWR **{acwr:.2f}** — sweet spot 0.81.3, >1.5 is aggressive ramp")
st.bar_chart(weekly, height=180, width="stretch")
else:
empty_state("No running activities yet.")
with right:
st.subheader("Polarized split (last 12 weeks)")
tiz = cached_weekly_tiz(weeks=12)
if not tiz.empty:
easy = tiz[["z1_s", "z2_s"]].sum().sum()
mod = tiz["z3_s"].sum()
hard = tiz[["z4_s", "z5_s"]].sum().sum()
total = easy + mod + hard
if total > 0:
shares = pd.Series(
{"easy (Z1+Z2)": easy / total,
"moderate (Z3)": mod / total,
"hard (Z4+Z5)": hard / total},
)
cols = st.columns(3)
cols[0].metric("easy", f"{shares['easy (Z1+Z2)']*100:.0f}%")
cols[1].metric("moderate", f"{shares['moderate (Z3)']*100:.0f}%")
cols[2].metric("hard", f"{shares['hard (Z4+Z5)']*100:.0f}%")
st.caption("Seiler polarised target ≈ 80 / <10 / 20")
fig2, ax = plt.subplots(figsize=(5, 1.6))
ax.barh([""], [shares["easy (Z1+Z2)"]], color="#2a9d8f")
ax.barh([""], [shares["moderate (Z3)"]], left=[shares["easy (Z1+Z2)"]], color="#e9c46a")
ax.barh([""], [shares["hard (Z4+Z5)"]],
left=[shares["easy (Z1+Z2)"] + shares["moderate (Z3)"]], color="#e76f51")
ax.set_xlim(0, 1)
ax.set_xticks([0, .25, .5, .75, 1])
ax.set_xticklabels(["0%", "25%", "50%", "75%", "100%"], fontsize=8)
ax.set_yticks([])
for s in ("top", "right", "left"):
ax.spines[s].set_visible(False)
fig2.tight_layout()
st.pyplot(fig2, clear_figure=True)
else:
empty_state("Run `openrun-time-in-zone` to populate the zone cache.")
# ---------------------------------------------------------------------------
# Row 4 — Recent activities
# ---------------------------------------------------------------------------
st.subheader("Recent activities")
if runs.empty:
empty_state("No running activities yet.")
else:
recent_runs = (
runs.dropna(subset=["start_time_local"])
.sort_values("start_time_local", ascending=False)
.head(10)
.copy()
)
recent_runs["pace"] = recent_runs["pace_min_per_km"].apply(
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"
display = recent_runs[[
"start_time_local", "activity_name", "distance", "pace",
"avg_hr", "training_load", "vo2_max",
]].rename(columns={
"start_time_local": "When",
"activity_name": "Name",
"distance": "Distance",
"pace": "Pace",
"avg_hr": "Avg HR",
"training_load": "TL",
"vo2_max": "VO₂",
})
st.dataframe(display, width="stretch", hide_index=True)

View File

@@ -0,0 +1,136 @@
"""Activities — filter + browse, click a row to drill in.
The list applies inclusive filters (type / date / distance / name) and
defers rendering big tables until the user has narrowed the set. Selecting
a row navigates to the Activity detail page via query-parameter.
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
import streamlit as st
from openrun.web._helpers import cached_activities, empty_state, show_sidebar
st.set_page_config(page_title="Activities · openrun", page_icon="📋", layout="wide")
show_sidebar()
st.title("📋 Activities")
runs = cached_activities(type=None)
if runs.empty:
empty_state("No activities yet.", "Run `openrun-sync` or import an export from the Sync page.")
st.stop()
# Drop unparseable rows once; everything downstream gets a clean frame.
runs = runs.dropna(subset=["start_time_local"]).copy()
runs["date"] = runs["start_time_local"].dt.date
# ---------------------------------------------------------------------------
# Filters
# ---------------------------------------------------------------------------
st.subheader("Filter")
f1, f2 = st.columns([2, 3])
with f1:
all_types = sorted(runs["activity_type"].dropna().unique().tolist())
default_types = [t for t in ("running", "trail_running") if t in all_types] or all_types[:1]
types = st.multiselect("Type", all_types, default=default_types)
with f2:
min_date = runs["date"].min()
max_date = runs["date"].max()
default_start = max(min_date, max_date - timedelta(days=180))
date_range = st.date_input(
"Date range",
value=(default_start, max_date),
min_value=min_date,
max_value=max_date,
)
f3, f4 = st.columns([2, 3])
with f3:
dist_min, dist_max = st.slider(
"Distance (km)",
min_value=0.0,
max_value=float(runs["distance_km"].max() or 100),
value=(0.0, float(runs["distance_km"].max() or 100)),
step=0.5,
)
with f4:
name_q = st.text_input("Name contains", placeholder="e.g. tempo, race, hill")
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
filt = runs.copy()
if types:
filt = filt[filt["activity_type"].isin(types)]
if isinstance(date_range, tuple) and len(date_range) == 2:
start, end = date_range
filt = filt[(filt["date"] >= start) & (filt["date"] <= end)]
filt = filt[filt["distance_km"].between(dist_min, dist_max)]
if name_q.strip():
needle = name_q.strip().lower()
filt = filt[filt["activity_name"].fillna("").str.lower().str.contains(needle)]
filt = filt.sort_values("start_time_local", ascending=False)
st.caption(f"**{len(filt):,}** activities match. Click a row to open detail.")
# ---------------------------------------------------------------------------
# Display + selection
# ---------------------------------------------------------------------------
if filt.empty:
empty_state("No activities match the filters.")
st.stop()
def _fmt_pace(p: float) -> str:
if pd.isna(p):
return ""
minutes = int(p)
seconds = int(round((p - minutes) * 60))
return f"{minutes}:{seconds:02d}/km"
display = filt[[
"activity_id", "start_time_local", "activity_name", "activity_type",
"distance_km", "pace_min_per_km", "avg_hr", "training_load",
]].copy().reset_index(drop=True)
display["pace"] = display["pace_min_per_km"].apply(_fmt_pace)
display["distance_km"] = display["distance_km"].round(2)
display["avg_hr"] = display["avg_hr"].round(0)
display = display.rename(columns={
"start_time_local": "When",
"activity_name": "Name",
"activity_type": "Type",
"distance_km": "Distance (km)",
"avg_hr": "Avg HR",
"training_load": "TL",
})[
["When", "Name", "Type", "Distance (km)", "pace", "Avg HR", "TL", "activity_id"]
].rename(columns={"pace": "Pace"})
event = st.dataframe(
display.drop(columns=["activity_id"]),
width="stretch",
hide_index=True,
height=520,
selection_mode="single-row",
on_select="rerun",
key="activities_table",
)
selected = event.selection.rows if hasattr(event, "selection") else []
if selected:
aid = int(display.iloc[selected[0]]["activity_id"])
st.query_params["aid"] = str(aid)
st.switch_page("pages/8_Activity_Detail.py")

View File

@@ -0,0 +1,267 @@
"""Race plan — editable plan rows + projected PMC through race day.
Workflow:
1. Plan rows live in `race_plan` (week_start = Monday of each week).
2. If empty, page auto-generates a 17-week ramp ending on the last race.
3. User edits via `st.data_editor`; on save we upsert by week_start.
4. `plan_to_daily_load` turns the plan into a daily series (using
`calibrate_tl_per_km` for the conversion factor).
5. `banister_forecast` splices history with the forecast and we plot the
resulting CTL / ATL / TSB through the final race date.
"""
from __future__ import annotations
from datetime import date, timedelta
import matplotlib.pyplot as plt
import pandas as pd
import streamlit as st
from openrun import (
banister_forecast,
daily_training_load_series,
default_config,
plan_to_daily_load,
)
from openrun.web._helpers import cached_tl_per_km, get_conn, show_sidebar
st.set_page_config(page_title="Race plan · openrun", page_icon="📈", layout="wide")
show_sidebar()
st.title("📈 Race plan")
st.caption(
"Edit weekly km / long-run km; the projected PMC redraws on save. Race-day TSB "
"in **+10 to +25** is the target for an A-race."
)
cfg = default_config()
tl_summary = cached_tl_per_km()
# ---------------------------------------------------------------------------
# Setup row: TL/km defaults
# ---------------------------------------------------------------------------
setup = st.columns(4)
default_tl = tl_summary["median"] if tl_summary["n"] > 0 else 11.0
default_race_tl = cfg.banister.race_day_tl_per_km
tl_per_km = setup[0].number_input(
"Training TL/km",
min_value=4.0,
max_value=25.0,
value=float(round(default_tl, 1)) if not pd.isna(default_tl) else 11.0,
step=0.5,
help="Empirical median from your history.",
)
race_tl = setup[1].number_input(
"Race-day TL/km",
min_value=4.0,
max_value=20.0,
value=float(default_race_tl),
step=0.5,
help="Typically 7 — race effort spreads load over more time than training runs.",
)
setup[2].metric("Calibrated median", f"{tl_summary['median']:.1f}" if tl_summary["n"] else "",
f"n={tl_summary['n']}")
setup[3].metric("Calibrated IQR",
f"{tl_summary['q1']:.1f}{tl_summary['q3']:.1f}" if tl_summary["n"] else "")
# ---------------------------------------------------------------------------
# Load plan from DB, or seed a default
# ---------------------------------------------------------------------------
conn = get_conn()
plan_df = pd.read_sql(
"SELECT week_start, weekly_km, long_run_km, notes FROM race_plan ORDER BY week_start",
conn,
parse_dates=["week_start"],
)
def _seed_default_plan(races: tuple[tuple[str, str], ...]) -> pd.DataFrame:
"""Generate a 17-week ramp ending at the last race date.
A simple Lydiard-ish progression — increasing weekly km plus a 3-week
block + 1-week step-down, peak ~4 weeks before race, then taper.
"""
if not races:
end = pd.Timestamp.today().normalize() + pd.Timedelta(weeks=17)
else:
end = pd.Timestamp(max(d for _, d in races)).normalize()
# Anchor on the Monday of the race week, work back 17 weeks.
end_monday = end - pd.Timedelta(days=end.weekday())
start_monday = end_monday - pd.Timedelta(weeks=16)
rows = []
base, peak = 30.0, 75.0 # km/week — change defaults via the editor
for i in range(17):
wk_start = start_monday + pd.Timedelta(weeks=i)
# Ramp 113: weeks 13 build, week 4 step-down, etc.
if i < 13:
block_pos = i % 4
block_idx = i // 4
week_km = base + (peak - base) * (block_idx * 4 + block_pos) / 12
if block_pos == 3:
week_km *= 0.7 # step-down
long_km = round(min(0.40 * week_km, 32.0), 1)
elif i < 16:
# Specific block — hold long-run distance
week_km = peak * 0.85
long_km = 32.0
else:
# Race week
week_km = 35.0
long_km = 30.0 # long-run column doubles as "race distance" for race weeks
rows.append({
"week_start": wk_start.date(),
"weekly_km": round(week_km, 1),
"long_run_km": long_km,
"notes": "",
})
return pd.DataFrame(rows)
if plan_df.empty:
st.info("No plan yet — seeding a 17-week default ramp ending on the last race.")
plan_df = _seed_default_plan(cfg.races)
plan_df["week_start"] = pd.to_datetime(plan_df["week_start"])
# ---------------------------------------------------------------------------
# Editor
# ---------------------------------------------------------------------------
race_dates_iso = {pd.Timestamp(d).date(): label for label, d in cfg.races}
def _label_row(d: pd.Timestamp) -> str:
week_days = [(d + timedelta(days=i)).date() for i in range(7)]
races_in_week = [race_dates_iso[wd] for wd in week_days if wd in race_dates_iso]
return " ".join(races_in_week)
plan_edit = plan_df.copy()
plan_edit["race"] = plan_edit["week_start"].apply(_label_row)
plan_edit["week_start"] = plan_edit["week_start"].dt.date
edited = st.data_editor(
plan_edit,
column_config={
"week_start": st.column_config.DateColumn("Week (Mon)"),
"weekly_km": st.column_config.NumberColumn("Weekly km", min_value=0, max_value=200, step=1.0),
"long_run_km": st.column_config.NumberColumn("Long-run km", min_value=0, max_value=80, step=1.0),
"notes": st.column_config.TextColumn("Notes"),
"race": st.column_config.TextColumn("Race", disabled=True),
},
width="stretch",
hide_index=True,
num_rows="dynamic",
key="plan_editor",
)
save_col, _ = st.columns([1, 6])
if save_col.button("💾 Save plan", type="primary"):
conn.execute("DELETE FROM race_plan")
for _, r in edited.iterrows():
if pd.isna(r["week_start"]):
continue
conn.execute(
"""INSERT INTO race_plan (week_start, weekly_km, long_run_km, notes, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))""",
(
pd.Timestamp(r["week_start"]).date().isoformat(),
float(r["weekly_km"]) if pd.notna(r["weekly_km"]) else None,
float(r["long_run_km"]) if pd.notna(r["long_run_km"]) else None,
(r["notes"] or "").strip() or None,
),
)
conn.commit()
st.cache_data.clear()
st.success(f"Saved {len(edited)} plan rows.")
# ---------------------------------------------------------------------------
# Projected PMC
# ---------------------------------------------------------------------------
st.subheader("Projected PMC")
history = daily_training_load_series(conn, include_manual=True)
plan_for_forecast = edited.dropna(subset=["week_start"]).copy()
plan_for_forecast["week_start"] = pd.to_datetime(plan_for_forecast["week_start"])
race_dates_tuple = tuple(d for _, d in cfg.races)
forecast = plan_to_daily_load(
plan_for_forecast,
tl_per_km=tl_per_km,
race_day_tl_per_km=race_tl,
race_dates=race_dates_tuple,
)
if forecast.empty:
st.info("Add rows to the plan above to see a projection.")
else:
today = pd.Timestamp.today().normalize()
pmc = banister_forecast(history, forecast, today=today)
if pmc.empty:
st.info("No PMC to plot — need either history or plan rows.")
else:
plot_pmc = pmc.copy()
fig, ax1 = plt.subplots(figsize=(12, 4.6))
ax1.plot(plot_pmc.index, plot_pmc["CTL"], color="#2a9d8f", lw=2.2, label="CTL")
ax1.plot(plot_pmc.index, plot_pmc["ATL"], color="#e76f51", lw=1.2, alpha=0.85, label="ATL")
ax1.axvline(today, color="black", lw=0.7, ls=":")
ax1.text(today, ax1.get_ylim()[1] * 0.97, " today ", fontsize=8, va="top")
ax1.set_ylabel("CTL / ATL")
ax1.legend(loc="upper left", fontsize=8)
ax2 = ax1.twinx()
ax2.fill_between(plot_pmc.index, plot_pmc["TSB"], 0,
where=plot_pmc["TSB"] >= 0, color="#2a9d8f", alpha=0.12, interpolate=True)
ax2.fill_between(plot_pmc.index, plot_pmc["TSB"], 0,
where=plot_pmc["TSB"] < 0, color="#e76f51", alpha=0.12, interpolate=True)
ax2.axhline(0, color="black", lw=0.4)
ax2.axhline(10, color="#2a9d8f", ls=":", lw=0.8)
ax2.axhline(25, color="#2a9d8f", ls=":", lw=0.8)
ax2.set_ylabel("TSB")
# Mark race dates with annotations.
for label, d in cfg.races:
rd = pd.Timestamp(d).normalize()
if rd in plot_pmc.index:
tsb = plot_pmc.loc[rd, "TSB"]
ax1.axvline(rd, color="#264653", alpha=0.4, lw=1.0)
ax1.text(rd, ax1.get_ylim()[1] * 0.85,
f" {label}\n TSB {tsb:+.0f}",
fontsize=8, alpha=0.85)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# Race-day TSB table — the actionable summary.
if cfg.races:
race_rows = []
for label, d in cfg.races:
rd = pd.Timestamp(d).normalize()
if rd in plot_pmc.index:
r = plot_pmc.loc[rd]
tsb = r["TSB"]
if tsb < -10:
tag = "🟠 fatigued"
elif tsb < 10:
tag = "🟡 building"
elif tsb <= 25:
tag = "🟢 race-ready"
else:
tag = "🔵 detrained"
race_rows.append({
"Race": label,
"Date": d,
"CTL": round(r["CTL"], 1),
"ATL": round(r["ATL"], 1),
"TSB": round(tsb, 1),
"Form": tag,
})
if race_rows:
st.subheader("Race-day projection")
st.dataframe(pd.DataFrame(race_rows), hide_index=True, width="stretch")

View File

@@ -0,0 +1,67 @@
"""Manual log — record off-watch sessions."""
from __future__ import annotations
from datetime import date
import pandas as pd
import streamlit as st
from openrun.web._helpers import get_conn, show_sidebar
st.set_page_config(page_title="Manual log · openrun", page_icon="📝", layout="wide")
show_sidebar()
st.title("📝 Manual log")
st.caption("Off-watch sessions (strength, hikes, unrecorded runs) — these feed the PMC when "
"`include_manual=True` is the default for the dashboard.")
with st.form("manual_log", clear_on_submit=True):
cols = st.columns(2)
activity_date = cols[0].date_input("Date", value=date.today())
activity_type = cols[1].selectbox(
"Type",
["running", "trail_running", "strength", "hike", "cycling", "swim", "other"],
index=2,
)
cols2 = st.columns(3)
distance_km = cols2[0].number_input("Distance (km)", min_value=0.0, step=0.1, value=0.0)
duration_min = cols2[1].number_input("Duration (min)", min_value=0, step=5, value=45)
training_load = cols2[2].number_input("Training load", min_value=0.0, step=1.0, value=30.0)
notes = st.text_input("Notes", placeholder="e.g. upper-body lift, morning hike")
submitted = st.form_submit_button("Log session", type="primary")
if submitted:
conn = get_conn()
conn.execute(
"""INSERT INTO manual_activities
(activity_date, activity_type, distance_km, duration_min,
training_load, notes, source, imported_at)
VALUES (?, ?, ?, ?, ?, ?, 'web', datetime('now'))""",
(
activity_date.isoformat(),
activity_type,
distance_km or None,
duration_min or None,
training_load or None,
notes or None,
),
)
conn.commit()
st.success(f"Logged {activity_type} on {activity_date.isoformat()}.")
st.cache_data.clear() # PMC etc. need to recompute
st.divider()
st.subheader("Recent manual entries")
rows = pd.read_sql(
"""SELECT activity_date, activity_type, distance_km, duration_min,
training_load, notes, source
FROM manual_activities
ORDER BY activity_date DESC, id DESC
LIMIT 50""",
get_conn(),
)
if rows.empty:
st.info("No manual sessions logged yet.")
else:
st.dataframe(rows, width="stretch", hide_index=True)

View File

@@ -0,0 +1,229 @@
"""Sync — Garmin live pull + in-browser export ingest.
Three things, top to bottom:
1. Status — what state the DB is in (last sync / last ingest).
2. Live sync — log in to Garmin (email/password, with an MFA round-trip when
the account needs one) and pull activities, per-second FIT, and wellness
in-process. Auth state lives in `.secrets/`; the MFA handshake is held in
`st.session_state` between the password and code steps.
3. Export ingest — upload a `connect.zip` or point at an unzipped folder.
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
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.garmin_api import run_sync
from openrun.ingest.garmin_export import ingest_path
from openrun.web._helpers import get_conn, show_sidebar
st.set_page_config(page_title="Sync · openrun", page_icon="🔄", layout="wide")
show_sidebar()
st.title("🔄 Sync")
cfg = default_config()
conn = get_conn()
st.subheader("Status")
st.write(
{
"DB path": str(cfg.db_path),
"Last live sync (UTC)": get_state(conn, "last_sync_utc") or "(never)",
"Last export ingest (UTC)": get_state(conn, "last_ingest_utc") or "(never)",
"Last ingest source": get_state(conn, "last_ingest_source") or "(none)",
}
)
st.divider()
st.subheader("Live sync (Garmin Connect)")
_user = gauth.current_user()
if _user:
st.success(f"Logged in as **{_user}**.")
c1, c2 = st.columns(2)
full = c1.checkbox(
"Full backfill",
value=False,
help="Re-scan all activities and pull 365 days of wellness (slow). "
"Leave off for a fast incremental top-up.",
)
want_fit = c2.checkbox(
"Download per-second FIT for new activities",
value=True,
help="Pulls each new activity's original FIT so decoupling, FIT time-in-zone, "
"and the route map work without a website export.",
)
fit_backfill = st.checkbox(
"Also backfill per-second FITs for past activities that are missing them",
value=False,
)
fit_type: str | None = None
fit_limit: int | None = None
if fit_backfill:
b1, b2 = st.columns(2)
fit_type = (b1.text_input("Type filter", value="running").strip() or None)
_lim = b2.number_input("Limit (0 = all, newest first)", min_value=0, value=50, step=10)
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)
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.rerun()
else:
st.caption(
"Log in to pull activities, **per-second FIT data**, and wellness straight from "
"Garmin — no website export needed. Your credentials go directly to Garmin; only "
"OAuth tokens are stored locally in `.secrets/`."
)
with st.form("garmin_login"):
email = st.text_input("Garmin email")
password = st.text_input("Password", type="password")
submitted = st.form_submit_button("Log in", type="primary")
if submitted:
if not email or not password:
st.warning("Enter both email and password.")
else:
try:
kind, payload = gauth.begin_login(email, password)
except Exception as exc: # noqa: BLE001
st.error(f"Login failed: {exc}")
else:
if kind == "needs_mfa":
st.session_state["garmin_mfa_state"] = payload
st.rerun()
else:
st.success(f"Logged in as {payload}.")
st.rerun()
if "garmin_mfa_state" in st.session_state:
st.info("Garmin requires a verification code (sent by email or your authenticator app).")
with st.form("garmin_mfa"):
code = st.text_input("MFA code")
mfa_submitted = st.form_submit_button("Verify", type="primary")
if mfa_submitted:
try:
user = 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.rerun()
st.divider()
st.subheader("Import a Garmin data export")
st.caption(
"Upload a `connect.zip` (Connect data export) or point at an unzipped "
"export folder (Takeout dumps). The ingest runs in-process and updates the DB."
)
mode = st.radio("Source", ["Upload zip", "Path on disk"], horizontal=True, label_visibility="collapsed")
if mode == "Upload zip":
uploaded = st.file_uploader("Upload connect.zip", type=["zip"])
cols = st.columns([1, 1, 4])
dry = cols[1].checkbox("Dry run", value=False, help="Don't write to the DB")
run = cols[0].button("Ingest", type="primary", disabled=uploaded is None)
if run and uploaded is not None:
# Stream the upload to a temp file so ingest_path can handle it like any zip.
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(uploaded.read())
tmp_path = Path(tmp.name)
try:
with st.status(f"Ingesting {uploaded.name}", expanded=True) as status:
lines: list[str] = []
def log(msg: str) -> None:
lines.append(msg)
status.write(msg)
summary = ingest_path(tmp_path, conn=get_conn(), dry_run=dry, progress=log)
status.update(label="Ingest complete", state="complete")
finally:
tmp_path.unlink(missing_ok=True)
st.cache_data.clear()
st.success(f"Ingested {sum(summary['counts'].values()):,} JSON rows + {summary['fits']} FIT files.")
st.json(summary)
else:
folder = st.text_input("Path to an unzipped export folder",
placeholder="/path/to/garmin_export/")
cols = st.columns([1, 1, 4])
dry = cols[1].checkbox("Dry run", value=False, help="Don't write to the DB", key="dry_folder")
run = cols[0].button("Ingest", type="primary",
disabled=not folder.strip(), key="run_folder")
if run and folder.strip():
p = Path(folder).expanduser()
if not p.exists():
st.error(f"Path does not exist: {p}")
else:
try:
with st.status(f"Ingesting {p.name}", expanded=True) as status:
def log(msg: str) -> None:
status.write(msg)
summary = ingest_path(p, conn=get_conn(), dry_run=dry, progress=log)
status.update(label="Ingest complete", state="complete")
st.cache_data.clear()
st.success(f"Ingested {sum(summary['counts'].values()):,} JSON rows + {summary['fits']} FIT files.")
st.json(summary)
except (FileNotFoundError, ValueError) as exc:
st.error(str(exc))
st.divider()
with st.expander("After ingest: link FITs (Takeout dumps only)"):
st.markdown(
"""
Takeout dumps embed *upload IDs* (not activity IDs) in FIT filenames, so the
ingest above can only link FITs from the Connect-style export. For Takeout,
run the by-content linker once:
```bash
uv run openrun-link-fit /path/to/export/
```
If you move the export folder later, run with `--relink` to rewrite the
stored paths.
"""
)

View File

@@ -0,0 +1,221 @@
"""Recovery — sleep, HRV, RHR, body battery, plus training↔recovery view.
Port of notebook 03. Layout:
Row 1: 4 tiles (last sleep hours / RHR / HRV last-night with status / BB high)
Row 2: Sleep composition (stacked area over last 60 days: deep / light / rem)
Row 3: HRV last-night + RHR overlay (twin axis), 90-day trend with rolling avg
Row 4: "Does yesterday's training affect today's HRV?" — scatter + Pearson r
Row 5: Weekly summary table
"""
from __future__ import annotations
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import streamlit as st
from openrun.web._helpers import (
cached_sleep_stages,
cached_wellness,
empty_state,
show_sidebar,
)
st.set_page_config(page_title="Recovery · openrun", page_icon="🛌", layout="wide")
show_sidebar()
st.title("🛌 Recovery")
wellness = cached_wellness()
stages = cached_sleep_stages()
if wellness.empty:
empty_state(
"No wellness data yet.",
"Run `openrun-sync` or import a Garmin export with daily sleep/HRV/RHR.",
)
st.stop()
# ---------------------------------------------------------------------------
# Row 1 — at-a-glance tiles (most recent non-NaN per metric)
# ---------------------------------------------------------------------------
def _latest(series: pd.Series) -> tuple[pd.Timestamp | None, float | None]:
s = series.dropna()
if s.empty:
return None, None
return s.index[-1], s.iloc[-1]
sleep_date, sleep_total_s = _latest(wellness.get("sleep_total_s", pd.Series(dtype=float)))
sleep_h = (sleep_total_s / 3600.0) if sleep_total_s else None
rhr_date, rhr_val = _latest(wellness.get("resting_hr", pd.Series(dtype=float)))
hrv_date, hrv_val = _latest(wellness.get("hrv_last_night", pd.Series(dtype=float)))
hrv_status = wellness.get("hrv_status", pd.Series(dtype=object)).dropna()
hrv_status_val = hrv_status.iloc[-1] if not hrv_status.empty else None
bb_date, bb_high = _latest(wellness.get("bb_highest", pd.Series(dtype=float)))
cols = st.columns(4)
cols[0].metric(
"Sleep last night",
f"{sleep_h:.1f}h" if sleep_h is not None else "",
sleep_date.strftime("%b %d") if sleep_date is not None else None,
)
cols[1].metric(
"Resting HR",
f"{rhr_val:.0f}" if rhr_val is not None else "",
rhr_date.strftime("%b %d") if rhr_date is not None else None,
)
cols[2].metric(
"HRV last night",
f"{hrv_val:.0f}" if hrv_val is not None else "",
f"{hrv_status_val}" if hrv_status_val else (hrv_date.strftime("%b %d") if hrv_date else None),
)
cols[3].metric(
"Body battery (high)",
f"{bb_high:.0f}" if bb_high is not None else "",
bb_date.strftime("%b %d") if bb_date is not None else None,
)
# ---------------------------------------------------------------------------
# Row 2 — Sleep composition over time
# ---------------------------------------------------------------------------
st.subheader("Sleep composition")
if stages.empty or stages[["deep_s", "light_s", "rem_s"]].dropna(how="all").empty:
empty_state("No sleep-stage data yet.")
else:
window = st.select_slider(
"Window",
options=[14, 30, 60, 90, 180],
value=60,
format_func=lambda d: f"{d}d",
key="sleep_window",
)
s = stages.tail(window).copy()
s["deep_h"] = s["deep_s"] / 3600
s["light_h"] = s["light_s"] / 3600
s["rem_h"] = s["rem_s"] / 3600
fig, ax = plt.subplots(figsize=(11, 3.6))
ax.stackplot(
s.index,
s["deep_h"].fillna(0),
s["light_h"].fillna(0),
s["rem_h"].fillna(0),
labels=["deep", "light", "rem"],
colors=["#264653", "#2a9d8f", "#e9c46a"],
alpha=0.9,
)
ax.set_ylabel("hours")
ax.legend(loc="upper left", fontsize=8, ncol=3)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Row 3 — HRV + RHR overlay
# ---------------------------------------------------------------------------
st.subheader("HRV & resting HR")
if "hrv_last_night" in wellness or "resting_hr" in wellness:
window2 = st.select_slider(
"Window",
options=[30, 60, 90, 180, 365],
value=90,
format_func=lambda d: f"{d}d",
key="hrv_window",
)
w = wellness.tail(window2)
fig, ax1 = plt.subplots(figsize=(11, 3.8))
if "hrv_last_night" in w:
ax1.plot(w.index, w["hrv_last_night"], color="#2a9d8f", lw=1.2, alpha=0.55, label="HRV")
ax1.plot(
w.index,
w["hrv_last_night"].rolling(7).mean(),
color="#2a9d8f", lw=2.0, label="HRV · 7d",
)
ax1.set_ylabel("HRV (ms)", color="#2a9d8f")
ax2 = ax1.twinx()
if "resting_hr" in w:
ax2.plot(w.index, w["resting_hr"], color="#e76f51", lw=1.0, alpha=0.4, label="RHR")
ax2.plot(
w.index,
w["resting_hr"].rolling(7).mean(),
color="#e76f51", lw=2.0, label="RHR · 7d",
)
ax2.set_ylabel("Resting HR (bpm)", color="#e76f51")
ax1.legend(loc="upper left", fontsize=8)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Row 4 — Yesterday's training vs today's HRV
# ---------------------------------------------------------------------------
st.subheader("Training load → next-morning HRV")
st.caption(
"If hard sessions actually tax your nervous system, expect lower next-morning HRV. "
"Pearson r reported below; weak signals (|r| < 0.2) usually mean other factors dominate."
)
if "hrv_last_night" in wellness:
from openrun.web._helpers import get_conn
daily_tl = pd.read_sql(
"""SELECT date(start_time_local) AS d, SUM(training_load) AS tl
FROM activities
WHERE activity_type IN ('running','trail_running','cycling','strength_training')
AND training_load IS NOT NULL
GROUP BY d""",
get_conn(),
parse_dates=["d"],
).set_index("d")["tl"]
if not daily_tl.empty:
# Next-day HRV: shift HRV index back one day so HRV(today) lines up with TL(yesterday).
hrv_next = wellness["hrv_last_night"].copy()
hrv_next.index = hrv_next.index - pd.Timedelta(days=1)
joined = pd.DataFrame({"tl": daily_tl, "hrv_next": hrv_next}).dropna()
if len(joined) >= 10:
r = joined["tl"].corr(joined["hrv_next"])
st.caption(f"Pearson r between yesterday's TL and tomorrow's HRV: **{r:+.2f}** (n={len(joined)})")
fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(joined["tl"], joined["hrv_next"], alpha=0.45, color="#264653", s=18)
if joined["tl"].std() > 0:
a, b = np.polyfit(joined["tl"], joined["hrv_next"], 1)
xs = np.linspace(joined["tl"].min(), joined["tl"].max(), 50)
ax.plot(xs, a * xs + b, color="#e76f51", lw=1.5)
ax.set_xlabel("Yesterday's training load")
ax.set_ylabel("Next-night HRV (ms)")
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
else:
st.info(f"Need ≥10 matched day-pairs; have {len(joined)}.")
else:
st.info("No training-load data to compare against.")
else:
st.info("No HRV data available.")
# ---------------------------------------------------------------------------
# Row 5 — Weekly summary
# ---------------------------------------------------------------------------
st.subheader("Weekly summary — last 12 weeks")
weekly = wellness.copy()
weekly["week"] = weekly.index.to_period("W-SUN").start_time
agg = weekly.groupby("week").agg(
sleep_h_mean=("sleep_hours", "mean"),
hrv_mean=("hrv_last_night", "mean"),
rhr_mean=("resting_hr", "mean"),
stress_mean=("avg_stress", "mean"),
).tail(12).round(1)
agg.index = agg.index.strftime("%Y-%m-%d")
st.dataframe(agg, width="stretch")

View File

@@ -0,0 +1,152 @@
"""Efficiency — m/beat trends, distance-bucket comparison, year-over-year.
Port of notebook 04. The headline metric is m/beat (metres covered per
heartbeat at the run's effort): `distance_m / (duration_s * avg_hr / 60)`.
Higher = more efficient aerobic system. Best compared YoY within a tight
distance bucket so terrain and intent don't dominate.
Layout:
Row 1: latest 30/90/365-day m/beat with delta vs prior period.
Row 2: m/beat over time + rolling 30-run median.
Row 3: distance-bucket × year heatmap (median m/beat per cell).
Row 4: easy-runs-only headline view (HR < 75% of LTHR).
"""
from __future__ import annotations
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import streamlit as st
from openrun import default_profile
from openrun.web._helpers import cached_activities, empty_state, show_sidebar
st.set_page_config(page_title="Efficiency · openrun", page_icon="🫀", layout="wide")
show_sidebar()
st.title("🫀 Efficiency — metres per heartbeat")
st.caption(
"How much ground you cover per heartbeat at the effort you ran. "
"Higher = more efficient aerobic system; meaningful YoY when distance + terrain are similar."
)
runs = cached_activities(type="running")
if runs.empty:
empty_state("No running activities yet.")
st.stop()
# Compute m/beat per run; mask the implausible ones the same way load_activities masks pace.
df = runs.dropna(subset=["distance_m", "duration_s", "avg_hr"]).copy()
df["m_per_beat"] = df["distance_m"] / (df["duration_s"] * df["avg_hr"] / 60.0)
df = df[(df["m_per_beat"] > 1.0) & (df["m_per_beat"] < 8.0)] # 1-8 m/beat covers walks→elites
df["year"] = df["start_time_local"].dt.year
df["month"] = df["start_time_local"].dt.to_period("M").dt.start_time
if df.empty:
empty_state("No runs with both `avg_hr` and `distance` populated.")
st.stop()
# ---------------------------------------------------------------------------
# Row 1 — period tiles with delta
# ---------------------------------------------------------------------------
def _period_median(days: int) -> tuple[float | None, float | None]:
now = pd.Timestamp.now("UTC").tz_localize(None)
cur = df[df["start_time_local"] >= (now - pd.Timedelta(days=days))]
prev = df[
(df["start_time_local"] < (now - pd.Timedelta(days=days)))
& (df["start_time_local"] >= (now - pd.Timedelta(days=2 * days)))
]
return (
float(cur["m_per_beat"].median()) if not cur.empty else None,
float(prev["m_per_beat"].median()) if not prev.empty else None,
)
cols = st.columns(3)
for col, days, label in zip(cols, (30, 90, 365), ("30d", "90d", "1y")):
cur, prev = _period_median(days)
delta = (cur - prev) if (cur is not None and prev is not None) else None
col.metric(
f"m/beat · {label}",
f"{cur:.2f}" if cur is not None else "",
f"{delta:+.2f} vs prior {label}" if delta is not None else None,
)
# ---------------------------------------------------------------------------
# Row 2 — m/beat over time with rolling 30-run median
# ---------------------------------------------------------------------------
st.subheader("Trend")
chrono = df.sort_values("start_time_local").set_index("start_time_local")
fig, ax = plt.subplots(figsize=(11, 3.6))
ax.scatter(chrono.index, chrono["m_per_beat"], alpha=0.35, s=12, color="#264653", label="run")
roll = chrono["m_per_beat"].rolling(30, min_periods=10).median()
ax.plot(roll.index, roll, color="#e76f51", lw=2.2, label="30-run median")
ax.set_ylabel("m/beat")
ax.legend(loc="upper left", fontsize=8)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Row 3 — distance bucket × year
# ---------------------------------------------------------------------------
st.subheader("Distance bucket × year")
st.caption("Median m/beat per cell. Empty cells = no runs in that bucket-year.")
bins = [0, 5, 8, 12, 18, 26, 35, 50, 100]
labels = ["<5", "58", "812", "1218", "1826", "2635", "3550", "50+"]
df["bucket"] = pd.cut(df["distance_km"], bins=bins, labels=labels, right=False)
pivot = df.pivot_table(
index="bucket", columns="year", values="m_per_beat", aggfunc="median", observed=True
)
if not pivot.empty:
fig, ax = plt.subplots(figsize=(8, 3.5))
im = ax.imshow(pivot.values, aspect="auto", cmap="viridis")
ax.set_xticks(range(len(pivot.columns)))
ax.set_xticklabels(pivot.columns)
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index)
ax.set_xlabel("year")
ax.set_ylabel("distance bucket (km)")
fig.colorbar(im, ax=ax, label="m/beat")
# Annotate
for i in range(pivot.shape[0]):
for j in range(pivot.shape[1]):
v = pivot.values[i, j]
if not pd.isna(v):
ax.text(j, i, f"{v:.2f}", ha="center", va="center",
color="white" if v < pivot.values.mean() else "black", fontsize=8)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Row 4 — easy-runs-only headline
# ---------------------------------------------------------------------------
st.subheader("Easy runs only (HR < 75 % of LTHR)")
profile = default_profile()
lthr = profile.lthr or int(profile.hr_max * 0.85) if profile.hr_max else 170
easy_threshold = lthr * 0.75
st.caption(
f"Filter: avg_hr < {easy_threshold:.0f} bpm. Removes hard sessions where pace at "
f"high HR confounds the m/beat reading. LTHR pulled from your profile = {lthr}."
)
easy = df[df["avg_hr"] < easy_threshold].copy()
if easy.empty:
st.info("No runs match the easy filter — adjust your LTHR in openrun.toml or try a wider window.")
else:
easy_monthly = easy.groupby("month")["m_per_beat"].median().tail(36)
fig, ax = plt.subplots(figsize=(11, 3.0))
ax.plot(easy_monthly.index, easy_monthly.values, color="#2a9d8f", lw=2.2, marker="o")
ax.set_ylabel("median m/beat (easy runs)")
fig.tight_layout()
st.pyplot(fig, clear_figure=True)

View File

@@ -0,0 +1,193 @@
"""Activity detail — drill-in for a single run.
Accessed via `?aid=<activity_id>`. Layout:
Header: tiles (date, distance, pace, avg HR, training load, vo2_max)
Section: splits table + pace-vs-HR scatter
Section: HR time-in-zone bars (FIT or split-based, whichever the cache has)
Section: FIT decoupling chart (if a FIT is linked)
Section: map (lat/long from FIT records, if present)
"""
from __future__ import annotations
import matplotlib.pyplot as plt
import pandas as pd
import streamlit as st
from openrun import (
HRZones,
default_profile,
load_fit_records,
load_splits,
time_in_zone_from_fit,
time_in_zone_from_splits,
)
from openrun.plots import plot_fit_decoupling
from openrun.web._helpers import cached_activities, empty_state, get_conn, show_sidebar
st.set_page_config(page_title="Activity · openrun", page_icon="🏃", layout="wide")
show_sidebar()
# ---------------------------------------------------------------------------
# Resolve activity from query param
# ---------------------------------------------------------------------------
aid_str = st.query_params.get("aid")
if not aid_str:
st.title("🏃 Activity detail")
empty_state("No activity selected.", "Open this page from the Activities list.")
if st.button("← Activities"):
st.switch_page("pages/2_Activities.py")
st.stop()
try:
aid = int(aid_str)
except ValueError:
st.error(f"Invalid activity id: {aid_str!r}")
st.stop()
runs = cached_activities(type=None)
match = runs[runs["activity_id"] == aid]
if match.empty:
st.error(f"No activity {aid} in the DB.")
st.stop()
row = match.iloc[0]
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
c_back, c_title = st.columns([1, 8])
if c_back.button("← Activities"):
st.query_params.clear()
st.switch_page("pages/2_Activities.py")
c_title.title(f"🏃 {row['activity_name'] or 'Activity'}")
st.caption(
f"{row['start_time_local']:%Y-%m-%d %H:%M} · {row['activity_type']} · id={aid}"
)
def _fmt_pace(p: float) -> str:
if pd.isna(p):
return ""
return f"{int(p)}:{int(round((p - int(p)) * 60)):02d}/km"
def _fmt_duration(s: float) -> str:
if pd.isna(s):
return ""
s = int(s)
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
return f"{h}h {m:02d}m" if h else f"{m}m {sec:02d}s"
cols = st.columns(6)
cols[0].metric("Distance", f"{row['distance_km']:.2f} km" if pd.notna(row["distance_km"]) else "")
cols[1].metric("Duration", _fmt_duration(row.get("moving_duration_s", row.get("duration_s"))))
cols[2].metric("Pace", _fmt_pace(row.get("pace_min_per_km")))
cols[3].metric("Avg HR", f"{int(row['avg_hr'])}" if pd.notna(row["avg_hr"]) else "")
cols[4].metric("Training load", f"{int(row['training_load'])}" if pd.notna(row["training_load"]) else "")
cols[5].metric("VO₂ max", f"{row['vo2_max']:.1f}" if pd.notna(row.get("vo2_max")) else "")
# ---------------------------------------------------------------------------
# Splits
# ---------------------------------------------------------------------------
splits = load_splits(get_conn(), activity_type=None)
my_splits = splits[splits["activity_id"] == aid].sort_values("split_seq")
if not my_splits.empty:
st.subheader(f"Splits ({len(my_splits)})")
s_show = my_splits[[
"split_index", "distance_m", "duration_s", "pace_min_per_km",
"avg_hr", "split_elev_gain_m",
]].copy()
s_show["distance_km"] = (s_show["distance_m"] / 1000).round(2)
s_show["pace"] = s_show["pace_min_per_km"].apply(_fmt_pace)
s_show["duration"] = s_show["duration_s"].apply(_fmt_duration)
s_show = s_show.rename(columns={
"split_index": "#",
"avg_hr": "Avg HR",
"split_elev_gain_m": "Elev↑ (m)",
})[["#", "distance_km", "duration", "pace", "Avg HR", "Elev↑ (m)"]].rename(
columns={"distance_km": "Distance (km)", "duration": "Duration", "pace": "Pace"}
)
st.dataframe(s_show, hide_index=True, width="stretch", height=260)
# Pace vs HR scatter per split — useful for spotting drift across the run.
if my_splits["pace_min_per_km"].notna().any() and my_splits["avg_hr"].notna().any():
fig, ax = plt.subplots(figsize=(8, 3))
ax.scatter(my_splits["pace_min_per_km"], my_splits["avg_hr"],
c=my_splits["split_seq"], cmap="viridis", s=42)
ax.set_xlabel("pace (min/km)")
ax.set_ylabel("avg HR (bpm)")
ax.invert_xaxis() # faster pace on right
ax.set_title("Splits: pace vs HR (colour = order)")
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
# ---------------------------------------------------------------------------
# Time in zone
# ---------------------------------------------------------------------------
st.subheader("Time in HR zone")
tiz_row = get_conn().execute(
"SELECT z1_s, z2_s, z3_s, z4_s, z5_s, source FROM activity_time_in_zone WHERE activity_id=?",
(aid,),
).fetchone()
tiz: dict[str, float] | None = None
source = None
if tiz_row is not None:
tiz = {f"Z{i}": tiz_row[f"z{i}_s"] for i in range(1, 6)}
source = tiz_row["source"]
elif not my_splits.empty:
tiz = time_in_zone_from_splits(my_splits)
source = "lap"
if tiz:
z_series = pd.Series(tiz, dtype=float).reindex(["Z1", "Z2", "Z3", "Z4", "Z5"]).fillna(0)
total = float(z_series.sum())
if total > 0:
fig, ax = plt.subplots(figsize=(8, 2.4))
colors = ["#2a9d8f", "#a4d4ae", "#e9c46a", "#f4a261", "#e76f51"]
ax.bar(z_series.index, z_series / 60, color=colors)
ax.set_ylabel("minutes")
for i, v in enumerate(z_series / 60):
ax.text(i, v, f"{v:.0f}m\n({v*60/total*100:.0f}%)", ha="center", va="bottom", fontsize=8)
ax.set_ylim(top=(z_series / 60).max() * 1.25)
fig.tight_layout()
st.pyplot(fig, clear_figure=True)
st.caption(f"Source: **{source}** (`fit` = per-second, `lap` = split-average bias).")
else:
st.info("No TIZ data — run `openrun-time-in-zone` to populate the cache.")
# ---------------------------------------------------------------------------
# FIT decoupling (if linked)
# ---------------------------------------------------------------------------
fit_linked = get_conn().execute(
"SELECT fit_path FROM activity_fit_files WHERE activity_id=?", (aid,)
).fetchone()
if fit_linked:
st.subheader("Pa:Hr decoupling (per-second from FIT)")
segments = st.select_slider("Segments", options=[2, 3, 4, 6], value=4)
try:
records = load_fit_records(get_conn(), aid)
if records.empty:
st.info("FIT linked but has no per-second records.")
else:
ax = plot_fit_decoupling(records, segments=segments)
st.pyplot(ax.figure, clear_figure=True)
except FileNotFoundError as exc:
st.error(str(exc))
else:
st.caption("No FIT file linked. Run `openrun-link-fit <export>` to enable decoupling charts.")

View File

@@ -0,0 +1,59 @@
"""Home — landing page once setup is complete. The plain index of sections.
`app.py` is the controller and shows the first-run nudge for fresh installs;
this page is just the "you've arrived, here's where things are" landing.
"""
from __future__ import annotations
import streamlit as st
from openrun.web._helpers import show_sidebar
show_sidebar()
st.title("openrun")
st.markdown(
"Local-first endurance analytics. All data stays on your machine — Garmin "
"sync, Banister CTL/ATL/TSB, decoupling, race-plan projection, off-watch "
"activity logging."
)
cols = st.columns(2)
with cols[0]:
st.markdown(
"""
### 📊 Dashboard
Today's fitness, fatigue, and form at a glance — plus recent activity
and weekly volume.
### 📋 Activities
Browse, filter, and inspect individual runs.
### 📈 Race plan
Periodised plan with projected PMC through race day.
### 📝 Manual log
Off-watch sessions (strength, hikes, unrecorded runs).
"""
)
with cols[1]:
st.markdown(
"""
### 🛌 Recovery
Sleep stages, HRV, RHR, and how prior-day load affects next-morning HRV.
### 🫀 Efficiency
Metres per heartbeat — your aerobic fitness signal, year over year.
### 🔄 Sync
Pull from Garmin Connect, or import a Garmin data export.
"""
)
st.divider()
st.caption("Need to redo your profile or zones?")
if st.button("Open setup wizard"):
st.session_state["show_welcome"] = True
st.switch_page("pages/0_Welcome.py")

View File

@@ -0,0 +1,92 @@
"""Integration: manual_activities flow through into the Banister PMC.
The contract is that `daily_training_load_series(..., include_manual=True)`
unions manual rows into the same Series the PMC consumes, so adding a
strength session lifts CTL on that day and the days following — same
recursion, no special case.
"""
from __future__ import annotations
import pandas as pd
from openrun.model import banister, daily_training_load_series
def _add_activity(conn, aid: int, when: str, atype: str, training_load: float) -> None:
conn.execute(
"""INSERT INTO activities
(activity_id, start_time_local, activity_type, training_load, raw, fetched_at)
VALUES (?, ?, ?, ?, '{}', 'now')""",
(aid, when, atype, training_load),
)
def _add_manual(conn, when: str, atype: str, training_load: float) -> None:
conn.execute(
"""INSERT INTO manual_activities
(activity_date, activity_type, training_load, source, imported_at)
VALUES (?, ?, ?, 'test', datetime('now'))""",
(when, atype, training_load),
)
def test_include_manual_increases_daily_sum(tmp_conn) -> None:
_add_activity(tmp_conn, aid=1, when="2026-05-01 06:00:00", atype="running", training_load=50.0)
_add_manual(tmp_conn, when="2026-05-01", atype="running", training_load=30.0)
tmp_conn.commit()
without = daily_training_load_series(tmp_conn)
with_manual = daily_training_load_series(tmp_conn, include_manual=True)
assert without.iloc[0] == 50.0
assert with_manual.iloc[0] == 80.0
def test_manual_only_day_appears_when_included(tmp_conn) -> None:
"""A day with only a manual row is invisible without the flag and appears with it."""
_add_manual(tmp_conn, when="2026-05-02", atype="running", training_load=25.0)
tmp_conn.commit()
without = daily_training_load_series(tmp_conn)
with_manual = daily_training_load_series(tmp_conn, include_manual=True)
assert without.empty
assert with_manual.loc[pd.Timestamp("2026-05-02")] == 25.0
def test_manual_load_lifts_banister_ctl_monotonically(tmp_conn) -> None:
"""For two otherwise-identical worlds, the one with extra manual TL must
show strictly higher CTL on the day the manual session was logged and on
every following day (the EWMA carries forward)."""
_add_activity(tmp_conn, aid=1, when="2026-05-01 06:00:00", atype="running", training_load=50.0)
_add_activity(tmp_conn, aid=2, when="2026-05-03 06:00:00", atype="running", training_load=60.0)
tmp_conn.commit()
base = banister(daily_training_load_series(tmp_conn))
_add_manual(tmp_conn, when="2026-05-02", atype="running", training_load=40.0)
tmp_conn.commit()
with_manual = banister(daily_training_load_series(tmp_conn, include_manual=True))
# 2026-05-01 is unchanged (manual is in the future at that point).
assert with_manual.loc["2026-05-01", "CTL"] == base.loc["2026-05-01", "CTL"]
# 2026-05-02 onward is strictly larger.
for d in ("2026-05-02", "2026-05-03"):
assert with_manual.loc[d, "CTL"] > base.loc[d, "CTL"]
def test_activity_type_filter_excludes_other_manual_rows(tmp_conn) -> None:
"""Manual rows whose type is not in activity_types stay out of the series."""
_add_manual(tmp_conn, when="2026-05-02", atype="strength", training_load=100.0)
tmp_conn.commit()
out = daily_training_load_series(tmp_conn, include_manual=True)
assert out.empty
def test_manual_same_day_as_garmin_row_sums(tmp_conn) -> None:
"""A manual row and a Garmin row on the same day must combine, not overwrite."""
_add_activity(tmp_conn, aid=1, when="2026-05-01 18:00:00", atype="running", training_load=70.0)
_add_manual(tmp_conn, when="2026-05-01", atype="running", training_load=30.0)
tmp_conn.commit()
s = daily_training_load_series(tmp_conn, include_manual=True)
assert s.loc[pd.Timestamp("2026-05-01")] == 100.0

97
tests/unit/test_auth.py Normal file
View File

@@ -0,0 +1,97 @@
"""Tests for `openrun.ingest.auth` — the web-friendly, MFA-resumable login flow.
We stub garth at the `sso.login` / `sso.resume_login` / `save` boundary so the
two-step (password → MFA code) state machine is testable without a real Garmin
account or network.
"""
from __future__ import annotations
from pathlib import Path
import openrun.ingest.auth as auth
class _FakeClient:
username = "athlete"
oauth1_token = None
oauth2_token = None
def _stub_garth(monkeypatch):
client = _FakeClient()
monkeypatch.setattr(auth.garth, "client", client)
monkeypatch.setattr(auth.garth, "save", lambda td: None)
return client
# --- token store helpers ---------------------------------------------------
def test_has_tokens(tmp_path: Path) -> None:
assert auth.has_tokens(tmp_path) is False
(tmp_path / "oauth1_token.json").write_text("{}")
assert auth.has_tokens(tmp_path) is True
def test_resume_no_tokens_is_false(tmp_path: Path, monkeypatch) -> None:
called = {"n": 0}
monkeypatch.setattr(auth.garth, "resume", lambda td: called.__setitem__("n", called["n"] + 1))
assert auth.resume(tmp_path) is False
assert called["n"] == 0 # short-circuits before touching garth
def test_resume_with_tokens(tmp_path: Path, monkeypatch) -> None:
(tmp_path / "oauth1_token.json").write_text("{}")
monkeypatch.setattr(auth.garth, "resume", lambda td: None)
assert auth.resume(tmp_path) is True
def test_resume_swallows_bad_tokens(tmp_path: Path, monkeypatch) -> None:
(tmp_path / "oauth1_token.json").write_text("garbage")
def _boom(td):
raise ValueError("corrupt")
monkeypatch.setattr(auth.garth, "resume", _boom)
assert auth.resume(tmp_path) is False
# --- login state machine ---------------------------------------------------
def test_begin_login_no_mfa(tmp_path: Path, monkeypatch) -> None:
client = _stub_garth(monkeypatch)
monkeypatch.setattr(auth.garth.sso, "login", lambda *a, **k: ("oauth1", "oauth2"))
kind, payload = auth.begin_login("e@x.com", "pw", tmp_path)
assert kind == "ok"
assert payload == "athlete"
assert client.oauth1_token == "oauth1" and client.oauth2_token == "oauth2"
def test_begin_login_needs_mfa(tmp_path: Path, monkeypatch) -> None:
_stub_garth(monkeypatch)
state = {"session": "opaque"}
monkeypatch.setattr(auth.garth.sso, "login", lambda *a, **k: ("needs_mfa", state))
kind, payload = auth.begin_login("e@x.com", "pw", tmp_path)
assert kind == "needs_mfa"
assert payload is state
def test_complete_mfa(tmp_path: Path, monkeypatch) -> None:
client = _stub_garth(monkeypatch)
seen = {}
def _resume(state, code):
seen["state"], seen["code"] = state, code
return ("o1", "o2")
monkeypatch.setattr(auth.garth.sso, "resume_login", _resume)
user = auth.complete_mfa({"s": 1}, " 123456 ", tmp_path)
assert user == "athlete"
assert seen["code"] == "123456" # trimmed
assert client.oauth1_token == "o1" and client.oauth2_token == "o2"

View File

@@ -0,0 +1,105 @@
"""Tests for `write_config` — TOML serialiser used by the first-run wizard.
The load-bearing invariant: write_config → load_config produces an equivalent
Config. If that holds, the wizard can write whatever the user picked and
trust the rest of the codebase to read it back the same way.
"""
from __future__ import annotations
from pathlib import Path
from openrun.config import (
BanisterParams,
Config,
HRZones,
UserProfile,
load_config,
write_config,
)
def _sample_config(tmp_path: Path) -> Config:
return Config(
user=UserProfile(
name="Test Runner",
height_cm=180.0,
weight_kg=72.5,
hr_max=200,
lthr=178,
resting_hr=48,
zones=HRZones(
z1=(100, 120), z2=(121, 140), z3=(141, 160),
z4=(161, 180), z5=(181, 200),
),
),
db_path=tmp_path / "data" / "garmin.db",
banister=BanisterParams(ctl_tau_days=42.0, atl_tau_days=7.0, race_day_tl_per_km=7.0),
races=(("wk 4 — 30K", "2026-06-13"), ("wk 17 — 50 mile", "2026-09-12")),
)
def test_write_then_load_roundtrip(tmp_path: Path) -> None:
cfg = _sample_config(tmp_path)
out = tmp_path / "openrun.toml"
write_config(cfg, out)
loaded = load_config(out)
assert loaded.user.name == cfg.user.name
assert loaded.user.hr_max == cfg.user.hr_max
assert loaded.user.lthr == cfg.user.lthr
assert loaded.user.resting_hr == cfg.user.resting_hr
assert loaded.user.weight_kg == cfg.user.weight_kg
assert loaded.user.height_cm == cfg.user.height_cm
assert loaded.user.zones == cfg.user.zones
assert loaded.banister == cfg.banister
assert loaded.races == cfg.races
def test_write_handles_missing_optional_fields(tmp_path: Path) -> None:
"""Height/weight/lthr/rhr are optional — omitted entirely from output."""
cfg = Config(
user=UserProfile(name="Minimal", hr_max=190),
db_path=tmp_path / "minimal.db",
)
out = tmp_path / "openrun.toml"
write_config(cfg, out)
body = out.read_text()
assert "height_cm" not in body
assert "weight_kg" not in body
assert "lthr" not in body
assert "resting_hr" not in body
loaded = load_config(out)
assert loaded.user.hr_max == 190
assert loaded.user.height_cm is None
def test_write_quotes_strings_with_special_chars(tmp_path: Path) -> None:
"""Race labels and athlete names commonly contain Unicode em-dashes and quotes;
the serialiser must escape these so the file parses back."""
cfg = Config(
user=UserProfile(name='O\'Brien — "Speedy"', hr_max=200),
db_path=tmp_path / "x.db",
races=(('wk 17 — Cascade Crest "50M"', "2026-09-12"),),
)
out = tmp_path / "openrun.toml"
write_config(cfg, out)
loaded = load_config(out)
assert loaded.user.name == 'O\'Brien — "Speedy"'
assert loaded.races == (('wk 17 — Cascade Crest "50M"', "2026-09-12"),)
def test_write_creates_parent_dir(tmp_path: Path) -> None:
cfg = Config(user=UserProfile(name="x", hr_max=200), db_path=tmp_path / "x.db")
nested = tmp_path / "a" / "b" / "openrun.toml"
write_config(cfg, nested)
assert nested.exists()
def test_no_races_omits_section(tmp_path: Path) -> None:
cfg = Config(user=UserProfile(name="x", hr_max=200), db_path=tmp_path / "x.db", races=())
out = tmp_path / "openrun.toml"
write_config(cfg, out)
assert "[[races]]" not in out.read_text()
assert load_config(out).races == ()

View File

@@ -0,0 +1,135 @@
"""Path B per-second: pulling original FITs from the API instead of a website export.
`download_fit` / `backfill_fits` are mocked at the `garth.download` boundary
(network-dependent, upstream-deprecated — see ROADMAP test conventions). The FIT
extractor is pure and tested directly.
"""
from __future__ import annotations
import io
import zipfile
import pytest
from openrun.ingest import garmin_api as g
# A FIT header carries the literal b".FIT" at bytes 8..12; payload after is opaque here.
FIT_BYTES = b"\x0e\x10\x00\x00\x20\x00\x00\x00.FITdata-goes-here"
def _zip(*members: tuple[str, bytes]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in members:
zf.writestr(name, data)
return buf.getvalue()
# --- _extract_fit_bytes ----------------------------------------------------
def test_extract_from_zip():
assert g._extract_fit_bytes(_zip(("9.fit", FIT_BYTES))) == FIT_BYTES
def test_extract_from_zip_extensionless_member_picks_largest():
blob = _zip(("small", b"x"), ("big", FIT_BYTES))
assert g._extract_fit_bytes(blob) == FIT_BYTES
def test_extract_from_bare_fit():
assert g._extract_fit_bytes(FIT_BYTES) == FIT_BYTES
@pytest.mark.parametrize("blob", [None, b"", b"not a fit at all"])
def test_extract_rejects_non_fit(blob):
assert g._extract_fit_bytes(blob) is None
# --- download_fit ----------------------------------------------------------
def _seed_activity(conn, aid: int, atype: str = "running") -> None:
conn.execute(
"INSERT INTO activities (activity_id, activity_type, start_time_gmt, raw, fetched_at) "
"VALUES (?, ?, ?, ?, datetime('now'))",
(aid, atype, "2026-05-01T10:00:00", "{}"),
)
def test_download_fit_writes_and_links(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 42)
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("42.fit", FIT_BYTES)))
ok = g.download_fit(tmp_conn, 42, fit_dir=tmp_path)
assert ok is True
dest = tmp_path / "42.fit"
assert dest.read_bytes() == FIT_BYTES
row = tmp_conn.execute(
"SELECT fit_path FROM activity_fit_files WHERE activity_id = 42"
).fetchone()
assert row is not None and row[0].endswith("42.fit")
def test_download_fit_skips_when_already_linked(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 7)
(tmp_path / "7.fit").write_bytes(FIT_BYTES)
g.record_link(tmp_conn, 7, tmp_path / "7.fit")
calls = []
monkeypatch.setattr(g.garth, "download", lambda path: calls.append(path) or b"")
assert g.download_fit(tmp_conn, 7, fit_dir=tmp_path) is True
assert calls == [] # no network call when on disk + linked
def test_download_fit_force_redownloads(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 7)
(tmp_path / "7.fit").write_bytes(b"stale")
g.record_link(tmp_conn, 7, tmp_path / "7.fit")
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("7.fit", FIT_BYTES)))
assert g.download_fit(tmp_conn, 7, fit_dir=tmp_path, force=True) is True
assert (tmp_path / "7.fit").read_bytes() == FIT_BYTES
def test_download_fit_returns_false_on_empty_response(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 99)
monkeypatch.setattr(g.garth, "download", lambda path: None)
assert g.download_fit(tmp_conn, 99, fit_dir=tmp_path) is False
assert tmp_conn.execute(
"SELECT 1 FROM activity_fit_files WHERE activity_id = 99"
).fetchone() is None
# --- backfill_fits ---------------------------------------------------------
def test_backfill_only_targets_unlinked(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 1)
_seed_activity(tmp_conn, 2)
(tmp_path / "1.fit").write_bytes(FIT_BYTES)
g.record_link(tmp_conn, 1, tmp_path / "1.fit") # 1 already has a FIT
pulled = []
monkeypatch.setattr(
g.garth, "download",
lambda path: pulled.append(path) or _zip(("x.fit", FIT_BYTES)),
)
got = g.backfill_fits(tmp_conn, fit_dir=tmp_path)
assert got == 1
assert pulled == ["/download-service/files/activity/2"]
def test_backfill_respects_type_filter(tmp_conn, tmp_path, monkeypatch):
_seed_activity(tmp_conn, 10, atype="running")
_seed_activity(tmp_conn, 11, atype="cycling")
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("x.fit", FIT_BYTES)))
got = g.backfill_fits(tmp_conn, fit_dir=tmp_path, activity_type="running")
assert got == 1
assert (tmp_path / "10.fit").exists()
assert not (tmp_path / "11.fit").exists()

110
tests/unit/test_manual.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for `openrun.ingest.manual` — CSV import + idempotency."""
from __future__ import annotations
from pathlib import Path
import pytest
from openrun.ingest.manual import import_csv
def _write_csv(tmp_path: Path, text: str) -> Path:
p = tmp_path / "manual.csv"
p.write_text(text)
return p
def test_import_csv_inserts_required_columns(tmp_conn, tmp_path: Path) -> None:
csv = _write_csv(tmp_path, """\
activity_date,activity_type,distance_km,duration_min,training_load,notes
2026-05-15,strength,,45,30,upper body
2026-05-16,hike,8.0,120,40,morning hike
""")
result = import_csv(tmp_conn, csv)
assert result.inserted == 2
assert result.updated == 0
assert result.skipped == 0
assert result.errors == []
rows = tmp_conn.execute(
"SELECT activity_date, activity_type, distance_km, duration_min, training_load, notes "
"FROM manual_activities ORDER BY activity_date"
).fetchall()
assert rows[0]["activity_date"] == "2026-05-15"
assert rows[0]["activity_type"] == "strength"
assert rows[0]["distance_km"] is None
assert rows[0]["duration_min"] == 45
assert rows[0]["training_load"] == 30
assert rows[0]["notes"] == "upper body"
assert rows[1]["distance_km"] == 8.0
def test_import_csv_requires_header_columns(tmp_conn, tmp_path: Path) -> None:
csv = _write_csv(tmp_path, "date,type\n2026-05-15,strength\n") # wrong column names
with pytest.raises(ValueError, match="missing required columns"):
import_csv(tmp_conn, csv)
def test_import_csv_external_id_makes_reimport_idempotent(tmp_conn, tmp_path: Path) -> None:
csv = _write_csv(tmp_path, """\
activity_date,activity_type,training_load,external_id
2026-05-15,strength,30,strava-12345
""")
import_csv(tmp_conn, csv)
second = import_csv(tmp_conn, csv)
assert second.inserted == 0
assert second.updated == 1
count = tmp_conn.execute("SELECT COUNT(*) FROM manual_activities").fetchone()[0]
assert count == 1
def test_import_csv_reimport_with_new_tl_updates_value(tmp_conn, tmp_path: Path) -> None:
"""External_id upsert should reflect changed training_load on re-import."""
csv1 = _write_csv(tmp_path, """\
activity_date,activity_type,training_load,external_id
2026-05-15,strength,30,k1
""")
import_csv(tmp_conn, csv1)
csv2 = _write_csv(tmp_path, """\
activity_date,activity_type,training_load,external_id
2026-05-15,strength,55,k1
""")
import_csv(tmp_conn, csv2)
tl = tmp_conn.execute("SELECT training_load FROM manual_activities WHERE external_id='k1'").fetchone()[0]
assert tl == 55
def test_import_csv_blank_external_id_allows_duplicates(tmp_conn, tmp_path: Path) -> None:
"""No external_id = caller is intentionally logging without dedupe key. Two imports = two rows."""
csv = _write_csv(tmp_path, """\
activity_date,activity_type,training_load
2026-05-15,strength,30
""")
import_csv(tmp_conn, csv)
import_csv(tmp_conn, csv)
assert tmp_conn.execute("SELECT COUNT(*) FROM manual_activities").fetchone()[0] == 2
def test_import_csv_skips_bad_rows_but_keeps_going(tmp_conn, tmp_path: Path) -> None:
csv = _write_csv(tmp_path, """\
activity_date,activity_type,training_load
2026-05-15,strength,30
bad-date,hike,40
2026-05-17,run,
""")
result = import_csv(tmp_conn, csv)
assert result.inserted == 2
assert result.skipped == 1
assert len(result.errors) == 1
assert "line 3" in result.errors[0]
def test_import_csv_accepts_iso_datetime_in_date_column(tmp_conn, tmp_path: Path) -> None:
csv = _write_csv(tmp_path, """\
activity_date,activity_type,training_load
2026-05-15T08:30:00,strength,30
""")
import_csv(tmp_conn, csv)
d = tmp_conn.execute("SELECT activity_date FROM manual_activities").fetchone()[0]
assert d == "2026-05-15"

View File

@@ -4,8 +4,9 @@ from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from openrun.model import calibrate_tl_per_km, personal_records
from openrun.model import calibrate_tl_per_km, personal_records, plan_to_daily_load
def _act(aid: int, dist_km: float, pace: float, when: str = "2026-01-01") -> dict:
@@ -114,3 +115,62 @@ def test_calibrate_tl_per_km_returns_nan_when_empty(tmp_conn) -> None:
result = calibrate_tl_per_km(tmp_conn, lookback_days=None)
assert result["n"] == 0
assert np.isnan(result["median"])
# ---------------------------------------------------------------------------
# plan_to_daily_load
# ---------------------------------------------------------------------------
def _plan_row(week_start: str, weekly_km: float, long_run_km: float) -> dict:
return {"week_start": pd.Timestamp(week_start), "weekly_km": weekly_km, "long_run_km": long_run_km}
def test_plan_distributes_weekly_km_across_7_days() -> None:
"""A single 70 km / 28 km long-run week should emit 7 daily entries summing to 70·tl_per_km."""
plan = pd.DataFrame([_plan_row("2026-05-04", 70.0, 28.0)]) # Mon 2026-05-04
series = plan_to_daily_load(plan, tl_per_km=10.0)
assert len(series) == 7
assert series.sum() == pytest.approx(700.0)
# Saturday (default 40 %) gets the biggest single load.
saturday = pd.Timestamp("2026-05-09")
assert series.idxmax() == saturday
assert series[saturday] == pytest.approx(700.0 * 0.40)
def test_plan_race_week_uses_race_day_tl_per_km() -> None:
"""If a `race_dates` entry is inside the week, race day gets long_run_km × race_tl,
and the remaining km distribute across Mon/Wed/Fri at training rate."""
plan = pd.DataFrame([_plan_row("2026-05-04", 30.0, 20.0)])
series = plan_to_daily_load(
plan,
tl_per_km=11.0,
race_day_tl_per_km=7.0,
race_dates=("2026-05-09",), # Saturday
)
sat = pd.Timestamp("2026-05-09")
assert series[sat] == pytest.approx(20.0 * 7.0) # race load
# Remaining km = 30 - 20 = 10. Spread across Mon/Wed/Fri @ 11.0 → 110 / 3 each.
for d in [pd.Timestamp("2026-05-04"), pd.Timestamp("2026-05-06"), pd.Timestamp("2026-05-08")]:
assert series[d] == pytest.approx(110.0 / 3, rel=1e-6)
def test_plan_empty_returns_empty_series() -> None:
assert plan_to_daily_load(pd.DataFrame(columns=["week_start", "weekly_km", "long_run_km"]),
tl_per_km=10.0).empty
def test_plan_zero_weekly_km_yields_zeros() -> None:
"""A rest week (weekly_km=0) shouldn't crash and should produce 7 zero-load days."""
plan = pd.DataFrame([_plan_row("2026-05-04", 0.0, 0.0)])
series = plan_to_daily_load(plan, tl_per_km=10.0)
assert (series == 0).all()
assert len(series) == 7
def test_plan_custom_weekday_weights() -> None:
"""Pass a flat 1/7 split, expect equal load each day."""
plan = pd.DataFrame([_plan_row("2026-05-04", 70.0, 28.0)])
weights = {i: 1 / 7 for i in range(7)}
series = plan_to_daily_load(plan, tl_per_km=10.0, weekday_weights=weights)
assert series.std() == pytest.approx(0.0, abs=1e-9)
assert series.iloc[0] == pytest.approx(100.0)

View File

@@ -0,0 +1,65 @@
"""Tests for the `run_sync` orchestrator in `openrun.ingest.garmin_api`.
`run_sync` is the auth-free body shared by the CLI and the web "Sync now"
button. We stub every network-touching sub-sync so the test verifies only the
orchestration contract: which steps run, what the summary holds, that progress
is streamed, and that `last_sync_utc` is recorded.
"""
from __future__ import annotations
import openrun.ingest.garmin_api as g
from openrun.db import get_state
def _stub_subsyncs(monkeypatch, tmp_path):
monkeypatch.setattr(g, "sync_activities", lambda conn, **kw: 3)
monkeypatch.setattr(g, "backfill_fits", lambda conn, **kw: 2)
monkeypatch.setattr(g, "_fit_dir", lambda: tmp_path)
for name in (
"sync_steps", "sync_sleep", "sync_stress", "sync_hrv",
"sync_intensity_minutes", "sync_resting_hr", "sync_body_battery",
):
monkeypatch.setattr(g, name, lambda conn, end, period: 1)
def test_run_sync_default_pass(tmp_conn, tmp_path, monkeypatch) -> None:
_stub_subsyncs(monkeypatch, tmp_path)
msgs: list[str] = []
summary = g.run_sync(tmp_conn, progress=msgs.append)
assert summary["activities"] == 3
assert "fit_backfill" not in summary # off by default
assert summary["steps"] == 1 and summary["body battery"] == 1
assert any("activities" in m for m in msgs)
assert get_state(tmp_conn, "last_sync_utc") is not None
def test_run_sync_includes_backfill_when_requested(tmp_conn, tmp_path, monkeypatch) -> None:
_stub_subsyncs(monkeypatch, tmp_path)
summary = g.run_sync(tmp_conn, fit_backfill=True, progress=lambda _m: None)
assert summary["fit_backfill"] == 2
def test_run_sync_skip_activities(tmp_conn, tmp_path, monkeypatch) -> None:
_stub_subsyncs(monkeypatch, tmp_path)
# If activities ran, the stub would put a key in the summary — assert it didn't.
summary = g.run_sync(tmp_conn, skip_activities=True, progress=lambda _m: None)
assert "activities" not in summary
assert summary["HRV"] == 1 # wellness still runs
def test_run_sync_forwards_fit_flag(tmp_conn, tmp_path, monkeypatch) -> None:
seen: dict = {}
monkeypatch.setattr(g, "sync_activities", lambda conn, **kw: seen.update(kw) or 0)
monkeypatch.setattr(g, "_fit_dir", lambda: tmp_path)
for name in (
"sync_steps", "sync_sleep", "sync_stress", "sync_hrv",
"sync_intensity_minutes", "sync_resting_hr", "sync_body_battery",
):
monkeypatch.setattr(g, name, lambda conn, end, period: 0)
g.run_sync(tmp_conn, fetch_fit=False, full=True, progress=lambda _m: None)
assert seen["fetch_fit"] is False
assert seen["full"] is True

View File

@@ -20,6 +20,8 @@ EXPECTED_TABLES = frozenset({
"daily_body_battery",
"daily_intensity_minutes",
"daily_resting_hr",
"manual_activities",
"race_plan",
"sync_state",
})

447
uv.lock generated
View File

@@ -9,6 +9,22 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "altair"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "narwhals" },
{ name = "packaging" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/1e/365a9144db3254f86f1b974660b9ede1e9a38c9dc0730e4a9b1192eec5d6/altair-6.1.0.tar.gz", hash = "sha256:dda699216cf85b040d968ae5a569ad45957616811e38760a85e5118269daca67", size = 765519 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/63/5dacc8d8306c715088b897a479e551bc0779fd2f0f26c97fec5e36542b4e/altair-6.1.0-py3-none-any.whl", hash = "sha256:fdf5fd939512e5b2fc4441c82dfd2635e706defbd037db0ac429ef5ddce66c3b", size = 796996 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -18,6 +34,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 },
]
[[package]]
name = "appnope"
version = "0.1.4"
@@ -36,6 +64,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047 },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
name = "cachetools"
version = "7.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/c1/67cfb86aa21144796ff51068326d467fbef8ee42f8d08a3a8a926106cf0c/cachetools-7.1.3.tar.gz", hash = "sha256:135cfe944bc3c1e805505f65dae0bef375a2f96261171ab66c79ef77d0bda39d", size = 45780 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/52/8ff5c1a3b2e821ced9b2998fba3ee29aa4525c0bf51e5ee55dd6f61a4ed5/cachetools-7.1.3-py3-none-any.whl", hash = "sha256:9876787e2346e20584d5cca236cb5d49d04e7193de91646f230725b2e1e8b804", size = 16763 },
]
[[package]]
name = "certifi"
version = "2026.4.22"
@@ -147,6 +202,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 },
]
[[package]]
name = "click"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147 },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -388,6 +455,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/3f/cf4653f527f9d131cb0a24a76d1b5e6ba58f8377e3ce43fd120720c1dbfe/garth-0.8.0-py3-none-any.whl", hash = "sha256:93006876194e64aa1754bd68db37de9ca2cc46a282c839b19c760f261a976a9d", size = 43324 },
]
[[package]]
name = "gitdb"
version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smmap" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 },
]
[[package]]
name = "gitpython"
version = "3.1.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507 },
]
[[package]]
name = "googleapis-common-protos"
version = "1.75.0"
@@ -400,6 +491,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 },
]
[[package]]
name = "idna"
version = "3.14"
@@ -488,6 +610,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
]
[[package]]
name = "jedi"
version = "0.20.0"
@@ -512,6 +643,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
]
[[package]]
name = "jupyter-client"
version = "8.8.0"
@@ -762,6 +920,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "narwhals"
version = "2.21.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201 },
]
[[package]]
name = "nest-asyncio"
version = "1.6.0"
@@ -841,6 +1008,7 @@ dependencies = [
{ name = "jinja2" },
{ name = "matplotlib" },
{ name = "pandas" },
{ name = "streamlit" },
]
[package.dev-dependencies]
@@ -857,6 +1025,7 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "matplotlib", specifier = ">=3.10.9" },
{ name = "pandas", specifier = ">=3.0.3" },
{ name = "streamlit", specifier = ">=1.40" },
]
[package.metadata.requires-dev]
@@ -1185,6 +1354,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 },
]
[[package]]
name = "pyarrow"
version = "24.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759 },
{ url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471 },
{ url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981 },
{ url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172 },
{ url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733 },
{ url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335 },
{ url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748 },
{ url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554 },
{ url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301 },
{ url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929 },
{ url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365 },
{ url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819 },
{ url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252 },
{ url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127 },
{ url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997 },
{ url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720 },
{ url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852 },
{ url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852 },
{ url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207 },
{ url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117 },
{ url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155 },
{ url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387 },
{ url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102 },
{ url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118 },
{ url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765 },
{ url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890 },
{ url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250 },
{ url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282 },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -1287,6 +1492,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964 },
]
[[package]]
name = "pydeck"
version = "0.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615 },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -1356,6 +1574,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
]
[[package]]
name = "python-multipart"
version = "0.0.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640 },
]
[[package]]
name = "pyzmq"
version = "27.1.0"
@@ -1399,6 +1626,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170 },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
]
[[package]]
name = "requests"
version = "2.34.0"
@@ -1440,6 +1680,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -1449,6 +1755,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "smmap"
version = "5.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390 },
]
[[package]]
name = "stack-data"
version = "0.6.3"
@@ -1463,6 +1778,71 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 },
]
[[package]]
name = "streamlit"
version = "1.57.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altair" },
{ name = "anyio" },
{ name = "blinker" },
{ name = "cachetools" },
{ name = "click" },
{ name = "gitpython" },
{ name = "httptools" },
{ name = "itsdangerous" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pandas" },
{ name = "pillow" },
{ name = "protobuf" },
{ name = "pyarrow" },
{ name = "pydeck" },
{ name = "python-multipart" },
{ name = "requests" },
{ name = "starlette" },
{ name = "tenacity" },
{ name = "toml" },
{ name = "typing-extensions" },
{ name = "uvicorn" },
{ name = "watchdog", marker = "platform_system != 'Darwin'" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216 },
]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 },
]
[[package]]
name = "tornado"
version = "6.5.5"
@@ -1528,6 +1908,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301 },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 },
]
[[package]]
name = "wcwidth"
version = "0.7.0"
@@ -1537,6 +1948,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825 },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
]
[[package]]
name = "wrapt"
version = "1.17.3"