Files
Stride/app/stats_insights.py

127 lines
5.1 KiB
Python

"""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", "latest"}
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": "runs" if q["reduce"] else 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