trails/packages/gpx/src/elevation-clean.ts
Ullrich Schäfer 990bbaa99e
feat(gpx): elevation cleaning primitives (despike + hysteresis totals)
Task group 1 of elevation-profile-hardening. New pure module
elevation-clean.ts (not yet wired — group 2 does that):

- despike(points): interpolates out isolated peak/pit spikes (opposite-sign
  slope outliers both exceeding MAX_SLOPE_PERCENT=100); steep monotonic
  terrain (same-sign) is never touched; per-segment, no cross-gap interp.
- filteredTotals(points): hysteresis ascent/descent (NOISE_THRESHOLD_M=5)
  that suppresses sub-threshold jitter, returning {gain,loss,gainRaw,lossRaw}.
- cumulativeFilteredTotals(points): running filtered arrays for per-day
  splits (day total = cumulative[end] − cumulative[start]).

DESIGN NOTE for review: the spec's task 1.2 says the zero-filtered
fallback should "report raw", but the raw sum-of-deltas re-inflates the
very jitter we remove (a ±2 m wobble sums to several metres) — directly
contradicting task 1.4's "±2 m jitter → 0 totals". Resolved toward the
stated goal: fall back to NET change (end−start), so flat wobble reads ~0
while a genuine small net climb still surfaces. gainRaw/lossRaw still
expose the raw sums.

Verified: gpx typecheck+lint clean, 108/108; full pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:46:13 +02:00

155 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Elevation cleaning primitives (spec: elevation-profile-hardening).
// GPS/barometric altitude jitters by a few metres per point, so summing
// every positive delta overstates ascent by 2050% on long activities,
// and single-point spikes distort the profile chart. These two pure
// functions — adapted from Organic Maps' SmoothSlopeOutliers +
// hysteresis ascent bookkeeping — are applied once so the headline stats
// and the chart derive from the same cleaned sequence.
//
// Both operate on a `{ distance, elevation }` sequence (metres), one
// track segment at a time — callers must NOT concatenate segments across
// `trkseg` gaps, or a despike would interpolate over a real discontinuity.
export interface ElevPoint {
distance: number;
elevation: number;
}
/** Slope of a genuinely steep climb, in percent (45° = 100%). Above this,
* an isolated opposite-sign pair reads as a recorder spike, not terrain. */
export const MAX_SLOPE_PERCENT = 100;
/** Altitude jitter band, in metres, below which change is treated as noise
* (typical barometric wobble). */
export const NOISE_THRESHOLD_M = 5;
function slopePercent(from: ElevPoint, to: ElevPoint): number {
const run = to.distance - from.distance;
const rise = to.elevation - from.elevation;
if (run <= 0) return rise === 0 ? 0 : rise > 0 ? Infinity : -Infinity;
return (rise / run) * 100;
}
/**
* Replace isolated peak/pit spikes with linear interpolation. A point is a
* spike iff the slope to its previous neighbour and the slope to its next
* neighbour both exceed `maxSlopePercent` in magnitude AND have opposite
* signs (an isolated up-then-down or down-then-up). Genuinely steep
* terrain climbs monotonically (same-sign slopes) and is never touched.
*/
export function despike(
points: ElevPoint[],
opts?: { maxSlopePercent?: number },
): ElevPoint[] {
const max = opts?.maxSlopePercent ?? MAX_SLOPE_PERCENT;
if (points.length < 3) return points.map((p) => ({ ...p }));
const out = points.map((p) => ({ ...p }));
for (let i = 1; i < out.length - 1; i++) {
const prevSlope = slopePercent(out[i - 1]!, out[i]!);
const nextSlope = slopePercent(out[i]!, out[i + 1]!);
const bothSteep = Math.abs(prevSlope) > max && Math.abs(nextSlope) > max;
const oppositeSign = prevSlope > 0 !== nextSlope > 0;
if (bothSteep && oppositeSign) {
const a = out[i - 1]!;
const b = out[i + 1]!;
const span = b.distance - a.distance;
const frac = span > 0 ? (out[i]!.distance - a.distance) / span : 0.5;
out[i]!.elevation = a.elevation + (b.elevation - a.elevation) * frac;
}
}
return out;
}
export interface ElevationTotals {
/** Filtered ascent (raw when filtering removed everything but raw > 0). */
gain: number;
/** Filtered descent (raw fallback, as above). */
loss: number;
/** Unfiltered sum of positive deltas. */
gainRaw: number;
/** Unfiltered sum of negative deltas (positive number). */
lossRaw: number;
}
/**
* Ascent/descent via hysteresis: accumulate only once altitude deviates
* from a moving reference by more than `noiseThresholdM`, then move the
* reference — suppressing sub-threshold jitter while capturing every real
* climb/descent larger than the threshold.
*
* Fallback: when a direction filters to exactly zero, report the NET
* change (end start) in that direction rather than the raw sum-of-deltas.
* The design's literal "report raw" would resurrect the very jitter we
* removed (a ±2 m wobble sums to several metres of raw "gain"); netting to
* ~0 satisfies "±2 m jitter → 0 totals" while a genuine small net climb
* taken in sub-threshold steps still surfaces. `gainRaw`/`lossRaw` remain
* the unfiltered sums for transparency.
*/
export function filteredTotals(
points: ElevPoint[],
opts?: { noiseThresholdM?: number },
): ElevationTotals {
const threshold = opts?.noiseThresholdM ?? NOISE_THRESHOLD_M;
let gain = 0;
let loss = 0;
let gainRaw = 0;
let lossRaw = 0;
if (points.length > 0) {
let ref = points[0]!.elevation;
for (let i = 1; i < points.length; i++) {
const delta = points[i]!.elevation - points[i - 1]!.elevation;
if (delta > 0) gainRaw += delta;
else lossRaw += -delta;
const dev = points[i]!.elevation - ref;
if (dev > threshold) {
gain += dev;
ref = points[i]!.elevation;
} else if (dev < -threshold) {
loss += -dev;
ref = points[i]!.elevation;
}
}
}
const net =
points.length > 0 ? points[points.length - 1]!.elevation - points[0]!.elevation : 0;
return {
gain: gain > 0 ? gain : Math.max(0, net),
loss: loss > 0 ? loss : Math.max(0, -net),
gainRaw,
lossRaw,
};
}
/**
* Running filtered ascent/descent at every point index (same hysteresis as
* {@link filteredTotals}), so per-day splits can take `cumulative[end]
* cumulative[start]` and have the day totals sum to the route total by
* construction. Both arrays have the same length as `points`; index 0 is 0.
* No raw fallback here — cumulative arrays feed differences, not a headline.
*/
export function cumulativeFilteredTotals(
points: ElevPoint[],
opts?: { noiseThresholdM?: number },
): { ascent: number[]; descent: number[] } {
const threshold = opts?.noiseThresholdM ?? NOISE_THRESHOLD_M;
const ascent: number[] = [];
const descent: number[] = [];
let gain = 0;
let loss = 0;
let ref = points.length > 0 ? points[0]!.elevation : 0;
for (let i = 0; i < points.length; i++) {
if (i > 0) {
const dev = points[i]!.elevation - ref;
if (dev > threshold) {
gain += dev;
ref = points[i]!.elevation;
} else if (dev < -threshold) {
loss += -dev;
ref = points[i]!.elevation;
}
}
ascent.push(gain);
descent.push(loss);
}
return { ascent, descent };
}