Add route heat colouring and detailed running analytics
This commit is contained in:
+77
-8
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user