Files

184 lines
6.5 KiB
Python

"""Versioned, compact display data calculated from full-resolution recordings."""
import math
from types import SimpleNamespace
import click
from .analytics import analyse, numeric
ANALYSIS_VERSION = 3
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
from .recording_windows import analyze as continuous_windows
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
activity.analysis_summary["intervals"] = continuous_windows(activity.samples or []) if activity.sport == "running" else []
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}.")