"""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 .stats_analysis import EXTRA_METRICS, EXTRA_UNITS, parse_analysis from .stats_dsl import UNITS, QueryError 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.", ), } METRICS.update(EXTRA_METRICS) 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"], "all_time": False, "bin": None, "take": None, "limit": None, "rank": None, "trend": None, "plot": None, "chart": "auto", "having": [], "derived": [], "aggregates": {}, "filter_groups": [], } def parse_query(args): q = dict(DEFAULT) if "query" in args: return parse_analysis(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["all_time"]: period = "during all time" elif 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] for spec in q["derived"]: lines.append(f" let {spec['name']} = {spec['expression']}") if q["end"] and not q["start"]: lines.append(f" ending {q['end']}") if not q["filter_groups"] or q["minimum"]: 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}") units = {**UNITS, **EXTRA_UNITS, **{spec["name"]: {spec["unit"]: 1} for spec in q["derived"]}} def clause(condition, prefix="and"): field, op = condition["field"], condition["op"] if op in {"has", "missing"}: return f" {prefix} {op} {field}" if op == "contains": return f" {prefix} title contains {json.dumps(condition['value'], ensure_ascii=False)}" unit = next(iter(units[field])) value = condition["value"] / units[field][unit] formatted = number_text(value) if unit == "/km" and value > 0 and float(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" return f" {prefix} {field} {op} {formatted} {unit}" for index, conditions in enumerate(q["filter_groups"]): for position, condition in enumerate(conditions): lines.append( clause( condition, "or" if index > 0 and position == 0 else "where" if index == 0 and position == 0 else "and", ) ) for condition in q["filters"]: lines.append(clause(condition)) metrics = ", ".join(query_metrics(q)) lines.extend( [ f" summarize {metrics}", ( f" by {q['bin']['field']} bins {number_text(q['bin']['width'])} {next(iter(units[q['bin']['field']]))}" if q["bin"] else 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]}") for condition in q["having"]: key = condition["field"] spec = q["aggregates"].get(key) field = spec["field"] if spec else key unit = ( "runs" if spec and spec["function"] == "count" else "sec/km" if spec and field == "pace" and spec["function"] in {"sum", "stddev"} else next(iter(units[field])) ) lines.append(f" having {key} {condition['op']} {number_text(condition['value'])} {unit}") if q["rank"]: lines.append(f" rank by {q['rank'][0]} {q['rank'][1]}") for name in ("take", "limit"): if q[name]: lines.append(f" {name} {q[name]}") if q["trend"]: lines.append(f" trend {q['trend']}") if q["plot"]: lines.append(f" plot {q['plot'][0]} vs {q['plot'][1]}") if q["chart"] != "auto": lines.append(f" chart {q['chart']}") 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, export=False, raw_group=False): from .analysis_engine import explore as analyze return analyze(owner_id, timezone, q, page, selected_bucket, export, raw_group)