mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(apple-health): record the ECG verdicts the parser was dropping
The classification map held three values. Apple's set is larger, so a real verdict was being thrown away on English devices: "Heart Rate Over 120" and "Heart Rate Under 50" both became null, and the column cannot tell that apart from a recording that carries no classification at all. The wordings are observed rather than remembered. The CSV writes a display string and it does not match the framework's case names — the case documented as InconclusiveHighHeartRate reaches the file as "Heart Rate Over 120". Across a corpus of 128 real exports the field held five distinct values; three of them mapped and two were dropped. The threshold belongs to the watch generation, so the two heart-rate arms match the number instead of pinning it, and the rest of the map takes its wording from Apple's ECG instructions for use. Classification is now split from the column write so the parser can tell a verdict it has never heard of from one it knows it cannot represent. Unrecognized is the second kind: the device did not fail to classify the waveform, it received a verdict it does not know, so recording it as "could not classify" would assert something Apple never said. Both still store null, because that is the column's whole vocabulary, and no column is added for a distinction nothing reads yet. The old table pinned one row for "Localized or unknown verdict". That assertion is still true, but it fused three different facts — a localised verdict, an unknown one, and the two documented verdicts being silently dropped beside them — and it read as "everything else is unknown" when two of those were verdicts Apple documents. It is split into the four outcomes. The language half stays open and is now named at the mapping. A German export writes the KEY in German too, so a non-English verdict never reaches this function; it fails the parse earlier.
This commit is contained in:
1 parent
3744073a01
commit
b15bf4da19
2 files changed
+163
-11
No files matched your search
+109
-10
@@ -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;
|
||||
@@ -56,19 +57,117 @@ function parseHeartRate(value: string | undefined): number | null {
|
||||
return Math.round(rate);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Rhythm classification.
|
||||
*
|
||||
* The map used to hold three verdicts. Apple's documented set is larger, so a
|
||||
* real verdict was being dropped on English devices: "Heart Rate Over 120" and
|
||||
* "Heart Rate Under 50" both landed on `null`, which the column cannot tell
|
||||
* apart from "this recording carries no classification at all".
|
||||
*
|
||||
* The English wordings below are OBSERVED, from a corpus of real Apple Watch
|
||||
* exports rather than taken from the framework's case names — the CSV writes a
|
||||
* display string, and it does not match the `HKElectrocardiogram.Classification`
|
||||
* identifiers. `InconclusiveHighHeartRate` reaches the file as "Heart Rate Over
|
||||
* 120", not as "High Heart Rate". The threshold is part of the wording and it
|
||||
* tracks the watch generation, so the two heart-rate arms match the number
|
||||
* rather than pin it.
|
||||
*
|
||||
* The remaining wordings are Apple's own result names from the ECG
|
||||
* instructions-for-use document. They are verdicts the app can produce; a given
|
||||
* file may never carry one, but mapping a documented verdict is not the same as
|
||||
* leaving a branch in for a hypothetical caller.
|
||||
*
|
||||
* ## What is still unresolved, and why it is not guessed here
|
||||
*
|
||||
* The CSV is localised, and not only in this field — a German export writes
|
||||
* `Klassifizierung,Sinusrhythmus`, with the KEY translated too. A non-English
|
||||
* verdict therefore reaches the `unknown` arm below, and in practice it never
|
||||
* gets this far, because the localised metadata keys fail the parse earlier.
|
||||
* Translating a clinical verdict is not something to do from memory, so the
|
||||
* language half is deliberately left open and named rather than filled in.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** What the parser was able to make of the `Classification` field. */
|
||||
export type EcgClassificationOutcome =
|
||||
/** A verdict this repository's enum represents. */
|
||||
| { kind: "mapped"; value: RhythmClassification }
|
||||
/** No classification field, or an empty one — the recording carries no verdict. */
|
||||
| { kind: "absent" }
|
||||
/** A verdict Apple documents that this repository's enum cannot express. */
|
||||
| { kind: "unrepresentable"; verdict: string }
|
||||
/** A verdict this parser has never seen — including every non-English one. */
|
||||
| { kind: "unknown" };
|
||||
|
||||
/**
|
||||
* Apple's English verdicts, folded, to the enum member each one means.
|
||||
*
|
||||
* The inconclusive shapes collapse onto `INCONCLUSIVE` honestly: that member is
|
||||
* documented as "device could not classify (poor signal / out-of-range HR)",
|
||||
* which is exactly what Apple says each of them is.
|
||||
*/
|
||||
const RHYTHM_BY_VERDICT: ReadonlyMap<string, RhythmClassification> = new Map<
|
||||
string,
|
||||
RhythmClassification
|
||||
>([
|
||||
// Observed in real exports.
|
||||
["sinus rhythm", "NOT_DETECTED"],
|
||||
["inconclusive", "INCONCLUSIVE"],
|
||||
// Documented in Apple's ECG instructions for use.
|
||||
["atrial fibrillation", "IRREGULAR"],
|
||||
["atrial fibrillation high heart rate", "IRREGULAR"],
|
||||
["high heart rate no atrial fibrillation detected", "NOT_DETECTED"],
|
||||
["poor recording", "INCONCLUSIVE"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* The heart-rate-bound verdicts, whose wording carries the threshold the watch
|
||||
* generation decides ("Heart Rate Over 120", "Heart Rate Over 150"). Apple's
|
||||
* framework documents both as inconclusive: the classifier could not check for
|
||||
* atrial fibrillation at that rate.
|
||||
*/
|
||||
const HEART_RATE_BOUND_VERDICT = /^heart rate (?:over|under) \d{2,3}$/;
|
||||
|
||||
/**
|
||||
* Verdicts Apple documents that this repository's enum has no honest member
|
||||
* for. `Unrecognized` is NOT `INCONCLUSIVE`: the device did not fail to
|
||||
* classify the waveform, it received a verdict it does not know. Storing it as
|
||||
* "could not classify" would assert something Apple did not say.
|
||||
*/
|
||||
const UNREPRESENTABLE_VERDICTS: ReadonlySet<string> = new Set(["unrecognized"]);
|
||||
|
||||
/**
|
||||
* Classify the `Classification` field.
|
||||
*
|
||||
* Split out from the column write so the parser can tell a verdict it has never
|
||||
* heard of from one it knows it cannot represent. Both still end as `null` in
|
||||
* the nullable enum column — that is the column's whole vocabulary — but they
|
||||
* are different facts, and the previous single `default: return null` could not
|
||||
* distinguish them. No column is added for the distinction: nothing reads one
|
||||
* yet, and a write-only column is the failure mode this repository keeps
|
||||
* rediscovering.
|
||||
*/
|
||||
export function classifyEcgRhythm(
|
||||
value: string | undefined,
|
||||
): EcgClassificationOutcome {
|
||||
const folded = foldForMatch(value ?? "");
|
||||
if (folded === "") return { kind: "absent" };
|
||||
const mapped = RHYTHM_BY_VERDICT.get(folded);
|
||||
if (mapped) return { kind: "mapped", value: mapped };
|
||||
if (HEART_RATE_BOUND_VERDICT.test(folded)) {
|
||||
return { kind: "mapped", value: "INCONCLUSIVE" };
|
||||
}
|
||||
if (UNREPRESENTABLE_VERDICTS.has(folded)) {
|
||||
return { kind: "unrepresentable", verdict: folded };
|
||||
}
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
function mapClassification(
|
||||
value: string | undefined,
|
||||
): RhythmClassification | null {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case "sinus rhythm":
|
||||
return "NOT_DETECTED";
|
||||
case "atrial fibrillation":
|
||||
return "IRREGULAR";
|
||||
case "inconclusive":
|
||||
return "INCONCLUSIVE";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const outcome = classifyEcgRhythm(value);
|
||||
return outcome.kind === "mapped" ? outcome.value : null;
|
||||
}
|
||||
|
||||
function resolveUnitScale(value: string | undefined): number | null {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
|
||||
import { classifyEcgRhythm } from "@/lib/apple-health/ecg-csv";
|
||||
|
||||
type ArchiveLimits = {
|
||||
maxMembers: number;
|
||||
maxEcgMembers: number;
|
||||
@@ -349,13 +351,64 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => {
|
||||
["Sinus Rhythm", "NOT_DETECTED"],
|
||||
["Atrial Fibrillation", "IRREGULAR"],
|
||||
["Inconclusive", "INCONCLUSIVE"],
|
||||
["Localized or unknown verdict", null],
|
||||
// Observed in real exports, and dropped to null until now.
|
||||
["Heart Rate Over 120", "INCONCLUSIVE"],
|
||||
["Heart Rate Under 50", "INCONCLUSIVE"],
|
||||
// The same verdict from a watch with a different threshold.
|
||||
["Heart Rate Over 150", "INCONCLUSIVE"],
|
||||
// Named in Apple's ECG instructions for use.
|
||||
["Poor Recording", "INCONCLUSIVE"],
|
||||
["Atrial Fibrillation - High Heart Rate", "IRREGULAR"],
|
||||
["High Heart Rate - No Atrial Fibrillation Detected", "NOT_DETECTED"],
|
||||
// Apple's own "this device does not know this verdict". Deliberately NOT
|
||||
// INCONCLUSIVE — the device did not fail to classify the waveform.
|
||||
["Unrecognized", null],
|
||||
// A verdict from a German watch. Still null, and still the open half.
|
||||
["Sinusrhythmus", null],
|
||||
["Some verdict this parser has never seen", null],
|
||||
])("passes through device classification %s only", async (raw, expected) => {
|
||||
await expect(
|
||||
parse(validCsv({ Classification: raw })),
|
||||
).resolves.toMatchObject({ rhythmClassification: expected });
|
||||
});
|
||||
|
||||
/**
|
||||
* The nullable column has one spelling for four different facts. The
|
||||
* classifier keeps them apart even though the column cannot, so a reader of
|
||||
* this code can see which nulls are honest and which are the unresolved
|
||||
* language half.
|
||||
*
|
||||
* The old table pinned `["Localized or unknown verdict", null]` as a single
|
||||
* row. That assertion is still true, but it fused a localised verdict with an
|
||||
* unknown one and with the two heart-rate verdicts that were being silently
|
||||
* dropped beside them — it read as "everything else is unknown" when two of
|
||||
* those were verdicts Apple documents. It is split apart here.
|
||||
*/
|
||||
it.each([
|
||||
["Sinus Rhythm", "mapped"],
|
||||
["Heart Rate Under 50", "mapped"],
|
||||
["", "absent"],
|
||||
[undefined, "absent"],
|
||||
["Unrecognized", "unrepresentable"],
|
||||
["Sinusrhythmus", "unknown"],
|
||||
["Fibrillation auriculaire", "unknown"],
|
||||
])("classifies %s as %s", (raw, kind) => {
|
||||
expect(classifyEcgRhythm(raw).kind).toBe(kind);
|
||||
});
|
||||
|
||||
it("treats every heart-rate threshold the same way", () => {
|
||||
// The number is the watch generation's, not a value to enumerate.
|
||||
for (const n of [50, 100, 120, 150]) {
|
||||
expect(classifyEcgRhythm(`Heart Rate Over ${n}`)).toEqual({
|
||||
kind: "mapped",
|
||||
value: "INCONCLUSIVE",
|
||||
});
|
||||
}
|
||||
// Not a threshold verdict — no number, so it stays unknown rather than
|
||||
// being swept into INCONCLUSIVE by a loose pattern.
|
||||
expect(classifyEcgRhythm("Heart Rate Over").kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bad timestamp", validCsv({ "Recorded Date": "not-a-date" })],
|
||||
["bad frequency", validCsv({ "Sample Rate": "not-a-rate" })],
|
||||
|
||||
Reference in new issue
Block a user