Files
Stride/app/stats.py
T

374 lines
12 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 .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,
"long_run": 10.0,
"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["long_run"] != 10:
lines.append(f" long run distance >= {number_text(q['long_run'])} km")
if q["route"]:
lines.append(f" route similar to {q['route']}")
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(
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']}"
)
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"}:
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)