127 lines
5.0 KiB
Python
127 lines
5.0 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"}
|
|
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
|