Merge pull request #582 from trails-cool/feat/elevation-hardening-wire

feat(gpx): apply elevation cleaning to totals + profile chart
This commit is contained in:
Ullrich Schäfer 2026-07-14 01:01:07 +02:00 committed by GitHub
commit 84db039648
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 114 additions and 28 deletions

View file

@ -7,8 +7,8 @@
## 2. Wire into existing computations ## 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) - [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)
- [ ] 2.2 Update `elevationSeries` in `packages/gpx/src/elevation-series.ts` to build from despiked points (despike per segment before flattening) - [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.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 - [ ] 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

View file

@ -40,4 +40,24 @@ describe("elevationSeries", () => {
expect(s[0]!.e).toBe(100); expect(s[0]!.e).toBe(100);
expect(s[s.length - 1]!.e).toBe(1099); 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);
});
}); });

View file

@ -1,4 +1,5 @@
import type { TrackPoint } from "./types.ts"; import type { TrackPoint } from "./types.ts";
import { despike } from "./elevation-clean.ts";
export interface ElevationSample { export interface ElevationSample {
/** Cumulative distance from start, metres */ /** 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. * keeping the first and last sample.
*/ */
export function elevationSeries(tracks: TrackPoint[][], maxPoints = 400): ElevationSample[] { export function elevationSeries(tracks: TrackPoint[][], maxPoints = 400): ElevationSample[] {
const withEle: TrackPoint[] = []; // Elevation-bearing points, tagged with their segment so despiking never
for (const seg of tracks) { // interpolates across a trkseg gap.
for (const p of seg) { const withEle: Array<{ p: TrackPoint; seg: number }> = [];
if (typeof p.ele === "number") withEle.push(p); tracks.forEach((seg, si) => {
} for (const p of seg) if (typeof p.ele === "number") withEle.push({ p, seg: si });
} });
if (withEle.length < 2) return []; if (withEle.length < 2) return [];
const full: ElevationSample[] = []; // Cumulative distance across consecutive elevation points (chart x-axis).
const dists: number[] = [];
let cum = 0; let cum = 0;
let prev: TrackPoint | null = null; for (let i = 0; i < withEle.length; i++) {
for (const p of withEle) { if (i > 0) {
if (prev) cum += haversineMeters(prev.lat, prev.lon, p.lat, p.lon); cum += haversineMeters(
full.push({ d: cum, e: p.ele as number, lat: p.lat, lng: p.lon }); withEle[i - 1]!.p.lat, withEle[i - 1]!.p.lon, withEle[i]!.p.lat, withEle[i]!.p.lon,
prev = p; );
}
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<number>(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; if (full.length <= maxPoints) return full;
// Stride downsample, preserving first and last. // Stride downsample, preserving first and last.

View file

@ -198,6 +198,28 @@ describe("parseGpxAsync — route (<rte>) 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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
${NOISY_ELEVATIONS.map((e, i) => `<trkpt lat="${(47 + i * 0.001).toFixed(3)}" lon="8.5"><ele>${e}</ele></trkpt>`).join("\n")}
</trkseg></trk></gpx>`;
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", () => { describe("parseGpxAsync — metadata fallback", () => {
const gpx = (body: string) => const gpx = (body: string) =>
`<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`; `<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`;

View file

@ -1,6 +1,7 @@
import type { Waypoint } from "@trails-cool/types"; import type { Waypoint } from "@trails-cool/types";
import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
import { repairTimestamps } from "./timestamp-repair.ts"; import { repairTimestamps } from "./timestamp-repair.ts";
import { despike, filteredTotals, type ElevPoint } from "./elevation-clean.ts";
/** /**
* Parse a GPX XML string into structured data. * 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 } { function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } {
let gain = 0; let gain = 0;
let loss = 0; let loss = 0;
let gainRaw = 0;
let lossRaw = 0;
const profile: ElevationProfile[] = []; const profile: ElevationProfile[] = [];
let totalDistance = 0; 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 2050%, and the profile chart is built from the same cleaned data as
// the headline totals (spec: elevation-profile-hardening).
for (const track of tracks) { for (const track of tracks) {
const eleSeq: ElevPoint[] = [];
for (let i = 0; i < track.length; i++) { for (let i = 0; i < track.length; i++) {
const pt = track[i]!; const pt = track[i]!;
if (i > 0) { if (i > 0) {
const prev = track[i - 1]!; totalDistance += haversineDistance(track[i - 1]!.lat, track[i - 1]!.lon, pt.lat, pt.lon);
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 });
} }
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 */ /** Haversine distance between two points in meters */

View file

@ -27,8 +27,14 @@ export interface GpxData {
/** Total distance in meters (haversine, works with or without elevation data) */ /** Total distance in meters (haversine, works with or without elevation data) */
distance: number; distance: number;
elevation: { elevation: {
/** Noise-filtered ascent in metres (hysteresis; the headline number). */
gain: number; gain: number;
/** Noise-filtered descent in metres. */
loss: number; loss: number;
/** Unfiltered ascent (sum of every positive delta) — diagnostic/fallback. */
gainRaw: number;
/** Unfiltered descent. */
lossRaw: number;
profile: ElevationProfile[]; profile: ElevationProfile[];
}; };
} }