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
+8 -1
View File
@@ -20,6 +20,8 @@ class FitParser:
sub_sport: str | None = None
device: str | None = None
has_baro_alt = False # True if any record used enhanced_altitude
with fitdecode.FitReader(io.BytesIO(raw_bytes)) as fit:
for frame in fit:
if not isinstance(frame, fitdecode.FitDataMessage):
@@ -60,7 +62,9 @@ class FitParser:
# enhanced_altitude is written by barometric altimeters (most
# modern Garmins). Fall back to GPS-derived altitude if absent.
_alt = _get(frame, "enhanced_altitude")
if _alt is None:
if _alt is not None:
has_baro_alt = True
else:
_alt = _get(frame, "altitude")
dp = DataPoint(
@@ -100,6 +104,8 @@ class FitParser:
if not points:
raise ValueError(f"No record messages found in {path.name}")
altitude_source = "barometric" if has_baro_alt else "gps"
return ParsedActivity(
points=points,
sport=sport,
@@ -109,6 +115,7 @@ class FitParser:
laps=laps,
source_file=path.name,
source_hash="",
altitude_source=altitude_source,
)
+1
View File
@@ -53,6 +53,7 @@ class GpxParser(BaseParser):
started_at=started_at,
source_file=path.name,
source_hash="", # set by factory
altitude_source="gps",
)
+1
View File
@@ -83,6 +83,7 @@ class TcxParser:
started_at=points[0].timestamp,
source_file=path.name,
source_hash="",
altitude_source="gps",
)