trails/openspec/changes/elevation-profile-hardening/design.md
Ullrich Schäfer 5dd4968626 docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
Add docs/inspirations.md as the durable record of the 2026-07-05/06
prior-art research — per-project learnings with source paths, canonical
credit lines, and the changes each spawned — and extend the
acknowledgment lists in philosophy.md/architecture.md (Organic Maps,
Endurain, wanderer).

New OpenSpec changes (proposal/design/specs/tasks each):
- Organic Maps: elevation-profile-hardening, gpx-parser-robustness,
  hiking-time-estimate, poi-index, hiking-foot-profile
- Endurain: account-export, activity-duplicate-review,
  fit-parsing-hardening, activity-locations, self-hosting-guide,
  activity-privacy-controls
- wanderer: federation-hardening, link-share-tokens
- credits-page (user-visible acknowledgments)

Updated in-flight changes with wanderer prior-art sections:
route-federation, route-discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:23:00 +02:00

70 lines
6.5 KiB
Markdown
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.

## 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 34 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.