"""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), }