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
+1
View File
@@ -31,6 +31,7 @@ class ActivitySummary(BaseModel):
hr_zones: Optional[dict]
named_route_id: Optional[int]
named_route_name: Optional[str] = None
active_spans: Optional[list] = None
class Config:
from_attributes = True
+3
View File
@@ -59,6 +59,9 @@ async def init_db():
await conn.execute(text(
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS original_name VARCHAR(256)"
))
await conn.execute(text(
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS active_spans JSON"
))
except Exception as e:
print(f"activities.moving_time_s column migration skipped: {e}")
+5
View File
@@ -107,6 +107,11 @@ class Activity(Base):
normalized_power = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
max_speed_ms = Column(Float, nullable=True)
# When recording was paused/resumed (e.g. a long lunch break mid-ride) the
# device leaves a gap in the data stream. Stored as a list of active
# [start_ms, end_ms] epoch spans (only set when a >5min gap splits the
# recording into 2+ spans); null means one continuous recording.
active_spans = Column(JSON, nullable=True)
avg_temperature_c = Column(Float, nullable=True)
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
+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": [],
+9 -3
View File
@@ -72,9 +72,14 @@ def _apply_garmin_summary(parsed: dict, summary: dict):
parsed["moving_time_s"] = summary["moving"]
if summary.get("elapsed") is not None:
parsed["duration_s"] = summary["elapsed"]
dur = parsed.get("duration_s")
if parsed.get("distance_m") and dur:
parsed["avg_speed_ms"] = parsed["distance_m"] / dur
# Recompute average speed over moving (timer) time, not elapsed wall-clock —
# otherwise a long mid-activity pause (e.g. a meal stop on a ride) drags the
# displayed pace down across the whole break. Falls back to elapsed only when
# moving time is unavailable.
moving = parsed.get("moving_time_s")
denom = moving if (moving and moving > 0) else parsed.get("duration_s")
if parsed.get("distance_m") and denom:
parsed["avg_speed_ms"] = parsed["distance_m"] / denom
@celery_app.task(bind=True, name="process_activity_file")
@@ -174,6 +179,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
normalized_power=parsed.get("normalized_power"),
avg_speed_ms=parsed.get("avg_speed_ms"),
max_speed_ms=parsed.get("max_speed_ms"),
active_spans=parsed.get("active_spans"),
avg_temperature_c=parsed.get("avg_temperature_c"),
calories=parsed.get("calories"),
training_stress_score=parsed.get("training_stress_score"),