267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""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",
|
||
"reduce": None,
|
||
"route": None,
|
||
}
|
||
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", "intervals", "efforts", "days", "health"}:
|
||
once("source", number)
|
||
options["source"] = line
|
||
lines[index] = "runs"
|
||
continue
|
||
elif match := re.fullmatch(r"reduce by run min (\d+(?:\.\d+)?) min", line):
|
||
once("reduce", number)
|
||
minutes = float(match[1])
|
||
if not 1 <= minutes <= 180:
|
||
raise QueryError("Use 1–180 matched minutes per run.", number)
|
||
options["reduce"] = minutes
|
||
elif match := re.fullmatch(r"route similar to (\d+)", line):
|
||
once("route", number)
|
||
options["route"] = int(match[1])
|
||
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["source"] in {"days", "health"} and any("best_distance" in s for s in q["derived"]):
|
||
raise QueryError("best_time requires the runs source.")
|
||
if q["flag"] and q["source"] in {"laps", "segments", "intervals"} and not q["reduce"]:
|
||
raise QueryError("Reduce recordings to independent runs before using a deviation flag.")
|
||
if q["reduce"] and q["source"] not in {"intervals", "segments"}:
|
||
raise QueryError("reduce by run requires intervals or segments.")
|
||
if q["reduce"] and (q["derived"] or q["cohorts"]):
|
||
raise QueryError("Reduce matched recordings before adding calculations or cohorts.")
|
||
if q["route"] and q["source"] in {"days", "health"}:
|
||
raise QueryError("Route matching requires a recording or runs source.")
|
||
if q["source"] in {"days", "health"} and q["group"] in {"run", "hour"}:
|
||
raise QueryError("Daily observations require calendar, all, or numeric grouping.")
|
||
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"] not in {"runs", "days", "health"}:
|
||
temporal = {
|
||
"gap_since_previous_run",
|
||
"distance_change",
|
||
"pace_change",
|
||
"heart_rate_change",
|
||
"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 not any(key in spec for key in ("window", "since", "best_distance")):
|
||
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(any(key in spec for key in ("window", "since", "best_distance")) 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", "latest"}
|
||
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.")
|