Private
Public Access
1
0

adding spec

This commit is contained in:
2026-03-16 10:57:16 +01:00
parent 16291e135c
commit db826f27e9

View File

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