Add saved analysis workspace and live dashboard statistics

This commit is contained in:
Peter Stockings
2026-10-03 12:27:20 +10:00
parent 5ede1a01d2
commit e6e2f22479
31 changed files with 1986 additions and 131 deletions
+73 -6
View File
@@ -25,6 +25,26 @@ FUNCTIONS = {
}
GROUPS = {"day", "week", "month", "year", "weekday", "hour", "run", "all"}
EXTRA_METRICS = {
"pace_variability": (
"Speed variability",
"%",
"Duration-weighted speed coefficient of variation across usable moving recording intervals. Lower means steadier recorded speed.",
),
"heart_rate_drift": (
"Speed/HR decoupling",
"%",
"Percent reduction in speed-to-HR efficiency from the first to second moving-time half. Positive means lower efficiency later. Requires four moving minutes and 80% HR coverage in each half; includes warm-up and terrain changes.",
),
"sensor_coverage": (
"Moving HR coverage",
"%",
"Percent of usable moving recording time with positive recorded heart rate. Gaps over ten seconds and pauses are excluded.",
),
"gap": (
"Grade-adjusted pace",
"/km",
"Modelled grade-adjusted pace from persisted recording analysis using Minetti running cost. Missing terrain stays missing.",
),
"count": ("Runs", "runs", "Number of runs."),
"speed": ("Speed", "km/h", "Recorded distance divided by timer duration."),
"gap_since_previous_run": (
@@ -91,6 +111,10 @@ EXTRA_METRICS = {
),
}
EXTRA_UNITS = {
"pace_variability": {"%": 1},
"heart_rate_drift": {"%": 1},
"sensor_coverage": {"%": 1},
"gap": {"/km": 1},
"count": {"runs": 1},
"speed": {"km/h": 1, "m/s": 3.6},
"gap_since_previous_run": {"days": 1, "h": 1 / 24, "min": 1 / 1440},
@@ -131,7 +155,7 @@ def expression_tree(source, catalog, line=1):
# Pace values inside arithmetic are seconds per km, not a formatted string.
return (
"sec/km"
if node.id == "pace"
if catalog[node.id][1] == "/km"
else "h"
if node.id == "duration"
else catalog[node.id][1]
@@ -199,6 +223,9 @@ def evaluate(tree, values):
def parse_analysis(text, metrics, defaults):
if not isinstance(text, str) or len(text) > 4000:
raise QueryError("Keep the query under 4,000 characters.")
from .stats_features import extract, validate
text, features = extract(text)
catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS}
lines = text.splitlines()
derived, aggregates, seen, locations = [], {}, set(), {}
@@ -258,14 +285,26 @@ def parse_analysis(text, metrics, defaults):
raise QueryError(
"Choose a new lowercase metric name; at most eight calculations.", number
)
_, unit = expression_tree(expression, catalog, number)
window = re.fullmatch(r"rolling_(mean|sum|count)\((\w+), (\d+) days\)", expression)
if window:
function, field, days = window.groups()
if field not in catalog or not 1 <= int(days) <= 365:
raise QueryError(
"Rolling functions need an earlier metric and 1–365 days.", number
)
unit = "runs" if function == "count" else catalog[field][1]
else:
_, unit = expression_tree(expression, catalog, number)
catalog[name] = (
name.replace("_", " ").capitalize(),
unit,
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
)
units[name] = {unit: 1}
derived.append({"name": name, "expression": expression, "unit": unit})
spec = {"name": name, "expression": expression, "unit": unit}
if window:
spec["window"] = {"function": function, "field": field, "days": int(days)}
derived.append(spec)
lines[index] = ""
elif line == "during all time":
once("all-time period", number)
@@ -335,14 +374,14 @@ def parse_analysis(text, metrics, defaults):
{"runs": 1}
if function == "count"
else {"sec/km": 1}
if field == "pace" and function in {"sum", "stddev"}
if catalog[field][1] == "/km" and function in {"sum", "stddev"}
else units[field]
)
unit = unit or next(iter(accepted))
if unit not in accepted:
raise QueryError(f"Use {' or '.join(accepted)} for {key}.", number)
try:
if field == "pace" and unit == "/km" and ":" in raw:
if unit == "/km" and ":" in raw:
minutes, seconds = raw.split(":")
if not re.fullmatch(r"\d+:\d{2}", raw) or int(seconds) >= 60:
raise ValueError
@@ -394,6 +433,7 @@ def parse_analysis(text, metrics, defaults):
)
q = parse_dsl("\n".join(lines), catalog, defaults, units=units)
q.update(options)
q.update(features)
q["derived"], q["aggregates"] = derived, aggregates
if group:
q["group"] = group
@@ -440,6 +480,33 @@ def parse_analysis(text, metrics, defaults):
groups[-1].extend(batch)
position += count
q["filter_groups"], q["filters"] = groups, []
if q["reference"]:
ref = q["reference"]
checked = parse_dsl(
f"runs\nwhere {ref['field']} = {ref['raw']} {ref['unit']}\nsummarize {ref['field']}",
catalog,
defaults,
units,
)
ref["value"] = checked["filters"][0]["value"]
if ref["field"] not in selected:
raise QueryError("Include the reference metric in summarize.")
for cohort in q["cohorts"]:
match = re.fullmatch(r"(\w+) (.+)", cohort["source"])
if not match or match[1] not in catalog:
raise QueryError("Cohorts need a numeric metric condition.")
field, condition = match.groups()
cohort_catalog = {**catalog, "cohort_input": catalog[field]}
checked = parse_dsl(
f"runs\nwhere cohort_input {condition}\nsummarize {field}",
cohort_catalog,
defaults,
{**units, "cohort_input": units[field]},
)
cohort["conditions"] = [{**item, "field": field} for item in checked["filters"]]
if not cohort["conditions"]:
raise QueryError("Supply a cohort comparison with a value and unit.")
validate(q, catalog)
return q
@@ -454,7 +521,7 @@ def describe(key, catalog, aggregates):
else f"{function.replace('_', ' ').capitalize()} · {catalog[field][0].lower()}"
)
unit = "runs" if function == "count" else catalog[field][1]
if field == "pace" and function in {"stddev", "sum"}:
if field and catalog[field][1] == "/km" and function in {"stddev", "sum"}:
unit = "sec/km"
definition = (
(