Files
HealthLog/tests/integration/demo-seed-shape.test.ts

131 lines
5.3 KiB
TypeScript

/**
* The demo's short-window charts need a shape to show. Every 90-day series in
* the seed is a mean-reverting walk, and around a settled target that is flat
* noise: a 7-day trend line over the last 30 days of blood pressure drew a
* straight line, which is exactly the view the demo exists to show off. The
* seed carries a busy stretch a few weeks back that lifts blood pressure to
* about 144/91 for some ten days, and ramipril goes up on the day it
* turns.
*
* The blood-pressure chart scales one axis to fit systolic and diastolic, so a
* swing has to be large to read: a first version lifted systolic by 12 mmHg,
* its 7-day mean swung 8 to 14 mmHg, and on a 60 to 140 axis that still drew a
* nearly flat line. This runs the real `scripts/seed-demo.ts` and holds the
* last 30 days to a swing of the size that reads, to day-to-day noise small
* enough for the 7-day mean to draw an arc, to today's reading back in the
* settled band, and to the dose change landing on the turn. The seed is random,
* so the bounds leave room around the measured values (swing about 23 mmHg,
* day-to-day steps around 1 to 2 mmHg outside the climb and the fall). The
* peak also has a ceiling: past 148 the chart's padded axis jumps to 50-200.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
let systolic: number[];
let diastolic: number[];
let means: number[];
let doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
beforeAll(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
const { seed } = await import("../../scripts/seed-demo");
await seed();
const user = await prisma.user.findUniqueOrThrow({
where: { username: "demo" },
select: { id: true },
});
const since = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
source: "WITHINGS",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true },
});
systolic = rows.map((r) => Number(r.value));
const diaRows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
source: "WITHINGS",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true },
});
diastolic = diaRows.map((r) => Number(r.value));
const ramipril = await prisma.medication.findFirstOrThrow({
where: { userId: user.id, name: "Ramipril" },
select: { id: true },
});
const changes = await prisma.medicationDoseChange.findMany({
where: { medicationId: ramipril.id, effectiveFrom: { gte: since } },
select: { effectiveFrom: true, doseValue: true },
});
doseChanges = changes.map((c) => ({
effectiveFrom: c.effectiveFrom,
doseValue: Number(c.doseValue),
}));
means = [];
for (let i = 6; i < systolic.length; i++) {
const week = systolic.slice(i - 6, i + 1);
means.push(week.reduce((a, b) => a + b, 0) / week.length);
}
}, 180_000);
describe("demo seed, short-window shape", () => {
it("gives the 7-day systolic mean over 30 days a rise and fall that reads", () => {
expect(systolic.length).toBeGreaterThanOrEqual(28);
const swing = Math.max(...means) - Math.min(...means);
expect(swing).toBeGreaterThanOrEqual(18);
expect(Math.max(...systolic)).toBeGreaterThanOrEqual(140);
// One mmHg past 148 and the padded axis rounds out to 50-200 in steps of
// 50, which flattens the rise again.
expect(Math.max(...systolic)).toBeLessThanOrEqual(148);
expect(Math.max(...diastolic)).toBeGreaterThanOrEqual(88);
});
it("keeps the day-to-day steps small enough for the 7-day mean to draw an arc", () => {
const steps = systolic.slice(1).map((v, i) => Math.abs(v - systolic[i]));
const sorted = [...steps].sort((a, b) => a - b);
expect(sorted[Math.floor(sorted.length / 2)]).toBeLessThanOrEqual(3);
});
it("leaves the median of the window in the settled band", () => {
// The usual range is the median ± 3 MAD of the last 30 days. A stretch
// that fills half the window moves that median onto the stretch and
// widens the band to something like 87-181 mmHg. The seed's own spread
// reaches about ±22 in one run of 150, so the bound sits at 24, still far
// below what a stretch that long produces.
const sorted = [...systolic].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
expect(median).toBeLessThanOrEqual(124);
const deviations = systolic
.map((v) => Math.abs(v - median))
.sort((a, b) => a - b);
const mad = deviations[Math.floor(deviations.length / 2)];
expect(3 * 1.4826 * mad).toBeLessThanOrEqual(24);
});
it("raises ramipril on the day the stretch turns", () => {
expect(doseChanges).toHaveLength(1);
expect(doseChanges[0].doseValue).toBe(10);
const daysBefore =
(Date.now() - doseChanges[0].effectiveFrom.getTime()) / 86_400_000;
expect(daysBefore).toBeGreaterThan(8);
expect(daysBefore).toBeLessThan(14);
});
it("brings today's systolic reading back down from the stretch", () => {
expect(systolic.at(-1)).toBeLessThanOrEqual(Math.max(...means) - 15);
expect(systolic.at(-1)).toBeLessThanOrEqual(125);
});
});