184 lines
6.5 KiB
Python
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}.")
|