Repository navigation
Add GPS to FIT — verify, close edge cases, surface in verification doc #466
Description
Original description
calculate gps for rowed route & add to fit file activity save
Left verbatim so the original intent stays visible; everything below elaborates it.
Context
GPS already lands in the saved FIT file today — the plumbing shipped with the FIT encoder and is covered by unit and round-trip tests. On the next/svelte-webgpu rebuild branch (epic #542) this logic is framework-free and has already moved under src/lib/; the three.js scene and React app it once sat beside are being deleted (PR #587). The ask in #466 therefore reads as the last-mile work around that plumbing: confirm the polyline actually renders on intervals.icu, close the known edge cases (gate-chain routes, loops, paused seconds, demo rows), and document the GPS trace in the verification runbook so a future change can't regress it silently.
Status on the branch: several of the edge cases below are already implemented in the Svelte+WebGPU port and just need verification/tests locked in — see the per-FR notes.
Grounding in the current code on next/svelte-webgpu (so the FR/AC below stay testable):
- Encoder:
src/lib/services/fitEncoderService.tswritespositionLat/positionLongas sint32 semicircles (toSemicircleswithSEMICIRCLES_PER_DEGREE = 2^31 / 180). Lat/lng columns are dropped entirely when no sample has a position (withoutEmptyFields). Hand-written encoder;@garmin/fitsdkstays a devDependency. - Sampler:
src/lib/services/activitySampler.tsis wired to the route —start(routeCoordinates)caches the polyline +cumulativeLengthsand computesrouteIsLoopviaisClosedLoop;record(data, elapsedSeconds, distance)interpolates lat/lng viapositionAt→interpolateAlongfromsrc/lib/utils/polylineGeometry.ts. Loop wrap and open-route clamp are already implemented (positionAtwrapsdistance % routeTotalMetreson a closed loop, clamps otherwise), withLOOP_CLOSURE_EPSILON_METRES = 10(D1(b)) exported from the same file. - Save/upload:
src/lib/ui/SessionSummary.sveltedynamically imports the encoder (encode()→import('../services/fitEncoderService')) and uploads viaintervalsIcuActivityService.uploadActivity(...)(handleSave); the sameencode(session)output drives the "⤓ Download .fit" button (handleDownload). No sample reshaping happens between sampler and encoder. The gate-chain schematic-trace notice is already implemented as a persistent chip inSessionSummary.sveltegated onrouteGeometrySource === 'gate-chain'(D2(a)). - Sampler is started/fed from the workout flow in
src/lib/services/workoutService.ts, withdistancein metres (src/lib/types/index.ts). - Tests: encoder/sampler/round-trip specs live under the branch's unit suite (Vitest); the independent reader test imports
@garmin/fitsdk(devDep) and asserts semicircle bounds. - Verification runbook:
docs/fit-upload-verification.mdmentions the map polyline but has no explicit lat/lng assertion step.
Known gaps (basis for the FRs below):
- Gate-chain routes (
geometrySource: 'gate-chain'—src/lib/types/index.ts) produce a straight-line polyline between start and finish gates. Sampler still interpolates along that line, so uploaded GPS runs through land. Current behaviour is "legal but ugly"; the schematic-trace chip (FR6) is already present. - Loops — rownative routes may close on themselves;
isClosedLoop+ the modulo wrap inactivitySampler.positionAtalready handle a loop rowed past the join. Needs its test locked in (AC4). - Paused seconds — the sampler keeps "first packet per elapsed second"; elapsed-time semantics during a pause depend on
workoutService.ts'sdistance/elapsedSecondspairing (pauseSession/resumeSession); a pause should not emit records with the pre-pause lat/lng at new timestamps. - Demo rows — the demo row is started from
src/routes/row/+page.svelte/ the ride-session store (src/lib/ui/rideSession.svelte.ts) with the selected route's coordinates, asApp.tsx:529did; verify the demo row either records GPS consistently or drops the fields (withoutEmptyFieldsbranch) rather than mid-stream nulls. - Download-.fit parity with the uploaded bytes — the same
encode(session)call is used inSessionSummary.svelte, but there is no explicit test asserting they are byte-identical.
Functional requirements
FR1 — GPS written for every route-backed session
For any session started with a WaterRoute whose coordinates.length >= 2 (every current geometrySource), every FIT record message written by fitEncoderService.encodeSession SHALL include non-null positionLat / positionLong fields, interpolated along the route polyline at the session's distance-along-route for that sample. No record SHALL have one of the two fields present and the other absent.
FR2 — Values match the route geometry
The lat/lng emitted at ActivitySample.distance = d SHALL equal interpolateAlong(route.coordinates, d) to the semicircle (one semicircle ≈ 1.86 cm). Units: input in { lat, lng } degrees per the project convention; output in FIT sint32 semicircles via toSemicircles (src/lib/services/fitEncoderService.ts).
FR3 — Routeless and no-geometry sessions
A session started with no route (demo row with no coordinates, or coordinates.length < 2) SHALL continue to produce a valid FIT file with positionLat / positionLong columns omitted entirely (current withoutEmptyFields behaviour in fitEncoderService.ts). The uploaded file MUST still accept at intervals.icu.
FR4 — Loops
For a route whose first and last coordinate are within LOOP_CLOSURE_EPSILON_METRES (D1), a session that exceeds the loop length SHALL continue to emit lat/lng by wrapping distance modulo the loop length (in the sampler, not in the encoder). An open route whose distance exceeds the polyline length SHALL clamp to the last coordinate rather than extrapolate. Already implemented in activitySampler.positionAt via isClosedLoop + modulo wrap; this FR is now a regression-lock, not new work.
FR5 — Paused seconds
During a pause (as determined by workoutService.ts session state — pauseSession/resumeSession), the sampler SHALL NOT emit a record message. Resuming after a pause SHALL emit the next record at the resumed elapsed-time with the lat/lng at the resumed distance; no "frozen" lat/lng at advancing timestamps.
FR6 — Gate-chain route policy
When the rowed route's geometrySource === 'gate-chain' (src/lib/types/index.ts), the sampler SHALL still interpolate GPS along the straight-line polyline, but the save/upload UI (src/lib/ui/SessionSummary.svelte) SHALL show a one-line notice that the GPS trace is a schematic start→finish line, not a real-water track. Already implemented as a persistent chip gated on the routeGeometrySource prop (D2(a)); this FR is now a regression-lock.
FR7 — Download-.fit parity
The bytes behind the "⤓ Download .fit" button (handleDownload in SessionSummary.svelte) SHALL be byte-identical to the bytes posted to intervals.icu for the same session (same encode(session) output, no re-encoding or sample reshaping between the two paths).
FR8 — Verification runbook step
docs/fit-upload-verification.md SHALL gain a step asserting that, for a route-backed row, the uploaded activity on intervals.icu displays the expected GPS polyline on the map (not a straight line between distant points, not an empty map), with a reference screenshot location and the external_id attachment convention already used by #446.
Non-functional requirements
- NFR1 — No new runtime dependency.
@garmin/fitsdkremains adevDependency; nothing insrc/lib/orsrc/routes/imports it. The encoder stays hand-written. - NFR2 — Bundle size. The FIT encoder chunk (dynamically imported by
SessionSummary.svelte) SHALL NOT grow by more than 1 KB gzipped vs. the branch point. - NFR3 — Physics isolation. Lat/lng interpolation is already O(log n) per sample via cached
cumulativeLengths; any added work (pause gating) MUST stay off the WebGPU frame loop (src/lib/renderer/engine/rideFrame.ts/frameGraph.ts) and MUST NOT raise the 1 Hz sampler cost measurably. - NFR4 — Coverage ratchet. Per
CLAUDE.md, raise thresholds invitest.config.tswhen the change improves coverage; never lower. New branches (pause gating, any remaining loop/gate-chain assertions) land with unit tests. - NFR5 — FIT structural gate.
npm run test:fit-csv(GarminFitCSVTool.jar,tools/) MUST pass locally on a sample session; the round-trip test SHALL extend its position-bounds check to include a loop case and a gate-chain case. - NFR6 — Real upload verification. When this PR touches
src/lib/services/fitEncoderService.ts,src/lib/services/activitySampler.ts,src/lib/services/intervalsIcuActivityService.ts, or theSessionSummary.svelteupload path, the end-to-end walk indocs/fit-upload-verification.mdis required perCLAUDE.md(Test & verify FIT upload to intervals.icu end-to-end: decoder-gated fixture, opt-in live contract check, documented manual verification #446 policy): attach the activity URL + a map screenshot + theexternal_id.
Acceptance criteria
- AC1 (FR1, FR2) — A unit test on
encodeSessionwith a non-trivial route (≥10 coordinates, ≥5 km) asserts everyrecordhas non-nullpositionLat/positionLongand that each value equalstoSemicircles(interpolateAlong(route, sample.distance))within 1 semicircle. - AC2 (FR1, FR3) — The round-trip test decodes the fixture with
@garmin/fitsdkand assertsrecords[i].positionLat/Longlie inside the fixture route's bounding box, inclusive of the first and last coordinates. - AC3 (FR3) — A unit test with
routeCoordinates = [](and a second withlength === 1) asserts the encoded file contains nopositionLat/positionLongfield in anyrecordmessage (field dropped globally, not nulled per-record). - AC4 (FR4, loops) — A unit test with a closed loop (first ≈ last within
LOOP_CLOSURE_EPSILON_METRES) and a session distance = 1.5 × loop-length asserts the last emitted lat/lng equals the lat/lng at 0.5 × loop-length (within 1 semicircle). An open-route counterpart asserts clamp to the final coordinate. (The sampler already wraps/clamps inpositionAt; this AC locks it against regression.) - AC5 (FR5) — A sampler test that pauses after N seconds and resumes M seconds later asserts: (a) no sample is recorded during the pause interval, (b) the first post-resume sample's elapsed-time is contiguous (no fabricated frozen-position seconds), (c) its lat/lng matches the resumed distance.
- AC6 (FR6) — With
geometrySource: 'gate-chain',SessionSummary.svelterenders a visible, accessible notice ("GPS trace is a schematic line between start and finish"); a Svelte component test (@testing-library/svelte) asserts the notice appears for'gate-chain'and is absent for every othergeometrySource. (The chip already exists; this AC locks the gating and ports the former React-Testing-Library assertion to Svelte Testing Library.) - AC7 (FR7) — A test asserts that the bytes passed to
intervalsIcuActivityService.uploadActivityare strictly equal (Uint8Arraydeep-equal) to the bytes delivered by the Download-.fit code path for the sameWorkoutSession. - AC8 (FR8) —
docs/fit-upload-verification.mdgains an explicit "GPS polyline on map" step with: route choice, expected map shape, pass/fail criteria, and where to paste the activity URL + screenshot in the PR body. (Follows the Test & verify FIT upload to intervals.icu end-to-end: decoder-gated fixture, opt-in live contract check, documented manual verification #446 pattern.) - AC9 (NFR1, NFR2) — A static test asserts
@garmin/fitsdkis not imported fromsrc/lib/orsrc/routes/. A bundle-size assertion keeps the FIT encoder chunk within +1 KB gzipped of the branch point. - AC10 (NFR5) —
npm run test:fit-csvruns clean on a loop-route sample and a gate-chain sample (two new fixtures undersrc/__tests__/__fixtures__/). CI job stays optional per existing policy (skip when JAR /javamissing). - AC11 (NFR6) — The PR body carries the intervals.icu activity URL, a screenshot of the map polyline, and the
external_id, per Test & verify FIT upload to intervals.icu end-to-end: decoder-gated fixture, opt-in live contract check, documented manual verification #446's attachment convention.
Open decisions
- D1 — Loop closure epsilon. The metres below which first-coord ≈ last-coord counts as a loop (FR4). Resolved on the branch as D1(b) = 10 m (
LOOP_CLOSURE_EPSILON_METRESinactivitySampler.ts). Options retained for the record:- D1(a) 2 m (strict).
- D1(b) 10 m (lenient; forgives GPX export rounding) — in force.
- D1(c) an explicit
isLoop: booleancarried from import.
- D2 — Gate-chain notice persistence. Resolved as D2(a) — a persistent chip on the SessionSummary panel (implemented). Alternatives: D2(b) one-shot toast; D2(c) FIT
activity.eventfield + map legend hint. - D3 — Elevation. D3(a) leave altitude unset (current) — default. D3(b) constant sea-level; D3(c) elevation lookup (out of scope).
- D4 — Pause representation in FIT. D4(a) sampler drops records during pause; FIT has a time gap — default. D4(b) also write
event { timer stop_all / start }around the pause. - D5 — Demo-row GPS. D5(a) yes, matches live rows — default. D5(b) no, demo rows drop lat/lng. (Demo starts from
src/routes/row/+page.svelte/rideSession.svelte.ts.) - D6 — Lat/lng precision check. D6(a) no snapping — default. D6(b) round to source polyline precision.
Each D-item above should be closed with a short comment before FR1–FR8 are considered locked (D1/D2 already closed in code).
Suggested phasing (non-binding)
- Phase 1 — Verification & doc. AC8, AC11, AC1 extension (FR1/FR2 regression test). No behaviour change; proves the current GPS already works end-to-end.
- Phase 2 — Edge cases. AC3 (no-route), AC4 (loops / clamp — lock existing behaviour), AC5 (pauses — the one genuinely new code path, in
activitySampler.ts+workoutService.ts). - Phase 3 — UX & parity. AC6 (gate-chain notice — lock existing chip), AC7 (download/upload parity), AC9 (import gate + bundle size), AC10 (FIT-CSV fixtures).
Out of scope (explicit)
- Elevation sourcing (D3(c)) — belongs in a separate issue if wanted.
- Rewriting routes that have no coordinates to synthesise some.
- Changing route coordinate order (
{lat, lng}internal,[lng, lat]on GeoJSON export per RFC 7946) — unchanged. - Multi-route stitching, replays, "ghost" GPS from past sessions.
Author notes
- Requirements re-grounded against the
next/svelte-webgpubranch for epic Rebuild virtualrow on Svelte + a Biketerra-model WebGPU renderer, on its own branch (epic) #542: the sampler, encoder, interpolation utility and SessionSummary now live undersrc/lib/and are framework-free; the former React component / three.js scene references are gone. The loop wrap, open-route clamp (FR4) and the gate-chain schematic-trace chip (FR6) are already implemented on the branch, so those FRs are regression-locks. The pause-gating path (FR5) is the main remaining new work. If any default recommendation (D1–D6) is wrong, push back before FR/AC lock.
Activity
[-]Add GPS to fit - placeholder[/-][+]Add GPS to FIT — verify, close edge cases, surface in verification doc[/+]#466 Phase 1 — verify GPS-in-FIT via encoder round-trip + verificatio…
#466 Phase 3 — gate-chain notice + download/upload byte parity + @gar…
#466 Phase 2 — loop wrap, open-route clamp assertion, single-vertex A…