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>
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
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]!);
|
||
}
|
||
});
|
||
});
|