feat: avg pace over moving time (fixes elapsed-based pace on paused activities); show mid-activity pauses on Body Battery band via active_spans
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user