From a66c54a1f18def921e95a13c7ab429dfddab9964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:13:39 +0200 Subject: [PATCH] feat(insights): scan the nightly RMSSD series for correlations and illness HRV_RMSSD was read nowhere HRV is analysed: the correlation scan and the illness engine only knew the SDNN series, so a strap or ring wearer's HRV never took part in either. RMSSD is now its own outcome channel (owned by the recovery module, like the summary tile) and its own illness scan type with a drop read as adverse. It is not merged into the SDNN channel: the estimators differ in scale, and one series of both would correlate the change of device rather than the body. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + src/__tests__/i18n-english-leak-guard.test.ts | 1 + src/lib/illness/__tests__/correlation.test.ts | 7 +++++++ src/lib/illness/correlation.ts | 4 ++++ .../__tests__/discovery-matrix-modules.test.ts | 15 +++++++++++++++ src/lib/insights/correlation-discovery.ts | 6 ++++++ src/lib/modules/surface.ts | 2 ++ 14 files changed, 43 insertions(+) diff --git a/messages/de.json b/messages/de.json index 9a2c80392..90d17c3c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Ruheherzfrequenz", "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_TEMPERATURE": "Körpertemperatur", diff --git a/messages/en.json b/messages/en.json index 8d6ab2cd4..eba85e21c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Resting heart rate", "HEART_RATE_VARIABILITY": "Heart-rate variability", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_TEMPERATURE": "Body temperature", diff --git a/messages/es.json b/messages/es.json index f86b42a52..f89ebd220 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_TEMPERATURE": "Temperatura corporal", diff --git a/messages/fr.json b/messages/fr.json index 3b3a92f2f..eada370a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Fréquence cardiaque au repos", "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_TEMPERATURE": "Température corporelle", diff --git a/messages/it.json b/messages/it.json index 85e7d2cf8..42929075e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_TEMPERATURE": "Temperatura corporea", diff --git a/messages/ko.json b/messages/ko.json index cc848901c..ffd1b4c6f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "안정 시 심박수", "HEART_RATE_VARIABILITY": "심박변이도", + "HRV_RMSSD": "심박변이도 (RMSSD)", "RECOVERY_SCORE": "회복", "OXYGEN_SATURATION": "산소포화도", "BODY_TEMPERATURE": "체온", diff --git a/messages/nl.json b/messages/nl.json index 745094620..d42032ded 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Rusthartslag", "HEART_RATE_VARIABILITY": "Hartslagvariabiliteit", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Herstel", "OXYGEN_SATURATION": "Zuurstofverzadiging", "BODY_TEMPERATURE": "Lichaamstemperatuur", diff --git a/messages/pl.json b/messages/pl.json index d87da90db..6985f25f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Spoczynkowe tętno", "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_TEMPERATURE": "Temperatura ciała", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 451ab270f..b091256da 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set([ "insights.derived.composite.READINESS.component.hrv", "labs.catalog.hs-crp", "measurements.typeHrvRmssd", + "illness.vital.HRV_RMSSD", "settings.sections.sources.metrics.hrvRmssd", "measurements.typeVo2Max", // "{hours} h {minutes} min": the SI symbols for hour and minute. diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 4c7e5cbd9..754026c7a 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -17,6 +17,7 @@ import { computeIllnessCorrelation, dayDiff, FUNCTIONAL_IMPACT_RETURN_KEY, + ILLNESS_SCAN_TYPES, isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, @@ -803,6 +804,12 @@ describe("isAdverseDeviation", () => { expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false); }); + it("reads a drop in the nightly RMSSD series as adverse, like SDNN", () => { + expect(ILLNESS_SCAN_TYPES).toContain("HRV_RMSSD"); + expect(isAdverseDeviation("HRV_RMSSD", "below")).toBe(true); + expect(isAdverseDeviation("HRV_RMSSD", "above")).toBe(false); + }); + it("treats neutral-direction vitals (WEIGHT) as never adverse", () => { expect(isAdverseDeviation("WEIGHT", "above")).toBe(false); expect(isAdverseDeviation("WEIGHT", "below")).toBe(false); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 95f47e60b..2633ff279 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -118,6 +118,7 @@ export const ADVERSE_DIRECTION: Partial< BLOOD_PRESSURE_SYS: "up", BLOOD_PRESSURE_DIA: "up", HEART_RATE_VARIABILITY: "down", + HRV_RMSSD: "down", RECOVERY_SCORE: "down", OXYGEN_SATURATION: "down", }; @@ -130,6 +131,9 @@ export const ADVERSE_DIRECTION: Partial< export const ILLNESS_SCAN_TYPES: MeasurementType[] = [ "RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", + // RMSSD, scanned on its own baseline: a nightly strap or ring reading drops + // during illness the same way SDNN does. + "HRV_RMSSD", "RECOVERY_SCORE", "OXYGEN_SATURATION", "BODY_TEMPERATURE", diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 92d0d96b4..8f53be251 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -127,6 +127,21 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { expect(keys).toContain("CUSTOM_METRIC:abc"); }); + it("scans the nightly RMSSD series as an outcome owned by recovery", async () => { + const on = await assembleDiscoveryMatrix("u1", OPTS); + expect( + on.series.find((s) => s.key === "HRV_RMSSD" && s.role === "outcome"), + ).toBeDefined(); + const off = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { recovery: false }, + }); + const keys = keysOf(off.series); + expect(keys).not.toContain("HRV_RMSSD"); + // SDNN is a plain vital and stays. + expect(keys).toContain("HEART_RATE_VARIABILITY"); + }); + it("does not read the weather at all with the environment module off", async () => { const read = vi.mocked(fetchEnvironmentSeries); read.mockClear(); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index db16e66c0..c08f20f70 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -662,6 +662,12 @@ export const DISCOVERY_BEHAVIOURS = [ export const DISCOVERY_OUTCOMES = [ "SLEEP_DURATION", "HEART_RATE_VARIABILITY", + // The nightly RMSSD series (WHOOP, Oura, Polar, Fitbit, Google Health) as + // its own outcome. It is not merged into the SDNN channel: the estimators + // differ in scale, so one series of both would correlate the switch between + // devices rather than the body. Neither HRV channel is a behaviour, so the + // two never pair with each other. + "HRV_RMSSD", "RESTING_HEART_RATE", "WEIGHT", // v1.22 — blood pressure as an OUTCOME (it was behaviour-only). This makes diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 21f670926..a11e848a2 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -212,6 +212,8 @@ const STATIC_SURFACE_MODULE = { "correlation:SLEEP_DURATION": "sleep", "correlation:BLOOD_GLUCOSE": "glucose", "correlation:RESTING_HEART_RATE": "recovery", + // The RMSSD series is the recovery module's (see `summary:HRV_RMSSD`). + "correlation:HRV_RMSSD": "recovery", "correlation:MEDICATION_COMPLIANCE": "medications", // Symptom burden is read from the illness journal. "correlation:SYMPTOM_SEVERITY": "illness",