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]
|
hr_zones: Optional[dict]
|
||||||
named_route_id: Optional[int]
|
named_route_id: Optional[int]
|
||||||
named_route_name: Optional[str] = None
|
named_route_name: Optional[str] = None
|
||||||
|
active_spans: Optional[list] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ async def init_db():
|
|||||||
await conn.execute(text(
|
await conn.execute(text(
|
||||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS original_name VARCHAR(256)"
|
"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:
|
except Exception as e:
|
||||||
print(f"activities.moving_time_s column migration skipped: {e}")
|
print(f"activities.moving_time_s column migration skipped: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class Activity(Base):
|
|||||||
normalized_power = Column(Float, nullable=True)
|
normalized_power = Column(Float, nullable=True)
|
||||||
avg_speed_ms = Column(Float, nullable=True)
|
avg_speed_ms = Column(Float, nullable=True)
|
||||||
max_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)
|
avg_temperature_c = Column(Float, nullable=True)
|
||||||
calories = Column(Float, nullable=True)
|
calories = Column(Float, nullable=True)
|
||||||
training_stress_score = 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
|
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):
|
def _bounding_box(coords):
|
||||||
if not coords:
|
if not coords:
|
||||||
return None
|
return None
|
||||||
@@ -241,9 +277,13 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
|
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
|
||||||
# Timer time = time the device was actively recording (excludes auto/manual pauses).
|
# Timer time = time the device was actively recording (excludes auto/manual pauses).
|
||||||
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
|
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(
|
avg_speed = _sanitize_speed(
|
||||||
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_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 {
|
return {
|
||||||
@@ -271,6 +311,7 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"total_training_effect")),
|
"total_training_effect")),
|
||||||
"polyline": encoded_polyline,
|
"polyline": encoded_polyline,
|
||||||
"bounding_box": bounding_box,
|
"bounding_box": bounding_box,
|
||||||
|
"active_spans": _active_spans(normalized_points),
|
||||||
"source_type": "fit",
|
"source_type": "fit",
|
||||||
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
|
||||||
"data_points": normalized_points,
|
"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,
|
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||||
"training_stress_score": None, "vo2max_estimate": None,
|
"training_stress_score": None, "vo2max_estimate": None,
|
||||||
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||||
|
"active_spans": _active_spans(data_points),
|
||||||
"source_type": "gpx",
|
"source_type": "gpx",
|
||||||
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
|
||||||
"data_points": data_points, "laps": [],
|
"data_points": data_points, "laps": [],
|
||||||
|
|||||||
@@ -72,9 +72,14 @@ def _apply_garmin_summary(parsed: dict, summary: dict):
|
|||||||
parsed["moving_time_s"] = summary["moving"]
|
parsed["moving_time_s"] = summary["moving"]
|
||||||
if summary.get("elapsed") is not None:
|
if summary.get("elapsed") is not None:
|
||||||
parsed["duration_s"] = summary["elapsed"]
|
parsed["duration_s"] = summary["elapsed"]
|
||||||
dur = parsed.get("duration_s")
|
# Recompute average speed over moving (timer) time, not elapsed wall-clock —
|
||||||
if parsed.get("distance_m") and dur:
|
# otherwise a long mid-activity pause (e.g. a meal stop on a ride) drags the
|
||||||
parsed["avg_speed_ms"] = parsed["distance_m"] / dur
|
# 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")
|
@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"),
|
normalized_power=parsed.get("normalized_power"),
|
||||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||||
max_speed_ms=parsed.get("max_speed_ms"),
|
max_speed_ms=parsed.get("max_speed_ms"),
|
||||||
|
active_spans=parsed.get("active_spans"),
|
||||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||||
calories=parsed.get("calories"),
|
calories=parsed.get("calories"),
|
||||||
training_stress_score=parsed.get("training_stress_score"),
|
training_stress_score=parsed.get("training_stress_score"),
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ function ActivityRefLabel({ viewBox, sport }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A small ⏸ glyph drawn over the gap between two active spans, marking where
|
||||||
|
// the recording was paused mid-activity (e.g. a long lunch break on a ride).
|
||||||
|
function PauseRefLabel({ viewBox }) {
|
||||||
|
if (!viewBox) return null
|
||||||
|
const { x, y, width = 0, height = 0 } = viewBox
|
||||||
|
const cx = x + width / 2, cy = y + height / 2
|
||||||
|
const barW = 2, barH = 8, gap = 1.5
|
||||||
|
return (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
|
<rect x={cx - gap - barW} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||||
|
<rect x={cx + gap} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Activity time spans are drawn as a solid coloured band in a reserved strip
|
// Activity time spans are drawn as a solid coloured band in a reserved strip
|
||||||
// *below* the battery bars (the negative Y region) so they don't obscure the data.
|
// *below* the battery bars (the negative Y region) so they don't obscure the data.
|
||||||
const ACTIVITY_BAND_BOTTOM = -18
|
const ACTIVITY_BAND_BOTTOM = -18
|
||||||
@@ -232,17 +247,42 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
|||||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
{(activities || []).map(a => {
|
{(activities || []).flatMap(a => {
|
||||||
const start = new Date(a.start_time).getTime()
|
|
||||||
const end = a.duration_s ? start + a.duration_s * 1000 : start
|
|
||||||
const x1 = nearestT(start), x2 = nearestT(end)
|
|
||||||
if (x1 == null || x2 == null) return null
|
|
||||||
const color = sportColor(a.sport_type)
|
const color = sportColor(a.sport_type)
|
||||||
return (
|
const start = new Date(a.start_time).getTime()
|
||||||
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
const fullEnd = a.duration_s ? start + a.duration_s * 1000 : start
|
||||||
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
// active_spans (set only when a long pause splits the recording)
|
||||||
label={<ActivityRefLabel sport={a.sport_type} />} />
|
// draws one band per moving span with the pause shown as a gap;
|
||||||
)
|
// otherwise one continuous band as before.
|
||||||
|
const spans = (a.active_spans && a.active_spans.length > 1)
|
||||||
|
? a.active_spans
|
||||||
|
: [[start, fullEnd]]
|
||||||
|
// Carry the sport icon on the longest span only.
|
||||||
|
let labelIdx = 0, labelLen = -1
|
||||||
|
spans.forEach((s, i) => { const l = s[1] - s[0]; if (l > labelLen) { labelLen = l; labelIdx = i } })
|
||||||
|
const els = []
|
||||||
|
spans.forEach((s, i) => {
|
||||||
|
const x1 = nearestT(s[0]), x2 = nearestT(s[1])
|
||||||
|
if (x1 != null && x2 != null) {
|
||||||
|
els.push(
|
||||||
|
<ReferenceArea key={`area-${a.id}-${i}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||||
|
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
||||||
|
label={i === labelIdx ? <ActivityRefLabel sport={a.sport_type} /> : undefined} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Mark the paused stretch before the next span with a faint dashed strip + ⏸.
|
||||||
|
if (i < spans.length - 1) {
|
||||||
|
const g1 = nearestT(s[1]), g2 = nearestT(spans[i + 1][0])
|
||||||
|
if (g1 != null && g2 != null && g1 !== g2) {
|
||||||
|
els.push(
|
||||||
|
<ReferenceArea key={`pause-${a.id}-${i}`} x1={g1} x2={g2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||||
|
fill={color} fillOpacity={0.12} stroke={color} strokeOpacity={0.7} strokeDasharray="3 3"
|
||||||
|
label={<PauseRefLabel />} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return els
|
||||||
})}
|
})}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user