153 lines
6.2 KiB
Python
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}.")
|