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[] = [