Precompute run analysis and serve compact route previews
This commit is contained in:
+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}.")
|
||||
Reference in New Issue
Block a user