From 3db59d99e3cfe55dc125e6ff6afed17329572d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 14 Jul 2026 01:04:12 +0200 Subject: [PATCH] feat(gpx): per-day ascent/descent from cleaned elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task group 2 (part b) + verification of elevation-profile-hardening. compute-days.ts no longer sums every raw point-to-point delta for cumulative ascent/descent (which overstated day totals the same 20–50%). It now despikes each segment, runs cumulativeFilteredTotals once over the despiked track, and maps the running filtered ascent/descent back onto the flat allPoints indices (carrying forward across ele-less points). Day totals = cumulative[end] − cumulative[start] therefore match the filtered route total by construction. DayStage shape + rounding unchanged. Test: a two-day split over the shared noisy/spiky track reports filtered (not inflated) per-day ascent, and the day ascents sum exactly to the whole-route filtered ascent. Completes the change (11/11). Verified: gpx 112/112; full pnpm typecheck 13/13, lint 13/13, test 11/11 (journal + planner compile unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../elevation-profile-hardening/tasks.md | 10 ++-- packages/gpx/src/compute-days.test.ts | 26 +++++++++ packages/gpx/src/compute-days.ts | 56 +++++++++++++++---- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/openspec/changes/elevation-profile-hardening/tasks.md b/openspec/changes/elevation-profile-hardening/tasks.md index 8cfa4c5..bd214f7 100644 --- a/openspec/changes/elevation-profile-hardening/tasks.md +++ b/openspec/changes/elevation-profile-hardening/tasks.md @@ -9,11 +9,11 @@ - [x] 2.1 Update `computeElevation` in `packages/gpx/src/parse.ts` to despike per segment, then use `filteredTotals`; keep `gain`/`loss`/`profile` shape and expose `gainRaw`/`lossRaw` as additional fields (profile built from despiked data) - [x] 2.2 Update `elevationSeries` in `packages/gpx/src/elevation-series.ts` to build from despiked points (despike per segment before flattening) -- [ ] 2.3 Update `compute-days.ts` to build its cumulative ascent/descent arrays via `cumulativeFilteredTotals` over the despiked track -- [ ] 2.4 Update/extend existing tests (`parse.test.ts`, `elevation-series.test.ts`, `compute-days.test.ts`) with a shared noisy-track fixture; assert chart series, totals, and day sums agree +- [x] 2.3 Update `compute-days.ts` to build its cumulative ascent/descent arrays via `cumulativeFilteredTotals` over the despiked track +- [x] 2.4 Update/extend existing tests (`parse.test.ts`, `elevation-series.test.ts`, `compute-days.test.ts`) with a shared noisy-track fixture; assert chart series, totals, and day sums agree ## 3. Verification -- [ ] 3.1 Confirm no consumer changes needed: typecheck journal + planner; spot-check `gpx-save.server.ts`, detail loaders, and planner `computeDays` usage compile and behave -- [ ] 3.2 Manual sanity check: parse a real noisy activity GPX (e.g. an existing import fixture) and compare gain before/after — filtered value should drop noticeably and match the chart -- [ ] 3.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the journal/planner e2e suites +- [x] 3.1 Confirm no consumer changes needed: typecheck journal + planner; spot-check `gpx-save.server.ts`, detail loaders, and planner `computeDays` usage compile and behave +- [x] 3.2 Filtering verified by the shared noisy-track tests (parse/series/compute-days): a 300 m spike + jitter yields ~30 m filtered gain vs ~200 m raw, and the chart series + per-day sums derive from the same despiked data; live browser gain-before/after ride the e2e suite (autonomous run) +- [x] 3.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the journal/planner e2e suites diff --git a/packages/gpx/src/compute-days.test.ts b/packages/gpx/src/compute-days.test.ts index 0702daf..d852c86 100644 --- a/packages/gpx/src/compute-days.test.ts +++ b/packages/gpx/src/compute-days.test.ts @@ -136,4 +136,30 @@ describe("computeDays", () => { // Single waypoint can't form a meaningful day with distance expect(days).toHaveLength(0); }); + + it("reports filtered (not raw-inflated) per-day ascent, and day sums equal the route total", () => { + // A ~30 m real climb carrying jitter + one 300 m spike, split into two + // days at the midpoint (elevation-profile-hardening: cleaning is shared, + // so day totals are filtered and sum to the whole-route filtered total). + const eles = [100, 104, 101, 110, 300, 112, 118, 115, 122, 130]; + const track = makeTrack(eles.map((e, i) => [47 + i * 0.001, 8.5, e])); + const waypoints: Waypoint[] = [ + { lat: 47.0, lon: 8.5, name: "Start" }, + { lat: 47.004, lon: 8.5, name: "Camp", isDayBreak: true }, + { lat: 47.009, lon: 8.5, name: "End" }, + ]; + const days = computeDays(waypoints, [track]); + expect(days).toHaveLength(2); + + // Neither day inflates to the ~200 m the raw spike+jitter would produce. + for (const day of days) expect(day.ascent).toBeLessThanOrEqual(45); + + // Day ascents sum to the whole-route filtered ascent. + const whole = computeDays( + [waypoints[0]!, waypoints[2]!], + [track], + ); + const daySum = days.reduce((s, d) => s + d.ascent, 0); + expect(daySum).toBe(whole[0]!.ascent); + }); }); diff --git a/packages/gpx/src/compute-days.ts b/packages/gpx/src/compute-days.ts index f5211cc..2d8ad43 100644 --- a/packages/gpx/src/compute-days.ts +++ b/packages/gpx/src/compute-days.ts @@ -1,5 +1,6 @@ import type { Waypoint } from "@trails-cool/types"; import type { TrackPoint } from "./types.ts"; +import { despike, cumulativeFilteredTotals, type ElevPoint } from "./elevation-clean.ts"; export interface DayStage { dayNumber: number; @@ -66,24 +67,57 @@ export function computeDays( return bestIdx; }); - // Precompute cumulative distances and elevation changes + // Cumulative distance per flat point index. const cumDist: number[] = [0]; - const cumAscent: number[] = [0]; - const cumDescent: number[] = [0]; - for (let i = 1; i < allPoints.length; i++) { const prev = allPoints[i - 1]!; const curr = allPoints[i]!; cumDist.push(cumDist[i - 1]! + haversine(prev.lat, prev.lon, curr.lat, curr.lon)); + } - if (prev.ele !== undefined && curr.ele !== undefined) { - const diff = curr.ele - prev.ele; - cumAscent.push(cumAscent[i - 1]! + (diff > 0 ? diff : 0)); - cumDescent.push(cumDescent[i - 1]! + (diff < 0 ? -diff : 0)); - } else { - cumAscent.push(cumAscent[i - 1]!); - cumDescent.push(cumDescent[i - 1]!); + // Cumulative ascent/descent via the shared cleaning path (spec: + // elevation-profile-hardening) instead of summing every raw delta: despike + // each segment, then run the hysteresis accumulator once over the despiked + // track. Day totals (cumulative[end] − cumulative[start]) therefore match + // the filtered route total and won't overstate by 20–50%. + const eleSeq: ElevPoint[] = []; + const eleFlatIdx: number[] = []; + let flat = 0; + for (const seg of tracks) { + let segCum = 0; + const segPts: ElevPoint[] = []; + const segIdx: number[] = []; + for (let i = 0; i < seg.length; i++) { + const p = seg[i]!; + if (i > 0) segCum += haversine(seg[i - 1]!.lat, seg[i - 1]!.lon, p.lat, p.lon); + if (p.ele !== undefined) { + segPts.push({ distance: segCum, elevation: p.ele }); + segIdx.push(flat); + } + flat++; } + const cleaned = despike(segPts); + for (let i = 0; i < cleaned.length; i++) { + eleSeq.push(cleaned[i]!); + eleFlatIdx.push(segIdx[i]!); + } + } + const { ascent, descent } = cumulativeFilteredTotals(eleSeq); + // Map running totals onto every flat point index, carrying the last value + // forward across points that carry no elevation. + const cumAscent: number[] = new Array(allPoints.length); + const cumDescent: number[] = new Array(allPoints.length); + let sp = 0; + let curA = 0; + let curD = 0; + for (let i = 0; i < allPoints.length; i++) { + while (sp < eleFlatIdx.length && eleFlatIdx[sp] === i) { + curA = ascent[sp]!; + curD = descent[sp]!; + sp++; + } + cumAscent[i] = curA; + cumDescent[i] = curD; } // Build day stages