mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
789ec60d97
commit
059ba571e3
16 files changed
+731
-4
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user