Files
Stride/app/records.py
T

153 lines
6.2 KiB
Python

"""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):
"""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 = {}, {}
for activity in sorted(activities, key=lambda row: (row.started_at, row.id)):
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)
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}.")