Extend run stats DSL with statistical and temporal analysis
This commit is contained in:
+83
-152
@@ -8,9 +8,8 @@ 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
|
||||
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."),
|
||||
@@ -31,6 +30,8 @@ METRICS = {
|
||||
"Recorded sample averages weighted by run timer duration.",
|
||||
),
|
||||
}
|
||||
METRICS.update(EXTRA_METRICS)
|
||||
|
||||
DEFAULT = {
|
||||
"metric": "pace",
|
||||
"secondary": "heart_rate",
|
||||
@@ -48,13 +49,25 @@ DEFAULT = {
|
||||
"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_dsl(args["query"], METRICS, DEFAULT)
|
||||
return parse_analysis(args["query"], METRICS, DEFAULT)
|
||||
for key in (
|
||||
"metric",
|
||||
"secondary",
|
||||
@@ -98,7 +111,9 @@ def number_text(value):
|
||||
|
||||
|
||||
def query_text(q):
|
||||
if q["start"]:
|
||||
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"
|
||||
@@ -107,9 +122,12 @@ def query_text(q):
|
||||
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']}")
|
||||
lines.append(f" where distance >= {number_text(q['minimum'])} km")
|
||||
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"),
|
||||
@@ -117,33 +135,74 @@ def query_text(q):
|
||||
):
|
||||
if q[key] is not None:
|
||||
lines.append(f" and {clause} {number_text(q[key])} {unit}")
|
||||
for condition in q["filters"]:
|
||||
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"}:
|
||||
lines.append(f" and {op} {field}")
|
||||
elif op == "contains":
|
||||
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(
|
||||
f" and title contains {json.dumps(condition['value'], ensure_ascii=False)}"
|
||||
clause(
|
||||
condition,
|
||||
"or"
|
||||
if index > 0 and position == 0
|
||||
else "where"
|
||||
if index == 0 and position == 0
|
||||
else "and",
|
||||
)
|
||||
)
|
||||
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}")
|
||||
for condition in q["filters"]:
|
||||
lines.append(clause(condition))
|
||||
metrics = ", ".join(query_metrics(q))
|
||||
lines.extend(
|
||||
[
|
||||
f" summarize {metrics}",
|
||||
f" by {q['group']}",
|
||||
(
|
||||
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)
|
||||
|
||||
|
||||
@@ -258,135 +317,7 @@ def period_bounds(q, timezone):
|
||||
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),
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user