metrics: replace naive elevation accumulation with hysteresis dead-band

GPS jitter and barometric quantization noise caused systematic overestimation
of elevation gain — in extreme cases 100% of reported gain was sub-1m noise.

Implements source-aware hysteresis: elevation is only committed when it
deviates from the last committed value by ≥5m (barometric) or ≥10m (GPS/GPX/TCX).

- ParsedActivity gains `altitude_source` field ("barometric"/"gps"/"unknown")
- FIT parser sets "barometric" when enhanced_altitude is present, else "gps"
- GPX and TCX parsers always set "gps"
- metrics._elevation() uses the threshold matching the source
- 5 new parametric tests covering flat GPS noise, threshold differences, and real climbs
This commit is contained in:
Davide Scaini
2026-04-20 20:29:20 +02:00
parent 696f538f56
commit 872651f471
6 changed files with 117 additions and 9 deletions
+72
View File
@@ -8,6 +8,7 @@ import pytest
from bincio.extract.metrics import (
MMP_DURATIONS_S,
_best_climb,
_elevation,
_fastest_time_for_distance,
_haversine_m,
compute,
@@ -126,6 +127,77 @@ def test_compute_no_elevation():
assert m.elevation_loss_m is None
# ── elevation hysteresis ──────────────────────────────────────────────────────
def _ele_pts(elevations: list[float]) -> list[DataPoint]:
return [_pt(i, elevation_m=e) for i, e in enumerate(elevations)]
def test_elevation_hysteresis_large_step_always_counted():
# A single 50m step is way above any threshold — both sources should count it.
pts = _ele_pts([100.0, 150.0])
gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 50.0
assert gain_gps == 50.0
def test_elevation_hysteresis_flat_gps_noise_suppressed():
# Flat coastal route: 16m of GPS noise oscillating within ±8m.
# All steps are sub-1m — hysteresis should return ~0 gain.
import math
n = 1000
elevations = [100.0 + 3.0 * math.sin(i * 0.1) for i in range(n)]
pts = _ele_pts(elevations)
gain, loss = _elevation(pts, "gps")
# With threshold=10m no oscillation within ±3m should ever commit.
assert gain == 0.0
assert loss == 0.0
def test_elevation_hysteresis_barometric_threshold_lower():
# Steps of exactly 7m — above barometric (5m) but below GPS (10m) threshold.
elevations = [0.0, 7.0, 0.0, 7.0]
pts = _ele_pts(elevations)
gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 14.0 # both 7m steps committed
assert gain_gps == 0.0 # 7m < 10m threshold → suppressed
def test_elevation_hysteresis_real_climb_approximated():
# Simulate a 200m climb with 0.2m barometric quantization noise.
# Build a staircase: 1000 steps, mostly 0.2m up/down noise, with a 200m net climb.
import random
random.seed(42)
elevations = [0.0]
for i in range(999):
# Mostly quantization noise, but drift upward at 0.2 m/step net
step = random.choice([-0.2, 0.0, 0.0, 0.2, 0.2, 0.4])
elevations.append(elevations[-1] + step)
# Force net gain ~200m by scaling
scale = 200.0 / (elevations[-1] - elevations[0]) if elevations[-1] != elevations[0] else 1
elevations = [e * scale for e in elevations]
pts = _ele_pts(elevations)
gain, _ = _elevation(pts, "barometric")
# Hysteresis should produce substantially less than naive accumulation
# and land reasonably close to the 200m net climb.
assert gain is not None
assert gain < 500.0 # not inflated like naive sum
assert gain > 100.0 # not zero either — real climbing exists
def test_elevation_hysteresis_unknown_treated_as_gps():
# "unknown" should apply the same 10m threshold as "gps"
elevations = [0.0, 7.0, 0.0, 7.0] # 7m steps
pts = _ele_pts(elevations)
gain_unknown, _ = _elevation(pts, "unknown")
gain_gps, _ = _elevation(pts, "gps")
assert gain_unknown == gain_gps
def test_compute_hr_stats():
pts = [
_pt(0, lat=48.0, lon=11.0, hr_bpm=120),