"""Estimated distance best efforts from full-resolution recorded samples.""" from bisect import bisect_left, bisect_right import click from .analytics import numeric EFFORTS_VERSION = 1 DISTANCES = ( ("1 km", 1000), ("2 km", 2000), ("5 km", 5000), ("10 km", 10000), ("Half marathon", 21097.5), ("Marathon", 42195), ) def best_efforts(activity): """Search piecewise-linear distance/time curves, counting internal stops. A minimum starts or ends at a recorded distance breakpoint. Evaluate both sets, interpolating the opposite boundary. At stationary boundaries use latest departure and earliest arrival, excluding stops outside the effort. Missing samples, distance resets and non-increasing times break the curve. Timestamp gaps remain elapsed time, including pauses in watch recording. """ if activity.sport != "running": return [] segments, segment = [], [] for sample in activity.samples: distance, time = sample.get("distance"), sample.get("time") if not numeric(distance) or not numeric(time) or distance < 0: if segment: segments.append(segment) segment = [] continue if segment and (distance < segment[-1][0] or time <= segment[-1][1]): segments.append(segment) segment = [] segment.append((distance, time)) if segment: segments.append(segment) best = {} for segment in segments: distances = [point[0] for point in segment] times = [point[1] for point in segment] def at(distance, departure=False, distances=distances, times=times): index = ( bisect_right(distances, distance) - 1 if departure else bisect_left(distances, distance) ) if distances[index] == distance: return times[index] if departure: index += 1 fraction = (distance - distances[index - 1]) / (distances[index] - distances[index - 1]) return times[index - 1] + fraction * (times[index] - times[index - 1]) for label, length in DISTANCES: if distances[-1] - distances[0] < length: continue starts = {d for d in distances if d + length <= distances[-1]} starts.update(d - length for d in distances if d - length >= distances[0]) for start in sorted(starts): seconds = at(start + length) - at(start, departure=True) if seconds > 0 and (label not in best or seconds < best[label]["seconds"]): best[label] = { "label": label, "distance": length, "seconds": seconds, "start_m": start, "end_m": start + length, } return [best[label] for label, _ in DISTANCES if label in best] def persist_efforts(activity): """Store derived data in the same transaction as the imported run.""" activity.best_efforts = best_efforts(activity) activity.efforts_version = EFFORTS_VERSION activity.has_route = any(sample.get("point") for sample in activity.samples) def record_book(activities, run_ids=None, presorted=False): """Compare persisted efforts in chronology; never read samples here. First efforts establish a record. Strict improvements earn historical PR badges, which remain on the run after a later activity beats that record. """ records, runs = {}, {} history = ( activities if presorted else sorted(activities, key=lambda row: (row.started_at, row.id)) ) for activity in history: efforts = ( [dict(effort) for effort in activity.best_efforts] if activity.efforts_version == EFFORTS_VERSION and activity.sport == "running" else [] ) for effort in efforts: previous = records.get(effort["label"]) effort["pr"] = previous is None or effort["seconds"] < previous["seconds"] - 1e-6 if effort["pr"]: records[effort["label"]] = dict(effort, activity=activity) if run_ids is None or activity.id in run_ids: runs[activity.id] = efforts return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs} def register_cli(app): @app.cli.command("backfill-efforts") @click.option("--batch-size", default=25, type=click.IntRange(1, 1000), show_default=True) @click.option("--owner-id", type=click.IntRange(1), help="Limit to one athlete.") @click.option("--force", is_flag=True, help="Recalculate even current-version efforts.") def backfill(batch_size, owner_id, force): """Backfill persisted best efforts in bounded, resumable transactions.""" from sqlalchemy.orm import undefer from .extensions import db from .models import Activity last_id, count = 0, 0 # A fixed upper bound prevents concurrent imports extending this run forever. maximum = db.session.scalar(db.select(db.func.max(Activity.id))) or 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.efforts_version != EFFORTS_VERSION) rows = db.session.scalars( query.options(undefer(Activity.samples)) .order_by(Activity.id) .limit(batch_size) .with_for_update() ).all() if not rows: break try: for row in rows: persist_efforts(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; effort version {EFFORTS_VERSION}.")