Precompute run analysis and serve compact route previews
This commit is contained in:
@@ -70,6 +70,9 @@ def create_app(config=None):
|
||||
from .records import register_cli as register_records_cli
|
||||
|
||||
register_records_cli(app)
|
||||
from .display import register_cli as register_display_cli
|
||||
|
||||
register_display_cli(app)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Versioned, compact display data calculated from full-resolution recordings."""
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import click
|
||||
|
||||
from .analytics import analyse, numeric
|
||||
|
||||
ANALYSIS_VERSION = 1
|
||||
CHART_LIMIT = 750
|
||||
ROUTE_TARGET = 1400
|
||||
CHART_KEYS = (
|
||||
"distance",
|
||||
"heart_rate",
|
||||
"speed",
|
||||
"elevation",
|
||||
"grade",
|
||||
"gap",
|
||||
"pace",
|
||||
"cadence",
|
||||
"power",
|
||||
"stride_length",
|
||||
"vertical_oscillation",
|
||||
"ground_contact",
|
||||
)
|
||||
ROUTE_KEYS = ("distance", "speed", "heart_rate", "grade")
|
||||
|
||||
|
||||
def evenly_spaced(length, limit):
|
||||
if length <= limit:
|
||||
return set(range(length))
|
||||
return {round(i * (length - 1) / (limit - 1)) for i in range(limit)}
|
||||
|
||||
|
||||
def simplify_indices(rows, tolerance):
|
||||
"""Iterative Douglas-Peucker in local metre coordinates; retain endpoints."""
|
||||
if len(rows) <= 2:
|
||||
return set(range(len(rows)))
|
||||
latitude = math.radians(rows[0]["point"][0])
|
||||
points = [
|
||||
(row["point"][1] * 111320 * math.cos(latitude), row["point"][0] * 111320) for row in rows
|
||||
]
|
||||
kept = {0, len(rows) - 1}
|
||||
stack = [(0, len(rows) - 1)]
|
||||
while stack:
|
||||
start, end = stack.pop()
|
||||
ax, ay = points[start]
|
||||
bx, by = points[end]
|
||||
dx, dy = bx - ax, by - ay
|
||||
denominator = dx * dx + dy * dy
|
||||
furthest, maximum = None, tolerance * tolerance
|
||||
for index in range(start + 1, end):
|
||||
px, py = points[index]
|
||||
fraction = (
|
||||
max(0, min(1, ((px - ax) * dx + (py - ay) * dy) / denominator))
|
||||
if denominator
|
||||
else 0
|
||||
)
|
||||
squared = (px - ax - fraction * dx) ** 2 + (py - ay - fraction * dy) ** 2
|
||||
if squared > maximum:
|
||||
furthest, maximum = index, squared
|
||||
if furthest is not None:
|
||||
kept.add(furthest)
|
||||
stack.extend(((start, furthest), (furthest, end)))
|
||||
return kept
|
||||
|
||||
|
||||
def compact_routes(samples):
|
||||
segments, segment = [], []
|
||||
for sample in samples:
|
||||
point = sample.get("point")
|
||||
if isinstance(point, list) and len(point) == 2 and all(numeric(v) for v in point):
|
||||
segment.append(sample)
|
||||
elif segment:
|
||||
segments.append(segment)
|
||||
segment = []
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
total = sum(len(segment) for segment in segments)
|
||||
result = []
|
||||
for segment in segments:
|
||||
# Preserve turns plus regular measurement samples for route colours.
|
||||
budget = max(2, round(ROUTE_TARGET * len(segment) / total))
|
||||
regular = evenly_spaced(len(segment), max(2, budget // 2))
|
||||
tolerance = 3.0
|
||||
kept = simplify_indices(segment, tolerance) | regular
|
||||
while len(kept) > budget:
|
||||
tolerance *= 2
|
||||
kept = simplify_indices(segment, tolerance) | regular
|
||||
result.append(
|
||||
[
|
||||
{
|
||||
"point": [round(v, 6) for v in segment[i]["point"]],
|
||||
**{
|
||||
key: round(segment[i][key], 3) if numeric(segment[i].get(key)) else None
|
||||
for key in ROUTE_KEYS
|
||||
},
|
||||
}
|
||||
for i in sorted(kept)
|
||||
]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def persist_analysis(activity):
|
||||
analysis = analyse(activity)
|
||||
activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")}
|
||||
routes = compact_routes(analysis["samples"])
|
||||
activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes]
|
||||
chart_indices = evenly_spaced(len(analysis["samples"]), CHART_LIMIT)
|
||||
activity.display_data = {
|
||||
"route_segments": routes,
|
||||
"chart": [
|
||||
{key: row.get(key) for key in CHART_KEYS}
|
||||
for i, row in enumerate(analysis["samples"])
|
||||
if i in chart_indices
|
||||
],
|
||||
}
|
||||
activity.analysis_version = ANALYSIS_VERSION
|
||||
|
||||
|
||||
def stored_analysis(activity):
|
||||
if activity.analysis_version == ANALYSIS_VERSION:
|
||||
return activity.analysis_summary
|
||||
# No on-demand full-recording fallback: pending backfills show unavailable metrics.
|
||||
empty = analyse(
|
||||
SimpleNamespace(samples=[], laps=[], sport=activity.sport, distance_m=activity.distance_m)
|
||||
)
|
||||
return {key: empty[key] for key in ("summary", "terrain", "laps")}
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("backfill-analysis")
|
||||
@click.option("--batch-size", default=25, type=click.IntRange(1, 1000), show_default=True)
|
||||
@click.option("--owner-id", type=click.IntRange(1))
|
||||
@click.option("--force", is_flag=True)
|
||||
def backfill(batch_size, owner_id, force):
|
||||
"""Backfill summaries, charts and simplified routes in resumable batches."""
|
||||
from sqlalchemy.orm import undefer
|
||||
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
|
||||
maximum = db.session.scalar(db.select(db.func.max(Activity.id))) or 0
|
||||
last_id, count = 0, 0
|
||||
while last_id < maximum:
|
||||
query = db.select(Activity).where(Activity.id > last_id, Activity.id <= maximum)
|
||||
if owner_id:
|
||||
query = query.where(Activity.owner_id == owner_id)
|
||||
if not force:
|
||||
query = query.where(Activity.analysis_version != ANALYSIS_VERSION)
|
||||
rows = db.session.scalars(
|
||||
query.options(undefer(Activity.samples), undefer(Activity.laps))
|
||||
.order_by(Activity.id)
|
||||
.limit(batch_size)
|
||||
.with_for_update()
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
try:
|
||||
for row in rows:
|
||||
persist_analysis(row)
|
||||
last_id = rows[-1].id
|
||||
db.session.commit()
|
||||
except Exception as error:
|
||||
db.session.rollback()
|
||||
raise click.ClickException(
|
||||
"Backfill failed; completed batches are saved. Rerun to resume."
|
||||
) from error
|
||||
count += len(rows)
|
||||
db.session.expunge_all()
|
||||
click.echo(f"Backfilled {count} activities (through ID {last_id}).")
|
||||
click.echo(f"Complete: {count} activities backfilled; analysis version {ANALYSIS_VERSION}.")
|
||||
@@ -57,6 +57,16 @@ class Activity(db.Model):
|
||||
best_efforts = db.Column(db.JSON, nullable=False, default=list, server_default="[]")
|
||||
efforts_version = db.Column(db.Integer, nullable=False, default=0, server_default="0")
|
||||
has_route = db.Column(db.Boolean, nullable=False, default=False, server_default=db.false())
|
||||
analysis_version = db.Column(db.Integer, nullable=False, default=0, server_default="0")
|
||||
analysis_summary = db.deferred(
|
||||
db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
|
||||
)
|
||||
display_data = db.deferred(
|
||||
db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
|
||||
)
|
||||
route_geometry = db.deferred(
|
||||
db.Column(db.JSON, nullable=False, default=list, server_default="[]")
|
||||
)
|
||||
samples = db.deferred(db.Column(db.JSON, nullable=False, default=list))
|
||||
laps = db.deferred(db.Column(db.JSON, nullable=False, default=list))
|
||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
+12
-9
@@ -24,11 +24,12 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from garmin_sync.service import SyncError, atomic_write
|
||||
|
||||
from .analytics import analyse, pace_text
|
||||
from .analytics import pace_text
|
||||
from .display import stored_analysis
|
||||
from .extensions import db, limiter
|
||||
from .models import Activity, Friendship, GarminConnection, User
|
||||
from .records import record_book
|
||||
from .services import activity_data, import_cache, import_upload
|
||||
from .services import activity_data, import_cache, import_upload, route_data
|
||||
|
||||
web = Blueprint("web", __name__)
|
||||
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
|
||||
@@ -76,11 +77,7 @@ def are_friends(first, second):
|
||||
|
||||
def latest_mapped_run(rows):
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in rows
|
||||
if row.sport == "running" and row.has_route
|
||||
),
|
||||
(row for row in rows if row.sport == "running" and row.has_route),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -311,7 +308,7 @@ def profile(username):
|
||||
@web.get("/activities/<int:activity_id>")
|
||||
def activity(activity_id):
|
||||
row, owner = get_activity(activity_id)
|
||||
analysis = analyse(row)
|
||||
analysis = stored_analysis(row)
|
||||
efforts = []
|
||||
if owner:
|
||||
history = db.session.scalars(db.select(Activity).filter_by(owner_id=row.owner_id)).all()
|
||||
@@ -332,6 +329,12 @@ def data(activity_id):
|
||||
return jsonify(activity_data(row, owner))
|
||||
|
||||
|
||||
@web.get("/activities/<int:activity_id>/route")
|
||||
def route(activity_id):
|
||||
row, _ = get_activity(activity_id)
|
||||
return jsonify(route_data(row))
|
||||
|
||||
|
||||
@web.post("/activities/<int:activity_id>/share")
|
||||
@login_required
|
||||
def share_activity(activity_id):
|
||||
@@ -366,7 +369,7 @@ def shared_activity(token):
|
||||
activity=row,
|
||||
owner_view=False,
|
||||
public_view=True,
|
||||
analysis=analyse(row),
|
||||
analysis=stored_analysis(row),
|
||||
pace_text=pace_text,
|
||||
data_url=url_for("web.shared_activity_data", token=token),
|
||||
)
|
||||
|
||||
+19
-40
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
from garmin_sync.service import SyncError, complete, decode_fit
|
||||
|
||||
from .display import ANALYSIS_VERSION, persist_analysis
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
from .records import persist_efforts
|
||||
@@ -112,6 +113,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa
|
||||
if existing:
|
||||
existing.samples, existing.laps, existing.metrics = samples, laps, metrics
|
||||
persist_efforts(existing)
|
||||
persist_analysis(existing)
|
||||
return existing
|
||||
row = Activity(
|
||||
owner_id=owner_id,
|
||||
@@ -128,6 +130,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa
|
||||
laps=laps,
|
||||
)
|
||||
persist_efforts(row)
|
||||
persist_analysis(row)
|
||||
db.session.add(row)
|
||||
return row
|
||||
|
||||
@@ -159,44 +162,20 @@ 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
|
||||
"""Read compact persisted data only, after the caller's access checks."""
|
||||
if activity.analysis_version != ANALYSIS_VERSION:
|
||||
return {"segments": [], "route_segments": [], "chart": []}
|
||||
data = activity.display_data
|
||||
routes = data.get("route_segments", [])
|
||||
return {
|
||||
"segments": [[sample["point"] for sample in segment] for segment in routes],
|
||||
"route_segments": routes,
|
||||
"chart": data.get("chart", []),
|
||||
}
|
||||
|
||||
analysis = analyse(activity)
|
||||
segments, segment = [], []
|
||||
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",
|
||||
"grade",
|
||||
"gap",
|
||||
"pace",
|
||||
"cadence",
|
||||
"power",
|
||||
"stride_length",
|
||||
"vertical_oscillation",
|
||||
"ground_contact",
|
||||
)
|
||||
}
|
||||
for sample in analysis["samples"][::step]
|
||||
]
|
||||
return {"segments": segments, "route_segments": route_segments, "chart": chart}
|
||||
|
||||
def route_data(activity):
|
||||
"""Dashboard preview: no measurements, charts or full-resolution samples."""
|
||||
return {
|
||||
"segments": activity.route_geometry if activity.analysis_version == ANALYSIS_VERSION else []
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ async function showDashboardRoute() {
|
||||
const data = await response.json();
|
||||
const segments = data.segments.filter(segment => segment.length);
|
||||
if (!segments.length) { status.textContent = "No GPS route recorded for this run."; return; }
|
||||
const map = L.map(container, {scrollWheelZoom: false});
|
||||
const map = L.map(container, {scrollWheelZoom: false, preferCanvas: true});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
@@ -48,7 +48,12 @@ async function showDashboardRoute() {
|
||||
if (previous !== null) elapsed += now - previous;
|
||||
previous = now;
|
||||
const distance = ((elapsed % 20000) / 20000) * length;
|
||||
const edge = edges.find(edge => edge.end >= distance) || edges.at(-1);
|
||||
let low = 0, high = edges.length - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (edges[mid].end < distance) low = mid + 1; else high = mid;
|
||||
}
|
||||
const edge = edges[low];
|
||||
const fraction = (distance - edge.start) / (edge.end - edge.start);
|
||||
runner.setLatLng([edge.a.lat + (edge.b.lat-edge.a.lat)*fraction, edge.a.lng + (edge.b.lng-edge.a.lng)*fraction]);
|
||||
frame = requestAnimationFrame(tick);
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
|
||||
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ analysis.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity in Stride?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.</p><button class="button primary">Save changes {{ icon('arrow') }}</button></form><form id="revoke-share" method="post" action="{{ url_for('web.revoke_activity_share',activity_id=activity.id) }}" {{ 'hidden' if not activity.share_token }}><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disable public link</button></form></section>{% endif %}</div>
|
||||
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.</p></div></details>
|
||||
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Routes are simplified for display, preserving segment endpoints and GPS gaps. Map measurements and chart traces are sampled for display; computations use all available records.</p></div></details>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
|
||||
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
|
||||
<p class="field-help" id="dashboard-route-status" role="status">{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}</p></div>
|
||||
<div id="dashboard-route-map" {% if mapped_run %}data-url="{{ url_for('web.data',activity_id=mapped_run.id) }}"{% endif %} aria-label="Map of the latest recorded running route"></div>
|
||||
<div id="dashboard-route-map" {% if mapped_run %}data-url="{{ url_for('web.route',activity_id=mapped_run.id) }}"{% endif %} aria-label="Map of the latest recorded running route"></div>
|
||||
</section>
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED {{ icon('route') }}</div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Persisted run analysis and compact routes
|
||||
|
||||
Imports and recording refreshes now calculate summaries, GAP, terrain breakdowns,
|
||||
lap GAP and charts from the full-resolution samples, in the same transaction as
|
||||
the activity. `analysis_version` identifies the calculation/display version.
|
||||
Current-version empty results distinguish incomplete recordings from pending
|
||||
backfills. Raw recordings remain intact.
|
||||
|
||||
The page reads `analysis_summary`; the detail JSON endpoint reads `display_data`.
|
||||
Both are deferred JSON columns so activity lists and record comparisons do not
|
||||
load them. No page request calculates full-resolution analysis or reads raw laps.
|
||||
Pending activities show unavailable derived metrics until backfilled.
|
||||
|
||||
Dashboard maps use the authorized `/activities/<id>/route` endpoint, which reads
|
||||
only the persisted `route_geometry` and returns coordinates. The full detail data
|
||||
remains protected by the same owner/friend/public-link access checks as before.
|
||||
No shared caching is introduced for private responses.
|
||||
|
||||
Route display combines Douglas-Peucker simplification (initial tolerance 3 m)
|
||||
with regular measurement samples for colours. Tolerance increases for complex
|
||||
routes to fit a target of 1,400 points across the recording. Each GPS segment's
|
||||
endpoints are retained, so heavily fragmented routes may exceed that target;
|
||||
gaps are never joined to meet a budget. Coordinates are rounded to six decimals.
|
||||
Map measurements use only distance, speed, HR and grade; other raw fields are
|
||||
omitted. Chart data retains up to 750 evenly spaced samples, including endpoints.
|
||||
Calculations and personal records always use full-resolution source data.
|
||||
|
||||
The dashboard replay uses canvas rendering and binary search through route edges
|
||||
instead of a linear search on each animation frame.
|
||||
|
||||
After the schema migration, backfill existing activities:
|
||||
|
||||
```sh
|
||||
flask --app app backfill-analysis --batch-size 25
|
||||
# Dokku avoids interpreting Flask's --app option as a Dokku option:
|
||||
dokku run stride env FLASK_APP=app flask backfill-analysis --batch-size 25
|
||||
```
|
||||
|
||||
Each batch commits independently and uses PostgreSQL row locks. Reruns skip
|
||||
current-version activities. `--owner-id ID` scopes the job; `--force` rebuilds
|
||||
current results. The job fixes an upper activity ID at start; concurrent imports
|
||||
calculate their own results. No downloads or Garmin authentication are needed.
|
||||
|
||||
Baseline measured on production on 2026-10-02, before this change: longest run
|
||||
6,845 samples; median of three internal authenticated requests: dashboard HTML
|
||||
8 ms, run HTML 156 ms, run JSON 213 ms / 2,654 KiB uncompressed. These timings
|
||||
exclude network transfer, browser rendering and map tile downloads.
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Persist compact activity analysis and route previews."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f08db2902a74"
|
||||
down_revision = "e57a0f824c61"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("activity") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("analysis_version", sa.Integer(), nullable=False, server_default="0")
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("analysis_summary", sa.JSON(), nullable=False, server_default="{}")
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("display_data", sa.JSON(), nullable=False, server_default="{}")
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("route_geometry", sa.JSON(), nullable=False, server_default="[]")
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("activity") as batch_op:
|
||||
for name in ("route_geometry", "display_data", "analysis_summary", "analysis_version"):
|
||||
batch_op.drop_column(name)
|
||||
@@ -10,8 +10,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from waitress import serve
|
||||
|
||||
from app import create_app
|
||||
from app.display import persist_analysis
|
||||
from app.extensions import db
|
||||
from app.models import Activity, User
|
||||
from app.records import persist_efforts
|
||||
|
||||
app = create_app(
|
||||
{
|
||||
@@ -36,6 +38,7 @@ with app.app_context():
|
||||
{
|
||||
"point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000],
|
||||
"distance": n * 20,
|
||||
"time": n * 5,
|
||||
"speed": 2.8 + 0.3 * math.sin(n / 20),
|
||||
"heart_rate": 140 + 12 * math.sin(n / 30),
|
||||
"elevation": 10 + n / 30,
|
||||
@@ -64,6 +67,10 @@ with app.app_context():
|
||||
laps=[{"distance": 1000, "duration": 350, "pace": 350, "heart_rate": 145}] * 6,
|
||||
)
|
||||
)
|
||||
for row in list(db.session.new):
|
||||
if isinstance(row, Activity):
|
||||
persist_efforts(row)
|
||||
persist_analysis(row)
|
||||
db.session.commit()
|
||||
|
||||
# Test-only auto-login, isolated to this ephemeral process/database.
|
||||
|
||||
+112
-2
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user