feat(gpx): apply elevation cleaning to totals + profile chart
Task group 2 (part a) of elevation-profile-hardening — wire the group-1 primitives into the headline stat and the chart. - computeElevation (parse.ts): despike each segment's elevation sequence, then take filteredTotals; gain/loss are now noise-filtered (no more 20–50% inflation from per-point jitter), the profile is built from the despiked data, and additive gainRaw/lossRaw expose the unfiltered sums. - elevationSeries: despike per segment (no cross-trkseg interpolation) before downsampling, so the chart and the headline totals derive from the same cleaned data. - types.ts: GpxData.elevation gains gainRaw/lossRaw (additive). Well-formed monotonic climbs are unchanged (steps exceed the 5 m threshold → filtered == raw); a shared noisy-track test proves jitter is filtered and a 300 m single-point spike is interpolated out of both the totals and the chart. Verified: gpx 111/111; full pnpm typecheck 13/13, lint 13/13, test 11/11 (journal + planner compile with the additive fields). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79124620d3
commit
29be9cbe5f
6 changed files with 114 additions and 28 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
||||
// 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", () => {
|
||||
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>`;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue