Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/src/onehz/clinical/hrv_time.dart
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,9 @@ Metric<double> 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));
}
Expand Down
30 changes: 25 additions & 5 deletions lib/src/onehz/clinical/readiness_lnrmssd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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> readinessLnRmssd(
List<double> historyLnRmssd, {
List<String>? dates,
double? meanNnTodayMs,
int windowDays = 7,
int minNights = readinessLnRmssdMinNights,
Expand All @@ -70,14 +74,30 @@ Metric<ReadinessLnRmssd> 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<double> 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<ReadinessLnRmssd>.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),
Comment thread
abdulsaheel marked this conversation as resolved.
);
}
final m = mean(priorWindow)!;
Expand Down
20 changes: 11 additions & 9 deletions lib/src/onehz/sleep/stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ void _consolidateStages(List<SleepStage> 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 = <List<int>>[];
Expand All @@ -214,11 +214,13 @@ void _consolidateStages(List<SleepStage> 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;
}
Expand Down
19 changes: 15 additions & 4 deletions lib/src/onehz/workout/hr_recovery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,10 @@ Metric<HrRecovery> 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<HrRecovery>.absent(
Expand All @@ -249,6 +249,17 @@ Metric<HrRecovery> 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--;
Comment thread
abdulsaheel marked this conversation as resolved.
if ((times[t] - wantTs).abs() > 3) {
return Metric<HrRecovery>.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;
Expand Down
65 changes: 56 additions & 9 deletions test/onehz/clinical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <double>[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 = <double>[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', () {
Expand Down Expand Up @@ -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,
Expand All @@ -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 = <double>[
for (var i = 0; i < 10; i++) i.isEven ? 1000.0 : 1010.0,
900, 1060,
];
final ts = <double>[
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 = <double>[1000, 1000, 200, 1000, 1000];
final ts = <double>[1000, 2000, 3000, 4000, 5000];
final rr = <double>[1000, 1000, 1000, 1000, 200, 1000, 1000, 1000];
final ts = <double>[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));
Expand All @@ -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 = <double>[900, 900, 900, 200, 1000, 1000, 1000];
final ts = <double>[for (var i = 0; i < 7; i++) 1000.0 + i * 1000.0];
final rr = <double>[900, 900, 900, 900, 200, 1000, 1000, 1000];
final ts = <double>[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),
Expand Down Expand Up @@ -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 = <double>[900, 900, 900, 1000, 1000, 1000];
final rr = <double>[900, 900, 900, 900, 1000, 1000, 1000];
final ts = <double>[
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);
Expand Down
34 changes: 34 additions & 0 deletions test/onehz/hr_recovery_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <int>[];
final ts = <int>[];
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 = <int>[];
final ts = <int>[];
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);
Expand Down
14 changes: 14 additions & 0 deletions test/onehz/sleep_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <SleepStage>[
...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)
Expand Down
Loading