mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(apple-health): read an ECG export whose metadata keys are translated
The CSV is written in the device's language, and the keys are translated along with the values: a German watch writes `Aufzeichnungsdatum` and `Klassifizierung`, not `Recorded Date` and `Classification`. Indexing on the English keys found none of them, so the parser never located the waveform and rejected the recording outright — a lost import, not a missing field. Two alias maps close it, both taken from real exports rather than from a dictionary: nine metadata keys and two verdicts, consistent across every German file observed. Adding a language is a data addition to those maps and nothing else. The verdict map is an indirection onto the English wording rather than a second verdict-to-enum table, so there stays exactly one place that decides what a verdict means. The other four shipped languages are absent on purpose. A wrong key silently mis-files a value and a wrong clinical verdict is worse than none, so an export this parser cannot place is refused — which an operator can see — rather than read into the wrong column, which they cannot. Two gaps are written down at the map: no observed file carries an average-heart-rate row in any language, and the paired layout's column header has only ever been seen in English.
This commit is contained in:
1 parent
29bad0a2b8
commit
8bf3c53d7d
2 files changed
+141
-3
No files matched your search
@@ -9,6 +9,7 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { RhythmClassification } from "@/generated/prisma/client";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
export interface NormalizedAppleHealthEcg {
|
||||
recordedAt: Date;
|
||||
@@ -87,9 +88,67 @@ function parseHeartRate(value: string | undefined): number | null {
|
||||
return Math.round(magnitude);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Localised exports.
|
||||
*
|
||||
* The CSV is written in the device's language, and not only in its values —
|
||||
* the metadata KEYS are translated too. A German watch writes
|
||||
* `Klassifizierung,Sinusrhythmus` and `Aufzeichnungsdatum,…`, so a parser
|
||||
* indexing on the English keys found none of them, never crossed into the
|
||||
* waveform, and rejected the whole recording.
|
||||
*
|
||||
* Both maps below are OBSERVED, from real exports: nine keys and two verdicts,
|
||||
* consistent across every German file seen. The other four shipped languages
|
||||
* are deliberately ABSENT rather than guessed. A wrong key silently mis-files a
|
||||
* value and a wrong clinical verdict is worse than no verdict, so a localised
|
||||
* file this parser cannot place is refused — which is visible — instead of
|
||||
* being read into the wrong column, which is not.
|
||||
*
|
||||
* Adding a language is a data addition to these two maps and nothing else.
|
||||
*
|
||||
* Two gaps worth naming. No observed file carries an average-heart-rate row in
|
||||
* any language, so there is no localised key for it here. And every observed
|
||||
* localised file uses the single-column layout, so the paired layout's
|
||||
* `Lead,Voltage` column header has only ever been seen in English; its
|
||||
* translated form is unknown and is not invented here.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Folded localised metadata key → the canonical English key we index on. */
|
||||
const METADATA_KEY_ALIASES: Readonly<Record<string, string>> = {
|
||||
// de — observed across real exports
|
||||
geburtstag: "Date of Birth",
|
||||
aufzeichnungsdatum: "Recorded Date",
|
||||
klassifizierung: "Classification",
|
||||
symptome: "Symptoms",
|
||||
softwareversion: "Software Version",
|
||||
gerat: "Device",
|
||||
messrate: "Sample Rate",
|
||||
ableitung: "Lead",
|
||||
einheit: "Unit",
|
||||
};
|
||||
|
||||
/**
|
||||
* Folded localised verdict → its English wording.
|
||||
*
|
||||
* Deliberately an indirection onto the English vocabulary rather than a second
|
||||
* verdict-to-enum table: one place decides what a verdict MEANS, and
|
||||
* translations only feed it.
|
||||
*/
|
||||
const CLASSIFICATION_ALIASES: Readonly<Record<string, string>> = {
|
||||
// de — observed across real exports
|
||||
sinusrhythmus: "Sinus Rhythm",
|
||||
uneindeutig: "Inconclusive",
|
||||
};
|
||||
|
||||
/** Resolve a metadata key to the English one this parser indexes on. */
|
||||
function canonicalMetadataKey(raw: string): string {
|
||||
return METADATA_KEY_ALIASES[foldForMatch(raw)] ?? raw;
|
||||
}
|
||||
|
||||
function mapClassification(
|
||||
value: string | undefined,
|
||||
raw: string | undefined,
|
||||
): RhythmClassification | null {
|
||||
const value = CLASSIFICATION_ALIASES[foldForMatch(raw ?? "")] ?? raw;
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case "sinus rhythm":
|
||||
return "NOT_DETECTED";
|
||||
@@ -261,11 +320,14 @@ export async function parseAppleHealthEcgCsv(input: {
|
||||
// THE CROSSING into the paired waveform: the `Lead,Voltage` column
|
||||
// header. Unlike the single-column boundary this one is explicit in
|
||||
// the file, so there is nothing to infer.
|
||||
if (key.toLowerCase() === "lead" && value.toLowerCase() === "voltage") {
|
||||
if (
|
||||
canonicalMetadataKey(key) === "Lead" &&
|
||||
value.toLowerCase() === "voltage"
|
||||
) {
|
||||
section = "paired";
|
||||
continue;
|
||||
}
|
||||
if (key !== "Name") metadata.set(key, value);
|
||||
if (key !== "Name") metadata.set(canonicalMetadataKey(key), value);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -556,4 +556,80 @@ describe("Apple Health ECG CSV — real-export shapes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a localised export", () => {
|
||||
/**
|
||||
* The header of a real German export, key for key. Every row here was
|
||||
* observed; nothing is translated by hand. The blank rows mid-header and
|
||||
* the no-break space before "Hertz" are the file's, not a typo.
|
||||
*/
|
||||
function germanCsv(
|
||||
overrides: Partial<Record<string, string>> = {},
|
||||
): string {
|
||||
const metadata: Record<string, string> = {
|
||||
Geburtstag: '"27.03.1987"',
|
||||
Aufzeichnungsdatum: "2020-07-31 22:48:19 +0200",
|
||||
Klassifizierung: "Sinusrhythmus",
|
||||
Symptome: "",
|
||||
Softwareversion: "1.13",
|
||||
Gerät: '"Watch4,2"',
|
||||
Messrate: "511,562\u00a0Hertz",
|
||||
...overrides,
|
||||
};
|
||||
return [
|
||||
"Name",
|
||||
...Object.entries(metadata).map(([k, v]) => `${k},${v}`),
|
||||
"",
|
||||
"",
|
||||
"Ableitung,Ableitung I",
|
||||
"Einheit,µV",
|
||||
"",
|
||||
"-180,596",
|
||||
"-199,83",
|
||||
"-217,333",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
it("reads a German export end to end", async () => {
|
||||
await expect(parse(germanCsv())).resolves.toMatchObject({
|
||||
recordedAt: new Date("2020-07-31T20:48:19.000Z"),
|
||||
samplingFrequency: 512,
|
||||
samples: [-181, -200, -217],
|
||||
lead: "Ableitung I",
|
||||
rhythmClassification: "NOT_DETECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the other observed German verdict", async () => {
|
||||
await expect(
|
||||
parse(germanCsv({ Klassifizierung: "Uneindeutig" })),
|
||||
).resolves.toMatchObject({ rhythmClassification: "INCONCLUSIVE" });
|
||||
});
|
||||
|
||||
it("returns no verdict for a language that is not mapped yet", async () => {
|
||||
// French is not in the alias map and is not guessed at. The recording
|
||||
// still imports; only the verdict is withheld.
|
||||
await expect(
|
||||
parse(germanCsv({ Klassifizierung: "Rythme sinusal" })),
|
||||
).resolves.toMatchObject({ rhythmClassification: null });
|
||||
});
|
||||
|
||||
it("refuses a file whose keys it cannot place, rather than mis-filing it", async () => {
|
||||
// An unmapped language's KEYS mean the parser never finds the waveform.
|
||||
// Refusing is visible; reading values into the wrong column is not.
|
||||
const french = germanCsv()
|
||||
.replace("Aufzeichnungsdatum,", "Date d'enregistrement,")
|
||||
.replace("Messrate,", "Fréquence d'échantillonnage,")
|
||||
.replace("Ableitung,Ableitung I", "Dérivation,Dérivation I")
|
||||
.replace("Einheit,µV", "Unité,µV");
|
||||
await expect(parse(french)).rejects.toThrow(/samples are missing/i);
|
||||
});
|
||||
|
||||
it("leaves the English header working unchanged", async () => {
|
||||
await expect(parse(validCsv())).resolves.toMatchObject({
|
||||
samplingFrequency: 512,
|
||||
rhythmClassification: "NOT_DETECTED",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user