Files
Stride/app/stats.py
T

307 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
}