feat: avg pace over moving time (fixes elapsed-based pace on paused activities); show mid-activity pauses on Body Battery band via active_spans
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

This commit is contained in:
2026-06-20 20:04:53 +01:00
parent 0e1d35364f
commit e33946e270
6 changed files with 111 additions and 14 deletions
+43 -1
View File
@@ -71,6 +71,42 @@ def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Option
return None
# Recording gaps longer than this split an activity into separate "active" spans.
# Auto-pause at traffic lights produces sub-minute gaps; a genuine break (a meal
# stop on a long ride, etc.) leaves a multi-minute hole in the stream.
PAUSE_GAP_S = 300
def _active_spans(points):
"""From normalised data points, return a list of active [start_ms, end_ms]
epoch spans, splitting wherever the recording paused for more than
PAUSE_GAP_S. Returns None when the recording is one continuous span (the
common case) so the payload stays small."""
ts = []
for p in points:
t = p.get("timestamp")
if not t:
continue
try:
ts.append(int(datetime.fromisoformat(t).timestamp() * 1000))
except (TypeError, ValueError):
continue
if len(ts) < 2:
return None
ts.sort()
gap_ms = PAUSE_GAP_S * 1000
spans = []
span_start = ts[0]
prev = ts[0]
for t in ts[1:]:
if t - prev > gap_ms:
spans.append([span_start, prev])
span_start = t
prev = t
spans.append([span_start, prev])
return spans if len(spans) > 1 else None
def _bounding_box(coords):
if not coords:
return None
@@ -241,9 +277,13 @@ def parse_fit_file(filepath: str) -> dict:
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
# Timer time = time the device was actively recording (excludes auto/manual pauses).
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
# When the FIT avgSpeed is missing/invalid we fall back to distance/time.
# Prefer moving (timer) time so the figure matches Garmin's moving-average
# semantics — using elapsed time badly understates pace on rides with a long
# mid-activity pause (e.g. a 5h elapsed / 1h48 moving ride).
avg_speed = _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=total_dist, dur_s=elapsed_s,
dist_m=total_dist, dur_s=moving_s or elapsed_s,
)
return {
@@ -271,6 +311,7 @@ def parse_fit_file(filepath: str) -> dict:
"total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"active_spans": _active_spans(normalized_points),
"source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points,
@@ -358,6 +399,7 @@ def parse_gpx_file(filepath: str) -> dict:
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"active_spans": _active_spans(data_points),
"source_type": "gpx",
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
"data_points": data_points, "laps": [],