"""Transparent, descriptive deviations from a prior matched-run cohort.""" from datetime import timedelta from .stats_analysis import statistic, valid def enrich(chart, q, keys, tokens, groups, history, included, start, measure, catalog): baseline_rows = [ row for row in history if q["baseline"] and start - timedelta(days=q["baseline"]) <= row.day < start and included(row) ] insights = [] for key, series in zip(keys, chart["series"], strict=True): spec = q["aggregates"].get(key) field = spec["field"] if spec else key field = field or "count" inputs = lambda rows, field=field: [ row.values[field] for row in rows if valid(row.values.get(field)) ] if q["chart"] == "histogram": values = inputs([row for token in tokens for row in groups[token]]) if values: import math count = min(30, max(1, math.ceil(math.sqrt(len(values))))) lo, hi = min(values), max(values) width = (hi - lo) / count if hi > lo else max(abs(lo) * 0.05, 1) bins = [0] * count for number in values: bins[min(count - 1, int((number - lo) / width))] += 1 series["histogram"] = { "edges": [lo + i * width for i in range(count + 1)], "counts": bins, "width": width, "field": catalog[field], } else: series["histogram"] = {"edges": [], "counts": [], "field": catalog[field]} if q["chart"] == "box": series["boxes"] = [ { "low": statistic(inputs(groups[token]), "min"), "q1": statistic(inputs(groups[token]), "p25"), "median": statistic(inputs(groups[token]), "median"), "q3": statistic(inputs(groups[token]), "p75"), "high": statistic(inputs(groups[token]), "max"), "count": len(inputs(groups[token])), } for token in tokens ] if not q["baseline"]: continue # Comparing totals/counts to a distribution of individual runs is meaningless. supported = ( spec["function"] not in {"sum", "count", "stddev"} if spec else field not in {"count", "distance", "duration", "elevation", "calories"} ) if not supported: continue numbers = inputs(baseline_rows) config = q["flag"] or {"sd": 2, "minimum": 3, "sustained": 1, "direction": "outside"} usable = supported and len(numbers) >= max(10, config["minimum"]) and len(set(numbers)) > 1 center = measure(baseline_rows, key) if usable else None spread = statistic(numbers, "stddev") if usable else None low = center - config["sd"] * spread if valid(center) else None high = center + config["sd"] * spread if valid(center) else None counts = [len(inputs(groups[token])) for token in tokens] flags, streak, last_direction = [], 0, None for number, count in zip(series["values"], counts, strict=True): direction = ( "above" if usable and valid(number) and number > high else "below" if usable and valid(number) and number < low else None ) qualified = ( direction and count >= config["minimum"] and config["direction"] in {direction, "outside"} ) streak = ( streak + 1 if qualified and direction == last_direction else 1 if qualified else 0 ) last_direction = direction if qualified else None flags.append(bool(q["flag"] and streak >= config["sustained"])) series["baseline"] = { "value": center, "low": low, "high": high, "count": len(numbers), "counts": counts, "flags": flags, "supported": supported, } flagged = [ {"label": label, "value": number, "count": count} for label, number, count, flag in zip( chart["labels"], series["values"], counts, flags, strict=True ) if flag ] insights.append( { "key": key, "label": series["label"], "unit": series["unit"], "baseline": center, "source": q["source"], "low": low, "high": high, "count": len(numbers), "usable": usable, "supported": supported, "start": (start - timedelta(days=q["baseline"])).isoformat(), "end": (start - timedelta(days=1)).isoformat(), "flagged": flagged, "rule": config, } ) return insights