Skip to content

Add GPS to FIT — verify, close edge cases, surface in verification doc #466

Open

Description

@MaximumTrainer

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.ts writes positionLat / positionLong as sint32 semicircles (toSemicircles with SEMICIRCLES_PER_DEGREE = 2^31 / 180). Lat/lng columns are dropped entirely when no sample has a position (withoutEmptyFields). Hand-written encoder; @garmin/fitsdk stays a devDependency.
  • Sampler: src/lib/services/activitySampler.ts is wired to the route — start(routeCoordinates) caches the polyline + cumulativeLengths and computes routeIsLoop via isClosedLoop; record(data, elapsedSeconds, distance) interpolates lat/lng via positionAt → interpolateAlong from src/lib/utils/polylineGeometry.ts. Loop wrap and open-route clamp are already implemented (positionAt wraps distance % routeTotalMetres on a closed loop, clamps otherwise), with LOOP_CLOSURE_EPSILON_METRES = 10 (D1(b)) exported from the same file.
  • Save/upload: src/lib/ui/SessionSummary.svelte dynamically imports the encoder (encode() → import('../services/fitEncoderService')) and uploads via intervalsIcuActivityService.uploadActivity(...) (handleSave); the same encode(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 in SessionSummary.svelte gated on routeGeometrySource === 'gate-chain' (D2(a)).
  • Sampler is started/fed from the workout flow in src/lib/services/workoutService.ts, with distance in 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.md mentions 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 in activitySampler.positionAt already 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's distance/elapsedSeconds pairing (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, as App.tsx:529 did; verify the demo row either records GPS consistently or drops the fields (withoutEmptyFields branch) rather than mid-stream nulls.
  • Download-.fit parity with the uploaded bytes — the same encode(session) call is used in SessionSummary.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/fitsdk remains a devDependency; nothing in src/lib/ or src/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 in vitest.config.ts when 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 (Garmin FitCSVTool.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 the SessionSummary.svelte upload path, the end-to-end walk in docs/fit-upload-verification.md is required per CLAUDE.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 + the external_id.

Acceptance criteria

  1. AC1 (FR1, FR2) — A unit test on encodeSession with a non-trivial route (≥10 coordinates, ≥5 km) asserts every record has non-null positionLat / positionLong and that each value equals toSemicircles(interpolateAlong(route, sample.distance)) within 1 semicircle.
  2. AC2 (FR1, FR3) — The round-trip test decodes the fixture with @garmin/fitsdk and asserts records[i].positionLat/Long lie inside the fixture route's bounding box, inclusive of the first and last coordinates.
  3. AC3 (FR3) — A unit test with routeCoordinates = [] (and a second with length === 1) asserts the encoded file contains no positionLat / positionLong field in any record message (field dropped globally, not nulled per-record).
  4. 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 in positionAt; this AC locks it against regression.)
  5. 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.
  6. AC6 (FR6) — With geometrySource: 'gate-chain', SessionSummary.svelte renders 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 other geometrySource. (The chip already exists; this AC locks the gating and ports the former React-Testing-Library assertion to Svelte Testing Library.)
  7. AC7 (FR7) — A test asserts that the bytes passed to intervalsIcuActivityService.uploadActivity are strictly equal (Uint8Array deep-equal) to the bytes delivered by the Download-.fit code path for the same WorkoutSession.
  8. AC8 (FR8) — docs/fit-upload-verification.md gains 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.)
  9. AC9 (NFR1, NFR2) — A static test asserts @garmin/fitsdk is not imported from src/lib/ or src/routes/. A bundle-size assertion keeps the FIT encoder chunk within +1 KB gzipped of the branch point.
  10. AC10 (NFR5) — npm run test:fit-csv runs clean on a loop-route sample and a gate-chain sample (two new fixtures under src/__tests__/__fixtures__/). CI job stays optional per existing policy (skip when JAR / java missing).
  11. 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_METRES in activitySampler.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: boolean carried 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.event field + 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-webgpu branch 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 under src/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

  1. changed the title [-]Add GPS to fit - placeholder[/-] [+]Add GPS to FIT — verify, close edge cases, surface in verification doc[/+] on Oct 2, 2026
  2. added 3 commits that reference this issue on Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions