feat(insights): derived framing for additive HealthKit metrics

Register four new derived metrics for the additive HealthKit signals:

- WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE,
  STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing
  the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES
  allowlist. Wrist temperature is a personal-deviation band only (no illness
  or cycle inference); stair speeds are personal trend bands (no population
  cutoff, geometry-confounded).
- SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated
  distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002
  test standard), as percent-of-predicted with a green/yellow/red band;
  band null when age/height/weight/sex are incomplete.

Fall count and the breathing-disturbance index stay trend-only by design,
documented in the registry: fall count is a zero-inflated discrete safety
event, the breathing index is a regulated screening signal where a derived
band would imply a diagnosis.

Note wrist temperature as a deferred correlation outcome channel held
pending a privacy review (cycle-phase inference risk), mirroring the
medication-compliance omission note.

Add provenance + standards entries, method/caveat copy across all six
locales, and tests for the engine routing, the Enright equation, and the
percent-of-predicted band.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:29:36 +02:00
1 parent 789ec60d97
commit 059ba571e3
16 files changed
+731 -4

No files matched your search

+16
View File
@@ -2621,6 +2621,22 @@
"method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.",
"caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.",
"description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.",
"caveat": "Nur ein persönliches Abweichungsband — beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.",
"caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.",
"caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung."
},
"SIX_MINUTE_WALK_BAND": {
"method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.",
"caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose."
}
},
"scores": {
+16
View File
@@ -2621,6 +2621,22 @@
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
"description": "A nightly activity-load proxy from your heart-rate-weighted effort."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.",
"caveat": "A personal-deviation band only — descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.",
"caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.",
"caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment."
},
"SIX_MINUTE_WALK_BAND": {
"method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.",
"caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis."
}
},
"scores": {
+16
View File
@@ -2621,6 +2621,22 @@
"method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.",
"caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.",
"description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.",
"caveat": "Solo una banda de desviación personal: descriptiva, no una señal de enfermedad, fiebre ni fase del ciclo. Tú la interpretas."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Tu ritmo típico al subir escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.",
"caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Tu ritmo típico al bajar escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.",
"caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas."
},
"SIX_MINUTE_WALK_BAND": {
"method": "La distancia ESTIMADA de marcha de seis minutos de tu dispositivo situada frente a la referencia de Enright y Sherrill para tu edad, estatura, peso y sexo, como porcentaje de lo previsto. La distancia es la estimación del dispositivo: nunca se recalcula aquí. Sin tu edad, estatura, peso y sexo se oculta el porcentaje y solo se muestran la distancia y la tendencia.",
"caveat": "Una estimación reformulada frente a una referencia publicada: una señal de capacidad funcional, no una prueba clínica de marcha de seis minutos ni un diagnóstico."
}
},
"scores": {
+16
View File
@@ -2621,6 +2621,22 @@
"method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.",
"caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.",
"description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.",
"caveat": "Une simple bande d’écart personnel — descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.",
"caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.",
"caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute."
},
"SIX_MINUTE_WALK_BAND": {
"method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.",
"caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic."
}
},
"scores": {
+16
View File
@@ -2621,6 +2621,22 @@
"method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.",
"caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.",
"description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.",
"caveat": "Solo una banda di scostamento personale — descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.",
"caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.",
"caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta."
},
"SIX_MINUTE_WALK_BAND": {
"method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.",
"caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi."
}
},
"scores": {
+16
View File
@@ -2621,6 +2621,22 @@
"method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.",
"caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.",
"description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.",
"caveat": "Tylko osobiste pasmo odchylenia — opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie."
},
"STAIR_ASCENT_SPEED_BASELINE": {
"method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.",
"caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku."
},
"STAIR_DESCENT_SPEED_BASELINE": {
"method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.",
"caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku."
},
"SIX_MINUTE_WALK_BAND": {
"method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.",
"caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza."
}
},
"scores": {
@@ -128,4 +128,40 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
STAIR_ASCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
STAIR_DESCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
};
@@ -249,4 +249,12 @@ export const DISCOVERY_OUTCOMES = [
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"WEIGHT",
// Wrist temperature is a credible future OUTCOME channel (near-daily, so
// n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night
// temperature?"), but it is deliberately NOT a channel yet: it is
// privacy-sensitive and deviation-framed, and folding it into the FDR
// matrix risks surfacing a cycle-phase correlation that strays into
// reproductive inference. Held pending a deliberate privacy review —
// documented here so the omission is intentional, not a gap (same posture
// as the medication-compliance omission above).
] as const;
@@ -39,6 +39,16 @@ describe("registry", () => {
expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true);
expect(
getDerivedMetricMeta("WRIST_TEMPERATURE_BASELINE")?.implemented,
).toBe(true);
expect(
getDerivedMetricMeta("STAIR_ASCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(
getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -59,7 +69,11 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE");
expect(DERIVED_METRIC_IDS.length).toBe(11);
expect(DERIVED_METRIC_IDS).toContain("WRIST_TEMPERATURE_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS.length).toBe(15);
});
});
@@ -127,6 +141,43 @@ describe("computeDerivedMetric dispatch", () => {
}
});
it.each([
"WRIST_TEMPERATURE_BASELINE",
"STAIR_ASCENT_SPEED_BASELINE",
"STAIR_DESCENT_SPEED_BASELINE",
] as const)(
"routes %s through the baseline engine (no data → insufficient, not not_implemented)",
async (metric) => {
const result = await computeDerivedMetric({
metric,
userId: "u1",
profile: PROFILE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).not.toBe("not_implemented");
// The baseline engine names its fixed type as the missing input.
expect(result.provenance.inputs).toContain(
metric.replace("_BASELINE", ""),
);
}
},
);
it("routes SIX_MINUTE_WALK_BAND to its engine (no data → insufficient, not not_implemented)", async () => {
const result = await computeDerivedMetric({
metric: "SIX_MINUTE_WALK_BAND",
userId: "u1",
profile: PROFILE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
}
});
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
const result = await computeDerivedMetric({
metric: "VITALS_BASELINE",
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { lookupNormalRange, hasSharpenedNorm } from "../norms";
import {
lookupNormalRange,
hasSharpenedNorm,
predictSixMinuteWalkDistance,
} from "../norms";
describe("lookupNormalRange — the age/sex reference-range enabler", () => {
it("returns a sex-specific VO2max band for a registered metric", () => {
@@ -59,3 +63,39 @@ describe("hasSharpenedNorm", () => {
expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false);
});
});
describe("predictSixMinuteWalkDistance — Enright & Sherrill 1998", () => {
it("matches the published male equation", () => {
// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m
expect(predictSixMinuteWalkDistance(40, 180, 80, "MALE")).toBeCloseTo(
712,
6,
);
});
it("matches the published female equation", () => {
// 2.11·165 − 2.29·65 − 5.78·40 + 667 = 635.1 m
expect(predictSixMinuteWalkDistance(40, 165, 65, "FEMALE")).toBeCloseTo(
635.1,
6,
);
});
it("returns null without a usable sex (the equations differ by sex)", () => {
expect(predictSixMinuteWalkDistance(40, 180, 80, null)).toBeNull();
});
it("returns null when weight is absent (no silently-dropped term)", () => {
expect(predictSixMinuteWalkDistance(40, 180, null, "MALE")).toBeNull();
});
it("returns null without height", () => {
expect(predictSixMinuteWalkDistance(40, null, 80, "MALE")).toBeNull();
});
it("returns null for non-adult / non-finite ages", () => {
expect(predictSixMinuteWalkDistance(12, 150, 45, "MALE")).toBeNull();
expect(predictSixMinuteWalkDistance(Number.NaN, 180, 80, "MALE")).toBeNull();
expect(predictSixMinuteWalkDistance(null, 180, 80, "MALE")).toBeNull();
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const findMany = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...a: unknown[]) => findMany(...a),
findFirst: (...a: unknown[]) => findFirst(...a),
},
},
}));
import {
computeSixMinuteWalkBand,
placeSixMinuteWalkBand,
} from "../six-minute-walk";
const NOW = new Date("2026-06-02T07:00:00Z");
// Enright male, 40 yr, 180 cm, 80 kg →
// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m predicted.
const MALE_40 = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NO_DEMO = { ageYears: null, sex: null, heightCm: null };
const NO_WEIGHT_PROFILE = MALE_40;
beforeEach(() => vi.clearAllMocks());
describe("placeSixMinuteWalkBand", () => {
it("green at/above 80% of predicted", () => {
expect(placeSixMinuteWalkBand(80)).toBe("green");
expect(placeSixMinuteWalkBand(100)).toBe("green");
});
it("yellow between 60 and 80%", () => {
expect(placeSixMinuteWalkBand(60)).toBe("yellow");
expect(placeSixMinuteWalkBand(79)).toBe("yellow");
});
it("red below 60%", () => {
expect(placeSixMinuteWalkBand(59)).toBe("red");
});
it("null without a percent", () => {
expect(placeSixMinuteWalkBand(null)).toBeNull();
});
});
describe("computeSixMinuteWalkBand", () => {
it("insufficient when no 6MWT reading exists", async () => {
findMany.mockResolvedValueOnce([]);
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("insufficient");
if (r.status === "insufficient") {
expect(r.reason).toBe("no_readings_in_window");
}
// No reading → never reach the weight read.
expect(findFirst).not.toHaveBeenCalled();
});
it("ok with a band + percent-of-predicted when full demographics + weight exist", async () => {
// 3 readings → trend = 712 - 700 = 12; distance 712 vs predicted 712 → 100%.
findMany.mockResolvedValueOnce([
{ value: 712 },
{ value: 700 },
{ value: 690 },
]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(712);
expect(r.value.predictedM).toBe(712);
expect(r.value.percentOfPredicted).toBe(100);
expect(r.value.band).toBe("green");
expect(r.value.trendDelta).toBe(12);
}
});
it("bands red on a low percent of predicted", async () => {
// 400 / 712 = 56% → red.
findMany.mockResolvedValueOnce([{ value: 400 }]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.percentOfPredicted).toBe(56);
expect(r.value.band).toBe("red");
}
});
it("ok but band/percent null without demographics", async () => {
findMany.mockResolvedValueOnce([{ value: 500 }]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", NO_DEMO, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(500);
expect(r.value.predictedM).toBeNull();
expect(r.value.percentOfPredicted).toBeNull();
expect(r.value.band).toBeNull();
// Single reading → trend suppressed.
expect(r.value.trendDelta).toBeNull();
}
});
it("ok but band null when weight is absent (no fabricated placement)", async () => {
findMany.mockResolvedValueOnce([{ value: 600 }]);
findFirst.mockResolvedValueOnce(null); // no recent weight
const r = await computeSixMinuteWalkBand("u1", NO_WEIGHT_PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(600);
expect(r.value.predictedM).toBeNull();
expect(r.value.percentOfPredicted).toBeNull();
expect(r.value.band).toBeNull();
}
});
});
+31
View File
@@ -23,6 +23,7 @@ import {
type BaselineProfile,
} from "./baseline";
import { computeFitnessAge } from "./fitness-age";
import { computeSixMinuteWalkBand } from "./six-minute-walk";
import { computeVascularAgeDelta } from "./vascular-age";
import { computeHrvBalance } from "./hrv-balance";
import { computeBmi } from "./bmi";
@@ -146,6 +147,36 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "WRIST_TEMPERATURE_BASELINE":
case "STAIR_ASCENT_SPEED_BASELINE":
case "STAIR_DESCENT_SPEED_BASELINE": {
// v1.10.3 additive HealthKit baselines — each pins ONE fixed type and
// reuses the type-generic baseline engine unchanged (median ± k·MAD).
// Wrist temperature is a personal-DEVIATION band (no illness/cycle
// inference); stair ascent/descent are personal trend bands (no
// population cutoff — stair pace is geometry-confounded). The fixed
// type is the metric's single registered input.
const baselineType: Record<
| "WRIST_TEMPERATURE_BASELINE"
| "STAIR_ASCENT_SPEED_BASELINE"
| "STAIR_DESCENT_SPEED_BASELINE",
MeasurementType
> = {
WRIST_TEMPERATURE_BASELINE: "WRIST_TEMPERATURE",
STAIR_ASCENT_SPEED_BASELINE: "STAIR_ASCENT_SPEED",
STAIR_DESCENT_SPEED_BASELINE: "STAIR_DESCENT_SPEED",
};
return computeVitalsBaseline(args.userId, args.profile, {
type: baselineType[args.metric],
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "SIX_MINUTE_WALK_BAND":
return computeSixMinuteWalkBand(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+16 -1
View File
@@ -39,9 +39,11 @@ export type { DeriveCoverageArgs } from "./coverage";
export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
BASELINE_CAPABLE_TYPES,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
isBaselineCapableType,
} from "./registry";
export type {
DerivedMetricId,
@@ -49,7 +51,11 @@ export type {
DerivedArchetype,
} from "./registry";
export { lookupNormalRange, hasSharpenedNorm } from "./norms";
export {
lookupNormalRange,
hasSharpenedNorm,
predictSixMinuteWalkDistance,
} from "./norms";
export type { NormRange, NormSex } from "./norms";
// ── server-only compute engines (do NOT value-import from a client component) ──
@@ -77,6 +83,15 @@ export {
} from "./fitness-age";
export type { FitnessAgeValue, FitnessBand } from "./fitness-age";
export {
computeSixMinuteWalkBand,
placeSixMinuteWalkBand,
} from "./six-minute-walk";
export type {
SixMinuteWalkValue,
SixMinuteWalkBand,
} from "./six-minute-walk";
export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age";
export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age";
+54
View File
@@ -24,6 +24,13 @@
* 377(9770):1011–1018, normal-range HR centiles by age).
* - Respiratory rate by age — clinical reference (WHO/PALS adult vs
* paediatric ranges).
* - Six-minute-walk distance — a published REGRESSION (not a bracket
* table): Enright & Sherrill 1998, "Reference Equations for the
* Six-Minute Walk in Healthy Adults", Am J Respir Crit Care Med
* 158(5):1384–1387; the test itself standardised by ATS 2002,
* "ATS Statement: Guidelines for the Six-Minute Walk Test",
* Am J Respir Crit Care Med 166(1):111–117. Surfaced as a
* percent-of-predicted re-frame, never a HealthLog-derived equation.
*
* Client-safe — pure data + a pure lookup, no server imports.
*/
@@ -145,6 +152,53 @@ export function lookupNormalRange(
return null;
}
/**
* Enright & Sherrill 1998 predicted six-minute-walk distance (metres) for a
* healthy adult. A published linear regression on age, height, weight, sex —
* NOT a HealthLog-derived model:
* - Men: 6MWD = 7.57·height_cm − 5.02·age − 1.76·weight_kg − 309
* - Women: 6MWD = 2.11·height_cm − 2.29·weight_kg − 5.78·age + 667
*
* Returns `null` when the inputs the equation needs are absent, so the caller
* surfaces the raw distance + trend without a fabricated placement (the
* `fitness-age.ts` `band: null` discipline). Sex is required (the two
* coefficient sets differ); height is required (the dominant term). Weight is
* required for the published full equation — when it is missing we return
* `null` rather than silently dropping the weight term, since the omission
* would inflate the predicted distance.
*
* Pure. The equation is for adults; for ages below 18 the reference does not
* apply and we return `null`.
*/
export function predictSixMinuteWalkDistance(
ageYears: number | null | undefined,
heightCm: number | null | undefined,
weightKg: number | null | undefined,
sex: NormSex,
): number | null {
if (sex !== "MALE" && sex !== "FEMALE") return null;
if (
ageYears == null ||
!Number.isFinite(ageYears) ||
ageYears < 18 ||
ageYears > 120
) {
return null;
}
if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) {
return null;
}
if (weightKg == null || !Number.isFinite(weightKg) || weightKg <= 0) {
return null;
}
const predicted =
sex === "MALE"
? 7.57 * heightCm - 5.02 * ageYears - 1.76 * weightKg - 309
: 2.11 * heightCm - 2.29 * weightKg - 5.78 * ageYears + 667;
// A non-positive prediction is physically meaningless — treat as no band.
return predicted > 0 ? predicted : null;
}
/** `true` when an age/sex-sharpened band exists for the metric+profile. */
export function hasSharpenedNorm(
metricId: MetricStatusMetricId,
+81 -1
View File
@@ -46,7 +46,27 @@ export type DerivedMetricId =
/** Persisted nightly stress composite (passthrough read of COMPUTED rows). */
| "STRESS_SCORE"
/** Persisted nightly strain composite (passthrough read of COMPUTED rows). */
| "STRAIN_SCORE";
| "STRAIN_SCORE"
/** v1.10.3: overnight wrist-temperature personal-deviation band (baseline engine). */
| "WRIST_TEMPERATURE_BASELINE"
/** v1.10.3: stair-ascent-speed personal trend band (baseline engine). */
| "STAIR_ASCENT_SPEED_BASELINE"
/** v1.10.3: stair-descent-speed personal trend band (baseline engine). */
| "STAIR_DESCENT_SPEED_BASELINE"
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
// - FALL_COUNT is a zero-inflated discrete safety EVENT (target = zero); a
// median ± k·MAD band on a near-constant-zero series is meaningless and a
// band would imply a smooth metric where there is none.
// - BREATHING_DISTURBANCES is a regulated sleep-apnea SCREENING signal (Apple
// publishes only NotElevated/Elevated, no numeric cutoff); a self-derived
// band would read as a HealthLog verdict on a screening signal and imply a
// diagnosis. The fired BREATHING_DISTURBANCE_EVENT carries the device's own
// classification in the awareness card; the continuous index stays a plain
// trend with the generic assessment.
/** Archetype of a derived metric — drives shaping + the QA inventory. */
export type DerivedArchetype =
@@ -104,6 +124,25 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [
"WEIGHT",
];
/**
* The full set of measurement types the type-generic baseline engine
* (`computeVitalsBaseline`, median ± k·MAD) can produce a personal band
* for. A superset of `VITALS_BASELINE_TYPES`: it adds the v1.10.3
* additive HealthKit signals that get an `any-user-baseline` derived
* metric of their own (wrist temperature as a personal-deviation band;
* stair ascent/descent speed as a personal trend band — NO population
* cutoff, since stair pace is geometry-confounded). The engine is
* type-generic; this allowlist is the gate, kept distinct from
* `VITALS_BASELINE_TYPES` so a stair-speed baseline is never mislabelled a
* "vital".
*/
export const BASELINE_CAPABLE_TYPES: MeasurementType[] = [
...VITALS_BASELINE_TYPES,
"WRIST_TEMPERATURE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
];
const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
VITALS_BASELINE: {
id: "VITALS_BASELINE",
@@ -210,6 +249,42 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
WRIST_TEMPERATURE_BASELINE: {
id: "WRIST_TEMPERATURE_BASELINE",
displayName: "Wrist-temperature baseline",
archetype: "any-user-baseline",
inputs: ["WRIST_TEMPERATURE"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
STAIR_ASCENT_SPEED_BASELINE: {
id: "STAIR_ASCENT_SPEED_BASELINE",
displayName: "Stair-ascent-speed baseline",
archetype: "any-user-baseline",
inputs: ["STAIR_ASCENT_SPEED"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
STAIR_DESCENT_SPEED_BASELINE: {
id: "STAIR_DESCENT_SPEED_BASELINE",
displayName: "Stair-descent-speed baseline",
archetype: "any-user-baseline",
inputs: ["STAIR_DESCENT_SPEED"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
SIX_MINUTE_WALK_BAND: {
id: "SIX_MINUTE_WALK_BAND",
displayName: "Estimated 6-minute-walk band",
archetype: "passthrough-reframe",
inputs: ["SIX_MINUTE_WALK_DISTANCE"],
minHistoryDays: 1,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -231,3 +306,8 @@ export function getDerivedMetricMeta(
export function isVitalsBaselineType(type: string): boolean {
return (VITALS_BASELINE_TYPES as string[]).includes(type);
}
/** `true` when the type-generic baseline engine can band this type. */
export function isBaselineCapableType(type: string): boolean {
return (BASELINE_CAPABLE_TYPES as string[]).includes(type);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* v1.10.3 — additive HealthKit signal: estimated six-minute-walk distance,
* re-framed against a published reference equation.
*
* `computeSixMinuteWalkBand(userId, profile, opts)` takes the device's
* ESTIMATED 6-minute-walk distance (HealthKit `sixMinuteWalkTestDistance` —
* an estimate, NOT a supervised in-clinic 6MWT) and re-frames it as a
* percent-of-predicted against the Enright & Sherrill 1998 reference
* equation for healthy adults. It NEVER recomputes the distance and NEVER
* derives its own equation — this is the `passthrough-reframe` archetype,
* the same posture `fitness-age.ts` takes against VO₂max.
*
* - **percentOfPredicted** — distance ÷ Enright-predicted × 100, placed in
* a green/yellow/red band (≥ 80% green; 60–80% yellow; < 60% red),
* `null` when the demographics the equation needs are absent (sex,
* adult age, height, AND weight — the published full equation). With no
* band the metric still surfaces the raw distance + trend, never a
* fabricated placement.
* - **trend** — signed latest-vs-prior delta, surfaced only once ≥ 3
* readings exist (the catalogue's trend gate).
*
* Standard: Enright & Sherrill 1998 (reference equations) + ATS 2002 (the
* test standard) — cited in `norms.ts`. Apple's value is framed as
* "estimated", surfaced + trended, never re-derived. No diagnosis: a low
* percent is a functional-capacity awareness signal, not a verdict.
*
* Server-only — reads the latest `SIX_MINUTE_WALK_DISTANCE` + `WEIGHT` rows
* via Prisma; age/height/sex come from the caller's profile. The placement
* helper is exported pure for the unit tests.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { predictSixMinuteWalkDistance } from "./norms";
import type { BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
/** Band placement, same green/yellow/red vocabulary the tokens speak. */
export type SixMinuteWalkBand = "green" | "yellow" | "red";
const SIX_MINUTE_WALK_TYPE: MeasurementType = "SIX_MINUTE_WALK_DISTANCE";
const WEIGHT_TYPE: MeasurementType = "WEIGHT";
/** Readings needed before the latest-vs-prior trend delta is surfaced. */
const MIN_TREND_READINGS = 3;
/**
* The estimate refreshes at most every few days; widen the read window so the
* latest value + trend survive sparse cadence (matches `fitness-age.ts`).
*/
const DEFAULT_WINDOW_DAYS = 180;
/** A weight reading older than this no longer reflects current capacity. */
const WEIGHT_WINDOW_DAYS = 90;
/** Percent-of-predicted band thresholds (% of Enright-predicted). */
const GREEN_PCT = 80;
const YELLOW_PCT = 60;
/** The successful `value` payload for the 6-minute-walk band. */
export interface SixMinuteWalkValue {
/** The latest device-estimated 6-minute-walk distance (m). */
distanceM: number;
/** Enright-predicted distance for the profile (m), or `null` without demographics. */
predictedM: number | null;
/** Distance ÷ predicted × 100, rounded; `null` without a prediction. */
percentOfPredicted: number | null;
/** Green/yellow/red placement, or `null` without a prediction. */
band: SixMinuteWalkBand | null;
/** Signed latest-vs-prior delta (m); `null` until ≥ 3 readings. */
trendDelta: number | null;
/** Distinct readings in the window. */
readingCount: number;
}
/**
* Place a percent-of-predicted in a band. Pure. ≥ 80% green; 60–80% yellow;
* < 60% red; `null` when the percent is absent.
*/
export function placeSixMinuteWalkBand(
percentOfPredicted: number | null,
): SixMinuteWalkBand | null {
if (percentOfPredicted == null) return null;
if (percentOfPredicted >= GREEN_PCT) return "green";
if (percentOfPredicted >= YELLOW_PCT) return "yellow";
return "red";
}
/**
* Estimated six-minute-walk band — passthrough re-frame of
* `SIX_MINUTE_WALK_DISTANCE`. Returns `insufficient` when no reading exists;
* otherwise an `ok` value carrying the latest distance, its
* percent-of-predicted placement (when demographics allow), and a trend once
* ≥ 3 readings exist. With incomplete demographics the band is `null` and the
* value + trend still surface — never a fabricated placement.
*/
export async function computeSixMinuteWalkBand(
userId: string,
profile: BaselineProfile,
opts?: { windowDays?: number; now?: Date },
): Promise<Derived<SixMinuteWalkValue>> {
const now = opts?.now ?? new Date();
const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS;
const computedAt = nowProvenanceTimestamp(now);
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId,
type: SIX_MINUTE_WALK_TYPE,
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "desc" },
select: { value: true },
});
if (rows.length === 0) {
const { coverage } = deriveCoverage({
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [SIX_MINUTE_WALK_TYPE],
fullHistoryDays: windowDays,
});
return buildInsufficient<SixMinuteWalkValue>({
coverage,
provenance: {
inputs: [SIX_MINUTE_WALK_TYPE],
source: "none",
windowDays,
computedAt,
},
reason: "no_readings_in_window",
});
}
const distanceM = rows[0].value;
// The Enright equation needs a recent weight; read the latest in a tighter
// window. Absent → predicted is null and the band is suppressed.
const weightSince = new Date(
now.getTime() - WEIGHT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const weightRow = await prisma.measurement.findFirst({
where: {
userId,
type: WEIGHT_TYPE,
deletedAt: null,
measuredAt: { gte: weightSince },
},
orderBy: { measuredAt: "desc" },
select: { value: true },
});
const predictedM = predictSixMinuteWalkDistance(
profile.ageYears,
profile.heightCm ?? null,
weightRow?.value ?? null,
profile.sex,
);
const percentOfPredicted =
predictedM != null && predictedM > 0
? Math.round((distanceM / predictedM) * 100)
: null;
const band = placeSixMinuteWalkBand(percentOfPredicted);
const trendDelta =
rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null;
const source: DerivedProvenanceSource = "live";
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: rows.length,
missing: [],
fullHistoryDays: MIN_TREND_READINGS,
});
return buildOk<SixMinuteWalkValue>({
value: {
distanceM,
predictedM: predictedM != null ? Math.round(predictedM) : null,
percentOfPredicted,
band,
trendDelta,
readingCount: rows.length,
},
coverage,
confidence,
provenance: {
inputs: [SIX_MINUTE_WALK_TYPE],
source,
windowDays,
computedAt,
},
});
}