Files
Stride/app/stats_features.py
T

267 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")