From a5016c6eed7f771491ac918b3658ec57758e4bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 12:49:15 +0200 Subject: [PATCH] Accept Apple's single-column ECG waveform layout Apple Health exports write ECG CSVs in two shapes. The importer only understood the paired Lead,Voltage form; the single-column form (Lead and Unit as metadata rows, a blank separator, then one bare reading per line) failed every row as malformed and the whole recording was skipped. The parser now treats a blank line after Lead and Unit metadata as the start of the waveform, reads one bounded numeric sample per row, and converts the declared unit to integer microvolts before the existing amplitude and safe-integer bounds. The paired layout parses exactly as before, and an unknown unit fails with a fixed message. Refs #794 --- src/lib/apple-health/ecg-csv.ts | 60 ++++++++++++++++++- .../__tests__/apple-health-ecg-import.test.ts | 56 +++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts index 76c6458a8..b32285671 100644 --- a/src/lib/apple-health/ecg-csv.ts +++ b/src/lib/apple-health/ecg-csv.ts @@ -71,6 +71,23 @@ function mapClassification( } } +function resolveUnitScale(value: string | undefined): number | null { + switch (value?.trim().toLowerCase()) { + case "µv": + case "μv": + case "uv": + case "microvolt": + case "microvolts": + return 1; + case "mv": + case "millivolt": + case "millivolts": + return 1_000; + default: + return null; + } +} + function splitPair(line: string): [string, string] | null { const comma = line.indexOf(","); if (comma < 0) return null; @@ -90,6 +107,8 @@ export async function parseAppleHealthEcgCsv(input: { const metadata = new Map(); const samples: number[] = []; let inSamples = false; + let singleColumn = false; + let unitScale = 1; let lead: string | null = null; const lines = createInterface({ input: input.stream, @@ -99,7 +118,46 @@ export async function parseAppleHealthEcgCsv(input: { try { for await (const rawLine of lines) { const line = rawLine.replace(/^\uFEFF/, "").trim(); - if (line === "") continue; + if (line === "") { + // Apple's single-column layout declares the lead and unit as + // metadata rows and separates them from the waveform with a blank + // line; one bare number per row follows. + if (!inSamples && metadata.has("Lead") && metadata.has("Unit")) { + const scale = resolveUnitScale(metadata.get("Unit")); + if (scale === null) { + throw parserError("sample unit is unsupported"); + } + unitScale = scale; + const declaredLead = metadata.get("Lead") ?? ""; + if (declaredLead.length > 0 && declaredLead.length <= 32) { + lead = declaredLead; + } + inSamples = true; + singleColumn = true; + } + continue; + } + if (singleColumn) { + if (line.includes(",")) { + throw parserError("row is malformed"); + } + if (samples.length >= input.maxSamples) { + throw parserError("sample limit exceeded"); + } + const value = Number(line); + if (!Number.isFinite(value)) { + throw parserError("sample value is invalid"); + } + const microvolts = Math.round(value * unitScale); + if ( + !Number.isSafeInteger(microvolts) || + Math.abs(microvolts) > 100_000 + ) { + throw parserError("sample value is invalid"); + } + samples.push(microvolts); + continue; + } const pair = splitPair(line); if (!pair) { throw parserError("row is malformed"); diff --git a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts index 3f18c9378..d939c7638 100644 --- a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -135,6 +135,28 @@ function validCsv(overrides: Partial> = {}): string { ].join("\n"); } +function singleColumnCsv( + overrides: Partial> = {}, +): string { + const metadata = { + "Recorded Date": "2026-07-18 08:14:03 +0200", + Classification: "Sinus Rhythm", + "Average Heart Rate": "64 bpm", + "Sample Rate": "512 Hz", + Lead: "Lead I", + Unit: "µV", + ...overrides, + }; + return [ + "Name,Private Patient Name", + ...Object.entries(metadata).map(([key, value]) => `${key},${value}`), + "", + "1.5", + "-2.4", + "3", + ].join("\n"); +} + async function collectMembers(path: string, limits = LIMITS) { const subject = await loadArchiveSubject(); const members: ArchiveMember[] = []; @@ -347,6 +369,40 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => { await expect(parse(validCsv(), 2)).rejects.toThrow(/sample|limit/i); }); + it("normalizes Apple's single-column waveform layout", async () => { + const parsed = await parse(singleColumnCsv()); + expect(parsed).toMatchObject({ + recordedAt: new Date("2026-07-18T06:14:03.000Z"), + samplingFrequency: 512, + samples: [2, -2, 3], + lead: "Lead I", + averageHeartRate: 64, + rhythmClassification: "NOT_DETECTED", + }); + expect(JSON.stringify(parsed)).not.toMatch(/Private Patient/i); + }); + + it("converts millivolt single-column samples to microvolts", async () => { + await expect(parse(singleColumnCsv({ Unit: "mV" }))).resolves.toMatchObject( + { samples: [1500, -2400, 3000] }, + ); + }); + + it.each([ + ["unsupported unit", singleColumnCsv({ Unit: "V" })], + ["paired row after the blank separator", `${singleColumnCsv()}\nI,0.001`], + ["out-of-bounds amplitude", singleColumnCsv().replace("-2.4", "999999")], + ["non-numeric sample", singleColumnCsv().replace("-2.4", "waveform")], + ])("rejects a single-column recording with %s", async (_label, csv) => { + await expect(parse(csv)).rejects.toThrow( + /invalid|malformed|unsupported|sample/i, + ); + }); + + it("stops single-column reading once the sample cap is exceeded", async () => { + await expect(parse(singleColumnCsv(), 2)).rejects.toThrow(/sample|limit/i); + }); + it("never logs or returns plaintext health values on failure", async () => { const subject = await loadParserSubject(); const spies: MockInstance[] = [