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:
commit
84db039648
6 changed files with 114 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
|
|
|
||||||
|
|
@ -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 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) {
|
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 */
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue