mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
A watch samples pulse about every 5 to 10 minutes at rest and about every 5 seconds in a workout. The daily value was the plain mean over a day's readings, so a workout hour of several hundred readings outweighed the other 23 hours: a day with one hard hour and 23 resting hours (60 bpm) read about 128 bpm, not 64. A day of pulse is now the mean of its per-hour means, in the rollup DAY bucket, the live per-day read the chart serves, and the series route's day grain. Windows over several days weigh each day once instead of by its reading count, so the bias does not return one level up. Reading counts, min/max, and the regression sums still run over every reading. The chart tooltip and the data table name the statistic, in all seven locales. The statistic is limited to pulse; HRV, SpO2 and glucose keep the plain mean (see src/lib/measurements/day-statistic.ts). Stored DAY rows are rewritten by the next rollup recompute (admin recompute route), not on read. Fixes #1107
283 lines
9.7 KiB
TypeScript
283 lines
9.7 KiB
TypeScript
/**
|
|
* A day of pulse is the mean of its hours' means, and a window of days weighs
|
|
* each day once.
|
|
*
|
|
* A watch samples about every 5 to 10 minutes at rest and about every 5 seconds
|
|
* in a workout, so a plain mean over a day's readings counts the workout hour
|
|
* hundreds of times and the other twenty-three hours a few dozen times each.
|
|
* The worked example here is the one from the issue: a workout hour of 700
|
|
* readings at 150 bpm and 23 resting hours of 10 readings at 60 bpm.
|
|
*
|
|
* mean of the readings (700 x 150 + 230 x 60) / 930 = 127.7
|
|
* mean of the hours' means (150 + 23 x 60) / 24 = 63.75
|
|
*
|
|
* Asserted against a real Postgres, in every place a daily pulse value or a
|
|
* window of days is made: the live per-day read the chart serves, the
|
|
* rollup's DAY bucket and the weeks folded from it, and the series route's
|
|
* day bucket. HRV is seeded with the very same readings as a control: a type
|
|
* without an activity-dependent sampling rate keeps the plain mean.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
|
|
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
|
|
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
|
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const prisma = getPrismaClient();
|
|
const USER = "pulse-hourly-mean";
|
|
const DAY = 86_400_000;
|
|
|
|
/** Monday of the ISO week two weeks back, 00:00 UTC. */
|
|
const now = new Date();
|
|
const dow = (now.getUTCDay() + 6) % 7;
|
|
const MONDAY = Date.UTC(
|
|
now.getUTCFullYear(),
|
|
now.getUTCMonth(),
|
|
now.getUTCDate() - dow - 14,
|
|
);
|
|
/** Tuesday: the workout day. Wednesday: an ordinary day. Same ISO week. */
|
|
const WORKOUT_DAY = MONDAY + DAY;
|
|
const REST_DAY = MONDAY + 2 * DAY;
|
|
|
|
const WORKOUT_HOUR = 10;
|
|
const WORKOUT_READINGS = 700;
|
|
const RESTING_READINGS_PER_HOUR = 10;
|
|
|
|
function dayRows(
|
|
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
|
unit: string,
|
|
dayStart: number,
|
|
workout: boolean,
|
|
): Prisma.MeasurementCreateManyInput[] {
|
|
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
|
for (let h = 0; h < 24; h += 1) {
|
|
const isWorkoutHour = workout && h === WORKOUT_HOUR;
|
|
const n = isWorkoutHour ? WORKOUT_READINGS : RESTING_READINGS_PER_HOUR;
|
|
for (let i = 0; i < n; i += 1) {
|
|
// Inside the hour, at least 1 s apart; the workout hour is dense.
|
|
const offsetMs = isWorkoutHour ? i * 5_000 : (5 + i * 4) * 60_000;
|
|
rows.push({
|
|
id: `${type}-${dayStart}-${h}-${i}`,
|
|
userId: USER,
|
|
type,
|
|
unit,
|
|
source: "APPLE_HEALTH",
|
|
value: isWorkoutHour ? 150 : 60,
|
|
measuredAt: new Date(dayStart + h * 3_600_000 + offsetMs),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: { id: USER, username: USER, timezone: "UTC" },
|
|
});
|
|
invalidateUserTimezone(USER);
|
|
for (const [type, unit] of [
|
|
["PULSE", "bpm"],
|
|
["HEART_RATE_VARIABILITY", "ms"],
|
|
] as const) {
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
...dayRows(type, unit, WORKOUT_DAY, true),
|
|
...dayRows(type, unit, REST_DAY, false),
|
|
],
|
|
});
|
|
}
|
|
await recomputeUserRollups(USER, {
|
|
types: ["PULSE", "HEART_RATE_VARIABILITY"],
|
|
from: new Date(MONDAY),
|
|
to: new Date(MONDAY + 7 * DAY),
|
|
});
|
|
}, 120_000);
|
|
|
|
const SAMPLE_MEAN_WORKOUT_DAY =
|
|
(WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60) /
|
|
(WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR);
|
|
const HOURLY_MEAN_WORKOUT_DAY = (150 + 23 * 60) / 24;
|
|
|
|
describe("the worked example", () => {
|
|
it("really is two different numbers", () => {
|
|
expect(SAMPLE_MEAN_WORKOUT_DAY).toBeCloseTo(127.74, 2);
|
|
expect(HOURLY_MEAN_WORKOUT_DAY).toBeCloseTo(63.75, 5);
|
|
});
|
|
});
|
|
|
|
describe("the live per-day read the chart serves", () => {
|
|
const read = (
|
|
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
|
grain: "daily" | "weekly",
|
|
) =>
|
|
readLiveBuckets({
|
|
userId: USER,
|
|
type,
|
|
from: new Date(MONDAY),
|
|
to: new Date(MONDAY + 7 * DAY),
|
|
cap: 400,
|
|
priorityJson: null,
|
|
grain,
|
|
timeZone: "UTC",
|
|
});
|
|
|
|
it("gives a workout day the mean of its hours, not of its readings", async () => {
|
|
const rows = await read("PULSE", "daily");
|
|
const workout = rows.find(
|
|
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
|
|
);
|
|
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
|
|
// The reading count and the band stay over every reading.
|
|
expect(workout?.count).toBe(
|
|
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
|
|
);
|
|
expect(workout?.minValue).toBe(60);
|
|
expect(workout?.maxValue).toBe(150);
|
|
const rest = rows.find(
|
|
(r) => new Date(r.measuredAt).getTime() === REST_DAY,
|
|
);
|
|
expect(rest?.value).toBeCloseTo(60, 6);
|
|
});
|
|
|
|
it("weighs each day once when it folds days into a week", async () => {
|
|
const [week] = await read("PULSE", "weekly");
|
|
// Mean of the two days' means, not of their readings (which would be ~114).
|
|
expect(week.value).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
|
|
expect(week.count).toBe(
|
|
WORKOUT_READINGS +
|
|
23 * RESTING_READINGS_PER_HOUR +
|
|
24 * RESTING_READINGS_PER_HOUR,
|
|
);
|
|
});
|
|
|
|
it("keeps the plain mean for a type with no activity-dependent sampling (HRV)", async () => {
|
|
const rows = await read("HEART_RATE_VARIABILITY", "daily");
|
|
const workout = rows.find(
|
|
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
|
|
);
|
|
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
|
|
const [week] = await read("HEART_RATE_VARIABILITY", "weekly");
|
|
const total =
|
|
WORKOUT_READINGS * 150 +
|
|
23 * RESTING_READINGS_PER_HOUR * 60 +
|
|
24 * RESTING_READINGS_PER_HOUR * 60;
|
|
const readings = WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR;
|
|
expect(week.value).toBeCloseTo(total / readings, 6);
|
|
});
|
|
});
|
|
|
|
describe("the rollup tier", () => {
|
|
const rollup = (
|
|
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
|
granularity: "DAY" | "WEEK",
|
|
) =>
|
|
readCanonicalRollupBuckets({
|
|
userId: USER,
|
|
type,
|
|
granularity,
|
|
from: new Date(MONDAY),
|
|
to: new Date(MONDAY + 7 * DAY),
|
|
toInclusive: true,
|
|
});
|
|
|
|
it("stores the mean of the hours' means as the DAY mean, and nothing else changes", async () => {
|
|
const days = await rollup("PULSE", "DAY");
|
|
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
|
|
expect(workout.mean).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
|
|
expect(workout.count).toBe(
|
|
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
|
|
);
|
|
expect(workout.minValue).toBe(60);
|
|
expect(workout.maxValue).toBe(150);
|
|
// The sums the regression composes from stay over every reading.
|
|
expect(workout.sumValue).toBe(
|
|
WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60,
|
|
);
|
|
});
|
|
|
|
it("gives a week the mean of its days' means", async () => {
|
|
const [week] = await rollup("PULSE", "WEEK");
|
|
expect(week.days).toBe(2);
|
|
expect(week.mean).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
|
|
expect(week.dayMean).toBeCloseTo(week.mean, 9);
|
|
});
|
|
|
|
it("keeps the plain mean for HRV, DAY and WEEK alike", async () => {
|
|
const days = await rollup("HEART_RATE_VARIABILITY", "DAY");
|
|
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
|
|
expect(workout.mean).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
|
|
const [week] = await rollup("HEART_RATE_VARIABILITY", "WEEK");
|
|
const total = WORKOUT_READINGS * 150 + 47 * RESTING_READINGS_PER_HOUR * 60;
|
|
expect(week.mean).toBeCloseTo(
|
|
total / (WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR),
|
|
6,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the series route's day bucket", () => {
|
|
async function series(kind: string) {
|
|
const s = await prisma.session.create({
|
|
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.clear();
|
|
cookieJar.set("healthlog_session", s.id);
|
|
const { GET } = await import("@/app/api/measurements/series/route");
|
|
const res = await GET(
|
|
new NextRequest(
|
|
`http://localhost/api/measurements/series?kind=${kind}&days=365`,
|
|
),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (
|
|
(await res.json()) as {
|
|
data: { points: Array<{ id: string; at: string; value: number }> };
|
|
}
|
|
).data.points;
|
|
}
|
|
|
|
it("is the mean of the hours' means for pulse", async () => {
|
|
const points = await series("pulse");
|
|
expect(points.every((p) => p.id.startsWith("day:"))).toBe(true);
|
|
const workout = points.find(
|
|
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
|
|
);
|
|
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 2);
|
|
});
|
|
|
|
it("is the plain mean for HRV", async () => {
|
|
const points = await series("heartRateVariability");
|
|
const workout = points.find(
|
|
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
|
|
);
|
|
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 2);
|
|
});
|
|
});
|