diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 1127832..f98f4fb 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -426,7 +426,9 @@ Metric sleepSessionWindowedRmssd( nd++; } } - if (nd == 0) continue; + // Same floor as [nocturnalRmssd]'s minBeatsPerWindow: a window with one or + // two differences would otherwise weigh as much as a full one in the mean. + if (nd < 5) continue; runs.addAll(diffRuns); rmssds.add(math.sqrt(ssd / nd)); } diff --git a/lib/src/onehz/clinical/readiness_lnrmssd.dart b/lib/src/onehz/clinical/readiness_lnrmssd.dart index d653556..82243c6 100644 --- a/lib/src/onehz/clinical/readiness_lnrmssd.dart +++ b/lib/src/onehz/clinical/readiness_lnrmssd.dart @@ -48,8 +48,12 @@ const int readinessLnRmssdMinNights = 4; /// [historyLnRmssd] trailing nightly ln(RMSSD), OLDEST→NEWEST, INCLUDING tonight /// as the last element. [meanNnTodayMs] tonight's mean NN (for the saturation /// guard; optional). [windowDays] rolling window (default 7). +/// [dates] optional `yyyy-MM-dd` labels parallel to [historyLnRmssd]. Pass +/// them: without them the window is the last [windowDays] ROWS, which after a +/// wear gap are nights from weeks ago (see [calendarDays]). Metric readinessLnRmssd( List historyLnRmssd, { + List? dates, double? meanNnTodayMs, int windowDays = 7, int minNights = readinessLnRmssdMinNights, @@ -70,14 +74,30 @@ Metric readinessLnRmssd( // pulls the mean/sd toward tonight, understating how far off a genuinely // suppressed/elevated night actually is, worst right when the window is // smallest (minNights). the baseline has to be strictly prior nights. - final priorWindow = historyLnRmssd.sublist(start, n - 1); - if (priorWindow.isEmpty) { - // only happens if minNights got set to 1 somewhere - there's no prior - // night to build a baseline from yet, so same as not enough history. + final List priorWindow; + // all-or-nothing: calendarDays falls back to the row index for a label it + // can't parse, and an index compared against epoch days is meaningless. + if (dates != null && + dates.length == n && + dates.every((d) => DateTime.tryParse(d) != null)) { + final day = calendarDays(dates); + priorWindow = [ + for (var i = 0; i < n - 1; i++) + if (day[i] < day[n - 1] && day[n - 1] - day[i] <= windowDays) + historyLnRmssd[i] + ]; + } else { + priorWindow = historyLnRmssd.sublist(start, n - 1); + } + if (priorWindow.isEmpty || priorWindow.length < minNights - 1) { + // empty only if minNights got set to 1 somewhere; short when a wear gap + // left too few nights inside the calendar window. either way there's no + // baseline yet, same as not enough history. return Metric.absent( tier: Tier.high, inputs_used: inputs, - note: needBaselineNote(have: historyLnRmssd.length, need: minNights + 1), + note: needBaselineNote( + have: priorWindow.length + 1, need: minNights < 2 ? 2 : minNights), ); } final m = mean(priorWindow)!; diff --git a/lib/src/onehz/sleep/stager.dart b/lib/src/onehz/sleep/stager.dart index 54f8a90..267a217 100644 --- a/lib/src/onehz/sleep/stager.dart +++ b/lib/src/onehz/sleep/stager.dart @@ -187,10 +187,10 @@ void _consolidateStages(List sm, int epochSec) { // --- Step 1: bridge short NREM gaps INSIDE a genuine REM episode. ---------- // Walk runs; a NREM run shorter than remGapEp is rescored to REM only when it - // is flanked on BOTH sides by SUBSTANTIAL REM runs (each ≥ remGapEp epochs) — - // i.e. it is a brief intrusion inside one sustained REM episode, not a stray - // single-epoch REM flicker sitting inside a NREM block (that flicker is left - // for step 2 to absorb into NREM). + // is flanked on BOTH sides by REM, the joined episode reaches remGapEp, and + // the flanking REM is at least as long as the gap — i.e. it is a brief + // intrusion inside one REM episode, not a NREM stretch between stray REM + // blips (those are left for step 2 to absorb into NREM). { // Precompute run boundaries. final runs = >[]; @@ -214,11 +214,13 @@ void _consolidateStages(List sm, int epochSec) { final right = (ri + 1 < runs.length && sm[runs[ri + 1][0]] == SleepStage.rem) ? runs[ri + 1][1] - runs[ri + 1][0] : 0; - // Bridge only when both sides are REM AND the COMBINED episode (the two - // flanking REM runs plus the gap) is a genuine REM episode ≥ remGapEp. - // This stitches an intrusion inside a real episode, but never fuses two - // single-epoch REM flickers that merely straddle a NREM stretch. - if (left > 0 && right > 0 && (left + right + gapLen) >= remGapEp) { + // Bridge only when both sides are REM, the joined episode is ≥ remGapEp, + // AND the REM outweighs the gap. Without that last check two 2-epoch + // blips around a 9-epoch NREM gap became 13 epochs of REM. Requiring + // left + right ≥ remGapEp instead cut the real Apple-Watch-labelled + // night from 150 to 87 min REM (truth 162); this costs it 5. + if (left > 0 && right > 0 && left + right >= gapLen && + left + right + gapLen >= remGapEp) { for (var k = start; k < end; k++) { sm[k] = SleepStage.rem; } diff --git a/lib/src/onehz/workout/hr_recovery.dart b/lib/src/onehz/workout/hr_recovery.dart index 19edebc..50f9f87 100644 --- a/lib/src/onehz/workout/hr_recovery.dart +++ b/lib/src/onehz/workout/hr_recovery.dart @@ -237,10 +237,10 @@ Metric hrRecovery( var t = -1; for (var i = end; i < times.length; i++) { if (i > end && times[i] - times[i - 1] > maxGapSec) break; - if (times[i] >= wantTs) { - t = i; - break; - } + // keep the last reachable sample: a tail ending at +58 s is still + // within the ±3 s check below. + t = i; + if (times[i] >= wantTs) break; } if (t < 0) { return Metric.absent( @@ -249,6 +249,17 @@ Metric hrRecovery( note: 'HR tail does not reach +${recoverySec}s without a gap — no HRR', ); } + // The first sample past +60 s can be up to maxGapSec late after a hole, and + // HR kept falling meanwhile. Take the nearer neighbour and require it + // inside the same ±3 s the median window uses. + if (t > end && wantTs - times[t - 1] < times[t] - wantTs) t--; + if ((times[t] - wantTs).abs() > 3) { + return Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'no HR sample within 3 s of +${recoverySec}s — no HRR', + ); + } target = t; } else { target = end + recoverySec; diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index aaa5d0e..adb8cef 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -563,6 +563,33 @@ void main() { expect(flat.value!.z, isNull); // z is undefined at SD = 0 expect(flat.value!.band, 'suppressed'); }); + test('with dates the baseline is the last 7 calendar days, not 7 rows', () { + // a week of nights in august, a 3-week wear gap, then two nights back. + final dates = [ + for (var d = 1; d <= 7; d++) '2026-08-0$d', + '2026-08-29', '2026-08-30', + ]; + final hist = [4.0, 4.05, 3.95, 4.0, 4.1, 3.9, 4.0, 3.5, 3.2]; + // positional: the august week is still "the last 7 nights". + expect(readinessLnRmssd(hist).present, isTrue); + // calendar: one prior night inside the window is no baseline. + final m = readinessLnRmssd(hist, dates: dates); + expect(m.present, isFalse); + expect(m.note, contains('need_baseline')); + }); + test('one unparseable date label falls back to rows, not a mixed scale', () { + // calendarDays maps a bad label to its row index, which compared with + // epoch days drops that row from the window. + final hist = [4.0, 4.05, 3.95, 4.0, 4.1, 3.9, 4.0, 3.5]; + final dates = [ + 'n/a', + for (var d = 2; d <= 8; d++) '2026-08-0$d', + ]; + final rows = readinessLnRmssd(hist, minNights: hist.length); + final m = readinessLnRmssd(hist, dates: dates, minNights: hist.length); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.z, rows.value!.z); + }); }); group('cosinor', () { @@ -835,8 +862,10 @@ void main() { } } - addWindow([1000, 1010, 990], 1000.0); // bucket 0, RMSSD = 15.8113883... - addWindow([1000, 1050, 950], 301000.0); // bucket 1, RMSSD = 79.0569415... + // bucket 0: diffs 10,-20,10,10,-20 -> RMSSD sqrt(220) + addWindow([1000, 1010, 990, 1000, 1010, 990], 1000.0); + // bucket 1: diffs 50,-100,50,50,-100 -> RMSSD sqrt(5500) + addWindow([1000, 1050, 950, 1000, 1050, 950], 301000.0); final m = sleepSessionWindowedRmssd( rr, @@ -846,12 +875,30 @@ void main() { windowSec: 300, ); expect(m.present, isTrue); - expect(m.value, closeTo((15.8113883 + 79.0569415) / 2.0, 1e-6)); + expect(m.value, + closeTo((math.sqrt(220.0) + math.sqrt(5500.0)) / 2.0, 1e-6)); + }); + + test('a window with too few differences does not count', () { + // One window of +-10 ms alternation (RMSSD 10), plus a window holding a + // single 160 ms difference. Averaged in, that one difference would + // weigh as much as the whole real window. + final rr = [ + for (var i = 0; i < 10; i++) i.isEven ? 1000.0 : 1010.0, + 900, 1060, + ]; + final ts = [ + for (var i = 0; i < 10; i++) 1000.0 + i * 1000.0, + 301000, 302000, + ]; + final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 601); + expect(m.present, isTrue); + expect(m.value, closeTo(10.0, 1e-9)); }); test('drops out-of-range and Malik-style ectopic beats before RMSSD', () { - final rr = [1000, 1000, 200, 1000, 1000]; - final ts = [1000, 2000, 3000, 4000, 5000]; + final rr = [1000, 1000, 1000, 1000, 200, 1000, 1000, 1000]; + final ts = [for (var i = 0; i < 8; i++) 1000.0 + i * 1000.0]; final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); expect(m.present, isTrue); expect(m.value, closeTo(0.0, 1e-9)); @@ -864,8 +911,8 @@ void main() { // every real Δ is 0. THIS is most of the "gen5 reads 2x gen4" gap: on the // real corpus it inflated the nightly headline by 51-102 % on MG // (87.7 -> 58.2, 82.9 -> 53.0, 76.9 -> 48.4 ms) and 2-13 % on gen4. - final rr = [900, 900, 900, 200, 1000, 1000, 1000]; - final ts = [for (var i = 0; i < 7; i++) 1000.0 + i * 1000.0]; + final rr = [900, 900, 900, 900, 200, 1000, 1000, 1000]; + final ts = [for (var i = 0; i < 8; i++) 1000.0 + i * 1000.0]; final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); expect(m.present, isTrue); expect(m.value, closeTo(0.0, 1e-9), @@ -896,9 +943,9 @@ void main() { // land back-to-back in the compacted survivor list, so without the // timestamp gap check their genuinely-adjacent-in-time-but-not seam // would be differenced as if the beats were 1 s apart. - final rr = [900, 900, 900, 1000, 1000, 1000]; + final rr = [900, 900, 900, 900, 1000, 1000, 1000]; final ts = [ - 1000, 2000, 3000, // flat run before the gap + 1000, 2000, 3000, 4000, // flat run before the gap 123000, 124000, 125000, // flat run after a ~2 min dropout ]; final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); diff --git a/test/onehz/hr_recovery_test.dart b/test/onehz/hr_recovery_test.dart index 05d87c9..25128b5 100644 --- a/test/onehz/hr_recovery_test.dart +++ b/test/onehz/hr_recovery_test.dart @@ -79,6 +79,40 @@ void main() { expect(m.confidence, closeTo(0.9, 1e-9)); }); + test('a hole around +60 s is not read as HRR-60', () { + // 0.5 bpm/s decay from 170, samples missing from +56 to +79 s. The first + // sample past +60 is +80 (HR 130), which would report a 40 bpm drop + // against a true HRR-60 of 30. + final hr = []; + final ts = []; + for (var i = 0; i < 30; i++) { + hr.add(170); + ts.add(i); + } + for (var s = 1; s <= 120; s++) { + if (s >= 56 && s <= 79) continue; + hr.add((170 - 0.5 * s).round()); + ts.add(29 + s); + } + final m = hrRecovery(hr, endIndex: 29, tsSec: ts); + expect(m.present, isFalse); + }); + + test('a contiguous tail ending at +58 s still gives HRR-60', () { + final hr = []; + final ts = []; + for (var i = 0; i < 30; i++) { + hr.add(170); + ts.add(i); + } + for (var s = 1; s <= 58; s++) { + hr.add((170 - 0.5 * s).round()); + ts.add(29 + s); + } + final m = hrRecovery(hr, endIndex: 29, tsSec: ts); + expect(m.present, isTrue, reason: m.note); + }); + test('tail too short to reach +60s → absent', () { final hr = [for (var i = 0; i < 40; i++) 160 - i]; final m = hrRecovery(hr, endIndex: 10, recoverySec: 60); diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index 422f5aa..7bd5153 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -885,6 +885,20 @@ void main() { } expect(t, 2); }); + + test('a NREM gap longer than the REM around it is not bridged', () { + // 2 + 2 epochs of REM around a 9-epoch NREM gap is 2 min of REM, not a + // 6.5-min episode. + final raw = [ + ...List.filled(100, SleepStage.nrem), + ...List.filled(2, SleepStage.rem), + ...List.filled(9, SleepStage.nrem), + ...List.filled(2, SleepStage.rem), + ...List.filled(100, SleepStage.nrem), + ]; + final c = consolidateSleepStages(raw, 30); + expect(c.where((s) => s == SleepStage.rem).length, 0); + }); }); // ------------------------------------------------ sleepCyclesMetric (#6)