"""Versioned, compact display data calculated from full-resolution recordings.""" import math from types import SimpleNamespace import click from .analytics import analyse, numeric ANALYSIS_VERSION = 2 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): from .effort_segments import analyze analysis = analyse(activity) effort, segments = analyze(activity.samples or []) analysis["summary"].update( {key: effort[key] for key in ("pace_variability", "heart_rate_drift", "sensor_coverage")} ) activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")} activity.analysis_summary["segments"] = segments 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}.")