diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 628de95c7..939cfaaac 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -280,11 +280,12 @@ class DayBundleInput { final List rhrHistory; final List respHistory; - /// Trailing robust nocturnal RMSSD means (ms) — the SAME `rmssd` series the - /// engine writes to metric_series (NREM-restricted, median-of-5-min). Used as - /// the history for the EWMA hrv baseline so its center and today's value are the - /// SAME metric (was previously reconstructed from ln(whole-window RMSSD), a - /// definition mismatch that made the z spuriously large). + /// Trailing nightly RMSSD (ms) — the SAME `rmssd` series the engine writes to + /// metric_series (the sleep-session mean of 5-min-window RMSSDs; absent when + /// that estimator abstains). Used as the history for the EWMA hrv baseline so + /// its center and today's value are the SAME metric (was previously + /// reconstructed from ln(whole-window RMSSD), a definition mismatch that made + /// the z spuriously large). final List rmssdHistory; /// Trailing RAW nightly skin-temp ADC means (NOT z-scores). The personal @@ -485,6 +486,10 @@ Map deriveDayBundle(Map inputJson) { final nn = corrected.nn; final nnTimes = corrected.nnTimesMs; final artifactFraction = (1.0 - corrected.cleanFraction).clamp(0.0, 1.0); + // Σ RR ÷ wall span of the raw sleep RR: above 1.10 the stream holds more + // beat-time than elapsed (duplicated or interleaved beats), and every RMSSD + // below refuses it. Null under 10 min of span. + final rrCov = rrCoverage(d.sleepRrMs, d.sleepRrTsMs); final hasSleep = (d.sleepJson['tst_sec']) != null; @@ -515,20 +520,21 @@ Map deriveDayBundle(Map inputJson) { // Whole-window time-domain HRV is kept for SDNN / detail rows only. The // nightly headline HRV is the mean of 5-min cleaned-window RMSSDs across the // detected sleep session, not one RMSSD over the whole night's NN stream. - final hrvT = hrvTime(nn, nnTimesMs: nnTimes); + final hrvT = hrvTime(nn, nnTimesMs: nnTimes, coverage: rrCov); // Keep the robust estimator as a secondary detail only; the canonical nightly // RMSSD follows the sleep-session windowed formulation. final nremMask = _nremMaskAlignedToNn(d, nnTimes, d.sleepRrTsMs); - final robustRmssd = nocturnalRmssd(nn, nnTimes, stageMaskPerSec: nremMask); - final sleepSessionRmssdMetric = sleepSessionWindowedRmssd( + final robustRmssd = nocturnalRmssd(nn, nnTimes, + stageMaskPerSec: nremMask, coverage: rrCov); + final sleepSessionRmssdMetric = sleepSessionRmssdDetail( d.sleepRrMs, d.sleepRrTsMs, startSec: d.sleepOnsetSec, endSec: d.sleepOffsetSec, ); - final sleepSessionRmssd = sleepSessionRmssdMetric.present - ? sleepSessionRmssdMetric.value - : null; + final sessionDetail = sleepSessionRmssdMetric.value; + final sleepSessionRmssd = + sleepSessionRmssdMetric.present ? sessionDetail!.rmssd : null; final hrvF = nn.length >= 20 ? hrvFreq(nn, nnTimes, artifactFraction: artifactFraction) : Metric.absent( @@ -982,7 +988,10 @@ Map deriveDayBundle(Map inputJson) { final hrvOriginMs = (d.sleepRrTsMs.isEmpty || d.sleepRrMs.isEmpty) ? null : d.sleepRrTsMs.first - d.sleepRrMs.first; - final hrvTimeline = _hrvTimeline(nn, nnTimes, hrvOriginMs); + // A rolling RMSSD of the same beats: none on an over-counted stream. + final hrvTimeline = rrCov?.overCounted == true + ? const >[] + : _hrvTimeline(nn, nnTimes, hrvOriginMs); // CV-06 — the SHAPE of the night: per-bin RMSSD over the same cleaned NN the // headline uses, so the curve and the number can never disagree. Bins that // fall under the beat floor stay in the series as HOLES on purpose — dropping @@ -996,7 +1005,9 @@ Map deriveDayBundle(Map inputJson) { // `startSec` is seconds from the FIRST BEAT, not an epoch — `origin_ms` is // the wall-clock instant that clock starts at, the same `hrvOriginMs` the // timeline above is placed on. - final nightShape = nightHrvShape(nn, nnTimes); + // Every bin is an RMSSD of the same beats: absent with them on an + // over-counted stream. + final nightShape = nightHrvShape(nn, nnTimes, coverage: rrCov); final strainCurve = _strainCurve( wakeHr, restingHr: rhrForTrimp, @@ -1064,6 +1075,20 @@ Map deriveDayBundle(Map inputJson) { 'tier': Tier.high, 'inputs_used': const ['rr_sleep_window'], 'note': sleepSessionRmssdMetric.note, + // Diagnostics of the same estimate; null when it is absent (the + // whole-night `hrv_time` envelope still carries diff_acf1). + 'windows': sessionDetail?.windows, + 'diff_acf1': sessionDetail?.diffAcf1 == null + ? null + : _round(sessionDetail!.diffAcf1!, 4), + 'rr_coverage': sessionDetail?.rrCoverage == null + ? null + : _round(sessionDetail!.rrCoverage!, 4), + 'overcounted_windows': sessionDetail?.overCountedWindows, + // Windows under the floor of clean successive differences, and that + // floor: how much of the night the mean does NOT rest on. + 'thin_windows': sessionDetail?.thinWindows, + 'min_diffs_per_window': sessionDetail?.minDiffsPerWindow, }, 'rmssd_nocturnal': robustRmssd.toJson(), 'hrv_freq': hrvF.toJson((v) => v.toJson()), @@ -1181,16 +1206,14 @@ Map deriveDayBundle(Map inputJson) { }; // Indexed scalars (also surfaced to metric_series by the engine). - // HEADLINE RMSSD = mean of 5-min cleaned-window RMSSDs across the detected - // sleep session. Fall back to the robust estimator, then the whole-window - // RMSSD only when the canonical sleep-session value is absent. - final rmssdScalar = - sleepSessionRmssd ?? - (robustRmssd.present - ? robustRmssd.value - : ((hrvT.present && hrvT.value!.rmssd != null) - ? hrvT.value!.rmssd - : null)); + // ONE ESTIMATOR. `rmssd` is the sleep-session mean of 5-min-window RMSSDs — + // the same number `ln_rmssd` (→ readiness) is the log of — or it is absent. + // The NREM median (`clinical.rmssd_nocturnal`) and the whole-night RMSSD + // (`scalars.rmssd_whole`, `clinical.hrv_time`) stay published under their + // own keys; they are different statistics over differently-cleaned beats and + // never stand in for this one (AGENTS §3.3). The fallback used to keep the + // chart unbroken by mixing three estimators into one series. + final rmssdScalar = sleepSessionRmssd; // Whole-window RMSSD kept available as a secondary detail (NOT the headline). final rmssdWholeScalar = (hrvT.present && hrvT.value!.rmssd != null) ? hrvT.value!.rmssd @@ -1503,6 +1526,8 @@ Map deriveDayBundle(Map inputJson) { 'nn_clean': nn.length, 'clean_fraction': _round(corrected.cleanFraction, 4), 'sleep_seconds': inBedSec ?? 0, + 'rr_coverage': rrCov == null ? null : _round(rrCov.coverage, 4), + 'rr_duplicate_beats': rrCov?.duplicateBeats, }, 'readiness_absent_diag': ?readinessAbsentDiag, 'scalars': { @@ -1516,7 +1541,8 @@ Map deriveDayBundle(Map inputJson) { // every stored bundle. 'rhr': rhrToday, 'rhr_nocturnal': rhrToday, - // Headline RMSSD (robust nocturnal, NREM). Whole-window kept separately. + // Headline RMSSD (sleep-session mean of 5-min windows; no fallback). + // Whole-night RMSSD kept separately. 'rmssd': rmssdScalar, 'rmssd_whole': rmssdWholeScalar, 'readiness': readinessScalar, diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index b9ddd57ef..2ee2eaef8 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -38,6 +38,14 @@ class RepositoryException implements Exception { String toString() => 'Repository error $status: $body'; } +/// Whether a stored `rmssd` came from the sleep-session estimator: its +/// envelope ([sessionEnvelope] = `clinical.rmssd_sleep_session`) holds a value. +/// A bundle derived before `rmssd` became that single estimator can hold an +/// earlier fallback (NREM median or whole-night) beside an absent envelope. +/// The one rule Today's confidence and Investigate's label both read. +bool rmssdFromSessionEstimator(Object? sessionEnvelope) => + sessionEnvelope is Map && sessionEnvelope['value'] is num; + /// The local data contract for every insights screen + the AI Coach. /// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). abstract class LocalRepository { diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 40b016a79..0da900fcd 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -373,9 +373,6 @@ class LocalRepositoryImpl extends LocalRepository { : (_sub(sleepBundle, 'clinical') ?? const {}); final cd = await _crossDay(); - final hrvTime = clinical['hrv_time'] is Map - ? (clinical['hrv_time'] as Map).cast() - : null; final rhrEnv = clinical['resting_hr'] is Map ? (clinical['resting_hr'] as Map).cast() : null; @@ -512,7 +509,7 @@ class LocalRepositoryImpl extends LocalRepository { 'baseline': (await _seriesMean('rmssd', before: (sleepBundle?['date'] as String?) ?? todayDay)) ?.round(), - 'confidence': (hrvTime?['confidence'] as num?) ?? 0.5, + 'confidence': hrvConfidenceForToday(clinical), }; return { @@ -870,6 +867,10 @@ class LocalRepositoryImpl extends LocalRepository { 'sdnn': _scalar(b, 'sdnn'), 'ln_rmssd': _scalar(b, 'ln_rmssd'), 'baseline': await _seriesMean('rmssd', before: served), + // Which estimator `rmssd` came from: the sleep-session mean when this + // envelope holds a value; on a bundle derived before `rmssd` became that + // single estimator, an earlier fallback stands beside an absent one. + 'rmssd_sleep_session': _sub(b, 'clinical.rmssd_sleep_session'), 'hrv_time': _sub(b, 'clinical.hrv_time'), 'hrv_freq': _sub(b, 'clinical.hrv_freq'), 'prsa_dc': _sub(b, 'clinical.prsa_dc'), @@ -4461,6 +4462,30 @@ Map? coachToday(Map? crossDay) { }; } +/// The confidence of the /today HRV block: the sleep-session headline's own, +/// because that is the estimate whose value the block shows — not the +/// whole-night `hrv_time` envelope's, a different estimator. Pure + public so +/// the Today seam is unit-testable. +/// +/// LEGACY BUNDLES. A day_result derived before `rmssd` became that single +/// estimator can hold a FALLBACK `rmssd` (the NREM median or the whole-night +/// value) beside an absent session envelope (`value: '—'`, confidence 0). The +/// session's confidence does not describe that number, and 0 would make every +/// consumer that honours confidence (Health's HRV row: `Metric.isEmpty`) blank +/// a value the widget, the trends and the baselines still show. Such a bundle +/// keeps the confidence it was always served with, `hrv_time`'s. A bundle +/// derived since never reaches that branch: its `rmssd` is null whenever the +/// session is absent, so no HRV block is built at all. +num hrvConfidenceForToday(Map clinical) { + final session = clinical['rmssd_sleep_session']; + final whole = clinical['hrv_time']; + final wholeConf = whole is Map ? whole['confidence'] as num? : null; + if (rmssdFromSessionEstimator(session)) { + return ((session as Map)['confidence'] as num?) ?? wholeConf ?? 0.5; + } + return wholeConf ?? 0.5; +} + /// The /today `stress` block from a day bundle — the pipeline's Baevsky block, /// verbatim, with NO fallback substitute when SI couldn't compute a score. /// (Previously mirrored getDayStress's `100 - readiness` fallback; removed for diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 88221f1f8..ca3873834 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -950,6 +950,8 @@ "investigateStrapCounterFactor": "integrierter Zähler · Smartphone-Faktor", "investigateTimeDomain": "Zeitbereich", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nächtlich (Mittel der 5-Min.-Fenster)", + "investigateRmssdStored": "RMSSD, nächtlich (frühere Schätzung)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN-Index", @@ -1352,7 +1354,7 @@ "healthCatMovementLoad": "Bewegung & Belastung", "healthCatBodyWear": "Körper & Tragezeit", "healthBlurbRestingHr": "Der niedrigste anhaltende Puls der Nacht", - "healthBlurbHrv": "RMSSD im saubersten Schlafabschnitt", + "healthBlurbHrv": "Mittlerer RMSSD der 5-Min.-Fenster im Schlaf", "healthBlurbHrvCv": "Wie stark das von Nacht zu Nacht schwankt", "healthBlurbLfHf": "Wie sich die Herzschlag-Variabilität auf die Frequenzbereiche verteilt", "healthBlurbDip": "Wie stark deine Herzfrequenz im Schlaf sinkt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 874abe33f..f2452c675 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4651,6 +4651,14 @@ "@investigateRmssd": { "description": "HRV metric label: RMSSD (kept as scientific abbreviation)" }, + "investigateRmssdNightly": "RMSSD, nightly (mean of 5-min windows)", + "@investigateRmssdNightly": { + "description": "HRV metric label: the nightly headline RMSSD, the mean of the RMSSDs of the sleep session's 5-minute windows (RMSSD kept as scientific abbreviation)" + }, + "investigateRmssdStored": "RMSSD, nightly (earlier estimate)", + "@investigateRmssdStored": { + "description": "HRV metric label: a nightly RMSSD stored by an earlier version of the app, computed by a method other than the 5-minute-window mean (RMSSD kept as scientific abbreviation)" + }, "investigateSdnn": "SDNN", "@investigateSdnn": { "description": "HRV metric label: SDNN (kept as scientific abbreviation)" @@ -6737,7 +6745,7 @@ "@healthBlurbRestingHr": { "description": "Explore catalogue one-line description for resting heart rate" }, - "healthBlurbHrv": "RMSSD over the cleanest window of sleep", + "healthBlurbHrv": "Mean RMSSD of 5-min windows across sleep", "@healthBlurbHrv": { "description": "Explore catalogue one-line description for HRV" }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5efd2cb99..3ee8aabba 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -912,6 +912,8 @@ "investigateStrapChipReported": "chip de la banda reportó", "investigateTimeDomain": "Dominio del tiempo", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nocturno (media de ventanas de 5 min)", + "investigateRmssdStored": "RMSSD, nocturno (estimación anterior)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "Índice SDNN", @@ -1308,7 +1310,7 @@ "healthCatMovementLoad": "Movimiento y carga", "healthCatBodyWear": "Cuerpo y uso", "healthBlurbRestingHr": "El ritmo sostenido más bajo de la noche", - "healthBlurbHrv": "RMSSD durante la ventana de sueño más limpia", + "healthBlurbHrv": "RMSSD medio de las ventanas de 5 min del sueño", "healthBlurbHrvCv": "Cuánto varía eso de una noche a otra", "healthBlurbLfHf": "Dónde se concentra la potencia del ritmo cardíaco entre frecuencias", "healthBlurbDip": "Cuánto baja tu frecuencia cardíaca mientras duermes", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0968ca11f..1327c36a4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -912,6 +912,8 @@ "investigateStrapChipReported": "puce du bracelet a indiqué", "investigateTimeDomain": "Domaine temporel", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nocturne (moyenne des fenêtres de 5 min)", + "investigateRmssdStored": "RMSSD, nocturne (estimation antérieure)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "Indice SDNN", @@ -1308,7 +1310,7 @@ "healthCatMovementLoad": "Mouvement et charge", "healthCatBodyWear": "Corps et port", "healthBlurbRestingHr": "Le rythme soutenu le plus bas de la nuit", - "healthBlurbHrv": "RMSSD sur la fenêtre de sommeil la plus propre", + "healthBlurbHrv": "RMSSD moyen des fenêtres de 5 min du sommeil", "healthBlurbHrvCv": "À quel point cela varie d'une nuit à l'autre", "healthBlurbLfHf": "Où se situe la puissance du rythme cardiaque selon les fréquences", "healthBlurbDip": "De combien votre fréquence cardiaque baisse pendant votre sommeil", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 2d63f9052..d26a197a7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -912,6 +912,8 @@ "investigateStrapChipReported": "बैंड चिप द्वारा दर्ज", "investigateTimeDomain": "टाइम डोमेन", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, रात का (5-मिनट विंडो का औसत)", + "investigateRmssdStored": "RMSSD, रात का (पहले का अनुमान)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN इंडेक्स", @@ -1308,7 +1310,7 @@ "healthCatMovementLoad": "गतिविधि और भार", "healthCatBodyWear": "शरीर और पहनावा", "healthBlurbRestingHr": "रात की सबसे कम स्थिर दर", - "healthBlurbHrv": "नींद के सबसे स्वच्छ समय-खंड पर RMSSD", + "healthBlurbHrv": "नींद की 5-मिनट विंडो का औसत RMSSD", "healthBlurbHrvCv": "यह रात-दर-रात कितना बदलता है", "healthBlurbLfHf": "धड़कन-समय की ऊर्जा विभिन्न आवृत्तियों में कहाँ है", "healthBlurbDip": "सोते समय आपकी हृदय गति कितनी गिरती है", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 33a526376..7cfae6e2c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -912,6 +912,8 @@ "investigateStrapChipReported": "腕带芯片上报值", "investigateTimeDomain": "时域指标", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD,夜间(5 分钟窗口的均值)", + "investigateRmssdStored": "RMSSD,夜间(早期估算)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN 指数", @@ -1308,7 +1310,7 @@ "healthCatMovementLoad": "运动与负荷", "healthCatBodyWear": "身体与佩戴", "healthBlurbRestingHr": "整夜持续保持的最低心率", - "healthBlurbHrv": "取睡眠中信号最干净时段的 RMSSD", + "healthBlurbHrv": "睡眠期间 5 分钟窗口 RMSSD 的均值", "healthBlurbHrvCv": "该数值在各夜之间的波动幅度", "healthBlurbLfHf": "心跳间期功率在各频段的分布", "healthBlurbDip": "睡眠期间心率下降的幅度", diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 213bdd3bd..d0ada201d 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -357,7 +357,7 @@ class _Cat { const _catalogue = <_Cat>[ _Cat('Heart & rhythm', [ _CatRow('resting_hr', 'rhr', 'The lowest sustained rate of the night'), - _CatRow('hrv', 'rmssd', 'RMSSD over the cleanest window of sleep'), + _CatRow('hrv', 'rmssd', 'Mean RMSSD of 5-min windows across sleep'), _CatRow('hrv_cv', 'hrv_cv', 'How much that swings from night to night'), _CatRow('lf_hf', 'lf_hf', 'Where beat-timing power sits across frequencies'), _CatRow('dip', 'dip_pct', 'How far your heart rate falls while you sleep'), diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index b05199282..b59c70ee0 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -396,7 +396,23 @@ class _InvestigateState extends State { return [ MonoTable(l?.investigateTimeDomain ?? 'Time domain', [ - (l?.investigateRmssd ?? 'RMSSD', ms(time['rmssd_ms'] ?? d.hrv['rmssd'])), + // Whole-night RMSSD, from the same `hrv_time` envelope as the rest of + // this table. Absent (refused) drops the row; it never borrows the + // nightly headline, which is a different statistic. + (l?.investigateRmssd ?? 'RMSSD', ms(time['rmssd_ms'])), + // The nightly headline, labelled by the estimator that produced it: + // the session mean when its envelope holds a value. A bundle derived + // before `rmssd` became that single estimator stores an earlier + // fallback (NREM median or whole-night) beside an absent envelope, and + // must not be passed off as the 5-min-window mean. + ( + rmssdFromSessionEstimator(d.hrv['rmssd_sleep_session']) + ? (l?.investigateRmssdNightly ?? + 'RMSSD, nightly (mean of 5-min windows)') + : (l?.investigateRmssdStored ?? + 'RMSSD, nightly (earlier estimate)'), + ms(d.hrv['rmssd']) + ), (l?.investigateSdnn ?? 'SDNN', ms(time['sdnn_ms'] ?? d.hrv['sdnn'])), (l?.investigateSdann ?? 'SDANN', ms(time['sdann_ms'])), (l?.investigateSdnnIndex ?? 'SDNN index', ms(time['sdnn_index_ms'])), diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 842b1ed70..741eee932 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -115,11 +115,13 @@ const _specs = { unit: 'ms', color: C.green, icon: LucideIcons.activity, - method: 'RMSSD over the longest artefact-free window during sleep. Beat ' - 'timing is recovered from the band\'s 1 Hz records and corrected by ' - 'the Lipponen–Tarvainen method before any statistic is taken. ' + method: 'The mean RMSSD of the sleep session\'s five-minute windows. Beat ' + 'timing is recovered from the band\'s 1 Hz records; each window keeps ' + 'only beats that pass an artefact filter, and a window with too few ' + 'clean beat-to-beat differences is left out. No value is shown when ' + 'no window qualifies or the beat stream holds duplicated beats. ' 'Pulse-derived, so this is PRV: real and trendable, but not ECG HRV.', - citation: 'Task Force 1996 · Lipponen & Tarvainen 2019', + citation: 'Task Force 1996', requires: {InputSignal.rrIntervals}, ), 'readiness': MetricSpec( @@ -150,9 +152,13 @@ const _specs = { icon: LucideIcons.wind, higherBetter: false, method: 'Breathing rate recovered from respiratory sinus arrhythmia — the ' - 'periodic modulation breathing imposes on beat timing — over a grid of ' - 'candidate rates.', - citation: 'Pimentel 2017', + 'periodic modulation breathing imposes on beat timing. The spectral ' + 'peak is found in overlapping five-minute stretches of clean beats ' + '(Lomb–Scargle on native beat times) and the night\'s rate is their ' + 'median, reported only when most stretches agree. At a sleeping heart ' + 'rate near or below 48 bpm the beats are too slow to resolve normal ' + 'breathing, and the rate is withheld.', + citation: 'Welch 1967 · Lomb–Scargle (Press & Rybicki 1989)', requires: {InputSignal.rrIntervals}, ), 'sleep': MetricSpec( diff --git a/test/investigate_rmssd_l10n_test.dart b/test/investigate_rmssd_l10n_test.dart new file mode 100644 index 000000000..791c8bc1f --- /dev/null +++ b/test/investigate_rmssd_l10n_test.dart @@ -0,0 +1,21 @@ +// The two nightly-RMSSD labels Investigate shows are translated in every +// shipped language, not left in English. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; + +void main() { + test('every language has its own nightly RMSSD labels', () { + final en = lookupAppLocalizations(const Locale('en')); + for (final code in ['de', 'es', 'fr', 'hi', 'zh']) { + final l = lookupAppLocalizations(Locale(code)); + expect(l.investigateRmssdNightly, isNot(en.investigateRmssdNightly), + reason: code); + expect(l.investigateRmssdStored, isNot(en.investigateRmssdStored), + reason: code); + expect(l.investigateRmssdNightly, startsWith('RMSSD'), reason: code); + expect(l.investigateRmssdStored, startsWith('RMSSD'), reason: code); + } + }); +} diff --git a/test/resp_rate_gappy_night_test.dart b/test/resp_rate_gappy_night_test.dart new file mode 100644 index 000000000..688e29a74 --- /dev/null +++ b/test/resp_rate_gappy_night_test.dart @@ -0,0 +1,79 @@ +// A sensor gap does not cost the night its respiratory rate. +// +// The RSA estimator used to read the beat-rate Nyquist off span/(n−1). Across +// a dropout that is the beat interval DIVIDED BY COVERAGE, so a 55 bpm night +// missing a quarter of its beats "beat" at ~41 bpm and was withheld whole as an +// alias — `scalars.resp_rate` null, the readiness resp driver and the resp +// baseline gone, and a note blaming a heart rate the user never had. The alias +// guard itself is real and stays: at a genuinely low heart rate the beats are +// too slow to resolve normal breathing, and the note now says so truthfully. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 7 * 3600; + +/// 7 h of RSA at [hrBpm], 40 ms at 15 br/min. Beats whose time falls in +/// [holeSec] (seconds from onset) happened but were never recorded. +Map _night({required double hrBpm, (int, int)? holeSec}) { + final base = 60000.0 / hrBpm, f = 15.0 / 60.0; + final rr = [], ts = []; + var tMs = 0.0; + while (tMs < _nightSec * 1000) { + final v = base + 40 * math.sin(2 * math.pi * f * tMs / 1000); + tMs += v; + final sec = tMs / 1000; + if (holeSec != null && sec >= holeSec.$1 && sec < holeSec.$2) continue; + rr.add(v); + ts.add(_t0 * 1000.0 + tMs); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, hrBpm.round()); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: rr, + sleepRrTsMs: ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const { + 'tst_sec': _nightSec, + 'efficiency_pct': 92.0, + }, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: _t0 + _nightSec, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('a night with a 1 h 45 min hole still publishes its breathing rate', () { + final b = _night(hrBpm: 55, holeSec: (2 * 3600, 3 * 3600 + 45 * 60)); + final rsa = _m(_m(b, 'respiration'), 'rsa'); + final scalars = _m(b, 'scalars'); + expect(scalars['resp_rate'], isNotNull, reason: '${rsa['note']}'); + expect(scalars['resp_rate'] as num, closeTo(15, 1.0)); + expect(rsa['note'] as String, isNot(contains('beat rate'))); + }); + + test('a genuinely low heart rate is still withheld, and the note says so', + () { + final b = _night(hrBpm: 45); + final rsa = _m(_m(b, 'respiration'), 'rsa'); + expect(_m(b, 'scalars')['resp_rate'], isNull); + expect(rsa['note'] as String, contains('alias')); + expect(rsa['note'] as String, contains('45')); + }); +} diff --git a/test/rmssd_legacy_bundle_test.dart b/test/rmssd_legacy_bundle_test.dart new file mode 100644 index 000000000..65460e4d8 --- /dev/null +++ b/test/rmssd_legacy_bundle_test.dart @@ -0,0 +1,159 @@ +// A day_result derived BEFORE `rmssd` became one estimator keeps what it was. +// +// Old bundles can hold a fallback `scalars.rmssd` (the NREM median or the +// whole-night value) beside an absent session envelope (`value: '—'`, +// confidence 0). Rows are immutable per version and only the last ~3 days +// re-derive after a bump, so most of a user's HRV history is such bundles for a +// long time. Serving the absent envelope's confidence 0 next to that value made +// Health's HRV row blank (`Metric.isEmpty` honours confidence) while the +// widget, the trends and the baselines still showed and used the number. Every +// consumer must agree: the retained value with the confidence it always had. + +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/models/payloads.dart'; +import 'package:openstrap_edge/ui2/screens/health_screen.dart' show HealthData; +import 'package:openstrap_edge/widget/widget_service.dart'; + +/// A pre-change bundle: rmssd fell back to the NREM median because the +/// session estimator abstained. +String _legacyBundle(String day, double rmssd) => jsonEncode({ + 'date': day, + 'scalars': {'rmssd': rmssd, 'sdnn': 70.0}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 7 * 3600, 'efficiency_pct': 90.0}, + }, + }, + 'clinical': { + 'rmssd_sleep_session': { + 'value': '—', + 'confidence': 0, + 'tier': 'HIGH', + 'inputs_used': ['rr_sleep_window'], + 'note': 'no valid 5-min windows for sleep-session RMSSD', + }, + 'rmssd_nocturnal': {'value': rmssd, 'confidence': 0.7, 'tier': 'HIGH'}, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 25000}, + 'confidence': 0.62, + 'tier': 'HIGH', + }, + }, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Map written; + late LocalRepositoryImpl repo; + // Calendar days, not 24 h steps: across a DST change "now ± n × 24 h" can + // land on the wrong local date. One captured date, offsets by day number. + final now = DateTime.now(); + String dayAt(int offset) => + dayLabelOf(DateTime(now.year, now.month, now.day + offset)); + final today = dayAt(0); + final tomorrow = dayAt(1); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rmssd_legacy_bundle_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + repo = LocalRepositoryImpl(getProfileMap: () => const {}); + // Three older nights and today, all written by the previous algorithm. + final values = [52.0, 50.0, 54.0, 48.3]; + for (var i = 0; i < values.length; i++) { + final day = dayAt(i - (values.length - 1)); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion - 1, + payloadJson: _legacyBundle(day, values[i]), + windowJson: '{}', + rmssd: values[i], + source: 'band', + series: {'rmssd': values[i]}, + ); + } + await LocalDb.refreshComputeFreshness(); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + setUp(() { + written = {}; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(const MethodChannel('home_widget'), + (call) async { + if (call.method == 'saveWidgetData') { + final args = (call.arguments as Map).cast(); + written[args['id'] as String] = args['data']; + } + return true; + }); + messenger.setMockMethodCallHandler( + const MethodChannel('openstrap/ios_config'), (call) async { + return call.method == 'appGroupIdentifier' ? 'group.test' : null; + }); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('home_widget'), null); + messenger.setMockMethodCallHandler( + const MethodChannel('openstrap/ios_config'), null); + }); + + test('every consumer serves the retained value, with its old confidence', + () async { + // Today: the value, and the confidence it was always served with. + final t = TodayData.fromJson(await repo.getToday()); + expect(t.hrv, isNotNull); + expect(t.hrv!.rmssd, 48.3); + expect(t.hrv!.confidence, 0.62, + reason: 'not the absent session envelope\'s 0'); + + // Health: the HRV row honours confidence, so 0 blanked it. + final health = await HealthData.load(repo); + expect(health.hrv.isEmpty, isFalse); + expect(health.hrv.value, 48.3); + + // Widget / Watch: the same number. + await WidgetService.push(t); + expect(written['hrv'], 48); + + // HRV detail and the trend chart read the stored scalar and series. + final dayHrv = await repo.getDayHrv(today); + expect(dayHrv['rmssd'], 48.3); + // The envelope that says WHICH estimator that is travels with it, so + // Investigate does not label it the 5-min-window mean. + expect((dayHrv['rmssd_sleep_session'] as Map)['value'], '—'); + final points = (await repo.getChart('hrv'))['points'] as List; + expect(points.whereType().any((pt) => pt.values.contains(48.3)), + isTrue, + reason: 'the stored series point is still drawn'); + + // The baseline the next derive scores against keeps the old values. + final window = (await debugSweepBaselineWindows('rmssd', [tomorrow])).single; + expect(window, containsAll([52.0, 50.0, 54.0, 48.3])); + }); +} diff --git a/test/rmssd_persistence_test.dart b/test/rmssd_persistence_test.dart new file mode 100644 index 000000000..ca909bf05 --- /dev/null +++ b/test/rmssd_persistence_test.dart @@ -0,0 +1,152 @@ +// An abstaining sleep-session RMSSD persists as NULL — over a stored number, +// and on every re-derive. +// +// `rmssd` used to fall back to the NREM median when the session estimator +// abstained. Without the fallback the scalar is null, and the stored day must +// follow: `day_result.rmssd`, the bundle and `metric_series('rmssd')` all +// overwritten (REPLACE with null, not skipped), the same on the second derive +// as on the first, and the next day's baseline must not fold the stale value. +// +// The night: a forced sleep window over 8 h of 1 Hz HR, with RR in 10-beat +// blocks every 5 minutes. Every 5-min window holds 9 successive differences — +// under the 20 the session estimator needs, over the 5 the NREM median needs — +// so the bundle still publishes `clinical.rmssd_nocturnal`, the number the old +// fallback would have stored. + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/data/db.dart'; + +const _dayId = '2025-09-10'; // fixed, not `now` +const _nextDay = '2025-09-11'; +const _priorDay = '2025-09-09'; + +int _sec(int y, int mo, int d, int h, int mi) => + DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; + +final _onset = _sec(2025, 9, 9, 22, 0); +final _offset = _sec(2025, 9, 10, 6, 0); + +Future _seedNight(Database db) async { + final batch = db.batch(); + var counter = 0; + for (var ts = _onset; ts < _offset; ts++) { + batch.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': counter++, + 'hr': 55, + 'ax': 0.0, + 'ay': 0.0, + 'az': 1.0, + 'device_family': 'gen4', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + for (var block = _onset + 30; block + 15 < _offset; block += 300) { + var tMs = block * 1000.0; + final perRecord = {}; + for (var i = 0; i < 10; i++) { + final rr = 1091.0 + 16 * math.sin(2 * math.pi * i / 12); + tMs += rr; + final recTs = tMs ~/ 1000; + final idx = perRecord[recTs] = (perRecord[recTs] ?? -1) + 1; + batch.insert('decoded_rr', { + 'device_id': '', + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'beat_index': idx, + 'rr_ts_ms': recTs * 1000, + 'beat_ts_ms': tMs.round(), + 'rr_ms': rr.round(), + 'device_family': 'gen4', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + await batch.commit(noResult: true); +} + +Future _seriesValue(Database db, String date, String key) async { + final rows = await db.query('metric_series', + columns: ['value'], where: 'date = ? AND key = ?', whereArgs: [date, key]); + return rows.isEmpty ? 'NO ROW' : rows.single['value']; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rmssd_persistence_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a stored rmssd is overwritten with null, identically on every derive', + () async { + final db = await LocalDb.instance; + await _seedNight(db); + await LocalDb.putSleepOverride( + dayId: _dayId, onsetTs: _onset, offsetTs: _offset, source: 'manual'); + // A real prior night for the baseline, and a same-day number written by + // the old fallback. + await LocalDb.putDayResult( + dayId: _priorDay, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'rmssd': 50.0}}), + windowJson: '{}', + rmssd: 50.0, + source: 'band', + series: {'rmssd': 50.0}, + ); + await LocalDb.putDayResult( + dayId: _dayId, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'rmssd': 37.0}}), + windowJson: '{}', + rmssd: 37.0, + source: 'band', + series: {'rmssd': 37.0}, + ); + expect(await _seriesValue(db, _dayId, 'rmssd'), 37.0); + + for (var pass = 1; pass <= 2; pass++) { + final done = await DerivationEngine() + .runDays(const Profile(), {_dayId}, force: true); + expect(done, 1, reason: 'pass $pass must derive'); + final row = await LocalDb.dayResult(_dayId); + final bundle = jsonDecode(row!['payload_json'] as String) as Map; + final clinical = (bundle['clinical'] as Map).cast(); + final session = (clinical['rmssd_sleep_session'] as Map); + expect(session['value'], '—', reason: 'pass $pass: ${session['note']}'); + expect(session['note'] as String, contains('fewer than 20')); + // The number the old fallback would have stored is still computed, under + // its own key. + expect((clinical['rmssd_nocturnal'] as Map)['value'], isA(), + reason: 'pass $pass: ${(clinical['rmssd_nocturnal'] as Map)['note']}'); + expect((bundle['scalars'] as Map)['rmssd'], isNull, reason: 'pass $pass'); + expect(row['rmssd'], isNull, reason: 'pass $pass: day_result.rmssd'); + expect(await _seriesValue(db, _dayId, 'rmssd'), isNull, + reason: 'pass $pass: metric_series is REPLACEd with null'); + expect(await _seriesValue(db, _dayId, 'ln_rmssd'), isNull, + reason: 'pass $pass'); + } + + // The next day's baseline folds the real prior night, not the stale 37. + final window = + (await debugSweepBaselineWindows('rmssd', [_nextDay])).single; + expect(window, contains(50.0)); + expect(window, isNot(contains(37.0))); + }); +} diff --git a/test/rmssd_single_estimator_test.dart b/test/rmssd_single_estimator_test.dart new file mode 100644 index 000000000..1bd02f251 --- /dev/null +++ b/test/rmssd_single_estimator_test.dart @@ -0,0 +1,110 @@ +// `rmssd` is ONE estimator: the sleep-session mean of 5-min-window RMSSDs, the +// same number `ln_rmssd` (→ readiness) is the log of, or it is absent. +// +// It used to fall back to the NREM median (`nocturnalRmssd`) and then to the +// whole-night RMSSD (`hrvTime`) whenever the session estimator abstained. Those +// are different statistics over differently-cleaned beats, so the charted HRV +// series, its baseline, the widget, the coach and the Health Connect export +// mixed three estimators, and on a fallback night the "HRV" shown was not the +// HRV readiness scored. The other two still publish, under their own keys. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 2 * 3600; + +Map _night({required bool session}) { + final rr = [], ts = []; + var t = _t0 * 1000.0; + for (var i = 0; i < _nightSec; i++) { + final v = 1000.0 + 16.0 * math.sin(2 * math.pi * i / 12); + t += v; + rr.add(v); + ts.add(t); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, 60); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: rr, + sleepRrTsMs: ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const {'tst_sec': _nightSec}, + hypnoStages: const [], + sleepOnsetSec: session ? _t0 : 0, + sleepOffsetSec: session ? (ts.last / 1000).ceil() + 1 : 0, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + rmssdHistory: [for (var i = 0; i < 14; i++) 5.5 + 0.2 * (i % 5)], + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('no session estimate: rmssd is absent, not a stand-in', () { + final b = _night(session: false); + final scalars = _m(b, 'scalars'); + final clinical = _m(b, 'clinical'); + expect(_m(clinical, 'rmssd_sleep_session')['value'], '—'); + // Before: scalars.rmssd == clinical.rmssd_nocturnal.value here, while + // ln_rmssd was null — two keys describing different quantities. + expect(scalars['rmssd'], isNull); + expect(scalars['ln_rmssd'], isNull); + // The other estimators still publish, under their own keys. + expect(_m(clinical, 'rmssd_nocturnal')['value'], isA()); + expect(scalars['rmssd_whole'], isNotNull); + // Nothing downstream folds a stand-in either. + expect(_m(_m(b, 'baselines'), 'hrv')['value'], isNull); + expect(_m(b, 'stress')['rmssd'], isNull); + }); + + test('with a session: rmssd IS the session estimate, and ln_rmssd its log', + () { + final b = _night(session: true); + final scalars = _m(b, 'scalars'); + final session = _m(_m(b, 'clinical'), 'rmssd_sleep_session'); + expect(session['value'], isA()); + expect(scalars['rmssd'] as num, + closeTo(session['value'] as num, 0.05)); // envelope rounds to 1 dp + expect(math.exp(scalars['ln_rmssd'] as num), + closeTo(scalars['rmssd'] as num, 1e-9)); + expect(session['windows'] as num, greaterThanOrEqualTo(20)); + expect(session['thin_windows'], isA()); + expect(session['min_diffs_per_window'], 20); + }); + + test("Today's HRV confidence is the headline's own, not the whole night's", + () { + // The block shows the session estimate, so it carries that estimate's + // confidence. It used to carry `hrv_time`'s, a different estimator. + expect( + hrvConfidenceForToday({ + 'rmssd_sleep_session': {'value': 61.0, 'confidence': 0.82}, + 'hrv_time': {'confidence': 0.3}, + }), + 0.82); + // A bundle derived BEFORE this change can hold a fallback rmssd beside an + // absent session envelope: it keeps the confidence it was served with. + expect( + hrvConfidenceForToday({ + 'rmssd_sleep_session': {'value': '—', 'confidence': 0}, + 'hrv_time': {'confidence': 0.62}, + }), + 0.62); + // Malformed rows only: fall back as before rather than throw. + expect(hrvConfidenceForToday({'hrv_time': {'confidence': 0.3}}), 0.3); + expect(hrvConfidenceForToday(const {}), 0.5); + }); +} diff --git a/test/rr_overcount_pipeline_test.dart b/test/rr_overcount_pipeline_test.dart new file mode 100644 index 000000000..817240cf5 --- /dev/null +++ b/test/rr_overcount_pipeline_test.dart @@ -0,0 +1,101 @@ +// An RR stream that banks more beat-time than elapsed loses every RMSSD. +// +// A double-ingested or two-device-interleaved RR stream reads Σ RR ÷ wall span +// ≈ 2.0 and passes every other gate: duplicated beats add zero differences and +// deflate RMSSD by ~1/√2, interleaved streams inflate it, and neither looks +// like jitter. Analytics refuses RMSSD above 1.10; the pipeline has to hand +// that coverage to the whole-night and NREM estimators too, and store it. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 7 * 3600; + +/// 7 h of RSA at HR 60 / 12 br/min (the jitter screen keeps it), optionally +/// with every (rr, ts) pair stored twice. +Map _night({bool duplicateBeats = false}) { + final rr = [], ts = []; + var tSec = 0.0; + var t = _t0 * 1000.0; + while (tSec < _nightSec - 2) { + final v = 1000 + 30 * math.sin(2 * math.pi * (12 / 60) * tSec); + tSec += v / 1000; + t += v; + rr.add(v); + ts.add(t); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, 60); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: duplicateBeats ? [for (final v in rr) ...[v, v]] : rr, + sleepRrTsMs: duplicateBeats ? [for (final x in ts) ...[x, x]] : ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const { + 'tst_sec': _nightSec, + 'efficiency_pct': 92.0, + }, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: (ts.last / 1000).ceil() + 1, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('an honest stream reads ≈ 1.0 and keeps its RMSSD, with diagnostics', + () { + final b = _night(); + final scalars = _m(b, 'scalars'); + final session = _m(_m(b, 'clinical'), 'rmssd_sleep_session'); + expect(scalars['rmssd'], isNotNull, reason: '${session['note']}'); + expect(session['windows'] as num, greaterThan(80)); + expect(session['diff_acf1'], isA()); + expect(session['rr_coverage'] as num, closeTo(1.0, 0.02)); + expect(session['overcounted_windows'], 0); + expect(_m(b, 'series')['hrv_timeline'] as List, isNotEmpty); + expect(_m(b, 'hrv_night_shape')['value'], isNot(anyOf(isNull, '—'))); + final coverage = _m(b, 'coverage'); + expect(coverage['rr_coverage'] as num, closeTo(1.0, 0.02)); + expect(coverage['rr_duplicate_beats'], 0); + }); + + test('every beat duplicated: every RMSSD is refused as an over-count', () { + final b = _night(duplicateBeats: true); + final scalars = _m(b, 'scalars'); + final clinical = _m(b, 'clinical'); + final session = _m(clinical, 'rmssd_sleep_session'); + expect(scalars['rmssd'], isNull); + expect(scalars['ln_rmssd'], isNull); + expect(session['value'], '—'); + expect(session['note'] as String, startsWith('rr_overcount')); + final coverage = _m(b, 'coverage'); + expect(coverage['rr_coverage'] as num, closeTo(2.0, 0.05)); + expect(coverage['rr_duplicate_beats'] as num, greaterThan(0)); + expect(scalars['rmssd_whole'], isNull, + reason: 'whole-night RMSSD refuses the same over-count'); + final nocturnal = _m(clinical, 'rmssd_nocturnal'); + expect(nocturnal['value'], anyOf(isNull, '—')); + expect(nocturnal['note'] as String, startsWith('rr_overcount'), + reason: 'the NREM median refuses it too, by name'); + final shape = _m(b, 'hrv_night_shape'); + expect(shape['value'], anyOf(isNull, '—')); + expect(shape['note'] as String, startsWith('rr_overcount'), + reason: 'so does the nightly HRV shape, every bin of it'); + expect(_m(b, 'series')['hrv_timeline'] as List, isEmpty, + reason: 'and the rolling RMSSD timeline is not drawn'); + }); +} diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index c74e73a80..877a54e6c 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -774,6 +774,71 @@ void main() { }); }); + // ── the whole-night RMSSD row never borrows the nightly headline ── + // + // The "Time domain" table is the whole-night `hrv_time` envelope. When that + // RMSSD was refused, the row used to fall back to `d.hrv['rmssd']`, the + // sleep-session mean of 5-min windows: a different statistic under the + // whole-night label. The headline now has its own, labelled row. + group('Time-domain RMSSD rows', () { + testWidgets('a refused whole-night RMSSD drops its row; nightly has its own', + (t) async { + t.view.physicalSize = const Size(390 * 3, 3000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Investigate('hrv', + data: InvestigateData(day: _day(0), hrv: { + 'rmssd': 61.0, + 'rmssd_sleep_session': {'value': 61.0, 'confidence': 0.82}, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 20000}, + 'confidence': 0.3, + 'tier': 'HIGH', + }, + })), + )); + await t.pumpAndSettle(); + expect(find.text('SDNN'), findsOneWidget, reason: 'the table rendered'); + expect(find.text('RMSSD'), findsNothing, + reason: 'whole-night RMSSD is absent, so its row is dropped'); + expect(find.text('RMSSD, nightly (mean of 5-min windows)'), + findsOneWidget); + expect(find.text('61.0 ms'), findsOneWidget); + }); + + testWidgets('a legacy fallback RMSSD is not labelled the 5-min-window mean', + (t) async { + // A bundle derived before `rmssd` became one estimator: the stored value + // is the NREM median, beside an absent session envelope. + t.view.physicalSize = const Size(390 * 3, 3000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Investigate('hrv', + data: InvestigateData(day: _day(0), hrv: { + 'rmssd': 48.3, + 'rmssd_sleep_session': { + 'value': '—', + 'confidence': 0, + 'note': 'no valid 5-min windows for sleep-session RMSSD', + }, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 25000}, + 'confidence': 0.62, + 'tier': 'HIGH', + }, + })), + )); + await t.pumpAndSettle(); + expect(find.text('RMSSD, nightly (mean of 5-min windows)'), findsNothing); + expect(find.text('RMSSD, nightly (earlier estimate)'), findsOneWidget); + expect(find.text('48.3 ms'), findsOneWidget); + }); + }); + // ── CV-10: three states, and "not screened" is not "clear" ── group('irregular-rhythm strip', () { testWidgets('counts the days it ran and refuses to reassure', (t) async {