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
+74
View File
@@ -0,0 +1,74 @@
from types import SimpleNamespace
import pytest
from app.analytics import analyse, enrich, running_cost
def run(grade=0):
return SimpleNamespace(
sport="running",
distance_m=600,
samples=[
{
"distance": i * 3,
"time": i,
"elevation": 100 + i * 3 * grade,
"speed": 3,
"heart_rate": 150,
"cadence": 170,
}
for i in range(201)
],
laps=[{"distance": 301}, {"distance": 299}],
)
def test_flat_gap_and_laps_use_distance_weighted_time():
result = analyse(run())
assert result["summary"]["gap"] == pytest.approx(1000 / 3)
assert result["summary"]["gap_coverage"] == 100
assert [lap["gap"] for lap in result["laps"]] == pytest.approx([1000 / 3] * 2)
assert sum(lap["gap_time"] for lap in result["laps"]) == pytest.approx(200)
assert result["terrain"][1]["heart_rate"] == 150
@pytest.mark.parametrize("grade", [-0.1, 0.1])
def test_slope_adjusts_pace_using_cost_ratio(grade):
activity = run(grade)
row = enrich(activity.samples)[100]
assert row["grade"] == pytest.approx(grade * 100)
assert row["gap"] == pytest.approx(1000 / 3 * 3.6 / running_cost(grade))
gap = analyse(activity)["summary"]["gap"]
assert (gap < 1000 / 3) if grade > 0 else (gap > 1000 / 3)
def test_missing_data_and_nonrunning_do_not_invent_gap():
activity = run()
for row in activity.samples:
row["elevation"] = None
assert analyse(activity)["summary"]["gap"] is None
activity = run()
activity.sport = "cycling"
assert analyse(activity)["summary"]["gap"] is None
activity = run()
for row in activity.samples:
row["time"] = None
assert analyse(activity)["summary"]["gap"] is None
def test_recording_gap_excluded_from_time_and_distance():
activity = run()
for row in activity.samples[100:]:
row["time"] += 100
result = analyse(activity)
assert result["summary"]["gap"] == pytest.approx(1000 / 3)
assert result["summary"]["gap_coverage"] == pytest.approx(99.5)
def test_extreme_slopes_and_stationary_records_have_no_gap():
assert analyse(run(0.5))["summary"]["gap"] is None
activity = run()
for row in activity.samples:
row["distance"] = 0
assert analyse(activity)["summary"]["gap"] is None
+33
View File
@@ -223,3 +223,36 @@ def test_real_cache_import_compatibility_without_persisting_user_data(tmp_path):
for row in db.session.scalars(db.select(Activity)):
assert row.distance_m >= 0 and row.duration_s >= 0
assert row.visibility == "private"
def test_refresh_preserves_sharing_and_converts_running_units(app):
from app.services import activity_from_fit
doc = {
"session": [{"sport": "running", "start_time": "2026-09-30T10:00:00Z"}],
"record": [
{
"timestamp": "2026-09-30T10:00:00Z",
"cadence": 80,
"fractional_cadence": 0.5,
"step_length": 1100,
"vertical_oscillation": 85,
"stance_time": 250,
"power": 300,
}
],
}
with app.app_context():
activity = db.session.get(Activity, 1)
activity.title = "My chosen title"
activity.visibility = "friends"
result = activity_from_fit(1, "test:1", [doc], refresh=True)
db.session.commit()
assert result.id == 1
assert result.title == "My chosen title"
assert result.visibility == "friends"
assert result.samples[0]["cadence"] == 161
assert result.samples[0]["stride_length"] == 1.1
assert result.samples[0]["vertical_oscillation"] == 8.5
assert result.samples[0]["ground_contact"] == 250
assert result.samples[0]["power"] == 300