Add route heat colouring and detailed running analytics

This commit is contained in:
Peter Stockings
2026-10-01 18:43:33 +10:00
parent e37569ad09
commit 24c7fc2c6a
11 changed files with 526 additions and 31 deletions
+77 -8
View File
@@ -23,9 +23,12 @@ def instant(value):
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
def activity_from_fit(owner_id, source_key, documents, metadata=None):
def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=False):
metadata = metadata or {}
if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)):
existing = db.session.scalar(
db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)
)
if existing and not refresh:
return None
sessions = [s for doc in documents for s in doc.get("session", [])]
if not sessions:
@@ -52,6 +55,26 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"heart_rate": number(record.get("heart_rate"), None),
"speed": number(record.get("enhanced_speed", record.get("speed")), None),
"elevation": number(record.get("enhanced_altitude", record.get("altitude")), None),
"time": instant(record["timestamp"]).timestamp()
if record.get("timestamp")
else None,
"cadence": (
(number(record.get("cadence")) + number(record.get("fractional_cadence"))) * 2
if sport == "running" and record.get("cadence") is not None
else None
),
"power": number(record.get("power"), None),
"stride_length": (
number(record.get("step_length")) / 1000
if record.get("step_length") is not None
else None
),
"vertical_oscillation": (
number(record.get("vertical_oscillation")) / 10
if record.get("vertical_oscillation") is not None
else None
),
"ground_contact": number(record.get("stance_time"), None),
}
)
laps = []
@@ -67,6 +90,27 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"pace": duration * 1000 / distance if distance > 0 else None,
}
)
metrics = {
key: number(first.get(key), None)
for key in (
"total_elapsed_time",
"total_moving_time",
"max_heart_rate",
"avg_power",
"max_power",
"total_calories",
"total_descent",
"total_training_effect",
"total_anaerobic_training_effect",
"avg_vertical_oscillation",
"avg_stance_time",
"avg_step_length",
"avg_running_cadence",
)
}
if existing:
existing.samples, existing.laps, existing.metrics = samples, laps, metrics
return existing
row = Activity(
owner_id=owner_id,
source_key=source_key,
@@ -77,6 +121,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
duration_s=sum(number(s.get("total_timer_time")) for s in sessions),
elevation_m=sum(number(s.get("total_ascent")) for s in sessions),
avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None),
metrics=metrics,
samples=samples,
laps=laps,
)
@@ -84,7 +129,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
return row
def import_cache(user, data_root):
def import_cache(user, data_root, refresh=False):
if not user.local_account_key:
return 0
directory = Path(data_root) / user.local_account_key
@@ -95,7 +140,7 @@ def import_cache(user, data_root):
metadata = json.loads((manifest.parent / "activity.json").read_text())
docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))]
source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}"
if activity_from_fit(user.id, source_key, docs, metadata):
if activity_from_fit(user.id, source_key, docs, metadata, refresh=refresh):
count += 1
return count
@@ -112,19 +157,43 @@ def import_upload(user, payload):
def activity_data(activity, owner_view=False):
"""Call only after access checks. Owners and accepted friends see the full route."""
from .analytics import analyse
analysis = analyse(activity)
segments, segment = [], []
for sample in activity.samples:
route_segments, route = [], []
for sample in analysis["samples"]:
point = sample.get("point")
if point:
segment.append(point)
route.append(sample)
elif segment:
segments.append(segment)
segment = []
route_segments.append(route)
route = []
if segment:
segments.append(segment)
route_segments.append(route)
step = max(1, math.ceil(len(activity.samples) / 750))
chart = [
{k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")}
for sample in activity.samples[::step]
{
k: sample.get(k)
for k in (
"distance",
"heart_rate",
"speed",
"elevation",
"grade",
"gap",
"pace",
"cadence",
"power",
"stride_length",
"vertical_oscillation",
"ground_contact",
)
}
for sample in analysis["samples"][::step]
]
return {"segments": segments, "chart": chart}
return {"segments": segments, "route_segments": route_segments, "chart": chart}