374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""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": [],
|
||
"display": "separate",
|
||
"baseline": None,
|
||
"flag": None,
|
||
"color": None,
|
||
"size": None,
|
||
"labels": {},
|
||
"reference": None,
|
||
"source": "runs",
|
||
"cohorts": [],
|
||
"reduce": None,
|
||
"route": None,
|
||
"comparison": "period",
|
||
}
|
||
|
||
|
||
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 = [q["source"], " " + period]
|
||
if q["reduce"]:
|
||
lines.append(f" reduce by run min {number_text(q['reduce'])} min")
|
||
if q["route"]:
|
||
lines.append(f" route similar to {q['route']}")
|
||
for spec in q["derived"]:
|
||
if not spec.get("inline"):
|
||
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"]: ({"s": 1, "min": 60} if spec["unit"] == "time" else {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(
|
||
key
|
||
+ (f" as {json.dumps(q['labels'][key], ensure_ascii=False)}" if key in q["labels"] else "")
|
||
for key in 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 year"
|
||
if q["comparison"] == "year"
|
||
else "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 in {"pace", "gap"} 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']}")
|
||
if q["display"] != "separate":
|
||
lines.append(f" display {q['display']}")
|
||
if q["baseline"]:
|
||
lines.append(f" baseline previous {q['baseline']} days")
|
||
if q["flag"]:
|
||
flag = q["flag"]
|
||
lines.append(
|
||
f" flag {flag['direction']} baseline {number_text(flag['sd'])} sd min {flag['minimum']} runs sustained {flag['sustained']} buckets"
|
||
)
|
||
for key in ("color", "size"):
|
||
if q[key]:
|
||
lines.append(f" {key} by {q[key]}")
|
||
if q["reference"]:
|
||
ref = q["reference"]
|
||
lines.append(f" reference {ref['field']} = {ref['raw']} {ref['unit']}")
|
||
for cohort in q["cohorts"]:
|
||
lines.append(
|
||
f" cohort {json.dumps(cohort['name'], ensure_ascii=False)} where {cohort['source']}"
|
||
)
|
||
from .stats_expressions import render
|
||
|
||
return render("\n".join(lines), q["derived"])
|
||
|
||
|
||
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"}:
|
||
raw = getattr(
|
||
row,
|
||
{"distance": "distance_m", "duration": "duration_s", "elevation": "elevation_m"}[key],
|
||
)
|
||
return (
|
||
raw / {"distance": 1000, "duration": 3600, "elevation": 1}[key]
|
||
if isinstance(raw, (int, float)) and math.isfinite(raw)
|
||
else None
|
||
)
|
||
summary = row.analysis_summary or {}
|
||
return summary.get("summary", summary).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)
|