mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
a48efb6976
commit
a5016c6eed
2 files changed
+115
-1
No files matched your search
@@ -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[] = [
|
||||
|
||||
Reference in new issue
Block a user