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
+247
View File
@@ -0,0 +1,247 @@
"""Presentation and matched-cohort baseline clauses for the analysis language."""
import ast
import json
import re
from .stats_dsl import QueryError
CHARTS = {"auto", "line", "bar", "area", "histogram", "box", "heatmap", "stacked", "none"}
def summary_parts(source):
parts, start, quoted, escaped = [], 0, False, False
for index, char in enumerate(source):
if escaped:
escaped = False
elif char == "\\" and quoted:
escaped = True
elif char == '"':
quoted = not quoted
elif char == "," and not quoted:
parts.append(source[start:index])
start = index + 1
parts.append(source[start:])
return parts
def extract(text):
options = {
"display": "separate",
"baseline": None,
"flag": None,
"color": None,
"size": None,
"labels": {},
"reference": None,
"source": "runs",
"cohorts": [],
"comparison": "period",
}
seen, lines = set(), text.splitlines()
def once(key, line):
if key in seen:
raise QueryError(f"Repeated {key} clause.", line)
seen.add(key)
for index, source in enumerate(lines):
line, number = source.strip(), index + 1
if line in {"runs", "laps", "segments"}:
once("source", number)
options["source"] = line
lines[index] = "runs"
continue
elif line == "compare previous year":
options["comparison"] = "year"
lines[index] = "compare previous period"
continue
elif match := re.fullmatch(r'cohort ("(?:[^"\\]|\\.)*") where (.+)', line):
name = json.loads(match[1])
if (
not 1 <= len(name) <= 60
or len(options["cohorts"]) >= 4
or name in {c["name"] for c in options["cohorts"]}
):
raise QueryError(
"Choose up to four different cohort names of 1–60 characters.", number
)
options["cohorts"].append({"name": name, "source": match[2]})
elif match := re.fullmatch(r"display (overlay|separate)", line):
once("display", number)
options["display"] = match[1]
elif match := re.fullmatch(r"baseline previous (\d+) days", line):
once("baseline", number)
days = int(match[1])
if not 7 <= days <= 732:
raise QueryError("Use a baseline of 7–732 days.", number)
options["baseline"] = days
elif match := re.fullmatch(
r"flag (above|below|outside) baseline (\S+) sd min (\d+) runs sustained (\d+) buckets",
line,
):
once("flag", number)
try:
sd = float(match[2])
minimum, sustained = int(match[3]), int(match[4])
if not 0.5 <= sd <= 5 or not 2 <= minimum <= 1000 or not 1 <= sustained <= 12:
raise ValueError
except ValueError:
raise QueryError(
"Use 0.5–5 sd, 2–1,000 runs and 1–12 consecutive buckets.", number
) from None
options["flag"] = {
"direction": match[1],
"sd": sd,
"minimum": minimum,
"sustained": sustained,
}
elif match := re.fullmatch(r"(color|size) by (\w+)", line):
once(match[1], number)
options[match[1]] = match[2]
elif match := re.fullmatch(r"reference (\w+) = (\S+) (\S+)", line):
once("reference", number)
options["reference"] = {"field": match[1], "raw": match[2], "unit": match[3]}
elif line.startswith("summarize "):
parts = []
for part in summary_parts(line[10:]):
if match := re.fullmatch(r'\s*(.+?)\s+as\s+("(?:[^"\\]|\\.)*")\s*', part):
key, encoded_label = match.groups()
key = re.sub(r"\s+", "", key)
try:
label = json.loads(encoded_label)
if not 1 <= len(label.strip()) <= 80:
raise ValueError
except ValueError:
raise QueryError(
"Use a summary label of 1–80 characters in double quotes.", number
) from None
if key in options["labels"]:
raise QueryError("Choose different summary expressions.", number)
options["labels"][key] = label
part = key
parts.append(part.strip())
lines[index] = "summarize " + ", ".join(parts)
continue
elif match := re.fullmatch(r"chart (\w+)", line):
if match[1] not in CHARTS:
raise QueryError(
"Choose auto, line, bar, area, histogram, box, heatmap, stacked or none.",
number,
)
once("chart", number)
options["chart"] = match[1]
else:
continue
lines[index] = ""
return "\n".join(lines), options
def validate(q, catalog):
if q["plot"] and q["chart"] == "none":
raise QueryError("A scatter plot conflicts with chart none.")
if q["cohorts"] and (
q["chart"] != "stacked"
or q["secondary"] != "none"
or q["extra_metrics"]
or q["trend"]
or q["baseline"]
):
raise QueryError(
"Cohorts require chart stacked, one summary metric and no trend or baseline."
)
if q["chart"] == "stacked" and not q["cohorts"]:
raise QueryError('Add named cohorts, e.g. cohort "Easy" where heart_rate <= 145 bpm.')
if q["cohorts"]:
key = q["metric"]
aggregate = q["aggregates"].get(key, {})
if aggregate.get("function") not in {"sum", "count"} and key not in {
"distance",
"duration",
"elevation",
"count",
"calories",
}:
raise QueryError(
"Stack additive metrics: sum, count, distance, duration, elevation or calories."
)
if q["compare"]:
raise QueryError("Stacked cohorts show the current period; use compare none.")
if q["source"] != "runs":
temporal = {
"gap_since_previous_run",
"distance_change",
"pace_change",
"heart_rate_change",
"rolling_7d_distance",
"rolling_28d_distance",
"rolling_7d_count",
"rolling_28d_count",
"run_day_streak",
}
fields = {
q["metric"],
q["secondary"],
*q["extra_metrics"],
q["sort"][0],
q["color"],
q["size"],
*(q["plot"] or []),
}
fields.update(spec["field"] for spec in q["aggregates"].values())
for conditions in [
q["filters"],
*q["filter_groups"],
*(cohort["conditions"] for cohort in q["cohorts"]),
]:
fields.update(condition["field"] for condition in conditions)
if q["bin"]:
fields.add(q["bin"]["field"])
for spec in q["derived"]:
if "window" not in spec:
fields.update(
node.id
for node in ast.walk(ast.parse(spec["expression"], mode="eval"))
if isinstance(node, ast.Name)
)
if fields & temporal or any("window" in spec for spec in q["derived"]):
raise QueryError(
"Run-history metrics and rolling calculations require the runs source."
)
for key in ("color", "size"):
if q[key] and (not q["plot"] or q[key] not in catalog):
raise QueryError(f"{key} by requires a scatter plot and a numeric metric.")
if q["flag"] and not q["baseline"]:
raise QueryError("Add baseline previous N days before defining a flag.")
if q["flag"] and (q["having"] or q["rank"] or q["limit"]):
raise QueryError(
"Persistent flags need every calendar bucket; remove having, rank and limit."
)
if q["baseline"] and (
q["all_time"] or q["group"] not in {"day", "week", "month", "year"} or q["trend"]
):
raise QueryError(
"Baselines need a bounded time period, calendar grouping and no trend transform."
)
if q["baseline"]:
additive = {"count", "distance", "duration", "elevation", "calories"}
selected = [
q["metric"],
*([] if q["secondary"] == "none" else [q["secondary"]]),
*q["extra_metrics"],
]
supported = lambda key: (
q["aggregates"][key]["function"] not in {"sum", "count", "stddev"}
if key in q["aggregates"]
else key not in additive
)
if not any(supported(key) for key in selected):
raise QueryError("Baselines require a mean, median, percentile or non-additive metric.")
if q["chart"] == "heatmap" and q["group"] != "day":
raise QueryError("Calendar heatmaps require by day.")
if q["chart"] == "heatmap" and (q["having"] or q["rank"] or q["limit"]):
raise QueryError("Calendar heatmaps need every day; remove having, rank and limit.")
if q["chart"] in {"box", "histogram"} and q["trend"]:
raise QueryError("Distribution charts show per-run inputs; remove the trend transform.")
if q["chart"] in {"box", "histogram", "heatmap"} and q["display"] == "overlay":
raise QueryError("Use display separate for distributions and calendar heatmaps.")