saving
This commit is contained in:
204
QUICKSTART.md
Normal file
204
QUICKSTART.md
Normal 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 24–72 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 1–5 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 30–60 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
422
README.md
@@ -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 (~150–180 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 (~150–180 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.8–1.3, > 1.5 = aggressive ramp / injury risk. Implemented inline in [02_running.ipynb](notebooks/02_running.ipynb).
|
||||
Sweet spot ~0.8–1.3, > 1.5 = aggressive ramp / injury risk. Surfaced on the Dashboard.
|
||||
|
||||
### Aerobic efficiency — "m/beat"
|
||||
|
||||
@@ -168,24 +205,22 @@ Sweet spot ~0.8–1.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
|
||||
- **5–10%** — sustainable
|
||||
- **> 10%** — pacing unsustainable for the distance, OR fueling shortfall, OR heat
|
||||
Friel's thresholds (interpret on **steady aerobic** efforts only):
|
||||
- **< 5 %** — aerobically developed
|
||||
- **5–10 %** — sustainable
|
||||
- **> 10 %** — pacing unsustainable for the distance, OR fueling shortfall, OR heat
|
||||
|
||||
In this dataset the per-second number is consistently **7–15 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: 102–122 (recovery)
|
||||
Z2: 123–143 (easy aerobic — long-run target)
|
||||
Z3: 144–164 (tempo / "junk-miles middle")
|
||||
Z4: 165–185 (threshold — LTHR=182 sits inside Z4)
|
||||
Z5: 186–209 (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 (144–164, 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: 140–200 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:30–6: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 24–72 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 30–60 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.
|
||||
|
||||
172
ROADMAP.md
172
ROADMAP.md
@@ -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%` / `5–10%` 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.**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
BIN
ruvector.db
BIN
ruvector.db
Binary file not shown.
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
163
src/openrun/ingest/manual.py
Normal file
163
src/openrun/ingest/manual.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,8 @@ EXPECTED_TABLES = (
|
||||
"daily_body_battery",
|
||||
"daily_intensity_minutes",
|
||||
"daily_resting_hr",
|
||||
"manual_activities",
|
||||
"race_plan",
|
||||
"sync_state",
|
||||
)
|
||||
|
||||
|
||||
6
src/openrun/web/__init__.py
Normal file
6
src/openrun/web/__init__.py
Normal 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
126
src/openrun/web/_helpers.py
Normal 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
70
src/openrun/web/app.py
Normal 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()
|
||||
24
src/openrun/web/launcher.py
Normal file
24
src/openrun/web/launcher.py
Normal 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()
|
||||
362
src/openrun/web/pages/0_Welcome.py
Normal file
362
src/openrun/web/pages/0_Welcome.py
Normal 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()
|
||||
204
src/openrun/web/pages/1_Dashboard.py
Normal file
204
src/openrun/web/pages/1_Dashboard.py
Normal 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.8–1.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)
|
||||
136
src/openrun/web/pages/2_Activities.py
Normal file
136
src/openrun/web/pages/2_Activities.py
Normal 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")
|
||||
267
src/openrun/web/pages/3_Race_Plan.py
Normal file
267
src/openrun/web/pages/3_Race_Plan.py
Normal 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 1–13: weeks 1–3 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")
|
||||
67
src/openrun/web/pages/4_Manual_Log.py
Normal file
67
src/openrun/web/pages/4_Manual_Log.py
Normal 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)
|
||||
229
src/openrun/web/pages/5_Sync.py
Normal file
229
src/openrun/web/pages/5_Sync.py
Normal 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.
|
||||
"""
|
||||
)
|
||||
221
src/openrun/web/pages/6_Recovery.py
Normal file
221
src/openrun/web/pages/6_Recovery.py
Normal 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")
|
||||
152
src/openrun/web/pages/7_Efficiency.py
Normal file
152
src/openrun/web/pages/7_Efficiency.py
Normal 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", "5–8", "8–12", "12–18", "18–26", "26–35", "35–50", "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)
|
||||
193
src/openrun/web/pages/8_Activity_Detail.py
Normal file
193
src/openrun/web/pages/8_Activity_Detail.py
Normal 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.")
|
||||
59
src/openrun/web/pages/Home.py
Normal file
59
src/openrun/web/pages/Home.py
Normal 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")
|
||||
92
tests/integration/test_manual_in_banister.py
Normal file
92
tests/integration/test_manual_in_banister.py
Normal 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
97
tests/unit/test_auth.py
Normal 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"
|
||||
105
tests/unit/test_config_writer.py
Normal file
105
tests/unit/test_config_writer.py
Normal 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 == ()
|
||||
135
tests/unit/test_fit_download.py
Normal file
135
tests/unit/test_fit_download.py
Normal 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
110
tests/unit/test_manual.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
65
tests/unit/test_run_sync.py
Normal file
65
tests/unit/test_run_sync.py
Normal 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
|
||||
@@ -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
447
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user