Add saved analysis workspace and live dashboard statistics
This commit is contained in:
+73
-6
@@ -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 = (
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user