adding spec
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user