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:
@@ -70,7 +70,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
|
||||
duration_s = _duration(pts)
|
||||
distance_m, moving_time_s, avg_speed_kmh, max_speed_kmh = _gps_stats(pts)
|
||||
gain, loss = _elevation(pts)
|
||||
gain, loss = _elevation(pts, activity.altitude_source)
|
||||
avg_hr, max_hr = _hr_stats(pts)
|
||||
avg_cad = _avg_nonnull([p.cadence_rpm for p in pts])
|
||||
avg_pow = _avg_nonnull([p.power_w for p in pts])
|
||||
@@ -347,17 +347,40 @@ def _duration(pts: list[DataPoint]) -> Optional[int]:
|
||||
return int((pts[-1].timestamp - pts[0].timestamp).total_seconds())
|
||||
|
||||
|
||||
def _elevation(pts: list[DataPoint]) -> tuple[Optional[float], Optional[float]]:
|
||||
# Hysteresis thresholds per altitude source.
|
||||
# Only commit a new elevation when it differs from the last committed value by
|
||||
# at least this amount, filtering out GPS noise and barometric quantization steps.
|
||||
_ELEVATION_THRESHOLD: dict[str, float] = {
|
||||
"barometric": 5.0, # barometric altimeter: smaller steps are real
|
||||
"gps": 10.0, # GPS altitude: noisier, needs wider dead-band
|
||||
"unknown": 10.0, # treat unknown as GPS to be conservative
|
||||
}
|
||||
|
||||
|
||||
def _elevation(
|
||||
pts: list[DataPoint],
|
||||
altitude_source: str = "unknown",
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Hysteresis-based elevation accumulation.
|
||||
|
||||
Only commits a new elevation when it differs from the last committed value
|
||||
by at least the source-specific threshold, filtering GPS jitter and
|
||||
barometric quantization noise that would otherwise inflate the gain figure.
|
||||
"""
|
||||
elevations = [p.elevation_m for p in pts if p.elevation_m is not None]
|
||||
if len(elevations) < 2:
|
||||
return None, None
|
||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
||||
gain = loss = 0.0
|
||||
for a, b in zip(elevations, elevations[1:]):
|
||||
diff = b - a
|
||||
if diff > 0:
|
||||
gain += diff
|
||||
else:
|
||||
loss += diff
|
||||
committed = elevations[0]
|
||||
for e in elevations[1:]:
|
||||
diff = e - committed
|
||||
if abs(diff) >= threshold:
|
||||
if diff > 0:
|
||||
gain += diff
|
||||
else:
|
||||
loss += diff
|
||||
committed = e
|
||||
return gain, loss
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user