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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:13:39 +02:00
1 parent c5d2053e5a
commit a66c54a1f1
14 files changed
+43

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -10392,6 +10392,7 @@
"vital": {
"RESTING_HEART_RATE": "안정 시 심박수",
"HEART_RATE_VARIABILITY": "심박변이도",
"HRV_RMSSD": "심박변이도 (RMSSD)",
"RECOVERY_SCORE": "회복",
"OXYGEN_SATURATION": "산소포화도",
"BODY_TEMPERATURE": "체온",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"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.
@@ -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);
+4
View File
@@ -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",
@@ -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();
@@ -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
+2
View File
@@ -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",