diff --git a/openspec/changes/elevation-profile-hardening/tasks.md b/openspec/changes/elevation-profile-hardening/tasks.md index a106214..8cfa4c5 100644 --- a/openspec/changes/elevation-profile-hardening/tasks.md +++ b/openspec/changes/elevation-profile-hardening/tasks.md @@ -7,8 +7,8 @@ ## 2. Wire into existing computations -- [ ] 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) -- [ ] 2.2 Update `elevationSeries` in `packages/gpx/src/elevation-series.ts` to build from despiked points (despike per segment before flattening) +- [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 diff --git a/packages/gpx/src/elevation-series.test.ts b/packages/gpx/src/elevation-series.test.ts index b1afd60..603807d 100644 --- a/packages/gpx/src/elevation-series.test.ts +++ b/packages/gpx/src/elevation-series.test.ts @@ -40,4 +40,24 @@ describe("elevationSeries", () => { expect(s[0]!.e).toBe(100); expect(s[s.length - 1]!.e).toBe(1099); }); + + it("despikes so the chart agrees with the cleaned stats (no spike survives)", () => { + // Same shape as parse.test.ts's noisy track: a 300 m single-point spike + // between two ~110 m points must be interpolated out of the chart series. + const eles = [100, 104, 101, 110, 300, 112, 118, 115, 122, 130]; + const seg = eles.map((e, i) => pt(47 + i * 0.001, 8.5, e)); + const s = elevationSeries([seg]); + expect(s).toHaveLength(eles.length); + expect(Math.max(...s.map((x) => x.e))).toBeLessThan(200); // spike gone + expect(s[0]!.e).toBe(100); // endpoints untouched + expect(s[s.length - 1]!.e).toBe(130); + }); + + it("does not interpolate a spike across a segment boundary", () => { + // A lone point that would look like a spike only if joined to the next + // segment must be left alone — despiking is per segment. + const s = elevationSeries([[pt(47, 8.5, 100), pt(47.001, 8.5, 500)], [pt(47.002, 8.5, 100), pt(47.003, 8.5, 110)]]); + // The 500 m point is an endpoint of its own segment → never despiked. + expect(s.some((x) => x.e === 500)).toBe(true); + }); }); diff --git a/packages/gpx/src/elevation-series.ts b/packages/gpx/src/elevation-series.ts index 3256d32..871f081 100644 --- a/packages/gpx/src/elevation-series.ts +++ b/packages/gpx/src/elevation-series.ts @@ -1,4 +1,5 @@ import type { TrackPoint } from "./types.ts"; +import { despike } from "./elevation-clean.ts"; export interface ElevationSample { /** Cumulative distance from start, metres */ @@ -29,23 +30,47 @@ function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number) * keeping the first and last sample. */ export function elevationSeries(tracks: TrackPoint[][], maxPoints = 400): ElevationSample[] { - const withEle: TrackPoint[] = []; - for (const seg of tracks) { - for (const p of seg) { - if (typeof p.ele === "number") withEle.push(p); - } - } + // Elevation-bearing points, tagged with their segment so despiking never + // interpolates across a trkseg gap. + const withEle: Array<{ p: TrackPoint; seg: number }> = []; + tracks.forEach((seg, si) => { + for (const p of seg) if (typeof p.ele === "number") withEle.push({ p, seg: si }); + }); if (withEle.length < 2) return []; - const full: ElevationSample[] = []; + // Cumulative distance across consecutive elevation points (chart x-axis). + const dists: number[] = []; let cum = 0; - let prev: TrackPoint | null = null; - for (const p of withEle) { - if (prev) cum += haversineMeters(prev.lat, prev.lon, p.lat, p.lon); - full.push({ d: cum, e: p.ele as number, lat: p.lat, lng: p.lon }); - prev = p; + for (let i = 0; i < withEle.length; i++) { + if (i > 0) { + cum += haversineMeters( + withEle[i - 1]!.p.lat, withEle[i - 1]!.p.lon, withEle[i]!.p.lat, withEle[i]!.p.lon, + ); + } + dists.push(cum); } + // Despike each segment's slice so the chart matches the headline stats + // (spec: elevation-profile-hardening). Only elevation is cleaned; distance + // and position are untouched. + const cleanE = new Array(withEle.length); + for (let start = 0; start < withEle.length; ) { + let end = start; + while (end + 1 < withEle.length && withEle[end + 1]!.seg === withEle[start]!.seg) end++; + const cleaned = despike( + withEle.slice(start, end + 1).map((x, i) => ({ distance: dists[start + i]!, elevation: x.p.ele as number })), + ); + for (let i = 0; i < cleaned.length; i++) cleanE[start + i] = cleaned[i]!.elevation; + start = end + 1; + } + + const full: ElevationSample[] = withEle.map((x, i) => ({ + d: dists[i]!, + e: cleanE[i]!, + lat: x.p.lat, + lng: x.p.lon, + })); + if (full.length <= maxPoints) return full; // Stride downsample, preserving first and last. diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts index 985541e..a893be9 100644 --- a/packages/gpx/src/parse.test.ts +++ b/packages/gpx/src/parse.test.ts @@ -198,6 +198,28 @@ describe("parseGpxAsync — route () support", () => { }); }); +// A ~30 m real climb (100 → 130) carrying a few metres of per-point jitter +// plus one 300 m single-point spike — shared with elevation-series.test.ts. +export const NOISY_ELEVATIONS = [100, 104, 101, 110, 300, 112, 118, 115, 122, 130]; +export const noisyTrackGpx = ` + +${NOISY_ELEVATIONS.map((e, i) => `${e}`).join("\n")} +`; + +describe("parseGpxAsync — noisy elevation cleaning", () => { + it("filters jitter (gain well below raw) and despikes the profile", async () => { + const r = await parseGpxAsync(noisyTrackGpx); + // Filtering removes jitter, so gain is far below the raw sum-of-deltas. + expect(r.elevation.gainRaw).toBeGreaterThan(r.elevation.gain); + // The real climb is ~30 m; filtered gain should reflect that, not the + // ~200 m the raw spike+jitter would produce. + expect(r.elevation.gain).toBeLessThanOrEqual(45); + expect(r.elevation.gain).toBeGreaterThan(20); + // The 300 m spike is interpolated out of the profile. + expect(Math.max(...r.elevation.profile.map((p) => p.elevation))).toBeLessThan(200); + }); +}); + describe("parseGpxAsync — metadata fallback", () => { const gpx = (body: string) => `\n${body}`; diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 5d8a8c5..d998043 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -1,6 +1,7 @@ import type { Waypoint } from "@trails-cool/types"; import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; import { repairTimestamps } from "./timestamp-repair.ts"; +import { despike, filteredTotals, type ElevPoint } from "./elevation-clean.ts"; /** * Parse a GPX XML string into structured data. @@ -173,31 +174,43 @@ function parseNoGoAreas(doc: Document): NoGoArea[] { function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } { let gain = 0; let loss = 0; + let gainRaw = 0; + let lossRaw = 0; const profile: ElevationProfile[] = []; let totalDistance = 0; + // Per segment: despike the elevation sequence (isolated GPS/barometer + // spikes interpolated out), then take noise-filtered ascent/descent via + // hysteresis — so a few metres of jitter per point no longer inflate gain + // by 20–50%, and the profile chart is built from the same cleaned data as + // the headline totals (spec: elevation-profile-hardening). for (const track of tracks) { + const eleSeq: ElevPoint[] = []; for (let i = 0; i < track.length; i++) { const pt = track[i]!; - if (i > 0) { - const prev = track[i - 1]!; - totalDistance += haversineDistance(prev.lat, prev.lon, pt.lat, pt.lon); - - if (pt.ele !== undefined && prev.ele !== undefined) { - const diff = pt.ele - prev.ele; - if (diff > 0) gain += diff; - else loss += Math.abs(diff); - } - } - - if (pt.ele !== undefined) { - profile.push({ distance: totalDistance, elevation: pt.ele }); + totalDistance += haversineDistance(track[i - 1]!.lat, track[i - 1]!.lon, pt.lat, pt.lon); } + if (pt.ele !== undefined) eleSeq.push({ distance: totalDistance, elevation: pt.ele }); } + if (eleSeq.length === 0) continue; + const cleaned = despike(eleSeq); + const totals = filteredTotals(cleaned); + gain += totals.gain; + loss += totals.loss; + gainRaw += totals.gainRaw; + lossRaw += totals.lossRaw; + for (const p of cleaned) profile.push({ distance: p.distance, elevation: p.elevation }); } - return { totalDistance: Math.round(totalDistance), gain: Math.round(gain), loss: Math.round(loss), profile }; + return { + totalDistance: Math.round(totalDistance), + gain: Math.round(gain), + loss: Math.round(loss), + gainRaw: Math.round(gainRaw), + lossRaw: Math.round(lossRaw), + profile, + }; } /** Haversine distance between two points in meters */ diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts index d1d7a70..fe12125 100644 --- a/packages/gpx/src/types.ts +++ b/packages/gpx/src/types.ts @@ -27,8 +27,14 @@ export interface GpxData { /** Total distance in meters (haversine, works with or without elevation data) */ distance: number; elevation: { + /** Noise-filtered ascent in metres (hysteresis; the headline number). */ gain: number; + /** Noise-filtered descent in metres. */ loss: number; + /** Unfiltered ascent (sum of every positive delta) — diagnostic/fallback. */ + gainRaw: number; + /** Unfiltered descent. */ + lossRaw: number; profile: ElevationProfile[]; }; }