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
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 12:49:15 +02:00
1 parent a48efb6976
commit a5016c6eed
2 files changed
+115 -1

No files matched your search

+59 -1
View File
@@ -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<string, string>();
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");
@@ -135,6 +135,28 @@ function validCsv(overrides: Partial<Record<string, string>> = {}): string {
].join("\n");
}
function singleColumnCsv(
overrides: Partial<Record<string, string>> = {},
): 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[] = [