Files
Stride/app/stats.py
T

393 lines
13 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 calendar
import json
import math
import operator
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
from .analytics import pace_text
from .extensions import db
from .models import Activity
from .stats_dsl import UNITS, QueryError, parse_dsl
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,
"start": None,
"days": None,
"months": None,
"extra_metrics": [],
"filters": [],
"sort": ["date", "desc"],
}
def parse_query(args):
q = dict(DEFAULT)
if "query" in args:
return parse_dsl(args["query"], METRICS, DEFAULT)
for key in (
"metric",
"secondary",
"weeks",
"group",
"minimum",
"maximum",
"hr_min",
"hr_max",
"end",
):
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 {"day", "week", "month"}:
raise ValueError("Group by day, 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 number_text(value):
return str(int(value)) if value == int(value) else repr(value)
def query_text(q):
if q["start"]:
period = f"during {q['start']} to {q['end']}"
elif q["months"]:
period = f"during last {q['months']} months"
elif q["days"]:
period = f"during last {q['days']} days"
else:
period = f"during last {q['weeks']} weeks"
lines = ["runs", " " + period]
if q["end"] and not q["start"]:
lines.append(f" ending {q['end']}")
lines.append(f" where distance >= {number_text(q['minimum'])} 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} {number_text(q[key])} {unit}")
for condition in q["filters"]:
field, op = condition["field"], condition["op"]
if op in {"has", "missing"}:
lines.append(f" and {op} {field}")
elif op == "contains":
lines.append(
f" and title contains {json.dumps(condition['value'], ensure_ascii=False)}"
)
else:
unit = next(iter(UNITS[field]))
value = condition["value"] / UNITS[field][unit]
formatted = number_text(value)
if field == "pace" and value > 0 and value.is_integer():
formatted = pace_text(value)
elif field == "duration" and value * 60 == round(value * 60) and value < 1:
formatted, unit = str(round(value * 60)), "min"
lines.append(f" and {field} {op} {formatted} {unit}")
metrics = ", ".join(query_metrics(q))
lines.extend(
[
f" summarize {metrics}",
f" by {q['group']}",
" compare " + ("previous period" if q["compare"] else "none"),
]
)
if q["sort"] != ["date", "desc"]:
lines.append(f" sort by {q['sort'][0]} {q['sort'][1]}")
return "\n".join(lines)
def query_metrics(q):
return list(
dict.fromkeys(
[
q["metric"],
*([q["secondary"]] if q["secondary"] != "none" else []),
*q["extra_metrics"],
]
)
)
def matches(row, conditions):
operations = {
">=": operator.ge,
"<=": operator.le,
">": operator.gt,
"<": operator.lt,
"=": operator.eq,
"!=": operator.ne,
}
for condition in conditions:
field, op = condition["field"], condition["op"]
if op == "contains":
if condition["value"].casefold() not in row.title.casefold():
return False
continue
value = metric_value(row, field)
valid = (
isinstance(value, (float, int)) and not isinstance(value, bool) and math.isfinite(value)
)
if op == "has":
if not valid:
return False
elif op == "missing":
if valid:
return False
elif not valid or not operations[op](value, condition["value"]):
return False
return True
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 period_bounds(q, timezone):
zone = ZoneInfo(timezone)
end_day = q["end"] or datetime.now(UTC).astimezone(zone).date()
end = end_day + timedelta(days=1)
if q["start"]:
start = q["start"]
elif q["months"]:
month_index = end.year * 12 + end.month - 1 - q["months"]
year, month = divmod(month_index, 12)
start = date(year, month + 1, min(end.day, calendar.monthrange(year, month + 1)[1]))
else:
start = end - timedelta(days=q["days"]) if q["days"] else end - timedelta(weeks=q["weeks"])
previous = start - (end - start)
if start.year < 4 or previous.year < 1:
raise QueryError("Choose dates after year 0003.")
return start, end, previous
def explore(owner_id, timezone, q, page=1, selected_bucket=None):
zone = ZoneInfo(timezone)
start, end, previous = period_bounds(q, timezone)
end_day = end - timedelta(days=1)
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, comparison 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 comparison == "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()
)
rows = [r for r in rows if matches(r, q["filters"])]
current = [r for r in rows if localday(r) >= start]
prior = [r for r in rows if localday(r) < start]
keys = query_metrics(q)
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
if q["group"] == "day"
else 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]
sort_key, direction = q["sort"]
if sort_key != "date":
available = [r for r in evidence if metric_value(r, sort_key) is not None]
unavailable = [r for r in evidence if metric_value(r, sort_key) is None]
evidence = (
sorted(available, key=lambda r: metric_value(r, sort_key), reverse=direction == "desc")
+ unavailable
)
elif direction == "asc":
evidence = list(reversed(evidence))
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),
}