Add guided running stats explorer with validated queries and comparisons
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
"""Validated run explorer queries; never load recording samples."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .analytics import pace_text
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
|
||||
METRICS = {
|
||||
"pace": ("Pace", "/km", "Total timer time divided by total distance."),
|
||||
"heart_rate": ("Heart rate", "bpm", "Run averages weighted by recorded timer duration."),
|
||||
"distance": ("Distance", "km", "Total recorded distance."),
|
||||
"duration": ("Timer time", "hours", "Total recorded timer time, excluding pauses."),
|
||||
"elevation": ("Ascent", "m", "Total recorded elevation gain."),
|
||||
"cadence": ("Cadence", "spm", "Recorded sample averages weighted by run timer duration."),
|
||||
"power": ("Power", "W", "Recorded sample averages weighted by run timer duration."),
|
||||
"ground_contact": (
|
||||
"Ground contact",
|
||||
"ms",
|
||||
"Recorded sample averages weighted by run timer duration.",
|
||||
),
|
||||
"stride_length": (
|
||||
"Stride length",
|
||||
"m",
|
||||
"Recorded sample averages weighted by run timer duration.",
|
||||
),
|
||||
}
|
||||
DEFAULT = {
|
||||
"metric": "pace",
|
||||
"secondary": "heart_rate",
|
||||
"weeks": 12,
|
||||
"group": "week",
|
||||
"minimum": 0.0,
|
||||
"maximum": None,
|
||||
"hr_min": None,
|
||||
"hr_max": None,
|
||||
"compare": True,
|
||||
"end": None,
|
||||
}
|
||||
|
||||
|
||||
def parse_query(args):
|
||||
q = dict(DEFAULT)
|
||||
if "query" in args:
|
||||
text = args["query"]
|
||||
if len(text) > 2000:
|
||||
raise ValueError("Keep the query under 2,000 characters.")
|
||||
patterns = [
|
||||
(r"runs", None),
|
||||
(r"during last (\d+) weeks", "weeks"),
|
||||
(r"ending (\d{4}-\d{2}-\d{2})", "end"),
|
||||
(r"where distance >= ([0-9.eE+-]+) km", "minimum"),
|
||||
(r"and distance <= ([0-9.eE+-]+) km", "maximum"),
|
||||
(r"and heart_rate >= ([0-9.eE+-]+) bpm", "hr_min"),
|
||||
(r"and heart_rate <= ([0-9.eE+-]+) bpm", "hr_max"),
|
||||
(r"summarize (\w+)(?:, (\w+))?", "metrics"),
|
||||
(r"by (week|month)", "group"),
|
||||
(r"compare (previous period|none)", "compare"),
|
||||
]
|
||||
seen = set()
|
||||
for line in text.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
for pattern, key in patterns:
|
||||
match = re.fullmatch(pattern, line)
|
||||
if match:
|
||||
if key in seen:
|
||||
raise ValueError(f"Repeated query clause: {line}")
|
||||
seen.add(key)
|
||||
if key == "metrics":
|
||||
q["metric"], q["secondary"] = match.group(1), match.group(2) or "none"
|
||||
elif key == "compare":
|
||||
q[key] = match.group(1) == "previous period"
|
||||
elif key:
|
||||
q[key] = match.group(1)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Unrecognised query clause: {line}")
|
||||
if None not in seen or "metrics" not in seen:
|
||||
raise ValueError("Include 'runs' and 'summarize pace, heart_rate' (or other metrics).")
|
||||
else:
|
||||
for key in q:
|
||||
if key in args:
|
||||
q[key] = args[key] or None
|
||||
q["compare"] = args.get("compare", "yes") == "yes"
|
||||
if q["metric"] not in METRICS or q["secondary"] not in {*METRICS, "none"}:
|
||||
raise ValueError("Choose a supported metric.")
|
||||
if q["group"] not in {"week", "month"}:
|
||||
raise ValueError("Group by week or month.")
|
||||
try:
|
||||
q["weeks"] = int(q["weeks"])
|
||||
if not 1 <= q["weeks"] <= 104:
|
||||
raise ValueError
|
||||
for key in ("minimum", "maximum", "hr_min", "hr_max"):
|
||||
q[key] = float(q[key]) if q[key] is not None else None
|
||||
if q[key] is not None and (not math.isfinite(q[key]) or not 0 <= q[key] <= 1000):
|
||||
raise ValueError
|
||||
q["minimum"] = q["minimum"] or 0
|
||||
q["end"] = date.fromisoformat(q["end"]) if q["end"] else None
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(
|
||||
"Use 1–104 weeks, a valid end date, and numeric filters from 0–1,000."
|
||||
) from None
|
||||
for low, high in (("minimum", "maximum"), ("hr_min", "hr_max")):
|
||||
if q[low] is not None and q[high] is not None and q[low] > q[high]:
|
||||
raise ValueError("A filter's minimum cannot exceed its maximum.")
|
||||
return q
|
||||
|
||||
|
||||
def query_text(q):
|
||||
lines = ["runs", f" during last {q['weeks']} weeks"]
|
||||
if q["end"]:
|
||||
lines.append(f" ending {q['end']}")
|
||||
lines.append(f" where distance >= {q['minimum']:g} km")
|
||||
for key, clause, unit in (
|
||||
("maximum", "distance <=", "km"),
|
||||
("hr_min", "heart_rate >=", "bpm"),
|
||||
("hr_max", "heart_rate <=", "bpm"),
|
||||
):
|
||||
if q[key] is not None:
|
||||
lines.append(f" and {clause} {q[key]:g} {unit}")
|
||||
metrics = q["metric"] + (f", {q['secondary']}" if q["secondary"] != "none" else "")
|
||||
lines.extend(
|
||||
[
|
||||
f" summarize {metrics}",
|
||||
f" by {q['group']}",
|
||||
" compare " + ("previous period" if q["compare"] else "none"),
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def metric_value(row, key):
|
||||
if key == "pace":
|
||||
return row.duration_s * 1000 / row.distance_m if row.distance_m > 0 else None
|
||||
if key == "heart_rate":
|
||||
return row.avg_hr
|
||||
if key in {"distance", "duration", "elevation"}:
|
||||
return (
|
||||
getattr(
|
||||
row,
|
||||
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[
|
||||
key
|
||||
],
|
||||
)
|
||||
/ {"distance": 1000, "duration": 3600, "elevation": 1}[key]
|
||||
)
|
||||
return (row.analysis_summary or {}).get(key)
|
||||
|
||||
|
||||
def aggregate(rows, key):
|
||||
pairs = [(r, metric_value(r, key)) for r in rows]
|
||||
pairs = [
|
||||
(r, v)
|
||||
for r, v in pairs
|
||||
if isinstance(v, (float, int))
|
||||
and math.isfinite(v)
|
||||
and (v > 0 or key in {"distance", "duration", "elevation"})
|
||||
and (key in {"distance", "duration", "elevation", "pace"} or r.duration_s > 0)
|
||||
]
|
||||
if not pairs:
|
||||
return None, 0
|
||||
if key in {"distance", "duration", "elevation"}:
|
||||
value = sum(v for _, v in pairs)
|
||||
else:
|
||||
weights = [(v, r.distance_m if key == "pace" else r.duration_s) for r, v in pairs]
|
||||
denominator = sum(w for _, w in weights)
|
||||
value = sum(v * w for v, w in weights) / denominator if denominator > 0 else None
|
||||
return value, len(pairs)
|
||||
|
||||
|
||||
def format_value(value, key):
|
||||
if value is None:
|
||||
return "—"
|
||||
return (
|
||||
pace_text(value)
|
||||
if key == "pace"
|
||||
else f"{value:,.2f}"
|
||||
if key == "stride_length"
|
||||
else f"{value:,.1f}"
|
||||
)
|
||||
|
||||
|
||||
def explore(owner_id, timezone, q, page=1, selected_bucket=None):
|
||||
zone = ZoneInfo(timezone)
|
||||
end_day = q["end"] or datetime.now(UTC).astimezone(zone).date()
|
||||
end = end_day + timedelta(days=1)
|
||||
start = end - timedelta(weeks=q["weeks"])
|
||||
previous = start - timedelta(weeks=q["weeks"])
|
||||
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
|
||||
filters = [
|
||||
Activity.owner_id == owner_id,
|
||||
Activity.sport == "running",
|
||||
Activity.started_at >= boundary(previous if q["compare"] else start),
|
||||
Activity.started_at < boundary(end),
|
||||
Activity.distance_m >= q["minimum"] * 1000,
|
||||
]
|
||||
for key, column, multiplier, operator in (
|
||||
("maximum", Activity.distance_m, 1000, "le"),
|
||||
("hr_min", Activity.avg_hr, 1, "ge"),
|
||||
("hr_max", Activity.avg_hr, 1, "le"),
|
||||
):
|
||||
if q[key] is not None:
|
||||
filters.append(column <= q[key] * multiplier if operator == "le" else column >= q[key])
|
||||
rows = db.session.execute(
|
||||
db.select(
|
||||
Activity.id,
|
||||
Activity.title,
|
||||
Activity.started_at,
|
||||
Activity.distance_m,
|
||||
Activity.duration_s,
|
||||
Activity.elevation_m,
|
||||
Activity.avg_hr,
|
||||
Activity.analysis_summary,
|
||||
)
|
||||
.where(*filters)
|
||||
.order_by(Activity.started_at.desc(), Activity.id.desc())
|
||||
).all()
|
||||
localday = lambda r: (
|
||||
(r.started_at.replace(tzinfo=UTC) if r.started_at.tzinfo is None else r.started_at)
|
||||
.astimezone(zone)
|
||||
.date()
|
||||
)
|
||||
current = [r for r in rows if localday(r) >= start]
|
||||
prior = [r for r in rows if localday(r) < start]
|
||||
keys = list(
|
||||
dict.fromkeys([q["metric"], *([q["secondary"]] if q["secondary"] != "none" else [])])
|
||||
)
|
||||
cards = []
|
||||
for key in keys:
|
||||
value, coverage = aggregate(current, key)
|
||||
before, _ = aggregate(prior, key)
|
||||
cards.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": METRICS[key][0],
|
||||
"unit": METRICS[key][1],
|
||||
"definition": METRICS[key][2],
|
||||
"value": format_value(value, key),
|
||||
"coverage": coverage,
|
||||
"previous": format_value(before, key),
|
||||
"delta": f"{value - before:+.1f}"
|
||||
if value is not None and before is not None
|
||||
else None,
|
||||
}
|
||||
)
|
||||
bucket = lambda day: (
|
||||
day.replace(day=1) if q["group"] == "month" else day - timedelta(days=day.weekday())
|
||||
)
|
||||
dates = sorted({bucket(start + timedelta(days=n)) for n in range((end - start).days)})
|
||||
series = []
|
||||
for key in keys:
|
||||
values, prior_values = [], []
|
||||
for day in dates:
|
||||
values.append(aggregate([r for r in current if bucket(localday(r)) == day], key)[0])
|
||||
# Align prior runs by elapsed days into the equally long comparison period.
|
||||
prior_values.append(
|
||||
aggregate(
|
||||
[r for r in prior if bucket(localday(r) + (start - previous)) == day], key
|
||||
)[0]
|
||||
)
|
||||
series.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": METRICS[key][0],
|
||||
"unit": METRICS[key][1],
|
||||
"values": values,
|
||||
"previous": prior_values if q["compare"] else [],
|
||||
}
|
||||
)
|
||||
evidence = current
|
||||
if selected_bucket is not None:
|
||||
if selected_bucket not in dates:
|
||||
raise ValueError("Choose a period from the current chart.")
|
||||
evidence = [r for r in current if bucket(localday(r)) == selected_bucket]
|
||||
pages = max(1, math.ceil(len(evidence) / 25))
|
||||
if not 1 <= page <= pages:
|
||||
raise ValueError("That results page does not exist.")
|
||||
return {
|
||||
"cards": cards,
|
||||
"chart": {"labels": [d.strftime("%d %b %Y") for d in dates], "series": series},
|
||||
"count": len(current),
|
||||
"prior_count": len(prior),
|
||||
"distance": sum(r.distance_m for r in current) / 1000,
|
||||
"rows": [
|
||||
{
|
||||
"id": r.id,
|
||||
"title": r.title,
|
||||
"date": localday(r).strftime("%d %b %Y"),
|
||||
"distance": f"{r.distance_m / 1000:.2f}",
|
||||
"values": [format_value(metric_value(r, k), k) for k in keys],
|
||||
}
|
||||
for r in evidence[(page - 1) * 25 : page * 25]
|
||||
],
|
||||
"start": start,
|
||||
"end": end_day,
|
||||
"previous_start": previous,
|
||||
"previous_end": start - timedelta(days=1),
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"evidence_count": len(evidence),
|
||||
}
|
||||
Reference in New Issue
Block a user