chore(openspec): archive elevation-profile-hardening
All 11 tasks complete (shipped in #581/#582/#583). Archive via `openspec archive` (CLI 1.6.0): - Creates openspec/specs/elevation-computation/spec.md — new capability (4 requirements: spike removal, threshold-filtered ascent/descent, cleaned profile series, per-day totals from cleaned data). Purpose filled in (CLI leaves TBD). - Moves the change to openspec/changes/archive/2026-07-13-elevation-profile-hardening/. Spec passes `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e711a624a
commit
a57441fce8
6 changed files with 55 additions and 0 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-05
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
## Context
|
||||
|
||||
`@trails-cool/gpx` computes elevation stats in three places, all naive:
|
||||
|
||||
- `parse.ts` `computeElevation()` sums every positive/negative point-to-point delta into `gain`/`loss`. GPS and barometric traces jitter by a few metres per point, so every wobble counts as ascent — long activities overstate gain badly.
|
||||
- `elevation-series.ts` `elevationSeries()` builds the chart series from raw points (stride-downsampled); single-point altitude spikes survive into the chart.
|
||||
- `compute-days.ts` builds cumulative ascent/descent arrays (same naive delta sum) to derive per-day totals.
|
||||
|
||||
Consumers: journal `gpx-save.server.ts` persists `parsed.elevation.gain/loss` to route/activity rows; route and activity detail loaders call `elevationSeries()`; the planner's multi-day sidebar uses `computeDays`. Planner *planned-route* ascent comes from BRouter's `totalAscend` and is not touched by this change.
|
||||
|
||||
Organic Maps (`libs/map/elevation_info.cpp`, reviewed 2026-07-05) handles the same problems with two algorithms we adapt here: opposite-sign slope-outlier despiking, and threshold (hysteresis) filtered accumulation with a raw fallback.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ascent/descent totals that are stable under GPS/barometer noise and close to what users see on other platforms (Garmin, Komoot).
|
||||
- A despiked elevation series so chart, totals, and per-day stats are all computed from the same cleaned data and agree with each other.
|
||||
- Pure, unit-testable primitives in `@trails-cool/gpx` with no API-shape change for consumers.
|
||||
|
||||
**Non-Goals:**
|
||||
- No backfill of stored `elevationGain`/`elevationLoss` on existing journal rows (values refresh on the next parse: re-import, sync, edit).
|
||||
- No DEM-based elevation correction (replacing recorded altitude with terrain-model altitude) — separate, much larger feature.
|
||||
- No change to BRouter-derived planner stats, no smoothing of route *geometry* (lat/lng untouched), no difficulty rating (possible follow-up).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. New module `elevation-clean.ts` with two pure primitives
|
||||
|
||||
`despike(points)` and `filteredTotals(points)` operate on the `{ distance, elevation }` sequence that already exists internally (`ElevationProfile[]`). Both `computeElevation` and `elevationSeries` call `despike` first, then derive totals/series from the cleaned sequence. One cleaning pass, consistent numbers everywhere.
|
||||
|
||||
*Alternative considered:* cleaning inside each consumer (parse, series, days) independently — rejected; the chart and the headline stats would disagree whenever thresholds drift apart.
|
||||
|
||||
### 2. Despiking: opposite-sign slope outliers, interpolated out
|
||||
|
||||
A point is a spike iff the slope to its previous neighbor and the slope to its next neighbor both exceed `MAX_SLOPE_PERCENT` **and** have opposite signs (an isolated peak or pit). Spikes are replaced by linear interpolation between neighbors. This is Organic Maps' `SmoothSlopeOutliers`. The opposite-sign condition is the safety property: genuinely steep terrain climbs monotonically (same-sign slopes) and is never altered.
|
||||
|
||||
Default `MAX_SLOPE_PERCENT = 100` (45°). Segment boundaries are respected — no interpolation across `trkseg` gaps.
|
||||
|
||||
*Alternative considered:* moving-average smoothing — rejected; it flattens real terrain (summits, cols) and shifts totals everywhere instead of only where data is broken.
|
||||
|
||||
### 3. Ascent/descent: hysteresis accumulation with raw fallback
|
||||
|
||||
Maintain a reference altitude; accumulate ascent (or descent) only once the current altitude deviates from the reference by more than `NOISE_THRESHOLD_M`, then move the reference. This suppresses sub-threshold jitter while capturing every real climb larger than the threshold. Compute **both** raw and filtered totals; report filtered unless it is zero while raw is non-zero (near-flat tracks where all variation is sub-threshold — report raw so short flat walks don't show 0 m against a technically non-zero trace). This mirrors Organic Maps' dual `m_ascent`/`m_lowestPassedAltitude`-style bookkeeping and its `GetTotalAscent()` preference order.
|
||||
|
||||
Default `NOISE_THRESHOLD_M = 5` (typical barometric jitter; GPS-only altitude is worse, but 5 m already removes the bulk of the inflation). Constants exported and overridable via an options argument for tests and future tuning.
|
||||
|
||||
*Alternative considered:* distance-binned resampling (sum deltas over N-metre bins) — simpler but conflates horizontal point density with vertical noise; hysteresis is independent of point spacing.
|
||||
|
||||
### 4. Per-day totals: cumulative filtered arrays
|
||||
|
||||
`compute-days.ts` keeps its cumulative-array design (day total = cumulative[end] − cumulative[start]) but the arrays are produced by running the hysteresis accumulator once over the despiked full track, recording the running filtered ascent/descent at every point index. Day totals therefore sum exactly to the route total by construction.
|
||||
|
||||
### 5. Chart series: despiked input, keep stride downsampling
|
||||
|
||||
`elevationSeries()` consumes despiked points. The existing stride downsample stays — Douglas-Peucker on the distance/elevation polyline (Organic Maps' `Simplify`) would preserve shape better at 400 points, but stride is adequate for current chart sizes and DP is an isolated follow-up, not a dependency of correctness.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Filtered totals change displayed numbers on re-parse while old rows keep naive values] → Accepted; documented in proposal. Mixed old/new values coexist until rows are re-parsed. A backfill job is possible later since GPX blobs are retained.
|
||||
- [Threshold too aggressive for genuinely rolling terrain (many real 3–4 m undulations)] → 5 m is conservative versus the 10 m+ some platforms use; raw totals remain available in the return shape (`gainRaw`/`lossRaw` added alongside `gain`/`loss`) so we can compare in the field before tuning.
|
||||
- [Despiking legitimate data (e.g. real cliff-edge out-and-back)] → Opposite-sign + both-slopes-exceed condition makes false positives require a physically implausible single-point spike; fixture tests pin the behavior.
|
||||
- [`elevationSeries` flattens across segments while despiking is per-segment] → Keep the flattening behavior; despike runs before flattening so no cross-segment interpolation occurs.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Pure library change: ship `@trails-cool/gpx` update, both apps pick it up in the same deploy. No schema, API, or config changes. Rollback = revert the PR.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking. Threshold tuning (5 m default) may warrant revisiting once real re-parsed activities can be compared against Garmin/Komoot numbers for the same tracks.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
## Why
|
||||
|
||||
Ascent/descent totals for imported and synced GPX are computed by summing every positive point-to-point elevation delta (`packages/gpx/src/parse.ts` `computeElevation`), with no noise handling. Real-world GPS/barometer traces are noisy, so the journal systematically overstates elevation gain — often by 20–50% on long activities — and single-point altitude spikes distort the elevation profile chart. Organic Maps solved both problems with two small, well-tested algorithms (slope-outlier despiking + threshold-filtered accumulation) that we reviewed and can adapt to `@trails-cool/gpx`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add **spike removal** to elevation processing: single points whose slopes to both neighbors exceed a maximum slope and have opposite signs (peak/pit spikes) are replaced by interpolation before any totals or series are computed.
|
||||
- Add **threshold-filtered ascent/descent**: accumulate gain/loss only when altitude deviates from a moving reference by more than a threshold (hysteresis), suppressing jitter. Both raw and filtered totals are computed; filtered is preferred, raw is the fallback when filtering removes everything (near-flat tracks).
|
||||
- Apply the same cleaned elevation data to the **elevation profile series** (`elevationSeries`) so the chart and the headline stats agree.
|
||||
- Apply filtered accumulation to **per-day ascent/descent** (`compute-days.ts`) so day totals stay consistent with the route total.
|
||||
- No change to planner routing stats: planned-route ascent continues to come from BRouter (`totalAscend`).
|
||||
- No backfill of stored `elevationGain`/`elevationLoss` on existing journal rows; values update when a GPX is re-parsed (new imports, syncs, edits). A backfill job is explicitly out of scope.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `elevation-computation`: How elevation series, ascent/descent totals, and per-day elevation stats are computed from GPX track points in `@trails-cool/gpx` — despiking, threshold-filtered accumulation, raw fallback, and series consistency guarantees.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- none — journal-elevation-profile (chart UI), activity-stats (display), gpx-save (persistence flow), and multi-day-routes (per-day stats display) keep their existing requirements; only the computation behind the numbers changes, which the new capability captures -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `packages/gpx/src/parse.ts` — `computeElevation` gains despike + filtered accumulation.
|
||||
- `packages/gpx/src/elevation-series.ts` — series built from despiked points.
|
||||
- `packages/gpx/src/compute-days.ts` — cumulative ascent/descent arrays use the filtered accumulator.
|
||||
- New `packages/gpx/src/elevation-clean.ts` (despike + filter primitives) with co-located unit tests and noisy-track fixtures.
|
||||
- Consumers (journal `gpx-save.server.ts`, route/activity detail loaders, planner multi-day breakdown) get corrected numbers with no API change — `GpxData["elevation"]` shape stays `{ gain, loss, profile }`.
|
||||
- No DB schema change, no migration, no API contract change.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Elevation spike removal
|
||||
The GPX package SHALL remove single-point elevation spikes before computing elevation totals or the elevation profile series. A point is a spike when the slopes to both its neighbors exceed the maximum-slope constant and have opposite signs; spike elevations SHALL be replaced by linear interpolation between the neighboring points. Track segment boundaries SHALL NOT be interpolated across.
|
||||
|
||||
#### Scenario: Isolated spike is interpolated out
|
||||
- **WHEN** a track contains a point whose elevation jumps 30 m above both neighbors over a few metres of distance
|
||||
- **THEN** the computed series replaces that point's elevation with the interpolation of its neighbors
|
||||
- **AND** ascent/descent totals do not include the spike
|
||||
|
||||
#### Scenario: Steep but real climb is preserved
|
||||
- **WHEN** a track climbs steeply and monotonically (successive slopes share the same sign)
|
||||
- **THEN** no point on the climb is altered
|
||||
|
||||
#### Scenario: No interpolation across segment gaps
|
||||
- **WHEN** a spike-like pattern spans the boundary between two track segments
|
||||
- **THEN** points are not interpolated across the segment boundary
|
||||
|
||||
### Requirement: Threshold-filtered ascent and descent totals
|
||||
The GPX package SHALL compute ascent and descent using hysteresis accumulation: elevation change is accumulated only when altitude deviates from a moving reference by more than the noise-threshold constant. Both raw (unfiltered) and filtered totals SHALL be computed; the reported `gain`/`loss` SHALL be the filtered values, except when both filtered totals are zero and raw totals are non-zero, in which case the raw values SHALL be reported. Raw totals SHALL remain available to callers alongside the reported values.
|
||||
|
||||
#### Scenario: Jitter does not inflate ascent
|
||||
- **WHEN** a track's elevation oscillates by ±2 m around a constant altitude over many points
|
||||
- **THEN** the reported ascent and descent are 0 m
|
||||
|
||||
#### Scenario: Real climbs are fully counted
|
||||
- **WHEN** a track climbs 400 m, descends 100 m, and climbs 200 m, each leg exceeding the noise threshold
|
||||
- **THEN** the reported ascent is approximately 600 m and descent approximately 100 m
|
||||
|
||||
#### Scenario: Near-flat track falls back to raw totals
|
||||
- **WHEN** every elevation variation in a track is below the noise threshold so filtered totals are zero
|
||||
- **THEN** the raw totals are reported instead of 0 m
|
||||
|
||||
### Requirement: Consistent cleaned data across totals, series, and per-day stats
|
||||
The elevation profile series, the ascent/descent totals, and the per-day elevation statistics SHALL all be derived from the same despiked elevation data, and per-day ascent/descent SHALL use the same filtered accumulation such that the per-day values sum to the whole-track totals.
|
||||
|
||||
#### Scenario: Chart and headline stats agree
|
||||
- **WHEN** a GPX with elevation spikes is parsed and rendered on a detail page
|
||||
- **THEN** the elevation chart and the ascent/descent stats reflect the same despiked data
|
||||
|
||||
#### Scenario: Day totals sum to route total
|
||||
- **WHEN** a multi-day route with day breaks is processed
|
||||
- **THEN** the sum of per-day ascent values equals the route's total ascent
|
||||
|
||||
### Requirement: Unchanged public data shape
|
||||
The elevation hardening SHALL NOT change the existing `GpxData["elevation"]` fields (`gain`, `loss`, `profile`) or the `ElevationSample` series shape consumed by the apps; raw totals SHALL be exposed as additional fields only.
|
||||
|
||||
#### Scenario: Existing consumers compile and run unchanged
|
||||
- **WHEN** the journal save path and detail loaders run against the updated package
|
||||
- **THEN** they persist and render gain/loss and the elevation series without code changes
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
## 1. Cleaning primitives
|
||||
|
||||
- [x] 1.1 Create `packages/gpx/src/elevation-clean.ts` with `despike(points, opts?)` — opposite-sign slope-outlier detection (default `MAX_SLOPE_PERCENT = 100`) with linear interpolation, operating per segment on `{ distance, elevation }` sequences
|
||||
- [x] 1.2 Add `filteredTotals(points, opts?)` to the same module — hysteresis accumulator (default `NOISE_THRESHOLD_M = 5`) returning `{ gain, loss, gainRaw, lossRaw }` with the raw-fallback rule (filtered zero + raw non-zero → report raw)
|
||||
- [x] 1.3 Add `cumulativeFilteredTotals(points, opts?)` returning per-point running filtered ascent/descent arrays (for per-day splits)
|
||||
- [x] 1.4 Write `elevation-clean.test.ts`: isolated spike interpolated; steep monotonic climb untouched; no cross-segment interpolation; ±2 m jitter → 0 totals; multi-leg climb counted fully; near-flat raw fallback; cumulative arrays sum to totals
|
||||
|
||||
## 2. Wire into existing computations
|
||||
|
||||
- [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)
|
||||
- [x] 2.3 Update `compute-days.ts` to build its cumulative ascent/descent arrays via `cumulativeFilteredTotals` over the despiked track
|
||||
- [x] 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
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Confirm no consumer changes needed: typecheck journal + planner; spot-check `gpx-save.server.ts`, detail loaders, and planner `computeDays` usage compile and behave
|
||||
- [x] 3.2 Filtering verified by the shared noisy-track tests (parse/series/compute-days): a 300 m spike + jitter yields ~30 m filtered gain vs ~200 m raw, and the chart series + per-day sums derive from the same despiked data; live browser gain-before/after ride the e2e suite (autonomous run)
|
||||
- [x] 3.3 Run `pnpm typecheck && pnpm lint && pnpm test` and the journal/planner e2e suites
|
||||
Loading…
Add table
Add a link
Reference in a new issue