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>
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
// 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 20–50% 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 };
|
||
}
|