From 646573e8f8bc078a4655e02320ace89b5783e061 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Mon, 5 Oct 2026 21:39:17 +0200 Subject: [PATCH 1/8] fix(hrv): wire the RR over-count gate and the headline's diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytics now refuses RMSSD when Σ RR exceeds 1.10 × the wall span (duplicated or interleaved beats). The headline picks that up through sleepSessionWindowedRmssd, but two things only edge can do: - Hand the over-count to every RMSSD. The pipeline now computes rrCoverage over the raw sleep RR and passes it to hrvTime, nocturnalRmssd and nightHrvShape, and skips the rolling RMSSD timeline when it is over-counted. Without it a duplicated stream lost its headline but the whole-night and NREM estimators, every bin of the nightly HRV shape and the timeline still published, deflated. - Store what the headline rests on. clinical.rmssd_sleep_session gains windows, diff_acf1, rr_coverage and overcounted_windows (from sleepSessionRmssdDetail; null when the headline is absent), and the coverage block gains rr_coverage and rr_duplicate_beats. Diagnostics only: no metric_series key, no coach column, no migration. test/rr_overcount_pipeline_test.dart: an honest 7 h night reads ≈ 1.0 and keeps its RMSSD with the diagnostics stored; the same night with every beat duplicated reads ≈ 2.0 and loses the headline, ln_rmssd, rmssd_whole, the NREM median and the nightly HRV shape, each with an rr_overcount note, and draws no HRV timeline. Needs an analytics pin containing rrCoverage / sleepSessionRmssdDetail. Requires kAlgoVersion bump (batched). --- lib/compute/onehz_pipeline.dart | 38 +++++++--- test/rr_overcount_pipeline_test.dart | 101 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 test/rr_overcount_pipeline_test.dart diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 628de95c7..72a134595 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -485,6 +485,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 +519,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 +987,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 +1004,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 +1074,16 @@ 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, }, 'rmssd_nocturnal': robustRmssd.toJson(), 'hrv_freq': hrvF.toJson((v) => v.toJson()), @@ -1503,6 +1523,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': { 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'); + }); +} From 548a5ffaa05879f27a76206bf69beefe2c6946cb Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Mon, 5 Oct 2026 21:04:24 +0200 Subject: [PATCH 2/8] fix(hrv): stored rmssd is one estimator, the session mean, or absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scalars.rmssd fell back from the sleep-session mean of 5-min-window RMSSDs 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 everything that reads the stored rmssd (HRV chart and row, widget, Health Connect export, coach v_daily.hrv, the hrv EWMA baseline and next day's rmssd_history, cross-day anomaly/glass-box inputs, stress block, CSV) mixed three estimators under one name. Readiness only ever used the session value (ln_rmssd), so on a fallback night the "HRV" shown was not the HRV that was scored. The three estimators also judge jitter on different beats, so the chain could replace a refused headline with a secondary estimate that passed on its own beats. The project's own rule is that no metric is derived from another as a fallback. rmssd is now sleepSessionRmssd or null. rmssd_nocturnal, rmssd_whole and hrv_time keep publishing under their own keys. A fallback night now shows HRV "—" instead of a different number. Two mixes outside the pipeline go with it: - Investigate's "Time domain" RMSSD row is the whole-night hrv_time envelope, and it used to borrow the headline when that was refused. It no longer does (an absent row is dropped), and the headline gets its own row, "RMSSD, nightly (mean of 5-min windows)" (new ARB key investigateRmssdNightly). - Today's HRV block took its confidence from hrv_time while showing the session value; it now uses rmssd_sleep_session's confidence via the pure seam hrvConfidenceForToday. - clinical.rmssd_sleep_session also stores thin_windows and min_diffs_per_window: how many 5-min windows the mean does not rest on, and the floor that dropped them. Stale doc comments that described the headline as the NREM median are corrected. Tests: test/rmssd_single_estimator_test.dart (no session estimate ⇒ rmssd, ln_rmssd, the hrv baseline value and stress.rmssd all null while rmssd_nocturnal and rmssd_whole still publish; with a session, rmssd is the session value and exp(ln_rmssd) == rmssd; Today's confidence); an Investigate widget test; and test/rmssd_persistence_test.dart, which drives the real engine over a night whose session abstains but whose NREM median publishes (RR in 10-beat blocks every 5 minutes): a stored same-day rmssd of 37 becomes null in the bundle, day_result.rmssd and metric_series rmssd / ln_rmssd on two consecutive derives, and the next day's baseline window folds the real prior night but not the stale 37. With the fallback restored that test fails. Requires kAlgoVersion bump (batched). --- lib/compute/onehz_pipeline.dart | 33 +++--- lib/data/local_repository_impl.dart | 18 ++- lib/l10n/app_en.arb | 4 + lib/ui2/screens/investigate.dart | 7 +- test/rmssd_persistence_test.dart | 152 ++++++++++++++++++++++++++ test/rmssd_single_estimator_test.dart | 102 +++++++++++++++++ test/ui2_wiring_r2_test.dart | 34 ++++++ 7 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 test/rmssd_persistence_test.dart create mode 100644 test/rmssd_single_estimator_test.dart diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 72a134595..26e3fd481 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -280,10 +280,10 @@ 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 + /// 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; @@ -1084,6 +1084,10 @@ Map deriveDayBundle(Map inputJson) { ? 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()), @@ -1201,16 +1205,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 @@ -1538,7 +1540,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_impl.dart b/lib/data/local_repository_impl.dart index 40b016a79..22561de84 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 { @@ -4461,6 +4458,19 @@ 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. Bundles always +/// carry `rmssd_sleep_session`; the trailing fallbacks only cover malformed +/// rows. Pure + public so the Today seam is unit-testable. +num hrvConfidenceForToday(Map clinical) { + final session = clinical['rmssd_sleep_session']; + final whole = clinical['hrv_time']; + return (session is Map ? session['confidence'] as num? : null) ?? + (whole is Map ? whole['confidence'] as num? : null) ?? + 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_en.arb b/lib/l10n/app_en.arb index 874abe33f..950f7e06d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4651,6 +4651,10 @@ "@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)" + }, "investigateSdnn": "SDNN", "@investigateSdnn": { "description": "HRV metric label: SDNN (kept as scientific abbreviation)" diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index b05199282..3aacaace4 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -396,7 +396,12 @@ 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'])), + (l?.investigateRmssdNightly ?? 'RMSSD, nightly (mean of 5-min windows)', + 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/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..0acaed46a --- /dev/null +++ b/test/rmssd_single_estimator_test.dart @@ -0,0 +1,102 @@ +// `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); + // 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/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index c74e73a80..ae46bbee7 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -774,6 +774,40 @@ 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, + '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); + }); + }); + // ── 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 { From 837a67e9983e1190b04fc04c9952a2b7738979dd Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Mon, 5 Oct 2026 18:50:23 +0200 Subject: [PATCH 3/8] fix(hrv): days derived before the single estimator keep their HRV as stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A day_result derived before rmssd became the single session estimator can hold a FALLBACK 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 for a long time most of a user's HRV history is such bundles, and the consumers disagreed about them: - Today's HRV confidence came from the absent envelope, 0, which blanked Health's HRV row (Metric.isEmpty honours confidence) while the widget, the trend chart and the baselines kept showing and using the number. hrvConfidenceForToday now uses the session confidence only when the session envelope holds a value; otherwise the bundle keeps the confidence it was always served with, hrv_time's. - Investigate labelled every stored rmssd "mean of 5-min windows". getDayHrv now also returns clinical.rmssd_sleep_session, and the row uses that label only when the envelope holds a value, otherwise "RMSSD, nightly (earlier estimate)" (new ARB key investigateRmssdStored), distinct from the whole-night "RMSSD" row. Bundles derived since never take the legacy branch: their rmssd is null whenever the session is absent. test/rmssd_legacy_bundle_test.dart stores four pre-change bundles and checks every consumer agrees on the retained value: Today (value and confidence), Health's HRV row, the widget, getDayHrv (value and the envelope), the trend chart and the next derive's baseline window. Its days are built by calendar date (DateTime(y, m, d + n)), not 24 h steps, so it holds across a DST change. An Investigate widget test checks the legacy label. --- lib/data/local_repository.dart | 8 ++ lib/data/local_repository_impl.dart | 27 ++++- lib/l10n/app_en.arb | 4 + lib/ui2/screens/investigate.dart | 15 ++- test/rmssd_legacy_bundle_test.dart | 159 ++++++++++++++++++++++++++ test/rmssd_single_estimator_test.dart | 8 ++ test/ui2_wiring_r2_test.dart | 31 +++++ 7 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 test/rmssd_legacy_bundle_test.dart diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index b9ddd57ef..70b0fc081 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -40,6 +40,14 @@ class RepositoryException implements Exception { /// The local data contract for every insights screen + the AI Coach. /// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). +/// 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; + abstract class LocalRepository { Future pendingActivityCount() async => (await pendingActivities()).length; diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 22561de84..0da900fcd 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -867,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'), @@ -4460,15 +4464,26 @@ 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. Bundles always -/// carry `rmssd_sleep_session`; the trailing fallbacks only cover malformed -/// rows. Pure + public so the Today seam is unit-testable. +/// 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']; - return (session is Map ? session['confidence'] as num? : null) ?? - (whole is Map ? whole['confidence'] as num? : null) ?? - 0.5; + 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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 950f7e06d..c260f04a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4655,6 +4655,10 @@ "@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)" diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index 3aacaace4..b59c70ee0 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -400,8 +400,19 @@ class _InvestigateState extends State { // 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'])), - (l?.investigateRmssdNightly ?? 'RMSSD, nightly (mean of 5-min windows)', - ms(d.hrv['rmssd'])), + // 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/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_single_estimator_test.dart b/test/rmssd_single_estimator_test.dart index 0acaed46a..1bd02f251 100644 --- a/test/rmssd_single_estimator_test.dart +++ b/test/rmssd_single_estimator_test.dart @@ -95,6 +95,14 @@ void main() { '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/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index ae46bbee7..877a54e6c 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -791,6 +791,7 @@ void main() { 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, @@ -806,6 +807,36 @@ void main() { 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" ── From 94c4394622e32a95f20cf4603df9782b3422b692 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 2 Oct 2026 23:54:15 +0200 Subject: [PATCH 4/8] test(resp): a sensor gap does not cost the night its respiratory rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RSA estimator read the beat-rate Nyquist off span/(n−1), which across a dropout is the beat interval divided by coverage. A 55 bpm night with a 1 h 45 min hole "beat" at 41.27 bpm and was withheld whole as an alias: scalars.resp_rate null, the readiness resp driver, baselines.resp and next days' resp_history gone, with a note blaming a heart rate the user never had. The fix is in analytics (median beat interval for both ceilings, Σ NN coverage for sub-window completeness) and reaches all three edge call sites through rsaRespRate: the nightly rate, the 30-min BRV bins and dayRespCurve. This pins it end to end through deriveDayBundle: the gappy night now publishes ~15 br/min with no "beat rate" note, and a genuine 45 bpm night is still withheld as an alias with the true heart rate in its note. Needs an analytics pin containing the gap-aware rsaRespRate. Requires kAlgoVersion bump (batched). --- test/resp_rate_gappy_night_test.dart | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 test/resp_rate_gappy_night_test.dart 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')); + }); +} From 942f806fe7b91344ce14b585281fffa4411ef3f6 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 2 Oct 2026 23:54:15 +0200 Subject: [PATCH 5/8] copy(resp): describe the respiratory-rate estimator the app ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resp_rate method text still described the old whole-night grid surrogate ("over a grid of candidate rates", citing Pimentel 2017). Analytics replaced that with Welch segmentation: Lomb–Scargle on native beat times in overlapping five-minute stretches, the median across them, published only when most stretches agree, and withheld when the sleeping heart rate is too low (near or below 48 bpm) to resolve normal breathing. The citation moves to Welch 1967 and Press & Rybicki 1989. Copy only. --- lib/ui2/screens/metric_detail.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 842b1ed70..90b61bd19 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -150,9 +150,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( From 2b1f11dcf1a0fd6281ef9a836645fa8737e0b101 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Thu, 8 Oct 2026 15:15:01 +0200 Subject: [PATCH 6/8] l10n: translate the nightly RMSSD labels in Investigate investigateRmssdNightly and investigateRmssdStored existed only in English, so Investigate showed them untranslated in de, es, fr, hi and zh. A test checks every shipped language has its own text for both. --- lib/l10n/app_de.arb | 2 ++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_hi.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ test/investigate_rmssd_l10n_test.dart | 21 +++++++++++++++++++++ 6 files changed, 31 insertions(+) create mode 100644 test/investigate_rmssd_l10n_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 88221f1f8..7e365bf80 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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5efd2cb99..a916793ae 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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0968ca11f..29c5fcf9b 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", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 2d63f9052..a3e7feb2d 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 इंडेक्स", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 33a526376..df12bf6e8 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 指数", 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); + } + }); +} From ccfa296948bd99825dfb5cfc8b6ae6653212a524 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:15:03 +0530 Subject: [PATCH 7/8] copy(hrv): describe the nightly RMSSD as the mean of 5-min sleep windows The Health catalogue blurb (all locales) and the HRV method text still described the old headline (cleanest / longest artefact-free window, Lipponen-Tarvainen corrected). The headline is now the sleep-session mean of qualifying 5-minute windows, filtered per window, and absent when none qualifies or the beat stream is over-counted. --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/screens/health_screen.dart | 2 +- lib/ui2/screens/metric_detail.dart | 10 ++++++---- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7e365bf80..ca3873834 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1354,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 c260f04a8..f2452c675 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6745,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 a916793ae..3ee8aabba 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1310,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 29c5fcf9b..1327c36a4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1310,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 a3e7feb2d..d26a197a7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1310,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 df12bf6e8..7cfae6e2c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1310,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/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 90b61bd19..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( From 4bd62b92da07480cdd9139667d16fa016f568ceb Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:15:03 +0530 Subject: [PATCH 8/8] docs(hrv): keep LocalRepository's doc on the class; reflow rmssdHistory doc rmssdFromSessionEstimator was inserted between LocalRepository's doc comment and the class, so the class doc attached to the function. --- lib/compute/onehz_pipeline.dart | 5 +++-- lib/data/local_repository.dart | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 26e3fd481..939cfaaac 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -283,8 +283,9 @@ class DayBundleInput { /// 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). + /// 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 diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index 70b0fc081..2ee2eaef8 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -38,8 +38,6 @@ class RepositoryException implements Exception { String toString() => 'Repository error $status: $body'; } -/// The local data contract for every insights screen + the AI Coach. -/// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). /// 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 @@ -48,6 +46,8 @@ class RepositoryException implements Exception { 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 { Future pendingActivityCount() async => (await pendingActivities()).length;