Files
HealthLog/src/lib/analytics/score/activity.ts
T
Marc-André Bombeck ac86199808 fix(stats): one standard deviation with its kind named at the call site
About a dozen hand-rolled spreads each picked population or sample
variance without saying so, and the two surfaces a person compares
picked differently: the series API reported the population SD of a
blood-pressure series, the MCP tools and the AI payload the sample SD of
the same readings, about 22 % apart at three readings.

`lib/stats/spread` holds `variance` and `standardDeviation` with an
explicit `population | sample` kind and `READING_SPREAD`, the kind every
surface uses to report the spread of a person's readings. The series
route and the feature payload both report it; the spreads that feed a
verdict (the normal-swing checks, the changepoint threshold) and the
clinical glucose SD stay the sample estimate, now said out loud. A guard
fails on a new hand-rolled spread and on the two surfaces drifting apart.
2026-10-20 04:36:54 +02:00

113 lines
3.2 KiB
TypeScript

import type { Derived } from "@/lib/insights/derived/types";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import type { ActivityPillarInput, PillarValue } from "./types";
import {
clampScore,
coverage,
failedPillar,
insufficientPillar,
mean,
okPillar,
readingSpread,
} from "./shared";
export const ACTIVITY_WINDOW_DAYS = 28;
export const ACTIVITY_MIN_DAYS = 21;
export const OLDER_ADULT_AGE = 60;
export const YOUNGER_STEP_PLATEAU = 10_000;
export const OLDER_STEP_PLATEAU = 8_000;
/**
* The activity window: the `ACTIVITY_WINDOW_DAYS` completed local days that
* end yesterday, as inclusive `YYYY-MM-DD` keys. Today is left out because
* its step total is still growing: counting it pulled every score computed
* during the day down by a partial day.
*/
export function activityWindowKeys(
asOf: Date,
timezone: string,
): { startDay: string; endDay: string } {
const today = userDayKey(asOf, timezone);
return {
startDay: shiftDateKey(today, -ACTIVITY_WINDOW_DAYS),
endDay: shiftDateKey(today, -1),
};
}
export function computeActivityPillar(
input: ActivityPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "ACTIVITY",
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
});
}
const { startDay, endDay } = activityWindowKeys(input.asOf, input.timezone);
const points = input.days.filter(
(point) => point.day <= endDay && point.day >= startDay,
);
if (points.length < ACTIVITY_MIN_DAYS) {
return insufficientPillar({
id: "ACTIVITY",
source: input.source,
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
requiredInputs: ACTIVITY_MIN_DAYS,
presentInputs: points.length,
historyDays: points.length,
missing: [`${Math.max(0, ACTIVITY_MIN_DAYS - points.length)} days`],
reason:
input.days.length > 0 ? "below_day_floor_or_stale" : "not_tracked",
});
}
const values = points.map((point) => point.value);
const observed = mean(values);
const plateau =
input.ageYears != null && input.ageYears >= OLDER_ADULT_AGE
? OLDER_STEP_PLATEAU
: YOUNGER_STEP_PLATEAU;
const score = clampScore((observed / plateau) * 100);
const noiseFloor = Math.max(
1,
Math.round(((0.5 * readingSpread(values)) / plateau) * 100),
);
const latestDay = [...points].sort((a, b) => b.day.localeCompare(a.day))[0];
return okPillar({
id: "ACTIVITY",
source: input.source,
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
coverage: coverage({
requiredInputs: ACTIVITY_MIN_DAYS,
presentInputs: ACTIVITY_MIN_DAYS,
historyDays: points.length,
}),
value: {
score,
observed: {
value: Math.round(observed),
unit: "steps/day",
label: `${Math.round(observed).toLocaleString("en-US")} steps/day`,
asOf: `${latestDay.day}T00:00:00.000Z`,
sources: input.sources,
},
reference: {
kind: "guideline-band",
low: 0,
high: plateau,
label: `benefit plateaus by ${plateau.toLocaleString("en-US")} steps/day`,
source: "Paluch 2022",
},
noiseFloor,
deltaEligible: true,
deltaIdentity: "ACTIVITY_STEPS",
},
});
}