Precompute run analysis and serve compact route previews

This commit is contained in:
Peter Stockings
2026-10-02 14:19:47 +10:00
parent 2d7069c12d
commit 6bfab483d4
13 changed files with 518 additions and 56 deletions
+112 -2
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from app import create_app
from app.display import persist_analysis
from app.extensions import db
from app.models import Activity, Friendship, User
from app.records import EFFORTS_VERSION, persist_efforts
@@ -55,6 +56,8 @@ def app(tmp_path):
)
)
db.session.commit()
persist_analysis(db.session.get(Activity, 1))
db.session.commit()
yield app
@@ -88,11 +91,106 @@ def test_owner_dashboard_activity_json_and_settings_render(app):
assert "point" not in data["chart"][0]
def test_route_preview_access_is_revoked_with_friendship(app):
owner, friend, anonymous = app.test_client(), app.test_client(), app.test_client()
login(owner)
login(friend, "other")
assert anonymous.get("/activities/1/route").status_code == 404
assert friend.get("/activities/1/route").status_code == 404
assert owner.get("/activities/1/route").json == {"segments": [[[-33, 151]]]}
owner.post("/activities/1/edit", data={"title": "Shared", "visibility": "friends"})
owner.post("/friends", data={"email": "other@example.test"})
friend.post("/friends/1/accept")
assert friend.get("/activities/1/route").status_code == 200
friend.post("/friends/1/remove")
assert friend.get("/activities/1/route").status_code == 404
def test_stored_analysis_requests_do_not_read_raw_samples_or_recalculate(app, monkeypatch):
from sqlalchemy import event
from app import display
def fail(_row):
raise AssertionError("Analysis must be precomputed")
monkeypatch.setattr(display, "analyse", fail)
with app.app_context():
db.session.get(Activity, 1).share_token = "compact-test"
db.session.commit()
statements = []
def capture(_conn, _cursor, statement, _params, _context, _many):
statements.append(statement)
engine = db.engine
event.listen(engine, "before_cursor_execute", capture)
client = app.test_client()
login(client)
try:
for path in (
"/dashboard",
"/activities/1",
"/activities/1/data",
"/activities/1/route",
"/shared/runs/compact-test",
"/shared/runs/compact-test/data",
):
assert client.get(path).status_code == 200
assert all("activity.samples" not in s and "activity.laps" not in s for s in statements)
finally:
event.remove(engine, "before_cursor_execute", capture)
def test_analysis_backfill_resumes_after_failed_batch(app, monkeypatch):
from app import display
with app.app_context():
db.session.get(Activity, 1).analysis_version = 0
db.session.add(
Activity(
owner_id=2,
source_key="analysis:2",
title="Older activity",
started_at=datetime(2026, 1, 1, tzinfo=UTC),
sport="cycling",
samples=[],
laps=[],
)
)
db.session.commit()
original = display.persist_analysis
def interrupt(row):
if row.id == 2:
raise ValueError("Interrupted")
original(row)
monkeypatch.setattr(display, "persist_analysis", interrupt)
runner = app.test_cli_runner()
result = runner.invoke(args=["backfill-analysis", "--batch-size", "1"])
assert result.exit_code != 0
with app.app_context():
assert db.session.get(Activity, 1).analysis_version == display.ANALYSIS_VERSION
assert db.session.get(Activity, 2).analysis_version == 0
monkeypatch.setattr(display, "persist_analysis", original)
result = runner.invoke(args=["backfill-analysis", "--owner-id", "1"])
assert result.exit_code == 0
assert "0 activities backfilled" in result.output
result = runner.invoke(args=["backfill-analysis", "--batch-size", "1"])
assert result.exit_code == 0
assert "1 activities backfilled" in result.output
result = runner.invoke(args=["backfill-analysis"])
assert result.exit_code == 0
assert "0 activities backfilled" in result.output
def test_personal_records_render_and_remain_owner_only(app):
with app.app_context():
row = db.session.get(Activity, 1)
row.samples = [{"distance": 0, "time": 0}, {"distance": 5000, "time": 1500}]
persist_efforts(row)
persist_analysis(row)
row.share_token = "record-test"
db.session.commit()
client = app.test_client()
@@ -127,6 +225,8 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
row = db.session.get(Activity, row_id)
assert row.efforts_version == EFFORTS_VERSION
assert row.best_efforts[0]["seconds"] == 300
assert row.analysis_version == 1
assert row.analysis_summary["summary"]["samples"] == 2
assert activity_from_fit(1, "test:new", [doc]) is None
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
activity_from_fit(1, "test:new", [doc], refresh=True)
@@ -136,6 +236,8 @@ def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
activity_from_fit(1, "test:new", [doc], refresh=True)
db.session.commit()
assert row.best_efforts == []
assert row.display_data == {"route_segments": [], "chart": []}
assert row.route_geometry == []
assert row.efforts_version == EFFORTS_VERSION
@@ -270,6 +372,10 @@ def test_efforts_migration_upgrades_existing_schema(tmp_path):
row = db.session.get(Activity, 1)
assert row.best_efforts == []
assert row.efforts_version == 0
assert row.analysis_version == 0
assert row.analysis_summary == {}
assert row.display_data == {}
assert row.route_geometry == []
assert row.has_route is False
assert row.title == "Old run"
result = runner.invoke(args=["backfill-efforts"])
@@ -282,7 +388,7 @@ def test_dashboard_replays_latest_accessible_gps_run(app):
client = app.test_client()
login(client)
page = client.get("/dashboard").data
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
assert b'id="dashboard-route-map" data-url="/activities/1/route"' in page
assert b'id="toggle-route-replay"' not in page
assert b"vendor/leaflet.js" in page
assert b'class="dashboard-map"' not in page
@@ -295,6 +401,7 @@ def test_dashboard_replays_latest_accessible_gps_run(app):
row = db.session.get(Activity, 1)
row.samples = []
persist_efforts(row)
persist_analysis(row)
db.session.commit()
client.post("/logout")
login(client)
@@ -343,7 +450,10 @@ def test_private_activity_is_hidden_even_from_accepted_friend(app):
def test_full_route_includes_start_and_finish():
points = [[0, 0], [0, 0.001], [0, 0.01], [0, 0.02]]
row = Activity(samples=[{"point": point} for point in points])
row = Activity(
sport="running", distance_m=0, laps=[], samples=[{"point": point} for point in points]
)
persist_analysis(row)
assert activity_data(row)["segments"] == [points]
+93
View File
@@ -0,0 +1,93 @@
import copy
import math
from types import SimpleNamespace
from app.analytics import analyse
from app.display import (
ANALYSIS_VERSION,
CHART_LIMIT,
compact_routes,
persist_analysis,
stored_analysis,
)
from app.services import activity_data, route_data
def long_run():
return SimpleNamespace(
sport="running",
distance_m=21000,
samples=[
{
"point": [-33 + i / 1000000, 151 + math.sin(i / 100) / 1000],
"distance": i * 3,
"time": i,
"speed": 3,
"elevation": 100 + math.sin(i / 100),
"heart_rate": 150,
}
for i in range(7001)
],
laps=[{"distance": 21000, "duration": 7000}],
)
def test_persisted_summaries_equal_full_resolution_and_raw_data_is_untouched():
row = long_run()
original = copy.deepcopy(row.samples)
expected = analyse(row)
persist_analysis(row)
assert row.analysis_version == ANALYSIS_VERSION
assert row.analysis_summary == {key: expected[key] for key in ("summary", "terrain", "laps")}
assert row.samples == original
assert len(row.display_data["chart"]) == CHART_LIMIT
assert row.display_data["chart"][0]["distance"] == 0
assert row.display_data["chart"][-1]["distance"] == 21000
assert sum(map(len, row.route_geometry)) <= 1400
assert row.route_geometry[0][0] == [round(v, 6) for v in original[0]["point"]]
assert row.route_geometry[-1][-1] == [round(v, 6) for v in original[-1]["point"]]
assert set(route_data(row)) == {"segments"}
assert set(row.display_data["route_segments"][0][0]) == {
"point",
"distance",
"speed",
"grade",
"heart_rate",
}
assert "point" not in row.display_data["chart"][0]
def test_simplification_retains_sharp_turns_and_does_not_bridge_gps_gaps():
points = [[0, i / 10000] for i in range(100)]
points += [[i / 10000, 0.0099] for i in range(1, 100)]
samples = [{"point": point} for point in points]
samples += [{"point": None}, {"point": [1, 1]}, {"point": [1, 1.01]}]
routes = compact_routes(samples)
assert len(routes) == 2
assert [0, 0.0099] in [row["point"] for row in routes[0]]
assert routes[0][0]["point"] == points[0]
assert routes[0][-1]["point"] == points[-1]
assert routes[1][0]["point"] == [1, 1]
assert routes[1][-1]["point"] == [1, 1.01]
def test_incomplete_and_non_gps_data_and_refresh_clear_stored_routes():
row = long_run()
persist_analysis(row)
row.samples = [
{"point": None, "distance": 0, "time": 0},
{"point": None, "distance": 100, "time": 30},
]
persist_analysis(row)
assert row.route_geometry == []
assert activity_data(row)["route_segments"] == []
assert len(activity_data(row)["chart"]) == 2
assert stored_analysis(row)["summary"]["samples"] == 2
row.analysis_version = 0
assert route_data(row) == {"segments": []}
assert activity_data(row) == {"segments": [], "route_segments": [], "chart": []}
def test_fragmented_routes_preserve_each_singleton():
samples = [{"point": [i / 1000, 0]} if i % 2 else {"point": None} for i in range(20)]
assert len(compact_routes(samples)) == 10