mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #1075 from MBombeck/fix/v1397-ecg-ingest
Accept the fractional heart rate and frequency HealthKit reports for ECGs
This commit is contained in:
4 files changed
+55
-12
No files matched your search
@@ -26425,11 +26425,12 @@ components:
|
||||
description: "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status:
|
||||
duplicate`."
|
||||
samplingFrequency:
|
||||
type: integer
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
description: Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor
|
||||
for the server-derived `durationSeconds`.
|
||||
description: Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the
|
||||
nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the
|
||||
server-derived `durationSeconds`.
|
||||
samples:
|
||||
minItems: 1
|
||||
maxItems: 32768
|
||||
@@ -26449,9 +26450,10 @@ components:
|
||||
maxLength: 40
|
||||
- type: "null"
|
||||
averageHeartRate:
|
||||
description: The device's average heart rate (BPM) for the strip.
|
||||
description: The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted
|
||||
as sent and rounded to whole beats per minute before it is stored.
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: number
|
||||
minimum: 1
|
||||
maximum: 300
|
||||
- type: "null"
|
||||
|
||||
@@ -92,13 +92,29 @@ const ecgIngestSchema = z
|
||||
.object({
|
||||
externalRecordingId: z.string().min(1).max(120),
|
||||
recordedAt: z.iso.datetime({ offset: true }),
|
||||
samplingFrequency: z.number().int().min(1).max(10_000),
|
||||
// HealthKit hands both of these over as doubles. A watch reports its
|
||||
// average heart rate with a fraction (64.7) often enough that requiring
|
||||
// an integer refused most new recordings while the old ones, synced when
|
||||
// the value happened to be whole, went through (#1060). The columns are
|
||||
// integers and the person reads whole beats per minute, so the server
|
||||
// rounds once here rather than every client doing it.
|
||||
samplingFrequency: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.transform((v) => Math.round(v)),
|
||||
samples: z
|
||||
.array(z.number().int().min(-1_000_000).max(1_000_000))
|
||||
.min(1)
|
||||
.max(MAX_SAMPLES),
|
||||
lead: z.string().min(1).max(40).nullable().optional(),
|
||||
averageHeartRate: z.number().int().min(1).max(300).nullable().optional(),
|
||||
averageHeartRate: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.transform((v) => Math.round(v))
|
||||
.nullable()
|
||||
.optional(),
|
||||
classification: ecgClassificationEnum.nullable().optional(),
|
||||
source: ingestSourceEnum,
|
||||
})
|
||||
@@ -198,9 +214,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = ecgIngestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
// Which field was refused and why, never the value: without it a refused
|
||||
// recording is a bare "invalid" in the log and the cause has to be guessed.
|
||||
annotate({
|
||||
action: { name: "insights.ecg.ingest" },
|
||||
meta: { outcome: "invalid" },
|
||||
meta: {
|
||||
outcome: "invalid",
|
||||
invalidFields: parsed.error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`)
|
||||
.slice(0, 8)
|
||||
.join(","),
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
|
||||
@@ -1678,11 +1678,10 @@ export const ecgIngestRequest = z
|
||||
),
|
||||
samplingFrequency: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.describe(
|
||||
"Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.",
|
||||
"Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.",
|
||||
),
|
||||
samples: z
|
||||
.array(z.number().int().min(-1_000_000).max(1_000_000))
|
||||
@@ -1700,12 +1699,13 @@ export const ecgIngestRequest = z
|
||||
.describe("Recording lead label when the device reports one (e.g. `I`)."),
|
||||
averageHeartRate: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("The device's average heart rate (BPM) for the strip."),
|
||||
.describe(
|
||||
"The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted as sent and rounded to whole beats per minute before it is stored.",
|
||||
),
|
||||
classification: ecgClassification
|
||||
.optional()
|
||||
.describe(
|
||||
|
||||
@@ -188,6 +188,23 @@ describe("POST /api/insights/ecg — round trip through the real routes", () =>
|
||||
expect(detail.body.classification).toBe("NOT_DETECTED");
|
||||
});
|
||||
|
||||
it("accepts the fractional heart rate and frequency HealthKit reports and rounds them (#1060)", async () => {
|
||||
// HealthKit hands both over as doubles; a watch's average heart rate is
|
||||
// often fractional. Requiring integers refused those recordings outright.
|
||||
const posted = await ingest(
|
||||
ingestPayload({ averageHeartRate: 64.7, samplingFrequency: 511.96 }),
|
||||
);
|
||||
expect(posted.status).toBe(201);
|
||||
expect(posted.data.status).toBe("inserted");
|
||||
|
||||
const list = await readList();
|
||||
expect(list.recordings[0]).toMatchObject({
|
||||
averageHeartRate: 65,
|
||||
samplingFrequency: 512,
|
||||
});
|
||||
expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10);
|
||||
});
|
||||
|
||||
it("never writes the waveform as plaintext", async () => {
|
||||
const posted = await ingest(ingestPayload());
|
||||
const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({
|
||||
|
||||
Reference in new issue
Block a user