"""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.")