Files
HealthLog/tests/integration/day-aggregates-pulse-hourly.test.ts
T
Marc-André Bombeck db524ded98 Average pulse by day in the doctor report, correlations and baselines
The doctor report's pulse average and day points, the correlation channels,
the illness correlation, the personal baseline and the Coach's tiered context
each folded pulse readings into a day by plain mean. They now take the day as
the mean of its local hours' means and a window as the mean of its days, in
the zone each reader buckets by. The day aggregates carry the weighted sums for
an hourly-mean type, so every caller reads one day value. Every other type is
unchanged.
2026-10-04 11:33:42 +02:00

176 lines
5.6 KiB
TypeScript

/**
* The per-day fold the background readers share (`readDayAggregates`) carries
* the hourly-mean day for pulse, cut on the requested zone's own hours and
* days; the vitals baseline and the tiered context read their live pulse days
* from it.
*
* Asia/Kolkata (UTC+05:30). Day one: six readings at 150 from 04:45Z to
* 04:55Z and two at 150 at 05:05Z and 05:10Z (all local 10:15 to 10:40, one
* local hour, two UTC hours), then one reading at 60 in each of local hours
* 12, 14 and 16. Hours' means 150, 60, 60, 60, day value 82.5; the readings
* average (8 x 150 + 180) / 11 = 125.45. Day two: one reading at 60.
*
* A second workout day ten days earlier (12:45Z to 13:10Z, local 18:15 to
* 18:40, then one reading at 60 at 14:00Z, 15:00Z and 16:00Z) sits in the
* tiered context's day band, which a zone this far from UTC reads live.
*/
import { beforeAll, describe, expect, it } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const USER = "day-aggregates-pulse-hourly";
const TZ = "Asia/Kolkata";
const now = new Date();
const D = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - 10,
);
const at = (h: number, m: number) => new Date(D + h * 3_600_000 + m * 60_000);
const dayKey = (ms: number) =>
new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(new Date(ms));
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, timezone: TZ },
});
invalidateUserTimezone(USER);
const readings: Array<[Date, number]> = [
...[45, 47, 49, 51, 53, 55].map((m): [Date, number] => [at(4, m), 150]),
[at(5, 5), 150],
[at(5, 10), 150],
[at(6, 30), 60],
[at(8, 30), 60],
[at(10, 30), 60],
[at(24 + 4, 0), 60],
...[45, 47, 49, 51, 53, 55, 65, 70].map((m): [Date, number] => [
at(-10 * 24 + 12, m),
150,
]),
[at(-10 * 24 + 14, 0), 60],
[at(-10 * 24 + 15, 0), 60],
[at(-10 * 24 + 16, 0), 60],
];
const rows: Prisma.MeasurementCreateManyInput[] = [];
for (const type of ["PULSE", "HEART_RATE_VARIABILITY"] as const) {
readings.forEach(([measuredAt, value], i) =>
rows.push({
id: `${type}-${i}`,
userId: USER,
type,
unit: type === "PULSE" ? "bpm" : "ms",
source: "APPLE_HEALTH",
value,
measuredAt,
}),
);
}
await prisma.measurement.createMany({ data: rows });
}, 120_000);
describe("readDayAggregates", () => {
it("carries the hourly-mean day for pulse, on the zone's own hours", async () => {
const days = await readDayAggregates({
userId: USER,
type: "PULSE",
since: new Date(D - 86_400_000),
timeZone: TZ,
});
expect(days.map((d) => d.day)).toEqual([
dayKey(D + 5 * 3_600_000),
dayKey(D + 28 * 3_600_000),
]);
expect(days[0]).toMatchObject({ n: 11, min: 60, max: 150 });
expect(days[0].sum).toBe(8 * 150 + 3 * 60);
expect(days[0].dayMean).toBeCloseTo(82.5, 9);
expect(days[1].dayMean).toBeCloseTo(60, 9);
});
it("splits the hours per segment", async () => {
const days = await readDayAggregates({
userId: USER,
type: "PULSE",
since: new Date(D - 86_400_000),
timeZone: TZ,
// Readings before 08:00Z (the workout and local hour 12) fall into
// segment 1, the rest of the day into segment 0.
segmentStarts: [at(8, 0)],
});
const first = days.filter((d) => d.day === dayKey(D + 5 * 3_600_000));
expect(first.map((d) => [d.segment, d.dayMean])).toEqual([
[1, expect.closeTo(105, 9)],
[0, expect.closeTo(60, 9)],
]);
});
it("leaves every other type without a day mean", async () => {
const days = await readDayAggregates({
userId: USER,
type: "HEART_RATE_VARIABILITY",
since: new Date(D - 86_400_000),
timeZone: TZ,
});
expect(days[0].dayMean ?? null).toBeNull();
});
});
describe("live pulse days of the readers built on it", () => {
it("the vitals baseline", async () => {
const { points, source } = await readDayMeanSeries(
USER,
"PULSE",
30,
now,
new Map(),
TZ,
);
expect(source).toBe("live");
expect(points.map((p) => p.mean)).toEqual([
expect.closeTo(82.5, 9),
expect.closeTo(82.5, 9),
expect.closeTo(60, 9),
]);
const hrv = await readDayMeanSeries(
USER,
"HEART_RATE_VARIABILITY",
30,
now,
new Map(),
TZ,
);
expect(hrv.points[0].mean).toBeCloseTo((8 * 150 + 3 * 60) / 11, 9);
});
it("the tiered context's recent days and its live day band", async () => {
const series = await buildTieredSeries(USER, "PULSE", {
now: now.getTime(),
tz: TZ,
skipEnsureFresh: true,
});
expect(series.recentDaily.map((p) => [p.value, p.count])).toEqual([
[82.5, 11],
[60, 1],
]);
const workout = series.dayBand.find((b) => b.count === 11);
expect(workout?.mean).toBeCloseTo(82.5, 2);
const hrv = await buildTieredSeries(USER, "HEART_RATE_VARIABILITY", {
now: now.getTime(),
tz: TZ,
skipEnsureFresh: true,
});
expect(hrv.recentDaily[0].value).toBeCloseTo((8 * 150 + 180) / 11, 2);
expect(hrv.dayBand.find((b) => b.count === 11)?.mean).toBeCloseTo(
(8 * 150 + 180) / 11,
2,
);
});
});