From db826f27e9196b931ca6892c2cfa2a78afd9683a Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 16 Mar 2026 10:57:16 +0100 Subject: [PATCH] adding spec --- rowing-courses-spec.md | 117 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/rowing-courses-spec.md b/rowing-courses-spec.md index 4459633d..4be9ec75 100644 --- a/rowing-courses-spec.md +++ b/rowing-courses-spec.md @@ -368,8 +368,8 @@ CREATE TABLE challenge_results ( corrected_time_s REAL, start_time TEXT NOT NULL, -- actual row start (from GPS) validation_status TEXT NOT NULL DEFAULT 'pending', - -- pending | valid | invalid | manual_ok - validation_note TEXT, + -- pending | valid | invalid | manual_ok | dq + validation_note TEXT, -- human-readable gate-by-gate log for debugging and athlete feedback submitted_at TEXT NOT NULL ); ``` @@ -439,14 +439,96 @@ If intervals.icu supports requesting multiple scopes in the initial grant (confi 2. Worker presents a list of the athlete's recent activities fetched from `GET /api/v1/athlete/{id}/activities`. 3. Athlete selects the relevant activity. 4. Worker fetches GPS stream: `GET /api/v1/athlete/{id}/activities/{activity_id}/streams?streams=latlng,time`. -5. Runs polygon intersection: for each polygon in the course (in order), scan the GPS track for a point inside the polygon. Record the timestamp of first entry. Elapsed time = finish entry time − start entry time. +5. Worker runs the course validation pipeline (see below). 6. Validates against challenge time window: `start_time` must be within `row_start` ↔ `row_end`. -7. Validates that all polygons were passed in order. -8. Sets `validation_status = valid` and `raw_time_s` from the computed elapsed time. Any failure sets `validation_status = invalid` with a descriptive `validation_note`. +7. Sets `validation_status = valid` and `raw_time_s` from the computed elapsed time. Any failure sets `validation_status = invalid` with a descriptive `validation_note`. The validation log is stored in `validation_note` so organisers and athletes can see exactly which gates were passed and when. -The polygon intersection logic is a TypeScript port of `coursetime_paths()` from `rowers/courseutils.py`. The point-in-polygon test uses the ray casting algorithm (same as the existing `coordinate_in_path()` in `rowers/models.py`). +**Submission window enforcement** is a simple timestamp check before any GPS fetching: `now() > challenge.submit_end` returns 403 immediately. -**Submission window enforcement** is a simple timestamp check at step 8: `now() > challenge.submit_end` returns 403 before any GPS fetching. +**Course validation pipeline** + +The authoritative reference implementation is `handle_check_race_course()` in `rowers/tasks.py` (line 1299). The TypeScript port must preserve all of the following behaviour. + +**Step 1 — GPS track preparation:** + +The intervals.icu stream returns `latlng` (array of `[lat, lon]` pairs) and `time` (array of elapsed seconds) as separate arrays. These are zipped into an array of `{lat, lon, time}` objects, then the track is resampled to 100ms resolution using linear interpolation between consecutive samples: + +```typescript +function interpolateTrack( + points: {lat: number, lon: number, time: number}[], + intervalMs = 100 +): {lat: number, lon: number, time: number}[] { + const result = []; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i], b = points[i + 1]; + const steps = Math.ceil((b.time - a.time) * 1000 / intervalMs); + for (let s = 0; s < steps; s++) { + const t = s / steps; + result.push({ + lat: a.lat + t * (b.lat - a.lat), + lon: a.lon + t * (b.lon - a.lon), + time: a.time + t * (b.time - a.time), + }); + } + } + result.push(points[points.length - 1]); + return result; +} +``` + +This step is critical. GPS watches typically record at 1s intervals, giving ~4m resolution at rowing pace. Without interpolation, a narrow gate polygon can be missed entirely if consecutive samples land on either side of it with none inside. At 100ms resolution the gap is ~40cm, sufficient for any gate polygon in practice. The Python version uses pandas `resample('100ms')` + `interpolate()` which is slow due to DataFrame overhead; the TypeScript array implementation performs the identical calculation in single-digit milliseconds. + +**Step 2 — Multi-pass detection:** + +Many rowers warm up by rowing through part or all of the course before their actual timed attempt. The algorithm must not use the first passage through the start polygon — it must find the best completed passage. + +All entry times through the start polygon are found (not just the first), using `time_in_path(..., getall=True)`. For each entry time, the algorithm attempts to complete the full polygon chain from that point forward using `coursetime_paths()`. All completed attempts are collected; if none complete, the course is marked invalid. + +**Step 3 — Net time calculation:** + +For each completed attempt: +- `endsecond` = time of exit through finish polygon +- `startsecond` = time of exit through start polygon (not entry — the clock starts when the rower clears the start gate, matching real racing practice) +- `net_time = endsecond − startsecond` + +The best (lowest) net time across all completed attempts is the official result. + +**Step 4 — Logging:** + +A per-submission log is written recording: each start polygon entry time found, each gate passage time, whether each attempt completed, and the final selected time. This log is stored in `validation_note` in D1. It serves two purposes: +- Organisers can inspect it to verify or override a result. +- Athletes whose submission was rejected can see exactly which gate they missed and approximately where their GPS track diverged from the course. + +The log format should be human-readable plain text, matching the style of the existing Rowsandall course log files. Example: + +``` +Course id 66, Record id 12345 +Found 2 entrytimes +Path starting at 142.3s + Gate 0 (Start): passed at 142.3s, 0m + Gate 1 (WP1): passed at 198.7s, 245m + Gate 2 (Finish): passed at 287.1s, 498m + Course completed: true, net time: 144.8s +Path starting at 412.1s + Gate 0 (Start): passed at 412.1s, 0m + Gate 1 (WP1): passed at 469.4s, 247m + Gate 2 (Finish): passed at 554.2s, 501m + Course completed: true, net time: 142.1s +Best time: 142.1s (attempt 2) +``` + +**Step 5 — Points calculation (handicap):** + +``` +velo = course_distance_m / net_time_s +points = 100 × (2 − reference_speed / velo) +``` + +Where `reference_speed` is the athlete's registered category reference speed from `course_standards`. Points are stored alongside raw and corrected times. + +**Point-in-polygon implementation:** + +Both `coordinate_in_path()` and `coursetime_paths()` from `rowers/courseutils.py` are ported directly to TypeScript. The point-in-polygon test uses the standard ray casting algorithm — no external library needed. The recursive structure of `coursetime_paths()` (pass start, slice remaining track, recurse for next gate) is preserved exactly. ### 2.4 Challenge organiser interface @@ -495,7 +577,14 @@ wrangler dev - [ ] Extend OAuth token scope to `ACTIVITY_READ` (or confirm it was included at Stage 1) - [ ] Activity list fetch from intervals.icu - [ ] GPS stream fetch from intervals.icu -- [ ] Polygon intersection engine (TypeScript port of `courseutils.py`) +- [ ] GPS track interpolation to 100ms resolution (`interpolateTrack()`) +- [ ] Point-in-polygon ray casting (`pointInPolygon()`, port of `coordinate_in_path()`) +- [ ] Single-gate time detection (`timeInPath()`, port of `time_in_path()`) +- [ ] Multi-gate course time (`coursetimePaths()`, port of `coursetime_paths()`) +- [ ] Multi-pass detection — all start entries, best completed time wins +- [ ] Net time calculation (start exit to finish exit, not GPS-start to finish) +- [ ] Validation log generation (gate-by-gate, stored in `validation_note`) +- [ ] Points calculation (`100 × (2 − reference_speed / velo)`) - [ ] Challenge CRUD endpoints - [ ] Standard collection CSV upload and parser - [ ] Handicap scoring computation @@ -533,5 +622,13 @@ wrangler dev 7. **intervals.icu OAuth scope strategy.** If `PROFILE_READ` and `ACTIVITY_READ` can be combined in a single OAuth grant, both should be requested at Stage 1 login. This avoids a re-authorisation prompt when Stage 2 launches. If intervals.icu only supports one scope per grant, Stage 2 users will need to re-authorise — acceptable but slightly awkward. Confirm with David Tinker before finalising the Stage 1 OAuth implementation. -8. **Rowsandall ZIP export feature.** Build a "Download my courses" button in the existing Rowsandall Django app, producing a ZIP of owned course KML files plus a `manifest.json` with owned and liked course ID lists (no account data, no activity data). This is a Rowsandall deliverable, not a new-platform deliverable, and should be scoped and scheduled separately. The new platform's import endpoint (parse ZIP, submit courses as provisional PRs, restore liked list in KV) is a Stage 1 deliverable. The Rowsandall export should be live well before the shutdown announcement so users have time to act on it. - +8. **Rowsandall ZIP export feature.** Build a "Download my courses" button in + the existing Rowsandall Django app, producing a ZIP of owned course KML files + plus a `manifest.json` with owned and liked course ID lists (no account data, + no activity data). This is a Rowsandall deliverable, not a new-platform + deliverable, and should be scoped and scheduled separately. The new + platform's import endpoint (parse ZIP, submit courses as provisional PRs, + restore liked list in KV) is a Stage 1 deliverable. The Rowsandall export + should be live well before the shutdown announcement so users have time to + act on it. +