segments: reject false efforts via geometric speed check

Long circuit rides were matching a segment START early and finding the
segment END hours later on a second pass, producing effort times of
~17000s on a 4.7km segment. The conformance check passed because the
full-circuit track covers all interior points within 50m over 5 hours.

Add a per-sport minimum geometric speed (segment_distance / elapsed_s):
cycling ≥ 1.0 m/s, running ≥ 0.5 m/s, default ≥ 0.2 m/s. When the
check fails, advance past the current start candidate and retry, so a
legitimate later match (e.g. a second lap done at real speed) is still
detected.
This commit is contained in:
Davide Scaini
2026-05-13 16:31:00 +02:00
parent 994f4287ef
commit 6e92ea4fce
+18
View File
@@ -21,6 +21,15 @@ MATCH_RADIUS_M = 25 # max distance to segment start/end to open/close a
CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point
CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate
# Minimum geometric speed (segment_distance / elapsed_s) per sport, in m/s.
# Rejects false matches from long circuit rides where the track passes the
# segment start early and the segment end hours later.
_MIN_SPEED_MS: dict[str, float] = {
'cycling': 1.0, # ~3.6 km/h — slowest realistic climb
'running': 0.5, # ~1.8 km/h
}
_MIN_SPEED_DEFAULT = 0.2 # hiking / walking / unknown
# ── fast distance approximation ─────────────────────────────────────────────── # ── fast distance approximation ───────────────────────────────────────────────
_R = 6_371_000.0 # Earth radius in metres _R = 6_371_000.0 # Earth radius in metres
@@ -250,6 +259,15 @@ def detect_one(track: ActivityTrack, seg: Segment) -> list[SegmentEffort]:
# No end found — no more efforts possible starting at or after start_idx. # No end found — no more efforts possible starting at or after start_idx.
break break
# Reject implausibly slow matches (e.g. circuit rides matching start
# early and end hours later on a second pass through the area).
elapsed = track.times[end_idx] - track.times[start_idx]
if elapsed > 0:
min_speed = _MIN_SPEED_MS.get(track.sport, _MIN_SPEED_DEFAULT)
if seg.distance_m / elapsed < min_speed:
search_from = start_idx + 1
continue
if _conformance_ok(track, seg, start_idx, end_idx): if _conformance_ok(track, seg, start_idx, end_idx):
efforts.append(_extract_effort(track, seg, start_idx, end_idx)) efforts.append(_extract_effort(track, seg, start_idx, end_idx))
search_from = end_idx + 1 search_from = end_idx + 1