trails/packages/gpx/src/elevation-clean.test.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

88 lines
3.6 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.

import { describe, it, expect } from "vitest";
import {
despike,
filteredTotals,
cumulativeFilteredTotals,
type ElevPoint,
} from "./elevation-clean.ts";
// Points 10 m apart horizontally unless a test needs otherwise.
const seq = (elevations: number[], step = 10): ElevPoint[] =>
elevations.map((elevation, i) => ({ distance: i * step, elevation }));
describe("despike", () => {
it("interpolates an isolated peak spike out", () => {
// A 100 m spike over 10 m horizontally = 1000% slope up then down.
const out = despike(seq([100, 100, 200, 100, 100]));
expect(out[2]!.elevation).toBeCloseTo(100, 6); // interpolated between neighbours
});
it("interpolates an isolated pit spike out", () => {
const out = despike(seq([200, 200, 100, 200, 200]));
expect(out[2]!.elevation).toBeCloseTo(200, 6);
});
it("leaves a steep monotonic climb untouched (same-sign slopes)", () => {
// Each step +60 m / 10 m = 600% — steep, but climbing, so never a spike.
const input = seq([0, 60, 120, 180, 240]);
const out = despike(input);
expect(out.map((p) => p.elevation)).toEqual([0, 60, 120, 180, 240]);
});
it("does not touch endpoints or short segments", () => {
expect(despike(seq([500, 0])).map((p) => p.elevation)).toEqual([500, 0]);
expect(despike(seq([0])).map((p) => p.elevation)).toEqual([0]);
});
});
describe("filteredTotals", () => {
it("suppresses sub-threshold jitter to zero", () => {
const t = filteredTotals(seq([100, 102, 99, 101, 98, 100]), { noiseThresholdM: 5 });
expect(t.gain).toBe(0);
expect(t.loss).toBe(0);
expect(t.gainRaw).toBeGreaterThan(0); // raw sees every wobble
});
it("counts a multi-leg climb fully when legs exceed the threshold", () => {
// +10 m per step, 5 steps = 100 m; each leg > 5 m threshold.
const t = filteredTotals(seq([0, 10, 20, 30, 40, 50]), { noiseThresholdM: 5 });
expect(t.gain).toBe(50);
expect(t.loss).toBe(0);
});
it("counts descent and separates it from ascent", () => {
const t = filteredTotals(seq([100, 130, 100]), { noiseThresholdM: 5 });
expect(t.gain).toBe(30);
expect(t.loss).toBe(30);
});
it("falls back to net change (not raw sum) when a direction filters to zero", () => {
// Sub-threshold steps that net +3 m: filtered gain is 0, so report the
// net (+3), NOT the raw sum-of-deltas (which would re-inflate jitter).
const points = seq([100, 102, 101, 103]);
const t = filteredTotals(points, { noiseThresholdM: 5 });
expect(t.gain).toBe(3); // net = 103 100
expect(t.gainRaw).toBeGreaterThan(t.gain); // raw sum (+2 1 +2 = 4) is larger
});
});
describe("cumulativeFilteredTotals", () => {
it("produces running arrays whose last values equal the filtered totals", () => {
const points = seq([0, 10, 20, 10, 0]);
const { ascent, descent } = cumulativeFilteredTotals(points, { noiseThresholdM: 5 });
expect(ascent).toHaveLength(points.length);
expect(descent).toHaveLength(points.length);
expect(ascent[0]).toBe(0);
const totals = filteredTotals(points, { noiseThresholdM: 5 });
expect(ascent[ascent.length - 1]).toBe(totals.gain);
expect(descent[descent.length - 1]).toBe(totals.loss);
});
it("is monotonically non-decreasing (a day split can subtract two indices)", () => {
const { ascent, descent } = cumulativeFilteredTotals(seq([0, 20, 10, 40, 40]), { noiseThresholdM: 5 });
for (let i = 1; i < ascent.length; i++) {
expect(ascent[i]!).toBeGreaterThanOrEqual(ascent[i - 1]!);
expect(descent[i]!).toBeGreaterThanOrEqual(descent[i - 1]!);
}
});
});