diff --git a/lib/aggregate.dart b/lib/aggregate.dart index d6cae60..8fe963d 100644 --- a/lib/aggregate.dart +++ b/lib/aggregate.dart @@ -5,13 +5,13 @@ export 'src/aggregate/aggregator.dart'; export 'src/aggregate/checkpoint.dart'; export 'src/aggregate/laws.dart'; export 'src/aggregate/primitives.dart'; +export 'src/onehz/clinical/hrv_time_aggregator.dart' hide hrvTimeOneSlice; - - - +export 'src/onehz/clinical/prsa_aggregator.dart'; +export 'src/onehz/foundations/rr_sandwich.dart'; diff --git a/lib/src/onehz/clinical/hrv_freq.dart b/lib/src/onehz/clinical/hrv_freq.dart index c8625e0..4a3bb69 100644 --- a/lib/src/onehz/clinical/hrv_freq.dart +++ b/lib/src/onehz/clinical/hrv_freq.dart @@ -19,6 +19,9 @@ import '../types.dart'; import '../util.dart'; +import '../../aggregate/json_support.dart'; + +part 'hrv_freq_stream.dart'; class HrvFreq { final double? ulf; @@ -88,6 +91,8 @@ Metric hrvFreq( // Times in seconds for Hz output; tachogram value = NN (ms). var tSec = [for (final t in nnTimesMs) t / 1000.0]; + // A non-finite beat time has no place on the clock, and an infinite last + // time made the segment walk endless (it never reaches infinity). if (tSec.any((t) => !t.isFinite)) { return const Metric.absent( tier: Tier.high, @@ -145,6 +150,22 @@ Metric hrvFreq( final vlf = _welchBandPower(tSec, nn, 0.003, 0.04, oversample: oversample); final lf = _welchBandPower(tSec, nn, 0.04, 0.15, oversample: oversample); final hfRaw = _welchBandPower(tSec, nn, 0.15, 0.40, oversample: oversample); + return _hrvFreqMetric(ulf, vlf, lf, hfRaw, + artifactFraction: artifactFraction, + hfArtifactGate: hfArtifactGate, + droppedBeats: droppedBeats); +} + +Metric _hrvFreqMetric( + double? ulf, + double? vlf, + double? lf, + double? hfRaw, { + required double artifactFraction, + required double hfArtifactGate, + int droppedBeats = 0, +}) { + const inputs = ['rr_cleaned', 'beat_times']; if (lf == null && hfRaw == null) { return const Metric.absent( tier: Tier.high, @@ -268,35 +289,46 @@ double? _welchBandPower( var k = 0; final step = segSec / 2; // 50 % overlap, the Welch default for (var start = tSec.first; start + segSec <= tSec.last; start += step) { - final end = start + segSec; - final ts = []; - final ys = []; - for (var i = 0; i < tSec.length; i++) { - if (tSec[i] < start) continue; - if (tSec[i] >= end) break; - ts.add(tSec[i]); - ys.add(y[i]); - } - // A segment has to be BOTH beat-dense and time-complete: a dropout in - // the middle leaves few beats spanning the full window, and its - // periodogram is a window function, not a spectrum. Endpoint span alone - // misses an internal dropout that still leaves beats near both edges, so - // also reject on the single largest gap between consecutive beats. - if (ts.length < minPointsPerSegment || ts.last - ts.first < segSec * 0.8) { - continue; - } - var maxGap = 0.0; - for (var i = 1; i < ts.length; i++) { - final g = ts[i] - ts[i - 1]; - if (g > maxGap) maxGap = g; - } - if (maxGap > segSec * 0.2) continue; - final ls = lombScargle(ts, ys, grid); - if (ls == null) continue; - final p = ls.bandPower(loHz, hiHz); + final p = _welchSegmentPower(tSec, y, start, segSec, loHz, hiHz, + grid: grid, minPointsPerSegment: minPointsPerSegment); + if (p == null) continue; if (!p.isFinite) continue; sum += p; k++; } return k == 0 ? null : sum / k; } + +/// One Welch segment. Streams call this exact function when a segment closes. +double? _welchSegmentPower( + List tSec, + List y, + double start, + double segSec, + double loHz, + double hiHz, { + required List grid, + required int minPointsPerSegment, +}) { + final end = start + segSec; + final ts = []; + final ys = []; + for (var i = 0; i < tSec.length; i++) { + if (tSec[i] < start) continue; + if (tSec[i] >= end) break; + ts.add(tSec[i]); + ys.add(y[i]); + } + if (ts.length < minPointsPerSegment || ts.last - ts.first < segSec * 0.8) { + return null; + } + var maxGap = 0.0; + for (var i = 1; i < ts.length; i++) { + final g = ts[i] - ts[i - 1]; + if (g > maxGap) maxGap = g; + } + if (maxGap > segSec * 0.2) return null; + final ls = lombScargle(ts, ys, grid); + if (ls == null) return null; + return ls.bandPower(loHz, hiHz); +} diff --git a/lib/src/onehz/clinical/hrv_freq_stream.dart b/lib/src/onehz/clinical/hrv_freq_stream.dart new file mode 100644 index 0000000..aee7b1b --- /dev/null +++ b/lib/src/onehz/clinical/hrv_freq_stream.dart @@ -0,0 +1,259 @@ +part of 'hrv_freq.dart'; + +class _HrvBandState { + final double loHz, hiHz, segSec; + double nextStart; + double sum; + int count; + + _HrvBandState(this.loHz, this.hiHz, this.nextStart, + {this.sum = 0.0, this.count = 0}) + : segSec = 10.0 / loHz; + + double? value(List times, List nn, double last, + double oversample) { + var total = sum; + var n = count; + final grid = _welchGrid(loHz, hiHz, segSec, oversample); + for (var start = nextStart; start + segSec <= last; start += segSec / 2) { + final p = _welchSegmentPower(times, nn, start, segSec, loHz, hiHz, + grid: grid, minPointsPerSegment: 16); + if (p != null && p.isFinite) { + total += p; + n++; + } + } + return n == 0 ? null : total / n; + } + + void settle(List times, List nn, double last, + double oversample) { + final grid = _welchGrid(loHz, hiHz, segSec, oversample); + while (nextStart + segSec <= last) { + final p = _welchSegmentPower(times, nn, nextStart, segSec, loHz, hiHz, + grid: grid, minPointsPerSegment: 16); + if (p != null && p.isFinite) { + sum += p; + count++; + } + nextStart += segSec / 2; + } + } + + Map toJson() => { + 'nextStart': encodeDouble(nextStart), + 'sum': encodeDouble(sum), + 'count': count, + }; +} + +List _welchGrid(double lo, double hi, double seg, double oversample) { + final df = 1.0 / seg / oversample; + return freqGrid(lo, hi, ((hi - lo) / df).ceil() + 1); +} + +// Laguna, Moody & Mark 1998 / Bigger 1992 HRV with Welch 1967 segmentation, as +// in hrvFreq(). Closed segments use the exact shared segment function; open data +// is evaluated provisionally with the same origin, grid and segment order. +/// Exact, append-only Welch state for [hrvFreq]. +/// Backward beats are omitted, with the batch's 5% refusal gate applied at +/// snapshot time. Checkpoint v2 retains the omission count; older states must +/// be recomputed. +class HrvFreqStream { + final double oversample; + final List _nn = [], _times = []; + late final List<_HrvBandState> _bands; + // _n counts all input beats, including clock regressions omitted from NN. + int _n = 0, _dropped = 0; + double? _first, _last; + + HrvFreqStream({this.oversample = 4.0}) { + _bands = [ + _HrvBandState(0.0003, 0.003, 0), + _HrvBandState(0.003, 0.04, 0), + _HrvBandState(0.04, 0.15, 0), + _HrvBandState(0.15, 0.40, 0), + ]; + } + + factory HrvFreqStream.fromJson(Map json) { + try { + final j = decodeMap(json); + if (field(j, 'type') != 'HrvFreqStream') { + throw const FormatException('not an HrvFreqStream checkpoint'); + } + if (field(j, 'version') != 2) { + throw const FormatException('unsupported HrvFreqStream version'); + } + final oversample = decodeDouble(field(j, 'oversample')); + final n = decodeCount(field(j, 'n')); + final dropped = decodeCount(field(j, 'dropped')); + final first = decodeDoubleOrNull(field(j, 'first')); + final last = decodeDoubleOrNull(field(j, 'last')); + final nn = decodeDoubles(field(j, 'nn')); + final times = decodeDoubles(field(j, 'times')); + final encodedBands = decodeList(field(j, 'bands')); + if (encodedBands.length != 4 || nn.length != times.length || + dropped > n || + !oversample.isFinite || oversample <= 0 || + (n == 0 && (first != null || last != null)) || + (n > 0 && (first == null || last == null))) { + throw const FormatException('inconsistent HrvFreqStream checkpoint'); + } + final s = HrvFreqStream(oversample: oversample) + .._n = n + .._dropped = dropped + .._first = first + .._last = last + .._nn.addAll(nn) + .._times.addAll(times); + if (!_nondecreasing(s._times)) { + throw const FormatException('decreasing checkpoint times'); + } + final configs = const [ + [0.0003, 0.003], + [0.003, 0.04], + [0.04, 0.15], + [0.15, 0.40], + ]; + s._bands.clear(); + for (var i = 0; i < configs.length; i++) { + final b = decodeMap(encodedBands[i]); + final next = decodeDouble(field(b, 'nextStart')); + final sum = decodeDouble(field(b, 'sum')); + final count = decodeCount(field(b, 'count')); + s._bands.add(_HrvBandState(configs[i][0], configs[i][1], next, + sum: sum, count: count)); + } + return s; + } on FormatException { + rethrow; + } catch (e) { + throw FormatException('malformed HrvFreqStream checkpoint: $e'); + } + } + + Map toJson() => { + 'type': 'HrvFreqStream', + 'version': 2, + 'oversample': encodeDouble(oversample), + 'n': _n, + 'dropped': _dropped, + 'first': _first == null ? null : encodeDouble(_first!), + 'last': _last == null ? null : encodeDouble(_last!), + 'nn': [for (final x in _nn) encodeDouble(x)], + 'times': [for (final x in _times) encodeDouble(x)], + 'bands': [for (final b in _bands) b.toJson()], + }; + + void fold(List nnMs, List nnTimesMs) { + if (nnMs.length != nnTimesMs.length) { + throw ArgumentError('nnMs and nnTimesMs must have the same length'); + } + if (nnMs.isEmpty) return; + final incoming = [for (final t in nnTimesMs) t / 1000.0]; + // Checked before any state changes: an infinite time would make the band + // settle endless, and a fold cannot be undone. + if (incoming.any((t) => !t.isFinite)) { + throw ArgumentError('beat times must be finite'); + } + _n += nnMs.length; + // A capped reset may overlap the previous clock. Match hrvFreq: retain + // each beat at/after the latest retained time; count the omitted beats. + for (var i = 0; i < incoming.length; i++) { + final time = incoming[i]; + if (_last != null && time < _last!) { + _dropped++; + continue; + } + if (_first == null) { + _first = time; + for (final b in _bands) { + b.nextStart = time; + } + } + _nn.add(nnMs[i]); + _times.add(time); + _last = time; + } + for (final b in _bands) { + b.settle(_times, _nn, _last!, oversample); + } + _compact(); + } + + Metric snapshot({ + List tailNnMs = const [], + List tailNnTimesMs = const [], + required double artifactFraction, + double hfArtifactGate = 0.15, + }) { + if (tailNnMs.length != tailNnTimesMs.length) { + throw ArgumentError('tailNnMs and tailNnTimesMs must have the same length'); + } + const inputs = ['rr_cleaned', 'beat_times']; + final n = _n + tailNnMs.length; + if (n < 16) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'too few beats for a spectral estimate'); + } + if (tailNnTimesMs.any((t) => !(t / 1000.0).isFinite)) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'non-finite beat time'); + } + if (_first == null) { + return hrvFreq(tailNnMs, tailNnTimesMs, artifactFraction: artifactFraction, + hfArtifactGate: hfArtifactGate, oversample: oversample); + } + final times = [..._times], nn = [..._nn]; + var latest = _last!, dropped = _dropped; + for (var i = 0; i < tailNnMs.length; i++) { + final time = tailNnTimesMs[i] / 1000.0; + if (time < latest) { + dropped++; + } else { + times.add(time); + nn.add(tailNnMs[i]); + latest = time; + } + } + if (dropped > 0 && + (dropped > n * kMaxRegressingBeatFraction || n - dropped < 16)) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'regressing beat time'); + } + final last = times.last; + if (last - _first! <= 0) { + return hrvFreq(nn, [for (final t in times) t * 1000.0], + artifactFraction: artifactFraction, + hfArtifactGate: hfArtifactGate, + oversample: oversample); + } + final powers = [ + for (final b in _bands) b.value(times, nn, last, oversample), + ]; + return _hrvFreqMetric(powers[0], powers[1], powers[2], powers[3], + artifactFraction: artifactFraction, hfArtifactGate: hfArtifactGate, + droppedBeats: dropped); + } + + void _compact() { + if (_times.isEmpty) return; + var keepFrom = _bands.map((b) => b.nextStart).reduce((a, b) => a < b ? a : b); + var cut = 0; + while (cut < _times.length && _times[cut] < keepFrom) { + cut++; + } + if (cut > 0) { + _times.removeRange(0, cut); + _nn.removeRange(0, cut); + } + } +} + +bool _nondecreasing(List values) { + for (var i = 1; i < values.length; i++) { + if (values[i] < values[i - 1]) return false; + } + return true; +} diff --git a/lib/src/onehz/clinical/hrv_jitter_evidence.dart b/lib/src/onehz/clinical/hrv_jitter_evidence.dart new file mode 100644 index 0000000..0b9dfb0 --- /dev/null +++ b/lib/src/onehz/clinical/hrv_jitter_evidence.dart @@ -0,0 +1,876 @@ +// Rejection and breathing-line exemption thresholds are an internal rule. +// Welch 1967 spectral estimation; Hirsch & Bishop 1981 provides the +// breathing/RSA basis. This is hrvTime()'s existing internal jitter policy, +// ESTIMATE with low confidence, not a published classifier. Shared formulas +// preserve the calculation; reordered sums can change decisions within rounding +// of a threshold. +// CLINICAL — mergeable evidence for the RMSSD jitter gate, for +// `hrvTimeAggregator`. +// +// Under [kNnDiffAcf1Floor] `hrvTime` decides on the difference RUNS themselves +// (rmssd_gate.dart): the white-noise share of the pooled Welch spectrum +// (64-beat segments every 32 beats from each run's start) and, for a refused +// timed night, the breathing-line test over the 5-min windows (128-beat +// segments every 64 beats from each in-window run's start). This aggregator +// keeps what those tests read as sums, so a night is never kept beat by beat: +// +// * NOISE SHARE. A run that starts and ends inside a state is reduced to its +// segment sums on the spot (as the batch does). A run cut by a chunk edge +// is a PIECE: its first and last 63 differences raw (the only ones whose +// coverage weight or segment depends on what lies past the edge), the +// segment sums of every segment fully inside it, and the squared +// differences of the rest by position mod 32 (their Hann coverage weight +// depends on nothing else). A piece cut on its left does not know where in +// its run it starts, so it keeps the segment sums for all 32 alignments; +// the join that tells it (or the series start, at finish) picks one. A +// piece is at most ~1.2k numbers, whatever its length. +// * BREATHING. An in-window run lies inside one 5-min window, so it is short; +// one cut by a chunk edge stays raw. Each finished run is reduced to its +// 128-beat segments' autocorrelation at lags 0..127, from which the +// window's spectrum at ANY frequency follows (the test reads it at +// frequencies that depend on the whole night's median beat): one record of +// ~130 numbers per window, O(windows). +// * The coarse-lattice test needs every distinct |difference| (it asks +// whether all are multiples of the smallest): kept as a sorted set. Its +// size is the number of distinct difference magnitudes, not of beats (on a +// 1 ms or 0.5 ms grid at most a few thousand). +// +// Sums are formed in another order than the batch's (and a cut piece's levels +// are integrated from its own start), so numbers agree to float rounding, not +// bit for bit; a verdict can differ only within rounding of a threshold. +// +// Not exported. + +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../../aggregate/aggregator.dart'; +import '../../aggregate/json_support.dart'; +import 'hrv_time.dart'; +import 'hrv_time_aggregator.dart' show Beats; +import 'rmssd_gate.dart'; + +const int _edge = 63; // raw differences kept at each end of a piece +const int _bins = kNoiseHiBin - kNoiseLoBin + 1; +const int _lags = 128; +const double _windowMs = 300000.0; + +double _t(double v) => v.isNaN ? 0.0 : v; + +/// Hann coverage of a point at offset a (mod 32) from the segment grid, when +/// two segments cover it. +final List _cw = [ + for (var a = 0; a < 32; a++) + noiseHann[a] * noiseHann[a] + noiseHann[a + 32] * noiseHann[a + 32] +]; + +class _Phase { + final Float64List psd = Float64List(kNoiseHiBin + 1); + double p = 0, p2 = 0; + int segs = 0; + + void addAll(_Phase o) { + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) { + psd[k] += o.psd[k]; + } + p += o.p; + p2 += o.p2; + segs += o.segs; + } + + _Phase copy() => _Phase()..addAll(this); + + Object toJson() => [ + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) encodeDouble(psd[k]), + encodeDouble(p), + encodeDouble(p2), + segs, + ]; + + static _Phase fromJson(Object? j) { + final l = decodeList(j); + if (l.length != _bins + 3) throw const FormatException('invalid phase'); + final ph = _Phase(); + for (var k = 0; k < _bins; k++) { + ph.psd[k + kNoiseLoBin] = decodeDouble(l[k]); + } + ph.p = decodeDouble(l[_bins]); + ph.p2 = decodeDouble(l[_bins + 1]); + ph.segs = decodeCount(l[_bins + 2]); + return ph; + } +} + +/// A contiguous run of differences cut by a chunk edge (see the header). +class _Piece { + _Piece(this.anchored) + : phases = [for (var i = 0; i < (anchored ? 1 : 32); i++) _Phase()]; + + /// Starts at its run's start (else its alignment in the run is unknown). + bool anchored; + + /// Ends at its run's end. + bool rightClosed = false; + int len = 0; // differences + final List front = []; + ListQueue back = ListQueue(); + final Float64List s2 = Float64List(32); + final List cnt = List.filled(32, 0); + + /// Levels of the last <= 64 points (integrated from the piece's start, or + /// from wherever a join left them; only differences within a segment + /// matter). + ListQueue xs = ListQueue()..add(0.0); + double x = 0.0; + List<_Phase> phases; + + /// Segments starting at or after this point are not formed (a join's + /// replay: they belong to the right piece's sums). + int stop = 1 << 62; + + final Float64List _seg = Float64List(kNoiseSegment); + + void append(double d) { + len++; + x += d; + xs.add(x); + if (xs.length > kNoiseSegment) xs.removeFirst(); + if (front.length < _edge) front.add(d); + back.add(d); + if (back.length > _edge) { + final popped = back.removeFirst(); + final idx = len - _edge - 1; + if (idx >= _edge) { + s2[idx % 32] += popped * popped; + cnt[idx % 32]++; + } + } + final start = len - (kNoiseSegment - 1); + if (start < 0 || start >= stop) return; + final phase = start % 32; + if (anchored && phase != 0) return; + var i = 0; + for (final v in xs) { + _seg[i++] = v; + } + final ph = phases[anchored ? 0 : phase]; + final p = addNoiseSegment(_seg, 0, ph.psd); + ph.p += p; + ph.p2 += p * p; + ph.segs++; + } + + /// Every difference, for a piece of at most 2 * 63 (front and back cover it). + List allDiffs() { + assert(len <= 2 * _edge); + final overlap = front.length - (len - back.length); + return [...front, ...back.skip(math.max(0, overlap))]; + } + + void anchor() { + if (anchored) return; + anchored = true; + phases = [phases[0]]; + } + + /// This piece, then [d], then [right] (open on its left, never anchored). + void join(double d, _Piece right) { + append(d); + if (right.len <= 2 * _edge) { + for (final v in right.allDiffs()) { + append(v); + } + rightClosed = right.rightClosed; + return; + } + final boundary = len; // right's point 0 + stop = boundary; + for (final v in right.front) { + append(v); + } + stop = 1 << 62; + // The replayed front sits in the interior of the joined piece. + var k = 0; + for (final v in back) { + final idx = boundary + k++; + if (idx >= _edge) { + s2[idx % 32] += v * v; + cnt[idx % 32]++; + } + } + back = ListQueue.of(right.back); + for (var r = 0; r < 32; r++) { + s2[(r + boundary) % 32] += right.s2[r]; + cnt[(r + boundary) % 32] += right.cnt[r]; + } + if (anchored) { + phases[0].addAll(right.phases[(32 - boundary % 32) % 32]); + } else { + for (var f = 0; f < 32; f++) { + phases[f].addAll(right.phases[((f - boundary) % 32 + 32) % 32]); + } + } + len = boundary + right.len; + xs = ListQueue.of(right.xs); + x = right.x; + rightClosed = right.rightClosed; + } + + /// Adds this piece, anchored and closed (a whole run), to [sums]' spectral + /// and coverage terms (not its squared-difference count: the state keeps + /// that). + void finalizeInto(NoiseShareSums sums) { + assert(anchored); + if (len <= 2 * _edge) { + final scratch = NoiseShareSums()..addRun(allDiffs()); + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) { + sums.psd[k] += scratch.psd[k]; + } + sums.pSum += scratch.pSum; + sums.p2Sum += scratch.p2Sum; + sums.segs += scratch.segs; + sums.wsd += scratch.wsd; + sums.wsum += scratch.wsum; + return; + } + final ph = phases[0]; + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) { + sums.psd[k] += ph.psd[k]; + } + sums.pSum += ph.p; + sums.p2Sum += ph.p2; + sums.segs += ph.segs; + for (var r = 0; r < 32; r++) { + final c = (_cw[r] + _cw[(r + 1) % 32]) / 2; + sums.wsd += c * s2[r]; + sums.wsum += c * cnt[r]; + } + // The edges: coverage from the segments at 0, 32, ... that fit the run. + final last = + len + 1 >= kNoiseSegment ? ((len + 1 - kNoiseSegment) ~/ 32) * 32 : -1; + double cov(int j) { + var c = 0.0; + for (var s = (j - kNoiseSegment + 1).clamp(0, 1 << 62); + s <= j && s <= last; + s++) { + if (s % 32 == 0) c += noiseHann[j - s] * noiseHann[j - s]; + } + return c; + } + + void edge(int i, double d) { + final c = (cov(i) + cov(i + 1)) / 2; + sums.wsd += c * d * d; + sums.wsum += c; + } + + for (var i = 0; i < front.length; i++) { + edge(i, front[i]); + } + var i = len - back.length; + for (final d in back) { + edge(i++, d); + } + } + + _Piece copy() { + final c = _Piece(anchored) + ..rightClosed = rightClosed + ..len = len + ..back = ListQueue.of(back) + ..xs = ListQueue.of(xs) + ..x = x + ..stop = stop + ..phases = [for (final p in phases) p.copy()]; + c.front.addAll(front); + c.s2.setAll(0, s2); + c.cnt.setAll(0, cnt); + return c; + } + + Object toJson() => { + 'anchored': anchored, + 'rightClosed': rightClosed, + 'len': len, + 'front': [for (final v in front) encodeDouble(v)], + 'back': [for (final v in back) encodeDouble(v)], + 's2': encodeDoubles(s2), + 'cnt': List.of(cnt), + 'xs': [for (final v in xs) encodeDouble(v)], + 'x': encodeDouble(x), + 'phases': [for (final p in phases) p.toJson()], + }; + + static _Piece fromJson(Object? j) { + final m = decodeMap(j); + final anchored = field(m, 'anchored'); + final rightClosed = field(m, 'rightClosed'); + if (anchored is! bool || rightClosed is! bool) { + throw const FormatException('invalid piece flags'); + } + final p = _Piece(anchored) + ..rightClosed = rightClosed + ..len = decodeCount(field(m, 'len')) + ..x = decodeDouble(field(m, 'x')); + p.front.addAll(decodeDoubles(field(m, 'front'))); + p.back = ListQueue.of(decodeDoubles(field(m, 'back'))); + p.xs = ListQueue.of(decodeDoubles(field(m, 'xs'))); + final s2 = decodeDoubles(field(m, 's2')); + final cnt = decodeList(field(m, 'cnt')); + final phases = decodeList(field(m, 'phases')); + final edge = math.min(p.len, _edge); + if (p.front.length != edge || + p.back.length != edge || + p.xs.length != math.min(p.len + 1, kNoiseSegment) || + s2.length != 32 || + cnt.length != 32 || + phases.length != (anchored ? 1 : 32)) { + throw const FormatException('inconsistent piece'); + } + p.s2.setAll(0, s2); + var interior = 0; + for (var r = 0; r < 32; r++) { + p.cnt[r] = decodeCount(cnt[r]); + interior += p.cnt[r]; + } + if (interior != math.max(0, p.len - 2 * _edge)) { + throw const FormatException('piece interior count'); + } + p.phases = [for (final ph in phases) _Phase.fromJson(ph)]; + return p; + } +} + +/// One 5-min window's breathing-test record: its beats' RR, its in-window +/// differences, and its 128-beat segments' lag sums. +class _Win { + double rrSum = 0; + int rrN = 0; + double sq = 0; + int n = 0; + int segs = 0; + final Float64List r = Float64List(_lags); + + void addAll(_Win o) { + rrSum += o.rrSum; + rrN += o.rrN; + sq += o.sq; + n += o.n; + segs += o.segs; + for (var m = 0; m < _lags; m++) { + r[m] += o.r[m]; + } + } + + /// The segments of one finished in-window run (from its start, as the + /// batch walks them). + void addRun(List diffs) { + const n = 128; + final x = List.filled(diffs.length + 1, 0.0); + for (var i = 0; i < diffs.length; i++) { + x[i + 1] = x[i] + diffs[i]; + } + final v = Float64List(n); + for (var s = 0; s + n <= x.length; s += n ~/ 2) { + var mean = 0.0; + for (var i = 0; i < n; i++) { + mean += x[s + i]; + } + mean /= n; + for (var i = 0; i < n; i++) { + v[i] = (x[s + i] - mean) * hann128[i]; + } + for (var m = 0; m < n; m++) { + var acc = 0.0; + for (var i = 0; i + m < n; i++) { + acc += v[i] * v[i + m]; + } + r[m] += acc; + } + segs++; + } + } + + /// [windowPsd] of this window at cycles-per-beat frequencies [f]: + /// Σ_segments |Σ v e^{iθk}|² = R0 + 2 Σ_m R_m cos(mθ), over the segments. + List? psd(List f) { + if (segs == 0) return null; + return [ + for (final fr in f) _cosSeries(2 * math.pi * fr) / segs, + ]; + } + + double _cosSeries(double theta) { + final c1 = math.cos(theta); + var prev = 1.0, cur = c1; // cos(0), cos(θ) + var sum = r[0]; + for (var m = 1; m < _lags; m++) { + sum += 2 * r[m] * cur; + final next = 2 * c1 * cur - prev; + prev = cur; + cur = next; + } + return sum; + } + + _Win copy() => _Win()..addAll(this); + + Object toJson() => [ + encodeDouble(rrSum), + rrN, + encodeDouble(sq), + n, + segs, + encodeDoubles(r), + ]; + + static _Win fromJson(Object? j) { + final l = decodeList(j); + if (l.length != 6) throw const FormatException('invalid window'); + final w = _Win() + ..rrSum = decodeDouble(l[0]) + ..rrN = decodeCount(l[1]) + ..sq = decodeDouble(l[2]) + ..n = decodeCount(l[3]) + ..segs = decodeCount(l[4]); + final r = decodeDoubles(l[5]); + if (r.length != _lags) throw const FormatException('invalid window lags'); + w.r.setAll(0, r); + return w; + } +} + +/// An in-window run cut by a chunk edge: raw (a window bounds it). +class _WinRun { + _WinRun(this.w, {required this.anchored}); + final int w; + bool anchored; + bool closed = false; + List diffs = []; + + _WinRun copy() => _WinRun(w, anchored: anchored) + ..closed = closed + ..diffs = List.of(diffs); + + Object toJson() => [ + w, + anchored, + closed, + [for (final d in diffs) encodeDouble(d)] + ]; + + static _WinRun? fromJson(Object? j) { + if (j == null) return null; + final l = decodeList(j); + if (l.length != 4 || l[0] is! int || l[1] is! bool || l[2] is! bool) { + throw const FormatException('invalid window run'); + } + return _WinRun(l[0] as int, anchored: l[1] as bool) + ..closed = l[2] as bool + ..diffs = decodeDoubles(l[3]).toList(); + } +} + +class JitterState { + int n = 0; + double firstNn = 0, firstT = 0, lastNn = 0, lastT = 0; + int lastW = 0; + + /// The run cut by the state's left edge (alignment unknown). + _Piece? head; + + /// The run cut by its right edge, when a seam lies inside the state. + _Piece? tail; + NoiseShareSums closed = NoiseShareSums(); + double ssd = 0; + int nd = 0; + // Only coarseLattice reads magnitudes numerically; it ignores their order. + // Checkpoints sort these sets on encode to retain their original ordering. + Set mags = {}; + + final SplayTreeMap wins = SplayTreeMap(); + _WinRun? headRun, tailRun; + double winSsd = 0; + int winNd = 0; + Set winMags = {}; + + JitterState copy() { + final c = JitterState() + ..n = n + ..firstNn = firstNn + ..firstT = firstT + ..lastNn = lastNn + ..lastT = lastT + ..lastW = lastW + ..head = head?.copy() + ..tail = tail?.copy() + ..ssd = ssd + ..nd = nd + ..mags = Set.of(mags) + ..headRun = headRun?.copy() + ..tailRun = tailRun?.copy() + ..winSsd = winSsd + ..winNd = winNd + ..winMags = Set.of(winMags); + _addSums(c.closed, closed); + for (final e in wins.entries) { + c.wins[e.key] = e.value.copy(); + } + return c; + } +} + +void _addSums(NoiseShareSums into, NoiseShareSums from) { + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) { + into.psd[k] += from.psd[k]; + } + into.pSum += from.pSum; + into.p2Sum += from.p2Sum; + into.segs += from.segs; + into.wsd += from.wsd; + into.wsum += from.wsum; +} + +/// What the jitter gate reads, for a finished state. +class JitterEvidence { + JitterEvidence(this.sums, this.mags, this.windows, this.winSsd, this.winNd, + this.winMags); + final NoiseShareSums sums; + final Iterable mags; + final List<_Win> windows; + final double winSsd; + final int winNd; + final Iterable winMags; + + /// `_jitterRefused` from these sums. + bool refused() { + final share = noiseShareFrom(sums, + lattice: () => coarseLattice(mags, sums.ssd / sums.nd)); + return share == null || share >= kNnDiffNoiseShareCeiling; + } + + /// `_breathingRmssd` from the window records. + double? breathingRmssd() { + final meanRr = [for (final w in windows) w.rrSum / w.rrN]; + final idx = steadyBreathingFrom( + windows: windows.length, + meanRrMs: meanRr, + psdAt: (w, f) => windows[w].psd(f), + diffs: (w) => windows[w].n, + addSquares: (w, acc) => acc + windows[w].sq, + allSsd: winSsd, + allNd: winNd, + lattice: (msd) => coarseLattice(winMags, msd), + ); + if (idx == null) return null; + var ss = 0.0, n = 0; + for (final i in idx) { + ss += windows[i].sq; + n += windows[i].n; + } + return n == 0 ? null : math.sqrt(ss / n); + } +} + +/// The jitter-gate evidence over a beat batch (see the header). +class JitterEvidenceAggregator + extends Aggregator { + const JitterEvidenceAggregator({this.originMs, required this.gapAware}); + final double? originMs; + final bool gapAware; + + int _window(double t) { + final origin = originMs; + if (origin == null) throw StateError('gap-aware HRV needs originMs'); + return ((_t(t) - origin) / _windowMs).floor(); + } + + bool _contiguous(double prevT, double t, double nn) => + !gapAware || _t(t) - _t(prevT) <= nn + 0.5; + + @override + JitterState empty() => JitterState(); + + _Win _win(JitterState s, int w) => s.wins.putIfAbsent(w, _Win.new); + + void _inRun(JitterState s, double d) { + s.ssd += d * d; + s.nd++; + s.mags.add(d.abs()); + } + + void _inWindow(JitterState s, int w, double d) { + final win = _win(s, w); + win.sq += d * d; + win.n++; + s.winSsd += d * d; + s.winNd++; + s.winMags.add(d.abs()); + } + + void _closePiece(JitterState s, _Piece p) => p.finalizeInto(s.closed); + + void _closeRun(JitterState s, _WinRun r) => _win(s, r.w).addRun(r.diffs); + + @override + JitterState addSlice(JitterState s, Beats batch, int start, int end) { + for (var i = start; i < end; i++) { + final v = batch.nnMs[i], t = batch.timeMs[i]; + final w = gapAware ? _window(t) : 0; + if (s.n == 0) { + s.firstNn = v; + s.firstT = t; + s.head = _Piece(false); + if (gapAware) s.headRun = _WinRun(w, anchored: false); + } else { + final contig = _contiguous(s.lastT, t, v); + final d = v - s.lastNn; + if (contig) { + _inRun(s, d); + (s.tail ?? s.head!).append(d); + } else if (s.tail == null) { + s.head!.rightClosed = true; + s.tail = _Piece(true); + } else { + _closePiece(s, s.tail!); + s.tail = _Piece(true); + } + if (gapAware) { + if (contig && w == s.lastW) { + _inWindow(s, w, d); + (s.tailRun ?? s.headRun!).diffs.add(d); + } else { + if (s.tailRun == null) { + s.headRun!.closed = true; + } else { + _closeRun(s, s.tailRun!); + } + s.tailRun = _WinRun(w, anchored: true); + } + } + } + if (gapAware) { + final win = _win(s, w); + win.rrSum += v; + win.rrN++; + } + s.n++; + s.lastNn = v; + s.lastT = t; + s.lastW = w; + } + return s; + } + + @override + JitterState combine(JitterState l, JitterState r) { + if (r.n == 0) return l; + if (l.n == 0) return r; + final contig = _contiguous(l.lastT, r.firstT, r.firstNn); + final d = r.firstNn - l.lastNn; + // Noise-share pieces. + final lOpen = l.tail ?? l.head!; + final rHead = r.head!; + if (contig) { + _inRun(l, d); + lOpen.join(d, rHead); + if (l.tail == null) { + l.tail = r.tail; // the joined head is closed iff r had a seam + } else if (r.tail != null) { + _closePiece(l, lOpen); + l.tail = r.tail; + } + } else { + if (l.tail == null) { + l.head!.rightClosed = true; + } else { + _closePiece(l, l.tail!); + } + rHead.anchor(); + if (r.tail != null) { + _closePiece(l, rHead); + l.tail = r.tail; + } else { + l.tail = rHead; + } + } + _addSums(l.closed, r.closed); + l.ssd += r.ssd; + l.nd += r.nd; + l.mags.addAll(r.mags); + // Breathing windows. + if (gapAware) { + for (final e in r.wins.entries) { + _win(l, e.key).addAll(e.value); + } + final lRun = l.tailRun ?? l.headRun!; + final rRun = r.headRun!; + if (contig && l.lastW == _window(r.firstT)) { + _inWindow(l, l.lastW, d); + lRun.diffs + ..add(d) + ..addAll(rRun.diffs); + if (l.tailRun == null) { + lRun.closed = rRun.closed; + l.tailRun = r.tailRun; + } else if (r.tailRun != null) { + _closeRun(l, lRun); + l.tailRun = r.tailRun; + } + } else { + if (l.tailRun == null) { + l.headRun!.closed = true; + } else { + _closeRun(l, l.tailRun!); + } + rRun.anchored = true; + if (r.tailRun != null) { + _closeRun(l, rRun); + l.tailRun = r.tailRun; + } else { + l.tailRun = rRun; + } + } + l.winSsd += r.winSsd; + l.winNd += r.winNd; + l.winMags.addAll(r.winMags); + } + l.n += r.n; + l.lastNn = r.lastNn; + l.lastT = r.lastT; + l.lastW = r.lastW; + return l; + } + + @override + JitterEvidence finish(JitterState state) { + final s = state.copy(); + // The left edge is the series start: a run starts there. + if (s.head != null) { + s.head!.anchor(); + _closePiece(s, s.head!); + } + if (s.tail != null) _closePiece(s, s.tail!); + if (s.headRun != null) _closeRun(s, s.headRun!); + if (s.tailRun != null) _closeRun(s, s.tailRun!); + s.closed + ..ssd = s.ssd + ..nd = s.nd; + return JitterEvidence( + s.closed, s.mags, s.wins.values.toList(), s.winSsd, s.winNd, s.winMags); + } + + @override + bool get persistable => true; + + @override + Object? encodeState(JitterState s) => { + 'type': 'HrvJitterEvidence', + 'version': 1, + 'n': s.n, + 'first': [encodeDouble(s.firstNn), encodeDouble(s.firstT)], + 'last': [encodeDouble(s.lastNn), encodeDouble(s.lastT), s.lastW], + 'head': s.head?.toJson(), + 'tail': s.tail?.toJson(), + 'closed': [ + for (var k = kNoiseLoBin; k <= kNoiseHiBin; k++) + encodeDouble(s.closed.psd[k]), + encodeDouble(s.closed.pSum), + encodeDouble(s.closed.p2Sum), + s.closed.segs, + encodeDouble(s.closed.wsd), + encodeDouble(s.closed.wsum), + ], + 'ssd': encodeDouble(s.ssd), + 'nd': s.nd, + 'mags': [for (final m in (s.mags.toList()..sort())) encodeDouble(m)], + 'wins': [ + for (final e in s.wins.entries) [e.key, e.value.toJson()] + ], + 'headRun': s.headRun?.toJson(), + 'tailRun': s.tailRun?.toJson(), + 'winSsd': encodeDouble(s.winSsd), + 'winNd': s.winNd, + 'winMags': [for (final m in (s.winMags.toList()..sort())) encodeDouble(m)], + }; + + @override + JitterState decodeState(Object? json) { + final m = decodeMap(json); + if (field(m, 'type') != 'HrvJitterEvidence' || field(m, 'version') != 1) { + throw const FormatException('unsupported HrvJitterEvidence'); + } + final s = JitterState()..n = decodeCount(field(m, 'n')); + final first = decodeList(field(m, 'first')); + final last = decodeList(field(m, 'last')); + if (first.length != 2 || last.length != 3 || last[2] is! int) { + throw const FormatException('invalid edges'); + } + s + ..firstNn = decodeDouble(first[0]) + ..firstT = decodeDouble(first[1]) + ..lastNn = decodeDouble(last[0]) + ..lastT = decodeDouble(last[1]) + ..lastW = last[2] as int; + final head = field(m, 'head'), tail = field(m, 'tail'); + s.head = head == null ? null : _Piece.fromJson(head); + s.tail = tail == null ? null : _Piece.fromJson(tail); + final closed = decodeList(field(m, 'closed')); + if (closed.length != _bins + 5) throw const FormatException('invalid sums'); + for (var k = 0; k < _bins; k++) { + s.closed.psd[k + kNoiseLoBin] = decodeDouble(closed[k]); + } + s.closed + ..pSum = decodeDouble(closed[_bins]) + ..p2Sum = decodeDouble(closed[_bins + 1]) + ..segs = decodeCount(closed[_bins + 2]) + ..wsd = decodeDouble(closed[_bins + 3]) + ..wsum = decodeDouble(closed[_bins + 4]); + s + ..ssd = decodeDouble(field(m, 'ssd')) + ..nd = decodeCount(field(m, 'nd')) + ..mags = Set.of(decodeDoubles(field(m, 'mags'))) + ..headRun = _WinRun.fromJson(field(m, 'headRun')) + ..tailRun = _WinRun.fromJson(field(m, 'tailRun')) + ..winSsd = decodeDouble(field(m, 'winSsd')) + ..winNd = decodeCount(field(m, 'winNd')) + ..winMags = Set.of(decodeDoubles(field(m, 'winMags'))); + for (final e in decodeList(field(m, 'wins'))) { + final pair = decodeList(e); + if (pair.length != 2 || pair[0] is! int) { + throw const FormatException('invalid window entry'); + } + if (s.wins.containsKey(pair[0])) { + throw const FormatException('duplicate window'); + } + s.wins[pair[0] as int] = _Win.fromJson(pair[1]); + } + // The parts must describe one state. + final pieces = + (s.head == null ? 0 : s.head!.len) + (s.tail == null ? 0 : s.tail!.len); + final ok = (s.n == 0) == (s.head == null) && + (s.tail == null || + (s.head != null && + s.head!.rightClosed && + s.tail!.anchored && + !s.tail!.rightClosed)) && + (s.head == null || !s.head!.anchored) && + (s.tail != null || s.head == null || !s.head!.rightClosed) && + s.nd >= pieces && + s.nd < math.max(1, s.n) && + s.winNd <= s.nd && + s.mags.length <= s.nd && + s.winMags.length <= s.winNd && + (gapAware + ? ((s.n == 0) == (s.headRun == null) && + (s.tailRun == null || + (s.headRun!.closed && s.tailRun!.anchored)) && + (s.headRun == null || !s.headRun!.anchored) && + s.wins.values.fold(0, (a, w) => a + w.rrN) == s.n && + s.wins.values.fold(0, (a, w) => a + w.n) == s.winNd) + : (s.headRun == null && s.tailRun == null && s.wins.isEmpty)); + if (!ok) throw const FormatException('inconsistent HrvJitterEvidence'); + return s; + } +} diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index e34f047..b98f6cf 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -15,8 +15,10 @@ // makes it behaviour — see that constant for the measurement and the threshold. import 'dart:math' as math; +import 'dart:typed_data'; import '../types.dart'; import '../util.dart'; +import 'hrv_time_aggregator.dart'; import 'rmssd_gate.dart'; /// Lag-1 ACF floor for the NN successive-difference series, below which RMSSD @@ -448,6 +450,8 @@ Metric hrvTime( ); } + // A non-finite beat time has no place on the clock: the 5-min segmenting + // threw on it. Same refusal as hrvFreq / rsaRespRate. if (nnTimesMs != null && nnTimesMs.length == nnMs.length && nnTimesMs.any((t) => !t.isFinite)) { @@ -457,95 +461,40 @@ Metric hrvTime( note: 'non-finite beat time', ); } + + // The values come from the same numeric core as [hrvTimeAggregator], run + // in one slice; this function adds only the envelope (confidence, note). + // // RMSSD / pNN50: root mean square of SUCCESSIVE differences — successive in // TIME, not merely adjacent in the compacted list. correctRr drops multi-beat // artifact runs while advancing its clock across them, so nn[i-1] and nn[i] // can sit either side of a seconds-long hole; differencing straight down the // list manufactured one large difference per dropped run. Same `keep`-mask // treatment irregular_rhythm.dart already applies. A pair is contiguous iff - // the elapsed time between the two beat times is the interval itself. + // the elapsed time between the two beat times is the interval itself. The + // differences are kept as contiguous RUNS (a seam ends a run) so the same + // pass feeds the lag-1 ACF without ever forming a pair across a hole. // - // The differences are kept as contiguous RUNS (a seam ends a run) so the same - // pass feeds [nnDiffAcf1] without ever forming a lag-1 pair across a hole. - final gapAware = nnTimesMs != null && nnTimesMs.length == nnMs.length; - final runs = >[]; - var run = []; - for (var i = 1; i < nnMs.length; i++) { - if (gapAware && nnTimesMs[i] - nnTimesMs[i - 1] > nnMs[i] + 0.5) { - if (run.isNotEmpty) { - runs.add(run); - run = []; - } - continue; - } - run.add(nnMs[i] - nnMs[i - 1]); - } - if (run.isNotEmpty) runs.add(run); - - var ssd = 0.0; - var nn50 = 0; - var pairs = 0; - for (final r in runs) { - for (final d in r) { - ssd += d * d; - if (d.abs() > 50) nn50++; - pairs++; - } - } // RMSSD and pNN50 are the two outputs made of successive differences, so they // are the two the jitter floor refuses. SDNN/SDANN are made of the levels and // are far less contaminated (jitter share 1–27 % against RMSSD's 11–100 % on // the audit corpus) — they keep publishing, which is what the header has - // always advised. - // Welford (1962) centered sums/products, Task Force (1996) HRV. - // Accumulate difference moments and lagged co-moments in sample order. - var dn = 0, ln = 0; - var dm = 0.0, d2 = 0.0, ma = 0.0, mb = 0.0, co = 0.0; - for (final r in runs) { - for (var i = 0; i < r.length; i++) { - final d = r[i]; - dn++; - final delta = d - dm; - dm += delta / dn; - d2 += delta * (d - dm); - if (i > 0) { - ln++; - final da = r[i - 1] - ma; - final db = d - mb; - ma += da / ln; - mb += db / ln; - co += da * (d - mb); - } - } - } - final double? acf1 = dn >= 30 && d2 > 0 - ? (co + ln * (ma - dm) * (mb - dm)) / d2 : null; - final jittery = _jitterRefused(acf1, runs); + // always advised. Refused: a long record may still show a breathing line + // steady in Hz; then RMSSD alone comes from those 5-min windows. + final gapAware = nnTimesMs != null && nnTimesMs.length == nnMs.length; + final nn = Float64List.fromList(nnMs); + final times = gapAware ? Float64List.fromList(nnTimesMs) : Float64List(nn.length); + final v = hrvTimeOneSlice(Beats(nn, times), + originMs: gapAware ? times[0] : null, gapAware: gapAware); + final acf1 = v.diffAcf1; + // pNN50 is null with a real ACF1 (>= 30 pairs) only when the floor refused. + final jittery = acf1 != null && v.pnn50 == null; // More beat-time than elapsed: the stream itself is wrong, so neither the // differences nor any breathing line in them describe one heart. final overCounted = coverage?.overCounted == true; - final usable = pairs > 0 && !jittery && !overCounted; - var rmssd = usable ? math.sqrt(ssd / pairs) : null; - final pnn50 = usable ? 100.0 * nn50 / pairs : null; - // Refused: a long record may still show a breathing line steady in Hz - // ([_steadyBreathingWindows]); then RMSSD alone comes from those 5-min windows. - if (jittery && gapAware && !overCounted) { - rmssd = _breathingRmssd(nnMs, nnTimesMs); - } + final rmssd = overCounted ? null : v.rmssd; + final pnn50 = overCounted ? null : v.pnn50; final byBreathing = jittery && rmssd != null; - final sdnn = _centeredMoments(nnMs).sd; - - double? sdann, sdnnIndex; - if (gapAware) { - final seg = _fiveMinSegments(nnMs, nnTimesMs); - if (seg.length >= 2) { - final moments = [for (final s in seg) _centeredMoments(s)]; - final means = [for (final m in moments) m.mean!]; - sdann = _centeredMoments(means).sd; - final sds = [for (final m in moments) m.sd].whereType().toList(); - sdnnIndex = sds.isEmpty ? null : mean(sds); - } - } // Confidence scales with beat count (ultra-short reads are less reliable), // with the artifact fraction we were handed, and with the measured jitter @@ -564,9 +513,9 @@ Metric hrvTime( return Metric( value: HrvTime( rmssd: rmssd, - sdnn: sdnn, - sdann: sdann, - sdnnIndex: sdnnIndex, + sdnn: v.sdnn, + sdann: v.sdann, + sdnnIndex: v.sdnnIndex, pnn50: pnn50, nBeats: nnMs.length, diffAcf1: acf1, @@ -578,7 +527,7 @@ Metric hrvTime( ? '${_overcountNote(coverage!)}. SDNN/SDANN survive it and are the ' 'lead here. PRV not ECG-HRV.' : jittery - ? '${_jitterNote(acf1!)}. SDNN/SDANN survive it and are the lead here. ' + ? '${_jitterNote(acf1)}. SDNN/SDANN survive it and are the lead here. ' '${byBreathing ? 'RMSSD only from 5-min windows on a breathing ' 'line steady in Hz, at floor confidence. ' : ''}PRV not ECG-HRV.' : 'PRV not ECG-HRV; RMSSD/pNN50 are quantization-sensitive at 1 Hz ' @@ -586,40 +535,6 @@ Metric hrvTime( ); } -/// RMSSD pooled over the 5-min windows of [nn] that sit on a breathing line -/// steady in Hz, or null. Same seam rule as [hrvTime]. -double? _breathingRmssd(List nn, List times) { - final wins = >>{}; - final rrSum = {}, rrN = {}; - var prevWin = -1; - for (var i = 0; i < nn.length; i++) { - final w = ((times[i] - times.first) / 300000.0).floor(); - rrSum[w] = (rrSum[w] ?? 0) + nn[i]; - rrN[w] = (rrN[w] ?? 0) + 1; - final runs = wins[w] ??= >[]; - if (i > 0 && w == prevWin && times[i] - times[i - 1] <= nn[i] + 0.5) { - runs.last.add(nn[i] - nn[i - 1]); - } else { - runs.add([]); - } - prevWin = w; - } - final keys = wins.keys.toList()..sort(); - final idx = _steadyBreathingWindows( - [for (final k in keys) wins[k]!], [for (final k in keys) rrSum[k]! / rrN[k]!]); - if (idx == null) return null; - var ss = 0.0, n = 0; - for (final i in idx) { - for (final r in wins[keys[i]]!) { - for (final d in r) { - ss += d * d; - n++; - } - } - } - return n == 0 ? null : math.sqrt(ss / n); -} - /// Robust NOCTURNAL RMSSD (ms). /// /// A single whole-night RMSSD is dominated by the few high-Δ segments produced @@ -757,29 +672,7 @@ Metric nocturnalRmssd( meanRr.add(mean([for (final i in seg) nnMs[i]])!); rmssds.add(math.sqrt(ssd / nd)); } - // Welford (1962) centered sums/products, Task Force (1996) HRV. - // Accumulate difference moments and lagged co-moments in sample order. - var dn = 0, ln = 0; - var dm = 0.0, d2 = 0.0, ma = 0.0, mb = 0.0, co = 0.0; - for (final r in runs) { - for (var i = 0; i < r.length; i++) { - final d = r[i]; - dn++; - final delta = d - dm; - dm += delta / dn; - d2 += delta * (d - dm); - if (i > 0) { - ln++; - final da = r[i - 1] - ma; - final db = d - mb; - ma += da / ln; - mb += db / ln; - co += da * (d - mb); - } - } - } - final double? acf1 = dn >= 30 && d2 > 0 - ? (co + ln * (ma - dm) * (mb - dm)) / d2 : null; + final acf1 = nnDiffAcf1(runs); final refused = _jitterRefused(acf1, runs); if (rmssds.isEmpty && !refused) { return const Metric.absent( @@ -962,6 +855,7 @@ Metric sleepSessionRmssdDetail( note: 'non-finite beat time', ); } + final buckets = >{}; final bucketsTs = >{}; final inRr = []; @@ -1063,29 +957,7 @@ Metric sleepSessionRmssdDetail( // THE HEADLINE nightly RMSSD (→ ln_rmssd → readiness). When the differences // are noise, the honest output is no headline, not a plausible one — the // readiness composite already treats a null HRV driver as absent. - // Welford (1962) centered sums/products, Task Force (1996) HRV. - // Accumulate difference moments and lagged co-moments in sample order. - var dn = 0, ln = 0; - var dm = 0.0, d2 = 0.0, ma = 0.0, mb = 0.0, co = 0.0; - for (final r in runs) { - for (var i = 0; i < r.length; i++) { - final d = r[i]; - dn++; - final delta = d - dm; - dm += delta / dn; - d2 += delta * (d - dm); - if (i > 0) { - ln++; - final da = r[i - 1] - ma; - final db = d - mb; - ma += da / ln; - mb += db / ln; - co += da * (d - mb); - } - } - } - final double? acf1 = dn >= 30 && d2 > 0 - ? (co + ln * (ma - dm) * (mb - dm)) / d2 : null; + final acf1 = nnDiffAcf1(runs); final refused = _jitterRefused(acf1, runs); if (rmssds.isEmpty && !refused) { return Metric.absent( @@ -1129,28 +1001,6 @@ Metric sleepSessionRmssdDetail( ); } -/// Group NN intervals into consecutive 5-minute (300 000 ms) segments by beat -/// time. Segments with <2 beats are dropped. -List> _fiveMinSegments(List nn, List times) { - const segMs = 300000.0; - final out = >[]; - if (nn.isEmpty) return out; - final t0 = times.first; - var curIdx = 0; - var cur = []; - for (var i = 0; i < nn.length; i++) { - final idx = ((times[i] - t0) / segMs).floor(); - if (idx != curIdx) { - if (cur.length >= 2) out.add(cur); - cur = []; - curIdx = idx; - } - cur.add(nn[i]); - } - if (cur.length >= 2) out.add(cur); - return out; -} - /// Range-filter [300, 2000] ms + Malik-style ectopic rejection against a local /// median, returned as CONTIGUOUS RUNS of kept intervals. /// @@ -1224,22 +1074,3 @@ List> _cleanWindowRuns(List rr, List ts) { if (run.isNotEmpty) runs.add(run); return runs; } - -// Welford, B. P. (1962), Technometrics 4(3):419-420. -// Origin-shifted batch recurrence; skips NaN just as #99's Moments does. -({double? mean, double? sd}) _centeredMoments(List values) { - var n = 0; - var origin = 0.0, offset = 0.0, m2 = 0.0; - for (final value in values) { - if (value.isNaN) continue; - if (!value.isFinite) throw ArgumentError.value(value, 'value', 'Must be finite'); - if (n == 0) { origin = value; n = 1; continue; } - final centered = value - origin; - final delta = centered - offset; - n++; - offset += delta / n; - m2 += delta * (centered - offset); - } - return (mean: n == 0 ? null : origin + offset, - sd: n < 2 ? null : math.sqrt(m2 / (n - 1))); -} diff --git a/lib/src/onehz/clinical/hrv_time_aggregator.dart b/lib/src/onehz/clinical/hrv_time_aggregator.dart new file mode 100644 index 0000000..b6c682f --- /dev/null +++ b/lib/src/onehz/clinical/hrv_time_aggregator.dart @@ -0,0 +1,551 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../../aggregate/aggregator.dart'; +import '../../aggregate/json_support.dart'; +import '../../aggregate/primitives.dart'; +import '../incremental_core.dart'; +import 'hrv_jitter_evidence.dart'; +import 'hrv_time.dart'; + +const double _fiveMinutesMs = 300000.0; + +/// NN intervals and their beat times. A missing time is represented by NaN. +class Beats extends Batch { + Beats(this.nnMs, this.timeMs) : assert(nnMs.length == timeMs.length); + + factory Beats.fromJson(Object? json) { + final map = decodeMap(json); + final nn = decodeDoubles(field(map, 'nnMs')); + final times = decodeDoubles(field(map, 'timeMs')); + if (nn.length != times.length) { + throw const FormatException('Beats columns differ in length'); + } + return Beats(nn, times); + } + + final Float64List nnMs; + final Float64List timeMs; + + @override + Object? toJson() => + {'nnMs': encodeDoubles(nnMs), 'timeMs': encodeDoubles(timeMs)}; + + @override + int get length => nnMs.length; + + @override + Beats copyRange(int start, int end) => Beats( + Float64List(end - start)..setRange(0, end - start, nnMs, start), + Float64List(end - start)..setRange(0, end - start, timeMs, start)); + + @override + Beats join(Beats other) { + final nn = Float64List(length + other.length); + final times = Float64List(length + other.length); + nn + ..setRange(0, length, nnMs) + ..setRange(length, nn.length, other.nnMs); + times + ..setRange(0, length, timeMs) + ..setRange(length, times.length, other.timeMs); + return Beats(nn, times); + } +} + +/// Builds the composed time-domain HRV summary for a columnar beat batch. +/// +/// With [gapAware] false, times and five-minute bins are deliberately ignored. +// The jitter rejection/exemption thresholds are an internal rule, ESTIMATE +// with low confidence; Welch 1967 and Hirsch & Bishop 1981 support spectral +// and respiratory components only. See rmssd_gate.dart. +// Task Force 1996 time-domain HRV, now also the hrvTime() one-slice path. +// Moments, two-beat differences, three-beat lag products and five-minute bins +// retain the same sufficient statistics. gapAware:false omits timestamp- +// dependent bins and gap tests. Floating-point order can change rounding and +// jitter-threshold decisions. Checked field tolerances are 1e-9 absolute plus +// 1e-9 relative; near-constant differences and jitter gates have the +// qualifications in test/aggregate/hrv_time_aggregator_test.dart. +Aggregator hrvTimeAggregator({ + double? originMs, + bool gapAware = true, +}) { + final core = _numericCore(originMs: originMs, gapAware: gapAware); + // Streaming finish uses the evidence already collected and merged by fold. + final evidence = JitterEvidenceAggregator(originMs: originMs, gapAware: gapAware); + final combined = validateCheckpoint(both(core, evidence), + (json) => _validateVersioned(json, gapAware)); + final checked = _CheckedBeats(combined, gapAware: gapAware); + return checked.map((result) => _finish( + result.$1.$1, result.$1.$2, result.$1.$3, result.$1.$4, + () => result.$2, gapAware: gapAware)); +} + +/// Runs the same numeric core over one slice, building jitter evidence only +/// when the ACF floor requires it. This helper is not exported by the public libraries. +HrvTime hrvTimeOneSlice(Beats beats, { + double? originMs, + bool gapAware = true, +}) { + final core = _numericCore(originMs: originMs, gapAware: gapAware); + final result = _CheckedBeats(core, gapAware: gapAware).run(beats); + JitterEvidence? cached; + JitterEvidence evidence() => cached ??= JitterEvidenceAggregator( + originMs: originMs, gapAware: gapAware).run(beats); + return _finish(result.$1, result.$2, result.$3, result.$4, evidence, + gapAware: gapAware); +} + +typedef _CoreSummary = ( + _DiffSummary, _LagSummary, RunningMoments, Map +); + +// Mapping only the finished summaries leaves both the accumulation order and +// checkpoint state shape unchanged, including the gap-unaware branch. +Aggregator _numericCore({ + double? originMs, + required bool gapAware, +}) { + final diffs = both( + Lookback( + 2, _DiffStats(gapAware: gapAware), + batchFromJson: Beats.fromJson), + Lookback( + 3, _LagStats(gapAware: gapAware), + batchFromJson: Beats.fromJson), + ); + final levels = Moments((batch) => batch.nnMs); + if (!gapAware) { + return both(diffs, levels).map((result) => ( + result.$1.$1, result.$1.$2, result.$2, + const {})); + } + + final bins = GroupBy( + (batch, row) { + final origin = originMs; + if (origin == null) throw StateError('gap-aware HRV needs originMs'); + return ((_timeAt(batch, row) - origin) / _fiveMinutesMs).floor(); + }, + Moments((batch) => batch.nnMs), + ); + return both(diffs, both(levels, bins)).map((result) => ( + result.$1.$1, result.$1.$2, result.$2.$1, result.$2.$2)); +} + +// Owns the boundary clock for atomic validation before composed children mutate. +class _CheckedBeatState { + _CheckedBeatState(this.inner, {this.first, this.last}); + S inner; + double? first, last; +} + +/// Refuses non-finite NN in both modes, and non-finite or regressing times in +/// gap-aware mode, before updating any child. Times are ignored otherwise. +class _CheckedBeats extends Aggregator<_CheckedBeatState, Beats, R> { + const _CheckedBeats(this.inner, {required this.gapAware}); + final Aggregator inner; + final bool gapAware; + + @override + _CheckedBeatState empty() => _CheckedBeatState(inner.empty()); + + @override + _CheckedBeatState addSlice( + _CheckedBeatState state, Beats batch, int start, int end) { + var previous = state.last; + for (var i = start; i < end; i++) { + if (!batch.nnMs[i].isFinite) { + throw ArgumentError('NN intervals must be finite'); + } + if (!gapAware) continue; + final time = batch.timeMs[i]; + if (!time.isFinite) throw ArgumentError('beat times must be finite'); + if (previous != null && time < previous) { + throw StateError('beat times must be non-decreasing'); + } + previous = time; + } + state.inner = inner.addSlice(state.inner, batch, start, end); + if (gapAware && start < end) { + state.first ??= batch.timeMs[start]; + state.last = previous; + } + return state; + } + + @override + _CheckedBeatState combine(_CheckedBeatState left, _CheckedBeatState right) { + if (gapAware && left.last != null && right.first != null && + right.first! < left.last!) { + throw StateError('beat times must be non-decreasing'); + } + left.inner = inner.combine(left.inner, right.inner); + left.first ??= right.first; + left.last = right.last ?? left.last; + return left; + } + + @override + R finish(_CheckedBeatState state) => inner.finish(state.inner); + @override + bool get persistable => inner.persistable; + @override + Object? encodeState(_CheckedBeatState state) => { + 'inner': inner.encodeState(state.inner), + 'first': state.first, + 'last': state.last, + 'gapAware': gapAware, + }; + @override + _CheckedBeatState decodeState(Object? json) { + final map = decodeMap(json); + final first = decodeDoubleOrNull(field(map, 'first')); + final last = decodeDoubleOrNull(field(map, 'last')); + if (field(map, 'gapAware') != gapAware || + (first == null) != (last == null) || + (first != null && (!first.isFinite || !last!.isFinite || last < first)) || + (!gapAware && first != null)) { + throw const FormatException('invalid HRV boundary clock'); + } + return _CheckedBeatState(inner.decodeState(field(map, 'inner')), + first: first, last: last); + } +} + +/// Version 2: `[core, evidence]`, the evidence counting what the core counts. +void _validateVersioned(Object? json, bool gapAware) { + final state = decodeList(json); + if (state.length != 2) throw const FormatException('Expected core and evidence'); + _validateCheckpoint(state[0], gapAware); + final core = decodeList(state[0]); + final evidence = decodeMap(state[1]); + final diffs = decodeMap(field(decodeMap(decodeList(core[0])[0]), 'inner')); + final rawLevels = gapAware ? decodeList(core[1])[0] : core[1]; + if (decodeCount(field(evidence, 'n')) != + decodeCount(field(decodeMap(rawLevels), 'count')) || + decodeCount(field(evidence, 'nd')) != decodeCount(field(diffs, 'pairs'))) { + throw const FormatException('Evidence disagrees with the core'); + } +} + +void _validateCheckpoint(Object? json, bool gapAware) { + final state = decodeList(json); + if (state.length != 2) throw const FormatException('Expected two states'); + final lookbacks = decodeList(state[0]); + if (lookbacks.length != 2) throw const FormatException('Expected two lookbacks'); + final diffs = decodeMap(lookbacks[0]); + final lags = decodeMap(lookbacks[1]); + final beats = decodeCount(field(diffs, 'length')); + if (decodeCount(field(lags, 'length')) != beats) { + throw const FormatException('Lookback lengths disagree'); + } + final diffPairs = decodeCount(field(decodeMap(field(diffs, 'inner')), 'pairs')); + final lagPairs = decodeCount(field(decodeMap(field(lags, 'inner')), 'pairs')); + final diffLimit = beats > 0 ? beats - 1 : 0; + final lagLimit = beats > 1 ? beats - 2 : 0; + if (diffPairs > diffLimit || lagPairs > lagLimit) { + throw const FormatException('Pair count exceeds beat windows'); + } + + final Object? rawLevels; + Map? bins; + if (gapAware) { + final levelAndBins = decodeList(state[1]); + if (levelAndBins.length != 2) { + throw const FormatException('Expected levels and bins'); + } + rawLevels = levelAndBins[0]; + bins = decodeMap(levelAndBins[1]); + } else { + rawLevels = state[1]; + } + final levels = decodeCount(field(decodeMap(rawLevels), 'count')); + if (levels > beats) { + throw const FormatException('Level count exceeds beat count'); + } + if (bins == null) return; + var binned = 0; + for (final bin in bins.values) { + final count = decodeCount(field(decodeMap(bin), 'count')); + if (binned > levels - count) { + throw const FormatException('Bin counts exceed level count'); + } + binned += count; + } + if (binned != levels) { + throw const FormatException('Bin counts disagree with level count'); + } +} + +double _timeAt(Beats batch, int row) { + final time = batch.timeMs[row]; + return time.isNaN ? 0.0 : time; +} + +bool _contiguous(Beats batch, int previous, int current, bool gapAware) => + !gapAware || + _timeAt(batch, current) - _timeAt(batch, previous) <= + batch.nnMs[current] + 0.5; + +class _DiffSummary { + const _DiffSummary( + this.pairs, this.over50, this.mean, this.m2, this.squares); + final int pairs; + final int over50; + final double mean; + final double m2; + final double squares; +} + +class _DiffState { + int pairs = 0; + int over50 = 0; + double mean = 0.0; + double m2 = 0.0; + double squares = 0.0; + + @pragma('vm:prefer-inline') + void add(double value) { + final nextPairs = pairs + 1; + final delta = value - mean; + mean += delta / nextPairs; + m2 += delta * (value - mean); + pairs = nextPairs; + } + + @pragma('vm:prefer-inline') + void merge(_DiffState other) { + if (other.pairs == 0) return; + if (pairs == 0) { + pairs = other.pairs; + over50 = other.over50; + mean = other.mean; + m2 = other.m2; + squares = other.squares; + return; + } + final leftPairs = pairs; + final combinedPairs = leftPairs + other.pairs; + final delta = other.mean - mean; + m2 += other.m2 + delta * delta * leftPairs * other.pairs / combinedPairs; + mean += delta * other.pairs / combinedPairs; + pairs = combinedPairs; + over50 += other.over50; + squares += other.squares; + } +} + +/// Processes every two-row window supplied by [Lookback]. +class _DiffStats extends Aggregator<_DiffState, Beats, _DiffSummary> { + const _DiffStats({required this.gapAware}); + final bool gapAware; + + @override + _DiffState empty() => _DiffState(); + + @override + _DiffState addSlice(_DiffState state, Beats batch, int start, int end) { + for (var i = start + 1; i < end; i++) { + if (!_contiguous(batch, i - 1, i, gapAware)) continue; + final diff = batch.nnMs[i] - batch.nnMs[i - 1]; + state.add(diff); + state.squares += diff * diff; + if (diff.abs() > 50) state.over50++; + } + return state; + } + + @override + _DiffState combine(_DiffState left, _DiffState right) { + left.merge(right); + return left; + } + + @override + bool get persistable => true; + + @override + Object? encodeState(_DiffState state) => { + 'pairs': state.pairs, + 'over50': state.over50, + 'mean': encodeDouble(state.mean), + 'm2': encodeDouble(state.m2), + 'squares': encodeDouble(state.squares), + }; + + @override + _DiffState decodeState(Object? json) { + final map = decodeMap(json); + final pairs = decodeCount(field(map, 'pairs')); + final over50 = decodeCount(field(map, 'over50')); + if (over50 > pairs) throw const FormatException('over50 exceeds pairs'); + return _DiffState() + ..pairs = pairs + ..over50 = over50 + ..mean = decodeDouble(field(map, 'mean')) + ..m2 = decodeDouble(field(map, 'm2')) + ..squares = decodeDouble(field(map, 'squares')); + } + + @override + _DiffSummary finish(_DiffState state) => + _DiffSummary( + state.pairs, state.over50, state.mean, state.m2, state.squares); +} + +class _LagSummary { + const _LagSummary(this.pairs, this.meanA, this.meanB, this.coMoment); + final int pairs; + final double meanA; + final double meanB; + final double coMoment; +} + +class _LagState { + int pairs = 0; + double meanA = 0.0; + double meanB = 0.0; + double coMoment = 0.0; + + @pragma('vm:prefer-inline') + void add(double a, double b) { + final nextPairs = pairs + 1; + final deltaA = a - meanA; + final deltaB = b - meanB; + meanA += deltaA / nextPairs; + meanB += deltaB / nextPairs; + coMoment += deltaA * (b - meanB); + pairs = nextPairs; + } + + @pragma('vm:prefer-inline') + void merge(_LagState other) { + if (other.pairs == 0) return; + if (pairs == 0) { + pairs = other.pairs; + meanA = other.meanA; + meanB = other.meanB; + coMoment = other.coMoment; + return; + } + final leftPairs = pairs; + final combinedPairs = leftPairs + other.pairs; + final deltaA = other.meanA - meanA; + final deltaB = other.meanB - meanB; + coMoment += other.coMoment + + deltaA * deltaB * leftPairs * other.pairs / combinedPairs; + meanA += deltaA * other.pairs / combinedPairs; + meanB += deltaB * other.pairs / combinedPairs; + pairs = combinedPairs; + } +} + +/// Processes every three-row window supplied by [Lookback]. +class _LagStats extends Aggregator<_LagState, Beats, _LagSummary> { + const _LagStats({required this.gapAware}); + final bool gapAware; + + @override + _LagState empty() => _LagState(); + + @override + _LagState addSlice(_LagState state, Beats batch, int start, int end) { + for (var i = start + 2; i < end; i++) { + if (!_contiguous(batch, i - 2, i - 1, gapAware) || + !_contiguous(batch, i - 1, i, gapAware)) { + continue; + } + final first = batch.nnMs[i - 1] - batch.nnMs[i - 2]; + final second = batch.nnMs[i] - batch.nnMs[i - 1]; + state.add(first, second); + } + return state; + } + + @override + _LagState combine(_LagState left, _LagState right) { + left.merge(right); + return left; + } + + @override + bool get persistable => true; + + @override + Object? encodeState(_LagState state) => { + 'pairs': state.pairs, + 'meanA': encodeDouble(state.meanA), + 'meanB': encodeDouble(state.meanB), + 'coMoment': encodeDouble(state.coMoment), + }; + + @override + _LagState decodeState(Object? json) { + final map = decodeMap(json); + return _LagState() + ..pairs = decodeCount(field(map, 'pairs')) + ..meanA = decodeDouble(field(map, 'meanA')) + ..meanB = decodeDouble(field(map, 'meanB')) + ..coMoment = decodeDouble(field(map, 'coMoment')); + } + + @override + _LagSummary finish(_LagState state) => + _LagSummary(state.pairs, state.meanA, state.meanB, state.coMoment); +} + +HrvTime _finish(_DiffSummary diffs, _LagSummary lags, RunningMoments levels, + Map bins, JitterEvidence Function() evidence, + {required bool gapAware}) { + if (levels.count < 2) return HrvTime(nBeats: levels.count); + + double? acf1; + if (diffs.pairs >= 30) { + if (diffs.m2 > 0) { + acf1 = (lags.coMoment + + lags.pairs * + (lags.meanA - diffs.mean) * + (lags.meanB - diffs.mean)) / + diffs.m2; + } + } + // Under the floor hrvTime refuses only when the runs show no respiratory + // line on a low floor, and then a timed night may still publish RMSSD from + // the windows on a breathing line steady in Hz: both read from [evidence]. + final jittery = + acf1 != null && acf1 < kNnDiffAcf1Floor && evidence().refused(); + final breathing = jittery && gapAware ? evidence().breathingRmssd() : null; + + double? sdann; + double? sdnnIndex; + if (gapAware) { + final binMeans = RunningMoments(); + var binSdSum = 0.0; + var binCount = 0; + for (final bin in bins.values) { + if (bin.count < 2) continue; + binMeans.add(bin.mean!); + binSdSum += bin.sampleSd!; + binCount++; + } + if (binCount >= 2) { + sdann = binMeans.sampleSd; + sdnnIndex = binSdSum / binCount; + } + } + + return HrvTime( + nBeats: levels.count, + rmssd: diffs.pairs > 0 && !jittery + ? math.sqrt(diffs.squares / diffs.pairs) + : breathing, + pnn50: diffs.pairs > 0 && !jittery + ? 100.0 * diffs.over50 / diffs.pairs + : null, + sdnn: levels.sampleSd, + sdann: sdann, + sdnnIndex: sdnnIndex, + diffAcf1: acf1, + ); +} diff --git a/lib/src/onehz/clinical/irregular_diagnostics.dart b/lib/src/onehz/clinical/irregular_diagnostics.dart index 411505d..977ec4c 100644 --- a/lib/src/onehz/clinical/irregular_diagnostics.dart +++ b/lib/src/onehz/clinical/irregular_diagnostics.dart @@ -46,6 +46,13 @@ class RrCleaningCounts { double? diagnosticArtifactFraction(double fraction, RrCleaningCounts? cleaning) => cleaning != null && cleaning.raw == 0 ? null : fraction; +/// A long-term SD (SDNN) below this many milliseconds is floating-point noise, +/// not variability. A constant series has SDNN 0 exactly, but the batch mean is +/// a repeated sum, so it leaves ~1e-11 ms of rounding around the true value +/// while the streaming running moments stay at exactly 0. Both paths treat +/// anything under this floor as "no long-term variability" so they agree. +const double kDegenerateSdnnMs = 1e-6; + /// Why a screen produced no value. Wire names (toJson): `too_few_beats`, /// `artifact`, `no_successive_pairs`, `no_long_term_variability`. enum IrregularAbstain { diff --git a/lib/src/onehz/clinical/irregular_rhythm.dart b/lib/src/onehz/clinical/irregular_rhythm.dart index 9ab0719..65ec49f 100644 --- a/lib/src/onehz/clinical/irregular_rhythm.dart +++ b/lib/src/onehz/clinical/irregular_rhythm.dart @@ -242,7 +242,7 @@ IrregularScreenResult irregularBeatScreenDetailed( final sd1 = sdsd / math.sqrt2; final v = 2 * sdnn * sdnn - sd1 * sd1; final sd2 = v > 0 ? math.sqrt(v) : 0.0; - if (sd2 <= 0) { + if (sd2 <= 0 || sdnn < kDegenerateSdnnMs) { // SD1/SD2 is undefined without long-term variability to divide by; emitting // ratio 0.0 with sd1 = sd2 = 0 published "perfectly regular" as a // measurement of a degenerate series. diff --git a/lib/src/onehz/clinical/irregular_rhythm_state.dart b/lib/src/onehz/clinical/irregular_rhythm_state.dart index 5719b57..7a6b054 100644 --- a/lib/src/onehz/clinical/irregular_rhythm_state.dart +++ b/lib/src/onehz/clinical/irregular_rhythm_state.dart @@ -399,7 +399,7 @@ class IrregularScreenState { final sd1 = sdsd / math.sqrt2; final v = 2 * sdnn * sdnn - sd1 * sd1; final sd2 = v > 0 ? math.sqrt(v) : 0.0; - if (sd2 <= 0) { + if (sd2 <= 0 || sdnn < kDegenerateSdnnMs) { return abstain( IrregularAbstain.noLongTermVariability, 'no long-term variability (SD2 = 0) — the SD1/SD2 ratio is ' diff --git a/lib/src/onehz/clinical/nocturnal.dart b/lib/src/onehz/clinical/nocturnal.dart index d34cd23..804aa6f 100644 --- a/lib/src/onehz/clinical/nocturnal.dart +++ b/lib/src/onehz/clinical/nocturnal.dart @@ -167,18 +167,36 @@ const int hrDipMinSamples = 300; /// handful of samples is fabrication, not measurement. Metric hrDip(List dayHr, List nightHr, {int minSamples = hrDipMinSamples}) { - const inputs = ['hr_1hz_day', 'hr_1hz_night']; final dv = dayHr.where((h) => h > 0).toList(); + return _hrDip(dv.length, mean(dv), nightHr, minSamples); +} + +/// [hrDip] from the day side's valid-sample count and sum, accumulated in +/// sample order (so `daySum / dayCount` is the batch day mean exactly). For a +/// caller that keeps the waking day as running totals instead of a list. +// HR-dip bands and minimum sample count are an internal rule, ESTIMATE with +// low confidence. The >=10%, 0-10%, <0% bands borrow the BP-dip convention. +// Avram 2019 / Dial 2025 above concern nocturnal RHR, not validation of these +// HR-dip bands. The night mean here is the mean of all valid night samples. +// Computes hrDip() with identical day mean and shared scoring when daySum is +// accumulated in sample order over the same valid samples. +Metric hrDipFromDayTotals(int dayCount, double daySum, + List nightHr, {int minSamples = hrDipMinSamples}) => + _hrDip(dayCount, dayCount == 0 ? null : daySum / dayCount, nightHr, + minSamples); + +Metric _hrDip( + int dayCount, double? dm, List nightHr, int minSamples) { + const inputs = ['hr_1hz_day', 'hr_1hz_night']; final nv = nightHr.where((h) => h > 0).toList(); - if (dv.length < minSamples || nv.length < minSamples) { + if (dayCount < minSamples || nv.length < minSamples) { return Metric.absent( tier: Tier.high, inputs_used: inputs, note: 'HR dip needs ≥$minSamples valid samples on each of day and night ' - '(have day=${dv.length}, night=${nv.length})', + '(have day=$dayCount, night=${nv.length})', ); } - final dm = mean(dv); final nm = mean(nv); if (dm == null || nm == null || dm <= 0) { return const Metric.absent( @@ -189,7 +207,7 @@ Metric hrDip(List dayHr, List nightHr, } final dip = (dm - nm) / dm * 100; final band = dip >= 10 ? 'dipper' : (dip >= 0 ? 'non_dipper' : 'riser'); - final conf = ((dv.length + nv.length) / 14400.0).clamp(0.4, 0.9); + final conf = ((dayCount + nv.length) / 14400.0).clamp(0.4, 0.9); return Metric( value: HrDip(dip, dm, nm, band), confidence: conf, diff --git a/lib/src/onehz/clinical/prsa_aggregator.dart b/lib/src/onehz/clinical/prsa_aggregator.dart new file mode 100644 index 0000000..3c180ee --- /dev/null +++ b/lib/src/onehz/clinical/prsa_aggregator.dart @@ -0,0 +1,160 @@ +import 'dart:typed_data'; + +import '../../aggregate/aggregator.dart'; +import '../../aggregate/json_support.dart'; +import '../../aggregate/primitives.dart'; +import '../types.dart'; +import 'prsa.dart'; + +// Bauer et al. 2006 PRSA, as in decelerationCapacity()/accelerationCapacity(). +// Lookback(2*l+1) reproduces the same anchor bounds and [i-l,i+l) profile. +// Merged profile sums and anchor counts give the same mathematical result. +// Checked capacity/profile absolute and relative tolerances are 1e-9; confidence +// absolute tolerance is 1e-12. +/// Running PRSA sums: the per-offset profile total and the anchor count. +class _PrsaState { + _PrsaState(int l) + : profile = Float64List(2 * l), + anchors = 0; + _PrsaState._(this.profile, this.anchors); + final Float64List profile; + int anchors; +} + +/// Folds every window of `2l + 1` rows. The window starting at `s` belongs to +/// the anchor candidate `i = s + l`; it tests (x[i-1], x[i]) and adds rows +/// `s .. s + 2l - 1` (the reference's `[i - l, i + l)`). The extra last row +/// keeps the reference's `i < n - l` bound. Run under `Lookback(2l + 1)`. +class _PrsaWindows extends Aggregator<_PrsaState, DoubleColumn, _PrsaState> { + const _PrsaWindows(this.l, this.deceleration, this.cap); + final int l; + final bool deceleration; + final double cap; + + @override + _PrsaState empty() => _PrsaState(l); + @override + _PrsaState addSlice(_PrsaState state, DoubleColumn batch, int start, int end) { + final x = batch.values; + for (var s = start; s + 2 * l + 1 <= end; s++) { + final prev = x[s + l - 1]; + if (prev <= 0) continue; + final cur = x[s + l]; + if ((cur - prev > 0) != deceleration) continue; + if ((cur / prev - 1).abs() > cap) continue; + state.anchors++; + for (var k = 0; k < 2 * l; k++) { + state.profile[k] += x[s + k]; + } + } + return state; + } + + @override + _PrsaState combine(_PrsaState left, _PrsaState right) { + for (var k = 0; k < 2 * l; k++) { + left.profile[k] += right.profile[k]; + } + left.anchors += right.anchors; + return left; + } + + @override + _PrsaState finish(_PrsaState state) => state; + @override + bool get persistable => true; + @override + Object? encodeState(_PrsaState state) => + {'profile': encodeDoubles(state.profile), 'anchors': state.anchors, + 'deceleration': deceleration, 'anchorRatioCap': encodeDouble(cap)}; + @override + _PrsaState decodeState(Object? json) { + final map = decodeMap(json); + if (field(map, 'deceleration') != deceleration || + decodeDouble(field(map, 'anchorRatioCap')).compareTo(cap) != 0) { + throw const FormatException('PRSA checkpoint policy mismatch'); + } + final profile = decodeDoubles(field(map, 'profile')); + final anchors = decodeCount(field(map, 'anchors')); + if (profile.length != 2 * l) { + throw FormatException('PRSA profile needs $l * 2 entries'); + } + if (anchors < 0) throw const FormatException('Negative PRSA anchors'); + return _PrsaState._(profile, anchors); + } +} + +/// Mergeable PRSA deceleration or acceleration capacity, matching +/// [decelerationCapacity] and [accelerationCapacity]. +Aggregator> prsaAggregator({ + required bool deceleration, + int l = 2, + double anchorRatioCap = 0.05, +}) { + final kind = deceleration ? 'DC' : 'AC'; + const inputs = ['rr_cleaned']; + if (l < 2) { + // Same absent Metric as the reference; Lookback needs a valid window. + final absent = Metric.absent( + tier: Tier.relative, + inputs_used: inputs, + note: 'PRSA ($kind) needs l≥2 for the s=2 Haar contrast', + ); + return Count().map((_) => absent); + } + final core = both( + Count(), + Lookback( + 2 * l + 1, + _PrsaWindows(l, deceleration, anchorRatioCap), + batchFromJson: DoubleColumn.fromJson, + ), + ); + return validateCheckpoint(core, (json) { + final state = decodeList(json); + if (state.length != 2) throw const FormatException('Expected two states'); + final count = decodeCount(state[0]); + final lookback = decodeMap(state[1]); + if (decodeCount(field(lookback, 'length')) != count) { + throw const FormatException('Lookback length disagrees with beat count'); + } + final anchors = decodeCount(field(decodeMap(field(lookback, 'inner')), 'anchors')); + final windows = count > 2 * l ? count - 2 * l : 0; + if (anchors > windows) { + throw const FormatException('PRSA anchors exceed windows'); + } + }).map((result) { + final (n, sums) = result; + if (n < 2 * l + 4) { + return Metric.absent( + tier: Tier.relative, + inputs_used: inputs, + note: 'too few beats for PRSA ($kind)', + ); + } + final anchors = sums.anchors; + if (anchors == 0) { + return Metric.absent( + tier: Tier.relative, + inputs_used: inputs, + note: 'no valid PRSA anchors ($kind)', + ); + } + final profile = [for (final v in sums.profile) v / anchors]; + final capacity = + (profile[l] + profile[l + 1] - profile[l - 1] - profile[l - 2]) / 4; + return Metric( + value: PrsaResult( + capacity: capacity, + profile: profile, + anchors: anchors, + kind: kind, + ), + confidence: (anchors / 1000.0).clamp(0.3, 0.95), + tier: Tier.relative, + inputs_used: inputs, + note: 'Bauer 2006 PRSA $kind; relative to own baseline on one device ' + 'family only', + ); + }); +} diff --git a/lib/src/onehz/foundations/rr_sandwich.dart b/lib/src/onehz/foundations/rr_sandwich.dart new file mode 100644 index 0000000..1dbe518 --- /dev/null +++ b/lib/src/onehz/foundations/rr_sandwich.dart @@ -0,0 +1,299 @@ +// Lipponen & Tarvainen 2019 classification with Peltola 2012 correction policy. +// The unpublished artifact-run cap is an internal ESTIMATE with low confidence: +// over-cap runs become gaps and each side is re-corrected. Without a cap split +// the result equals uncapped correction. Shared kernel arithmetic and raw seam +// context preserve capped batch NN/classes; settled stream plus provisional tail +// equals capped batch. RrSandwich preserves source-beat absolute timestamps; +// uncapped arbitrarily long artifact runs have no exactness guarantee. +// FOUNDATION — the RR "sandwich": a mergeable summary over RAW RR that feeds +// capped, corrected NN into a downstream aggregator (docs/AGGREGATOR_DESIGN.md, +// "Proposed next step"). +// +// Contract (test/aggregate/rr_sandwich_test.dart pins it): +// * any chunking of a beat series (empty chunks and one-beat chunks +// included), combined left to right or right to left, finishes to what one +// pass gives, and that is `correctRr(all beats, maxArtifactRun: K)`: the +// inner aggregator sees exactly the capped NN, each with the ABSOLUTE raw +// timestamp of the input beat it came from (`nnSourceIndex`), never +// correctRr's first-beat-relative clock; +// * state is bounded (raw head, settled middle folded into `inner`, raw +// tail), never the series; +// * persistable when `inner` is. + +import 'dart:typed_data'; + +import '../../aggregate/aggregator.dart'; +import '../../aggregate/json_support.dart'; +import '../clinical/hrv_time_aggregator.dart' show Beats; +import 'rr_correction.dart'; + +/// Raw RR beats: interval (ms) and the beat's timestamp (epoch ms), the pair +/// `correctRr(rrMs, rrTsMs: tsMs)` takes. Timestamps non-decreasing. +class RawRr extends Batch { + RawRr(this.rrMs, this.tsMs) : assert(rrMs.length == tsMs.length); + + factory RawRr.of(List rrMs, List tsMs) => + RawRr(Float64List.fromList(rrMs), Float64List.fromList(tsMs)); + + factory RawRr.fromJson(Object? json) { + final map = decodeMap(json); + final rr = decodeDoubles(field(map, 'rrMs')); + final ts = decodeDoubles(field(map, 'tsMs')); + if (rr.length != ts.length) { + throw const FormatException('RawRr columns differ in length'); + } + return RawRr(rr, ts); + } + + final Float64List rrMs; + final Float64List tsMs; + + @override + int get length => rrMs.length; + + @override + RawRr copyRange(int start, int end) => RawRr( + Float64List(end - start)..setRange(0, end - start, rrMs, start), + Float64List(end - start)..setRange(0, end - start, tsMs, start)); + + @override + RawRr join(RawRr other) => RawRr( + Float64List(length + other.length) + ..setRange(0, length, rrMs) + ..setRange(length, length + other.length, other.rrMs), + Float64List(length + other.length) + ..setRange(0, length, tsMs) + ..setRange(length, length + other.length, other.tsMs)); + + @override + Object? toJson() => + {'rrMs': encodeDoubles(rrMs), 'tsMs': encodeDoubles(tsMs)}; +} + +/// Opaque [RrSandwich] state: the raw head (first `min(m, W)` beats), the +/// raw tail (last `W` beats, only once `m > W`), and the inner aggregator's +/// state over the NN of the settled middle. `m` is the number of beats seen. +class RrSandwichState { + RrSandwichState(this.m, this.head, this.tail, this.mid); + int m; + RawRr head, tail; + S mid; +} + +/// Raw RR -> capped, corrected NN -> [inner]. Parameters are `correctRr`'s. +/// [windowBeats] must be at least 3. A one-beat threshold window lets recovery +/// phase propagate indefinitely (cr105_regressions_test.dart), beyond [reach]. +/// [inner] must be persistable so finishing can copy its consumed middle state. +/// Unsupported configurations throw when creating or restoring a state. +/// +/// Each NN reaches [inner] with the ABSOLUTE timestamp (`rrTsMs`) of the input +/// beat it came from (`RrCorrectionResult.nnSourceIndex`), so a seam needs no +/// clock translation: there is no per-chunk clock at all. +/// +/// Locality. The class and the value of a beat depend only on the raw beats at +/// most [reach] either side of it: two centred threshold windows (4H, H = +/// windowBeats ~/ 2), a cap decision whose run is at most K long (the streaming +/// corrector's settle delay is 4H + 2K + 4; one more K covers a run that +/// straddles the edge), and the spline anchors. So a chunk of `m` beats has a +/// settled region `[reach, m - reach)` that is final whatever precedes or +/// follows it, and everything else is kept raw: W = 2 * reach beats at each +/// end. `combine` re-corrects only `left.tail ++ right.head` and settles the +/// beats between the two settled regions. State is bounded: head, tail, and +/// whatever [inner] keeps for the settled NN. +/// +/// With `maxArtifactRun: null` (uncapped) the reach is sized as for the +/// default cap; an artifact run longer than that is not bounded-state +/// exact, since the uncapped corrector lets one run reach arbitrarily far. +class RrSandwich extends Aggregator, RawRr, R> { + const RrSandwich( + this.inner, { + this.maxArtifactRun = kMaxArtifactRun, + this.alpha = 5.2, + this.windowBeats = 91, + this.minThresholdMs = 100, + this.reanchorGapMs = 1000, + }); + + /// Receives the settled NN (`nnMs`) with the absolute timestamp of each + /// one's source beat (`timeMs`). + final Aggregator inner; + final int? maxArtifactRun; + final double alpha; + final int windowBeats; + final double minThresholdMs; + final double reanchorGapMs; + + /// Beats of context either side that can change a beat's output. + int get reach => 4 * (windowBeats ~/ 2) + 3 * (maxArtifactRun ?? kMaxArtifactRun) + 4; + int get _w => 2 * reach; + + static final _none = RawRr(Float64List(0), Float64List(0)); + + /// NN of `correctRr(seg)` whose source beat lies in `[lo, hi)`, as a + /// [Beats] carrying each source beat's absolute timestamp. + Beats _nn(RawRr seg, int lo, int hi) { + final c = correctRr(seg.rrMs, + alpha: alpha, + windowBeats: windowBeats, + minThresholdMs: minThresholdMs, + reanchorGapMs: reanchorGapMs, + maxArtifactRun: maxArtifactRun); + final nn = [], t = []; + for (var k = 0; k < c.nn.length; k++) { + final i = c.nnSourceIndex[k]; + if (i >= lo && i < hi) { + nn.add(c.nn[k]); + t.add(seg.tsMs[i]); + } + } + return Beats(Float64List.fromList(nn), Float64List.fromList(t)); + } + + S _state(Beats b) => inner.addSlice(inner.empty(), b, 0, b.length); + + @override + RrSandwichState empty() { + _checkSupported(); + return RrSandwichState(0, _none, _none, inner.empty()); + } + + void _checkSupported() { + if (windowBeats < 3) { + throw ArgumentError.value(windowBeats, 'windowBeats', 'must be at least 3'); + } + if (!inner.persistable) { + throw UnsupportedError('RrSandwich needs a persistable inner to copy state'); + } + } + + @override + RrSandwichState addSlice( + RrSandwichState state, RawRr batch, int start, int end) { + if (end <= start) return state; + return combine(state, _chunk(batch.copyRange(start, end))); + } + + RrSandwichState _chunk(RawRr raw) { + final m = raw.length, w = _w, r = reach; + if (m <= w) return RrSandwichState(m, raw, _none, inner.empty()); + return RrSandwichState(m, raw.copyRange(0, w), raw.copyRange(m - w, m), + _state(_nn(raw, r, m - r))); + } + + /// Beats `[lo, hi)` of the series [s] and [t] hold, joined; [t] follows [s]. + RawRr _range(RrSandwichState l, RrSandwichState r, int lo, int hi) { + final w = _w, ml = l.m; + RawRr part(RrSandwichState s, int a, int b) { + if (b <= a) return _none; + if (s.m <= w) return s.head.copyRange(a, b); + final base = s.m - w; // tail[0] is beat `base` + assert(a >= base, 'range reaches before the tail'); + return s.tail.copyRange(a - base, b - base); + } + + final left = part(l, lo, hi < ml ? hi : ml); + final a = lo > ml ? lo - ml : 0, b = hi - ml; + if (b <= a) return left; + assert(b <= r.head.length, 'range reaches past the head'); + final right = r.head.copyRange(a, b); + return left.length == 0 ? right : left.join(right); + } + + @override + RrSandwichState combine(RrSandwichState left, RrSandwichState right) { + if (left.m == 0) return right; + if (right.m == 0) return left; + final w = _w, rc = reach, ml = left.m, mr = right.m, m = ml + mr; + // Settled regions: left [rc, ml - rc), right (merged coordinates) + // [ml + rc, m - rc). The beats between them are settled here, from the + // raw windows around the seam. + final aEnd = ml > w ? ml - rc : rc; + final cStart = mr > w ? ml + rc : m - rc; + var mid = left.mid; + if (cStart > aEnd) { + final lo = aEnd - rc, hi = cStart + rc; + final seg = _range(left, right, lo, hi); + mid = inner.combine(mid, _state(_nn(seg, aEnd - lo, cStart - lo))); + } + if (mr > w) mid = inner.combine(mid, right.mid); + final head = ml >= w + ? left.head + : left.head.join(right.head.copyRange(0, (m < w ? m : w) - ml)); + var tail = _none; + if (m > w) { + tail = mr >= w + ? (mr == w ? right.head : right.tail) + : _range(left, right, m - w, ml).join(right.head); + } + return RrSandwichState(m, head, tail, mid); + } + + @override + R finish(RrSandwichState state) { + final m = state.m, w = _w, rc = reach; + if (m <= w) return inner.finish(_state(_nn(state.head, 0, m))); + // The settled middle is combined into fresh states; combine may consume + // its inputs, so work on a copy of the stored one. + _checkSupported(); + final mid = inner.decodeState(inner.encodeState(state.mid)); + var out = _state(_nn(state.head, 0, rc)); + out = inner.combine(out, mid); + out = inner.combine(out, _state(_nn(state.tail, rc, w))); + return inner.finish(out); + } + + @override + bool get persistable => inner.persistable; + + @override + Object? encodeState(RrSandwichState state) => { + 'm': state.m, + 'head': state.head.toJson(), + 'tail': state.tail.toJson(), + 'mid': inner.encodeState(state.mid), + }; + + @override + RrSandwichState decodeState(Object? json) { + _checkSupported(); + final map = decodeMap(json); + final m = field(map, 'm'); + if (m is! int || m < 0) throw const FormatException('bad RrSandwich m'); + final head = RawRr.fromJson(field(map, 'head')); + final tail = RawRr.fromJson(field(map, 'tail')); + final w = _w; + if (head.length != (m < w ? m : w) || tail.length != (m > w ? w : 0)) { + throw const FormatException('RrSandwich window lengths disagree with m'); + } + return RrSandwichState(m, head, tail, inner.decodeState(field(map, 'mid'))); + } +} + +/// Adapts a column aggregator over NN (e.g. `prsaAggregator`) to the [Beats] +/// batch [RrSandwich] feeds: it sees `Beats.nnMs` as a [DoubleColumn]. +class NnColumn extends Aggregator { + const NnColumn(this.inner); + final Aggregator inner; + + @override + S empty() => inner.empty(); + + @override + S addSlice(S state, Beats batch, int start, int end) => + inner.addSlice(state, DoubleColumn(batch.nnMs), start, end); + + @override + S combine(S left, S right) => inner.combine(left, right); + + @override + R finish(S state) => inner.finish(state); + + @override + bool get persistable => inner.persistable; + + @override + Object? encodeState(S state) => inner.encodeState(state); + + @override + S decodeState(Object? json) => inner.decodeState(json); +} diff --git a/lib/src/onehz/respiration/cvhr_apnea.dart b/lib/src/onehz/respiration/cvhr_apnea.dart index 348c2af..acf585d 100644 --- a/lib/src/onehz/respiration/cvhr_apnea.dart +++ b/lib/src/onehz/respiration/cvhr_apnea.dart @@ -35,6 +35,7 @@ import 'dart:math' as math; import '../types.dart'; import '../util.dart'; +import '../../aggregate/json_support.dart'; class CvhrResult { final int cycleCount; // detected CVHR cycles @@ -127,6 +128,9 @@ Metric cvhrApneaScreen( } final tSec = [for (final t in nnTimesMs) t / 1000.0]; + // A non-finite beat time has no place on the 1 Hz grid: NaN slipped past + // every gap test and then failed the resample, and an infinite one would be + // a stretch of its own. Same answer as rsaRespRate / hrvFreq. if (tSec.any((t) => !t.isFinite)) { return const Metric.absent( tier: Tier.high, @@ -149,45 +153,68 @@ Metric cvhrApneaScreen( ); } - // SEGMENT AT GAPS. Beats more than [maxGapSec] apart are two recordings, not - // one: interpolating a straight line across the hole invents a flat stretch - // with no cycles in it, and charging the hole to `analyzedHours` divides a - // real cycle count by time we never observed. Both push the index DOWN, so a - // charging break used to read as an improvement. - final segStart = [0]; - for (var i = 1; i < tSec.length; i++) { - if (tSec[i] - tSec[i - 1] > maxGapSec) segStart.add(i); - } - - var cycleCount = 0; - var analyzedHours = 0.0; - final depths = []; // ms - final widths = []; // s - for (var s = 0; s < segStart.length; s++) { - final lo = segStart[s]; - final hi = (s + 1 < segStart.length ? segStart[s + 1] : tSec.length) - 1; - final segT = tSec.sublist(lo, hi + 1); - final segNn = nnMs.sublist(lo, hi + 1); - final spanSec = segT.last - segT.first; - // Too short to hold even one cycle plus its baseline window — it also can't - // be scored, so it contributes neither cycles nor hours. - if (segT.length < 60 || spanSec < 60) continue; - analyzedHours += spanSec / 3600.0; - _scoreSegment( - segT, - segNn, + return _cvhrMetric(_segmentContributions(tSec, nnMs, minWidthSec: minWidthSec, maxWidthSec: maxWidthSec, depthWidthRatioMin: depthWidthRatioMin, envWindowSec: envWindowSec, - onCycle: (depth, width) { - cycleCount++; + maxGapSec: maxGapSec), artifactFraction); +} + +class _CvhrContribution { + final double hours; + final List depths, widths; + const _CvhrContribution(this.hours, this.depths, this.widths); + + Map toJson() => { + 'hours': encodeDouble(hours), + 'depths': [for (final x in depths) encodeDouble(x)], + 'widths': [for (final x in widths) encodeDouble(x)], + }; +} + +List<_CvhrContribution> _segmentContributions(List t, List nn, + {required double minWidthSec, + required double maxWidthSec, + required double depthWidthRatioMin, + required double envWindowSec, + required double maxGapSec}) { + final out = <_CvhrContribution>[]; + var lo = 0; + for (var hi = 1; hi <= t.length; hi++) { + if (hi != t.length && t[hi] - t[hi - 1] <= maxGapSec) continue; + final segT = t.sublist(lo, hi); + final spanSec = segT.last - segT.first; + if (segT.length >= 60 && spanSec >= 60) { + final depths = []; + final widths = []; + _scoreSegment(segT, nn.sublist(lo, hi), + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + envWindowSec: envWindowSec, + onCycle: (depth, width) { depths.add(depth); widths.add(width); - }, - ); + }); + out.add(_CvhrContribution(spanSec / 3600.0, depths, widths)); + } + lo = hi; } + return out; +} +Metric _cvhrMetric( + List<_CvhrContribution> contributions, double artifactFraction) { + const inputs = ['rr_cleaned', 'beat_times']; + var analyzedHours = 0.0; + final depths = []; + final widths = []; + for (final c in contributions) { + analyzedHours += c.hours; + depths.addAll(c.depths); + widths.addAll(c.widths); + } if (analyzedHours <= 0) { return const Metric.absent( tier: Tier.high, @@ -195,7 +222,7 @@ Metric cvhrApneaScreen( note: 'no gap-free stretch long enough for a CVHR screen (<60 s)', ); } - + final cycleCount = depths.length; final perHour = cycleCount / analyzedHours; // Confidence also scales with how much time was analysed. Any gap-free // stretch of a minute counts, so a night that dropout left with one clean @@ -225,6 +252,343 @@ Metric cvhrApneaScreen( ); } +/// Exact, append-only state for [cvhrApneaScreen]. Closed gap-free stretches +/// are reduced to their scored contributions; only the current stretch's beats +/// are retained for the provisional end-window calculation. +// Hayano 2011 CVHR screening, as in cvhrApneaScreen(). Closed gap-free stretches +// retain scored contributions; the current stretch is rescored provisionally +// with shared batch stages. +class CvhrStream { + final double minWidthSec, maxWidthSec, depthWidthRatioMin, envWindowSec, + maxGapSec; + final List<_CvhrContribution> _closed = []; + final List _rawNn = [], _rawTimes = []; + final List _grid = [], _smooth = [], _base = [], _exc = []; + int _n = 0; + int _openCount = 0; + double? _first, _last, _openStart; + + CvhrStream({ + this.minWidthSec = 10, + this.maxWidthSec = 120, + this.depthWidthRatioMin = 0.7, + this.envWindowSec = 130, + this.maxGapSec = 30, + }); + + factory CvhrStream.fromJson(Map json) { + try { + final j = decodeMap(json); + if (field(j, 'type') != 'CvhrStream') { + throw const FormatException('not a CvhrStream checkpoint'); + } + if (field(j, 'version') != 1) { + throw const FormatException('unsupported CvhrStream version'); + } + final minWidthSec = decodeDouble(field(j, 'minWidthSec')); + final maxWidthSec = decodeDouble(field(j, 'maxWidthSec')); + final depthWidthRatioMin = decodeDouble(field(j, 'depthWidthRatioMin')); + final envWindowSec = decodeDouble(field(j, 'envWindowSec')); + final maxGapSec = decodeDouble(field(j, 'maxGapSec')); + final n = decodeCount(field(j, 'n')); + final first = decodeDoubleOrNull(field(j, 'first')); + final last = decodeDoubleOrNull(field(j, 'last')); + final openStart = decodeDoubleOrNull(field(j, 'openStart')); + final openCount = decodeCount(field(j, 'openCount')); + final rawNn = decodeDoubles(field(j, 'rawNn')); + final rawTimes = decodeDoubles(field(j, 'rawTimes')); + final grid = decodeDoubles(field(j, 'grid')); + final smooth = decodeDoubles(field(j, 'smooth')); + final base = decodeDoubles(field(j, 'base')); + final exc = decodeDoubles(field(j, 'exc')); + final encodedClosed = decodeList(field(j, 'closed')); + if (!minWidthSec.isFinite || !maxWidthSec.isFinite || + !depthWidthRatioMin.isFinite || !envWindowSec.isFinite || + !maxGapSec.isFinite || rawNn.length != rawTimes.length || + smooth.length > grid.length || base.length > smooth.length || + exc.length != base.length || n < openCount || + (n == 0 && (first != null || last != null || openStart != null || + openCount != 0 || rawNn.isNotEmpty || grid.isNotEmpty)) || + (n > 0 && (first == null || last == null || openStart == null || + openCount == 0 || rawNn.isEmpty))) { + throw const FormatException('inconsistent CvhrStream checkpoint'); + } + final s = CvhrStream( + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + envWindowSec: envWindowSec, + maxGapSec: maxGapSec, + ) + .._n = n + .._first = first + .._last = last + .._openStart = openStart + .._openCount = openCount + .._rawNn.addAll(rawNn) + .._rawTimes.addAll(rawTimes) + .._grid.addAll(grid) + .._smooth.addAll(smooth) + .._base.addAll(base) + .._exc.addAll(exc); + if (n > 0 && + (s._rawTimes.isEmpty || !_cvhrNondecreasing(s._rawTimes))) { + throw const FormatException('decreasing checkpoint times'); + } + for (final value in encodedClosed) { + final c = decodeMap(value); + final hours = decodeDouble(field(c, 'hours')); + final depths = decodeDoubles(field(c, 'depths')); + final widths = decodeDoubles(field(c, 'widths')); + if (!hours.isFinite || hours < 0 || depths.length != widths.length) { + throw const FormatException('invalid CVHR segment'); + } + s._closed.add(_CvhrContribution(hours, depths.toList(), widths.toList())); + } + return s; + } on FormatException { + rethrow; + } catch (e) { + throw FormatException('malformed CvhrStream checkpoint: $e'); + } + } + + Map toJson() => { + 'type': 'CvhrStream', + 'version': 1, + 'minWidthSec': encodeDouble(minWidthSec), + 'maxWidthSec': encodeDouble(maxWidthSec), + 'depthWidthRatioMin': encodeDouble(depthWidthRatioMin), + 'envWindowSec': encodeDouble(envWindowSec), + 'maxGapSec': encodeDouble(maxGapSec), + 'n': _n, + 'first': _first == null ? null : encodeDouble(_first!), + 'last': _last == null ? null : encodeDouble(_last!), + 'openStart': _openStart == null ? null : encodeDouble(_openStart!), + 'openCount': _openCount, + 'rawNn': [for (final x in _rawNn) encodeDouble(x)], + 'rawTimes': [for (final x in _rawTimes) encodeDouble(x)], + 'grid': [for (final x in _grid) encodeDouble(x)], + 'smooth': [for (final x in _smooth) encodeDouble(x)], + 'base': [for (final x in _base) encodeDouble(x)], + 'exc': [for (final x in _exc) encodeDouble(x)], + 'closed': [for (final x in _closed) x.toJson()], + }; + + void fold(List nnMs, List nnTimesMs) { + if (nnMs.length != nnTimesMs.length) { + throw ArgumentError('nnMs and nnTimesMs must have the same length'); + } + if (nnMs.isEmpty) return; + final times = [for (final t in nnTimesMs) t / 1000.0]; + // Checked before any state changes (a fold cannot be undone): the batch + // abstains on such a time, so no snapshot after it could be the batch's. + if (times.any((t) => !t.isFinite)) { + throw ArgumentError('beat times must be finite'); + } + if (!_cvhrNondecreasing(times) || + (_last != null && times.first < _last!)) { + throw StateError('beat times must be non-decreasing'); + } + _first ??= times.first; + for (var i = 0; i < times.length; i++) { + if (_openCount > 0 && times[i] - _last! > maxGapSec) { + _closeOpen(); + } + _openStart ??= times[i]; + _openCount++; + _rawNn.add(nnMs[i]); + _rawTimes.add(times[i]); + _last = times[i]; + _settleOpen(); + } + _n += nnMs.length; + } + + Metric snapshot({ + List tailNnMs = const [], + List tailNnTimesMs = const [], + required double artifactFraction, + double maxArtifact = 0.30, + }) { + if (tailNnMs.length != tailNnTimesMs.length) { + throw ArgumentError('tailNnMs and tailNnTimesMs must have the same length'); + } + final tailTimes = [for (final t in tailNnTimesMs) t / 1000.0]; + const inputs = ['rr_cleaned', 'beat_times']; + final n = _n + tailNnMs.length; + if (n < 60) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few beats for a CVHR screen (need ≥60)', + ); + } + if (artifactFraction > maxArtifact) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'artifact fraction ${round6(artifactFraction)} > gate ' + '— CVHR dip detection unreliable', + ); + } + // The folded times are finite (fold refuses others); a non-finite tail + // time gets the batch's answer. + if (tailTimes.any((t) => !t.isFinite)) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'non-finite beat time', + ); + } + if (!_cvhrNondecreasing(tailTimes) || + (tailTimes.isNotEmpty && _last != null && tailTimes.first < _last!)) { + throw StateError('beat times must be non-decreasing'); + } + // Nothing folded yet: the tail's first beat is the origin, as in the batch. + final first = _first ?? (tailTimes.isEmpty ? null : tailTimes.first); + final last = tailTimes.isEmpty ? _last : tailTimes.last; + if (first == null || last == null || last - first <= 0) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'degenerate beat times', + ); + } + return _cvhrMetric(_snapshotContributions(tailNnMs, tailTimes), + artifactFraction); + } + + void _closeOpen() { + final c = _openContribution(); + if (c != null) _closed.add(c); + _rawNn.clear(); + _rawTimes.clear(); + _grid.clear(); + _smooth.clear(); + _base.clear(); + _exc.clear(); + _openCount = 0; + _openStart = null; + } + + void _settleOpen() { + final start = _openStart!; + while (start + _grid.length < _rawTimes.last) { + final g = start + _grid.length; + _grid.add(_resampleLinear(_rawTimes, _rawNn, [g])[0]); + } + while (_smooth.length + 7 < _grid.length) { + _smooth.add(_savgolAt(_grid, _smooth.length, 7)); + } + final half = (envWindowSec / 2).round(); + while (_base.length + half < _smooth.length) { + final i = _base.length; + final b = _medianAt(_smooth, i, half); + _base.add(b); + _exc.add(math.max(0.0, _smooth[i] - b)); + } + var keep = 0; + final lastGrid = start + _grid.length - 1; + while (keep + 1 < _rawTimes.length && _rawTimes[keep + 1] <= lastGrid) { + keep++; + } + if (keep > 0) { + _rawTimes.removeRange(0, keep); + _rawNn.removeRange(0, keep); + } + } + + List<_CvhrContribution> _snapshotContributions( + List tailNn, List tailTimes) { + final out = <_CvhrContribution>[..._closed]; + if (_openCount == 0) { + if (tailTimes.isNotEmpty) { + out.addAll(_segmentContributions(tailTimes, tailNn, + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + envWindowSec: envWindowSec, + maxGapSec: maxGapSec)); + } + return out; + } + var split = 0; + var previous = _last!; + while (split < tailTimes.length && tailTimes[split] - previous <= maxGapSec) { + previous = tailTimes[split++]; + } + final open = _openContribution(tailNn.sublist(0, split), + tailTimes.sublist(0, split)); + if (open != null) out.add(open); + if (split < tailTimes.length) { + out.addAll(_segmentContributions(tailTimes.sublist(split), tailNn.sublist(split), + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + envWindowSec: envWindowSec, + maxGapSec: maxGapSec)); + } + return out; + } + + _CvhrContribution? _openContribution( + [List tailNn = const [], List tailTimes = const []]) { + final start = _openStart!; + final count = _openCount + tailNn.length; + final last = tailTimes.isEmpty ? _last! : tailTimes.last; + if (count < 60 || last - start < 60) return null; + final exc = _openExcursion(tailNn, tailTimes); + final depths = []; + final widths = []; + _cvhrCycles(exc, + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + onCycle: (depth, width) { + depths.add(depth); + widths.add(width); + }); + return _CvhrContribution((last - start) / 3600.0, depths, widths); + } + + List _openExcursion(List tailNn, List tailTimes) { + final rawNn = [..._rawNn, ...tailNn]; + final rawTimes = [..._rawTimes, ...tailTimes]; + final grid = [..._grid]; + final start = _openStart!; + final last = rawTimes.last; + while (start + grid.length < last) { + grid.add(_resampleLinear(rawTimes, rawNn, [start + grid.length])[0]); + } + final nGrid = (last - start).floor() + 1; + while (grid.length < nGrid) { + grid.add(_resampleLinear(rawTimes, rawNn, [start + grid.length])[0]); + } + final smooth = [..._smooth]; + while (smooth.length < grid.length) { + smooth.add(_savgolAt(grid, smooth.length, 7)); + } + final half = (envWindowSec / 2).round(); + final base = [..._base]; + while (base.length < smooth.length) { + base.add(_medianAt(smooth, base.length, half)); + } + return [ + ..._exc, + for (var i = _exc.length; i < smooth.length; i++) + math.max(0.0, smooth[i] - base[i]), + ]; + } +} + +bool _cvhrNondecreasing(List values) { + for (var i = 1; i < values.length; i++) { + if (values[i] < values[i - 1]) return false; + } + return true; +} + // ───────────────────────────────────────────────────────────────────────────── // RESP-01 — the 30-night PERSONAL DISTRIBUTION. // @@ -509,6 +873,16 @@ void _scoreSegment( required double envWindowSec, required void Function(double depthMs, double widthSec) onCycle, }) { + final exc = _cvhrExcursion(t, nn, envWindowSec); + _cvhrCycles(exc, + minWidthSec: minWidthSec, + maxWidthSec: maxWidthSec, + depthWidthRatioMin: depthWidthRatioMin, + onCycle: onCycle); +} + +List _cvhrExcursion( + List t, List nn, double envWindowSec) { // Resample NN onto a uniform 1 Hz grid by piecewise-linear interpolation of // the tachogram (NN vs beat time). This gives evenly-spaced points for the // sliding-window baseline and the polynomial smoother. Only ever called on a @@ -528,18 +902,22 @@ void _scoreSegment( // The CVHR bradycardia shows up as a local NN MAXIMUM (NN ↑ ⇔ HR ↓) standing // proud of this baseline; the following tachycardia is the recovery valley. final halfEnv = (envWindowSec / 2).round(); - final base = List.filled(nGrid, 0); - for (var j = 0; j < nGrid; j++) { - final lo = math.max(0, j - halfEnv); - final hi = math.min(nGrid - 1, j + halfEnv); - base[j] = median(smooth.sublist(lo, hi + 1)) ?? smooth[j]; - } + final base = slidingMedians(smooth, halfEnv); // Baseline-subtracted bradycardic excursion (clip negatives — we only score // the NN-up bradycardia, not the tachycardia trough). final exc = [ for (var j = 0; j < nGrid; j++) math.max(0.0, smooth[j] - base[j]) ]; + return exc; +} + +void _cvhrCycles(List exc, + {required double minWidthSec, + required double maxWidthSec, + required double depthWidthRatioMin, + required void Function(double depthMs, double widthSec) onCycle}) { + final nGrid = exc.length; // Prominence threshold: a dip must clear a robust noise floor AND a fraction // of the typical excursion amplitude. We anchor it BELOW the median positive // excursion (Hayano's adaptive amplitude criterion is permissive enough to @@ -558,13 +936,15 @@ void _scoreSegment( // the run width and the peak depth; apply the width + depth/width gates. var i = 0; while (i < nGrid) { - if (exc[i] <= prom) { + // `!(x > prom)` rather than `x <= prom`: a NaN excursion is neither, and + // would stall this loop. A non-finite excursion never starts a dip. + if (!_above(exc[i], prom)) { i++; continue; } final start = i; var peakDepth = 0.0; - while (i < nGrid && exc[i] > prom) { + while (i < nGrid && _above(exc[i], prom)) { if (exc[i] > peakDepth) peakDepth = exc[i]; i++; } @@ -577,6 +957,10 @@ void _scoreSegment( } } +/// True when [e] is a finite excursion above [prom]. NaN and +-Infinity never +/// are, so they can neither start nor extend a dip. +bool _above(double e, double prom) => e.isFinite && e > prom; + /// Piecewise-linear resample of (t,y) onto a sorted [grid] of times (same unit). /// Out-of-range grid points clamp to the nearest endpoint value. List _resampleLinear( @@ -610,38 +994,43 @@ List _savgolQuadratic(List x, {int halfWin = 7}) { final n = x.length; final out = List.filled(n, 0); for (var i = 0; i < n; i++) { - final lo = math.max(0, i - halfWin); - final hi = math.min(n - 1, i + halfWin); - final m = hi - lo + 1; - if (m < 3) { - out[i] = x[i]; - continue; - } - // Fit y = a + b·u + c·u² with u centred at i; return a (value at u=0). - var s0 = 0.0, s1 = 0.0, s2 = 0.0, s3 = 0.0, s4 = 0.0; - var sy = 0.0, suy = 0.0, su2y = 0.0; - for (var k = lo; k <= hi; k++) { - final u = (k - i).toDouble(); - final u2 = u * u; - s0 += 1; - s1 += u; - s2 += u2; - s3 += u2 * u; - s4 += u2 * u2; - sy += x[k]; - suy += u * x[k]; - su2y += u2 * x[k]; - } - // Solve the 3x3 normal equations for [a,b,c]; we only need a. - final a = _solve3( - [s0, s1, s2, s1, s2, s3, s2, s3, s4], - [sy, suy, su2y], - ); - out[i] = a == null ? x[i] : a[0]; + out[i] = _savgolAt(x, i, halfWin); } return out; } +double _savgolAt(List x, int i, int halfWin) { + final lo = math.max(0, i - halfWin); + final hi = math.min(x.length - 1, i + halfWin); + final m = hi - lo + 1; + if (m < 3) return x[i]; + var s0 = 0.0, s1 = 0.0, s2 = 0.0, s3 = 0.0, s4 = 0.0; + var sy = 0.0, suy = 0.0, su2y = 0.0; + for (var k = lo; k <= hi; k++) { + final u = (k - i).toDouble(); + final u2 = u * u; + s0 += 1; + s1 += u; + s2 += u2; + s3 += u2 * u; + s4 += u2 * u2; + sy += x[k]; + suy += u * x[k]; + su2y += u2 * x[k]; + } + final a = _solve3( + [s0, s1, s2, s1, s2, s3, s2, s3, s4], + [sy, suy, su2y], + ); + return a == null ? x[i] : a[0]; +} + +double _medianAt(List x, int i, int halfWin) { + final lo = math.max(0, i - halfWin); + final hi = math.min(x.length - 1, i + halfWin); + return slidingMedians(x.sublist(lo, hi + 1), hi - lo + 1)[0]; +} + /// Solve a 3x3 linear system (row-major A, rhs b) via Cramer's rule. Null if /// singular. List? _solve3(List a, List b) { diff --git a/lib/src/onehz/respiration/cvhr_stream.dart b/lib/src/onehz/respiration/cvhr_stream.dart new file mode 100644 index 0000000..1421df2 --- /dev/null +++ b/lib/src/onehz/respiration/cvhr_stream.dart @@ -0,0 +1,3 @@ +/// Streaming CVHR is implemented beside its batch stages so both paths share +/// the exact scorer. +export 'cvhr_apnea.dart' show CvhrStream; diff --git a/lib/src/onehz/respiration/resp_rate.dart b/lib/src/onehz/respiration/resp_rate.dart index 8e57c1d..b9bcf08 100644 --- a/lib/src/onehz/respiration/resp_rate.dart +++ b/lib/src/onehz/respiration/resp_rate.dart @@ -38,6 +38,7 @@ import 'dart:math' as math; import '../types.dart'; import '../util.dart'; import '../foundations/fusion.dart'; +import '../../aggregate/json_support.dart'; /// Hard physiological + Nyquist band for adult respiration on a 1 Hz signal. /// Lower 0.1 Hz = 6 br/min; upper 0.5 Hz = 30 br/min (the 1 Hz Nyquist limit). @@ -187,6 +188,8 @@ Metric rsaRespRate( ); } final tSec = [for (final t in nnTimesMs) t / 1000.0]; + // A non-finite beat time has no place on the clock, and an infinite last + // time made the sub-window walk below endless (it never reaches infinity). if (tSec.any((t) => !t.isFinite)) { return const Metric.absent( tier: Tier.high, @@ -256,69 +259,69 @@ Metric rsaRespRate( 'respiratory peak against itself over time', ); } - final peaks = []; // br/min - final peakHz = []; // the same peaks in Hz, index-aligned - final peakPwr = []; // their spectral power, index-aligned - var atCeiling = 0; - var atFloor = 0; - // The ceilings those sub-windows peaked at, br/min — their own, which - // move with heart rate; not the whole input's. - var ceilingLo = double.infinity, ceilingHi = 0.0; - var belowBand = 0; - var thin = 0; - var gappy = 0; - for (var s = tSec.first; s + segSec <= tSec.last + 1e-9; s += segSec / 2) { - final lo = _lowerBound(tSec, s); - final hi = _lowerBound(tSec, s + segSec); - final k = hi - lo; - // A sub-window has to be BOTH time-complete and beat-dense: a dropout in - // the middle leaves few beats spanning the full 5 minutes, and its - // periodogram is a window function, not a spectrum. - final segT = tSec.sublist(lo, hi); - final segNn = nnMs.sublist(lo, hi); - // TIME-COMPLETE = the kept beats COVER ≥ 80 % of the sub-window. Σ NN, not - // first-to-last span: two beats near the edges with a 3-minute hole between - // them passed the span test and handed Lomb–Scargle a window function. - // Judged first, so a sub-window a sensor gap emptied is named a gap. - final coveredSec = segNn.fold(0, (a, b) => a + b) / 1000.0; - if (coveredSec < segSec * 0.8) { - gappy++; - continue; - } - if (k < 30) { - thin++; - continue; - } - final segSpan = segT.last - segT.first; // still sets the grid resolution - // Per sub-window Nyquist: heart rate moves through the night, so the - // resolvable ceiling does too. A sub-window whose beat rate cannot cover - // the HF band is dropped for the SAME alias reason as the whole window — - // from its median beat interval, gap-free, like the whole-input check. - final segHi = rsaCeilingHz(median(segNn)! / 1000.0); - if (segHi < rsaHiHz) { - belowBand++; - continue; - } - final grid = math.max(64, ((segHi - rsaLoHz) * 4 * segSpan).ceil()); - final ls = lombScargle(segT, segNn, freqGrid(rsaLoHz, segHi, grid)); - if (ls == null) { - thin++; - continue; - } - final pk = ls.peakFreq(rsaLoHz, segHi); - if (pk == null) { - thin++; - continue; - } - // A peak pinned to the top of the searchable band means the true rate is - // at or above what this sub-window can resolve. That is an ABSENCE, not a - // rate: reporting the edge would publish the ceiling as a measurement. - if (pk >= segHi - (segHi - rsaLoHz) / (grid - 1)) { - atCeiling++; - ceilingLo = math.min(ceilingLo, segHi * 60); - ceilingHi = math.max(ceilingHi, segHi * 60); - continue; - } + final segments = <_RsaSegmentResult>[]; + for (var i = 0;; i++) { + final s = tSec.first + i * (segSec / 2); + if (s + segSec > tSec.last + 1e-9) break; + segments.add(_rsaSegment(tSec, nnMs, s, segSec)); + } + return _rsaMetricFromSegments(segments, + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + segSec: segSec, + hiHz: hiHz); +} + +class _RsaSegmentResult { + // 0 = usable peak, 1 = ceiling, 2 = below HF band, 3 = thin, 5 = floor, 4 = gappy + // (kept beats cover < 80 % of the sub-window). + final int kind; + final double? brpm, peakHz, power; + // kind 1 only: the sub-window's own ceiling, br/min. + final double? ceilingBrpm; + const _RsaSegmentResult(this.kind, + [this.brpm, this.peakHz, this.power, this.ceilingBrpm]); + + Map toJson() => { + 'kind': kind, + 'brpm': brpm == null ? null : encodeDouble(brpm!), + 'peakHz': peakHz == null ? null : encodeDouble(peakHz!), + 'power': power == null ? null : encodeDouble(power!), + 'ceiling': ceilingBrpm == null ? null : encodeDouble(ceilingBrpm!), + }; +} + +_RsaSegmentResult _rsaSegment( + List tSec, List nnMs, double start, double segSec) { + final lo = _lowerBound(tSec, start); + final hi = _lowerBound(tSec, start + segSec); + final k = hi - lo; + // A sub-window has to be BOTH time-complete and beat-dense: a dropout in + // the middle leaves few beats spanning the full 5 minutes, and its + // periodogram is a window function, not a spectrum. + final segT = tSec.sublist(lo, hi); + final segNn = nnMs.sublist(lo, hi); + // TIME-COMPLETE = the kept beats COVER ≥ 80 % of the sub-window. Σ NN, not + // first-to-last span: two beats near the edges with a 3-minute hole between + // them passed the span test and handed Lomb–Scargle a window function. + // Judged first, so a sub-window a sensor gap emptied is named a gap. + final coveredSec = segNn.fold(0, (a, b) => a + b) / 1000.0; + if (coveredSec < segSec * 0.8) return const _RsaSegmentResult(4); + if (k < 30) return const _RsaSegmentResult(3); + final segSpan = segT.last - segT.first; // still sets the grid resolution + // Per sub-window Nyquist from its median beat interval, gap-free, like the + // whole-input check. + final segHi = rsaCeilingHz(median(segNn)! / 1000.0); + if (segHi < rsaHiHz) return const _RsaSegmentResult(2); + final grid = math.max(64, ((segHi - rsaLoHz) * 4 * segSpan).ceil()); + final ls = lombScargle(segT, segNn, freqGrid(rsaLoHz, segHi, grid)); + if (ls == null) return const _RsaSegmentResult(3); + final pk = ls.peakFreq(rsaLoHz, segHi); + if (pk == null) return const _RsaSegmentResult(3); + if (pk >= segHi - (segHi - rsaLoHz) / (grid - 1)) { + return _RsaSegmentResult(1, null, null, null, segHi * 60); + } // The same absence at the BOTTOM edge. A true rate below [rsaLoHz] (slow // deep-sleep or paced breathing at 7.5–9 br/min) leaves only the skirt of // its main lobe inside the band, so the in-band maximum sits on or just @@ -333,12 +336,45 @@ Metric rsaRespRate( // sub-windows) 2/segSpan is 1.33 br/min and blanked a true 10 br/min, // while the floor failure there already sits on the first bin. if (pk <= rsaLoHz + math.min(2 / segSpan, 2 / rsaSegmentSec)) { + return const _RsaSegmentResult(5); + } + return _RsaSegmentResult(0, pk * 60.0, pk, _powerAt(ls, pk)); +} + +Metric _rsaMetricFromSegments( + List<_RsaSegmentResult> segments, { + required double artifactFraction, + required double tolBrpm, + required double minConsensus, + required double segSec, + required double hiHz, +}) { + const inputs = ['rr_cleaned', 'beat_times']; + final peaks = []; + final peakHz = []; + final peakPwr = []; + var atCeiling = 0, atFloor = 0, belowBand = 0, thin = 0, gappy = 0; + // The ceilings those sub-windows peaked at, br/min — their own, which + // move with heart rate; not the whole input's. + var ceilingLo = double.infinity, ceilingHi = 0.0; + for (final segment in segments) { + if (segment.kind == 0) { + peaks.add(segment.brpm!); + peakHz.add(segment.peakHz!); + peakPwr.add(segment.power!); + } else if (segment.kind == 1) { + atCeiling++; + ceilingLo = math.min(ceilingLo, segment.ceilingBrpm!); + ceilingHi = math.max(ceilingHi, segment.ceilingBrpm!); + } else if (segment.kind == 2) { + belowBand++; + } else if (segment.kind == 5) { atFloor++; - continue; + } else if (segment.kind == 4) { + gappy++; + } else { + thin++; } - peaks.add(pk * 60.0); - peakHz.add(pk); - peakPwr.add(_powerAt(ls, pk)); } final dropped = atCeiling + atFloor + belowBand + thin + gappy; final total = peaks.length + dropped; @@ -443,6 +479,266 @@ Metric rsaRespRate( ); } +/// Exact, append-only Welch state for [rsaRespRate]. +/// +/// The whole-input ceiling comes from the MEDIAN beat interval, so every folded +/// NN is kept once more in [_sorted] ([double.compareTo] order, as `median` +/// sorts); a snapshot merges the tail in. Checkpoint version 3 also carries +/// the floor outcomes. Versions 1 and 2 +/// are refused because their closed windows did not apply the floor guard. +// Welch 1967; Lomb 1976; Scargle 1982; Press & Rybicki 1989; DeBoer, Karemaker & +// Strackee 1984, as in rsaRespRate(). Shared segment scoring, retained exact NN +// median and provisional windows reproduce batch. +class RsaRespStream { + final List _nn = [], _times = []; + List _sorted = []; + final List<_RsaSegmentResult> _closed = []; + int _n = 0; + double? _first, _last, _nextStart; + + RsaRespStream(); + + factory RsaRespStream.fromJson(Map json) { + try { + final j = decodeMap(json); + if (field(j, 'type') != 'RsaRespStream') { + throw const FormatException('not an RsaRespStream checkpoint'); + } + if (field(j, 'version') != 3) { + throw const FormatException('unsupported RsaRespStream version'); + } + final n = decodeCount(field(j, 'n')); + final first = decodeDoubleOrNull(field(j, 'first')); + final last = decodeDoubleOrNull(field(j, 'last')); + final next = decodeDoubleOrNull(field(j, 'nextStart')); + final nn = decodeDoubles(field(j, 'nn')); + final times = decodeDoubles(field(j, 'times')); + final encoded = decodeList(field(j, 'closed')); + final sorted = decodeDoubles(field(j, 'sorted')); + if (nn.length != times.length || + sorted.length != n || + !_nondecreasingBy(sorted) || + (n == 0 && (first != null || last != null || next != null)) || + (n > 0 && (first == null || last == null || next == null))) { + throw const FormatException('inconsistent RsaRespStream checkpoint'); + } + final s = RsaRespStream() + .._n = n + .._first = first + .._last = last + .._nextStart = next + .._nn.addAll(nn) + .._times.addAll(times) + .._sorted = sorted; + if (!_nondecreasing(s._times)) { + throw const FormatException('decreasing checkpoint times'); + } + for (final value in encoded) { + final r = decodeMap(value); + final kind = field(r, 'kind'); + if (kind is! int || kind < 0 || kind > 5) { + throw const FormatException('invalid RSA segment'); + } + final brpm = decodeDoubleOrNull(field(r, 'brpm')); + final peakHz = decodeDoubleOrNull(field(r, 'peakHz')); + final power = decodeDoubleOrNull(field(r, 'power')); + final ceiling = decodeDoubleOrNull(field(r, 'ceiling')); + if ((kind == 0) != (brpm != null && peakHz != null && power != null) || + (kind == 1) != (ceiling != null)) { + throw const FormatException('invalid RSA segment'); + } + s._closed.add(_RsaSegmentResult(kind, brpm, peakHz, power, ceiling)); + } + return s; + } on FormatException { + rethrow; + } catch (e) { + throw FormatException('malformed RsaRespStream checkpoint: $e'); + } + } + + Map toJson() => { + 'type': 'RsaRespStream', + 'version': 3, + 'n': _n, + 'first': _first == null ? null : encodeDouble(_first!), + 'last': _last == null ? null : encodeDouble(_last!), + 'nextStart': _nextStart == null ? null : encodeDouble(_nextStart!), + 'nn': [for (final x in _nn) encodeDouble(x)], + 'times': [for (final x in _times) encodeDouble(x)], + 'closed': [for (final x in _closed) x.toJson()], + 'sorted': [for (final x in _sorted) encodeDouble(x)], + }; + + void fold(List nnMs, List nnTimesMs) { + if (nnMs.length != nnTimesMs.length) { + throw ArgumentError('nnMs and nnTimesMs must have the same length'); + } + if (nnMs.isEmpty) return; + final times = [for (final t in nnTimesMs) t / 1000.0]; + // Checked before any state changes: an infinite time would make [_settle] + // endless, and a fold cannot be undone. + if (times.any((t) => !t.isFinite)) { + throw ArgumentError('beat times must be finite'); + } + if (!_nondecreasing(times) || + (_last != null && times.first < _last!)) { + throw StateError('beat times must be non-decreasing'); + } + _first ??= times.first; + _nextStart ??= _first; + _nn.addAll(nnMs); + _times.addAll(times); + _sorted = _mergeSorted(_sorted, [...nnMs]..sort()); + _n += nnMs.length; + _last = times.last; + final span = _last! - _first!; + if (span >= 2 * rsaSegmentSec) { + _settle(); + _compact(); + } + } + + Metric snapshot({ + List tailNnMs = const [], + List tailNnTimesMs = const [], + required double artifactFraction, + double tolBrpm = 2.0, + double minConsensus = 0.5, + double maxArtifact = 0.30, + }) { + if (tailNnMs.length != tailNnTimesMs.length) { + throw ArgumentError('tailNnMs and tailNnTimesMs must have the same length'); + } + final tail = [for (final t in tailNnTimesMs) t / 1000.0]; + final n = _n + tailNnMs.length; + // A non-finite tail time: the batch's own answer (it abstains). + if (n < 20 || _first == null || tail.any((t) => !t.isFinite)) { + return rsaRespRate([..._nn, ...tailNnMs], + [..._times.map((x) => x * 1000.0), ...tailNnTimesMs], + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + maxArtifact: maxArtifact); + } + if (!_nondecreasing(tail) || + (tail.isNotEmpty && _last != null && tail.first < _last!)) { + throw StateError('beat times must be non-decreasing'); + } + if (artifactFraction > maxArtifact) { + return Metric.absent( + tier: Tier.high, + inputs_used: const ['rr_cleaned', 'beat_times'], + note: 'artifact fraction ${round6(artifactFraction)} > gate ' + '— RSA peak unreliable'); + } + final times = [..._times, ...tail]; + final nn = [..._nn, ...tailNnMs]; + final last = times.last; + final span = last - _first!; + if (span <= 0) { + return rsaRespRate(nn, [for (final t in times) t * 1000.0], + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + maxArtifact: maxArtifact); + } + // Same median the batch takes over settled ++ tail. + final beatSec = + percentileSorted(_mergeSorted(_sorted, [...tailNnMs]..sort()), 50)! / + 1000.0; + final hiHz = rsaCeilingHz(beatSec); + if (hiHz < rsaHiHz) { + return Metric.absent( + tier: Tier.high, + inputs_used: const ['rr_cleaned', 'beat_times'], + note: 'sleeping heart rate ${round6(60 / beatSec)} bpm (median beat ' + 'interval ${round6(beatSec)} s) can resolve breathing from beat ' + 'timing only up to ${round6(hiHz * 60)} br/min, below the top of ' + 'the normal 9–24 br/min band, so any peak could be an alias; rate ' + 'withheld'); + } + var segSec = rsaSegmentSec; + if (span < 2 * segSec) segSec = span / 2; + if (segSec < 60) { + return rsaRespRate(nn, [for (final t in times) t * 1000.0], + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + maxArtifact: maxArtifact); + } + if (span < 2 * rsaSegmentSec) { + return rsaRespRate(nn, [for (final t in times) t * 1000.0], + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + maxArtifact: maxArtifact); + } + final segments = <_RsaSegmentResult>[..._closed]; + for (var i = _closed.length;; i++) { + final start = _first! + i * (rsaSegmentSec / 2); + if (start + rsaSegmentSec > last + 1e-9) break; + segments.add(_rsaSegment(times, nn, start, rsaSegmentSec)); + } + return _rsaMetricFromSegments(segments, + artifactFraction: artifactFraction, + tolBrpm: tolBrpm, + minConsensus: minConsensus, + segSec: rsaSegmentSec, + hiHz: hiHz); + } + + void _settle() { + while (_nextStart! + rsaSegmentSec < _last! - 1e-9) { + _closed.add(_rsaSegment(_times, _nn, _nextStart!, rsaSegmentSec)); + _nextStart = _first! + _closed.length * (rsaSegmentSec / 2); + } + } + + void _compact() { + var cut = 0; + while (cut < _times.length && _times[cut] < _nextStart!) { + cut++; + } + if (cut > 0) { + _times.removeRange(0, cut); + _nn.removeRange(0, cut); + } + } +} + +/// [a] and [b] sorted by [double.compareTo]; the merge is sorted the same way. +List _mergeSorted(List a, List b) { + if (b.isEmpty) return a; + if (a.isEmpty) return b; + final out = List.filled(a.length + b.length, 0); + var i = 0, j = 0, k = 0; + while (i < a.length && j < b.length) { + out[k++] = a[i].compareTo(b[j]) <= 0 ? a[i++] : b[j++]; + } + while (i < a.length) { + out[k++] = a[i++]; + } + while (j < b.length) { + out[k++] = b[j++]; + } + return out; +} + +bool _nondecreasingBy(List values) { + for (var i = 1; i < values.length; i++) { + if (values[i].compareTo(values[i - 1]) < 0) return false; + } + return true; +} + +bool _nondecreasing(List values) { + for (var i = 1; i < values.length; i++) { + if (values[i] < values[i - 1]) return false; + } + return true; +} + /// RIIV respiratory rate from the 1 Hz green PPG ADC. /// /// Respiratory-Induced Intensity Variation: a 0.1–0.5 Hz band-pass on the green diff --git a/lib/src/onehz/respiration/respiration.dart b/lib/src/onehz/respiration/respiration.dart index 0b70bbd..31eb061 100644 --- a/lib/src/onehz/respiration/respiration.dart +++ b/lib/src/onehz/respiration/respiration.dart @@ -22,5 +22,6 @@ library onehz_respiration; export 'resp_rate.dart'; export 'cvhr_apnea.dart'; +export 'cvhr_stream.dart'; export 'relative_odi.dart'; export 'brv_trend.dart'; diff --git a/test/aggregate/cr105_regressions_test.dart b/test/aggregate/cr105_regressions_test.dart new file mode 100644 index 0000000..f4c66fb --- /dev/null +++ b/test/aggregate/cr105_regressions_test.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:openstrap_analytics/aggregate.dart'; +import 'package:test/test.dart'; + +Beats beats(List nn, List ts) => + Beats(Float64List.fromList(nn), Float64List.fromList(ts)); + +class ConsumingInner extends Aggregator, Beats, List> { + @override + List empty() => []; + @override + List addSlice(List s, Beats b, int a, int z) => + s..addAll(b.nnMs.sublist(a, z)); + @override + List combine(List a, List b) => b..insertAll(0, a); + @override + List finish(List s) => List.of(s); +} + +void main() { + // why: every child must see the same finite intervals, and failed folds are atomic. + // # catches: Moments skips NaN after differences/evidence have already counted it. + for (final gap in [false, true]) { + for (final bad in [double.nan, double.infinity, double.negativeInfinity]) { + test('HRV refuses non-finite NN before mutation ($gap, $bad)', () { + final a = hrvTimeAggregator(originMs: 0, gapAware: gap); + final s = a.fold(beats([800, 810], [800, 1610])); + final before = jsonEncode(a.encodeState(s)); + expect( + () => + a.addSlice(s, beats([820, bad, 830], [2430, 3250, 4080]), 0, 3), + throwsArgumentError); + expect(jsonEncode(a.encodeState(s)), before); + }); + } + } + // why: a backward clock cannot justify successive differences, including seams. + // # catches: a negative delta is mistaken for a small contiguous delta. + for (final mode in ['slice', 'append', 'combine']) { + test('HRV refuses a regressing clock ($mode)', () { + final a = hrvTimeAggregator(originMs: 1000); + final b = beats([800, 900, 870], [1000, 1900, 1800]); + if (mode == 'slice') { + expect(() => a.fold(b), throwsStateError); + } else { + final s = a.fold(b, 0, 2); + final before = jsonEncode(a.encodeState(s)); + expect( + () => mode == 'append' + ? a.addSlice(s, b, 2, 3) + : a.combine(s, a.fold(b, 2, 3)), + throwsStateError); + expect(jsonEncode(a.encodeState(s)), before); + } + }); + } + // why: a deferred checkpoint cannot borrow the live piece's histogram counts. + // # catches: _Piece.toJson returns cnt directly. + test('HRV jitter evidence checkpoint owns count bins', () { + final a = hrvTimeAggregator(gapAware: false); + final b = beats(List.generate(300, (i) => 800 + (i % 7).toDouble()), + List.filled(300, double.nan)); + final s = a.fold(b, 0, 150); + final saved = a.encodeState(s); + final before = jsonEncode(saved); + a.addSlice(s, b, 150, 300); + expect(jsonEncode(saved), before); + expect(a.finish(a.decodeState(saved)).nBeats, 150); + }); + // why: a PRSA profile cannot mix anchors selected by different policies. + // # catches: restore validates profile size but ignores mode and ratio cap. + for (final (label, other) in [ + ('mode', prsaAggregator(deceleration: false)), + ('cap', prsaAggregator(deceleration: true, anchorRatioCap: .10)), + ]) { + test('PRSA refuses mismatched policy $label', () { + final a = prsaAggregator(deceleration: true); + final b = DoubleColumn( + Float64List.fromList(List.generate(30, (i) => 800 + i.toDouble()))); + expect(() => other.decodeState(a.encodeState(a.fold(b))), + throwsFormatException); + }); + } + // why: a one-beat window can carry alternating recovery phase indefinitely. + // # catches: restart a 401-beat 800/950 tail at odd index 201 and emit 950 NN. + test('sandwich refuses context-free threshold windows', () { + final a = RrSandwich(const Count(), windowBeats: 1); + final rr = List.generate(401, (i) => i.isEven ? 800.0 : 950.0); + final raw = RawRr.of(rr, List.generate(401, (i) => i * 1000.0)); + expect(() => a.run(raw), throwsArgumentError); + }); + // why: finish needs an owned middle even when combine consumes its right input. + // # catches: finish passes state.mid directly to a consuming non-persistable child. + test('sandwich refuses an inner without a state-copy codec', () { + final a = RrSandwich, List>(ConsumingInner()); + expect( + () => a.fold(RawRr.of( + List.filled(1300, 800.0), List.generate(1300, (i) => i * 800.0))), + throwsUnsupportedError); + }); +} diff --git a/test/aggregate/hrv_time_aggregator_test.dart b/test/aggregate/hrv_time_aggregator_test.dart new file mode 100644 index 0000000..ad5fd92 --- /dev/null +++ b/test/aggregate/hrv_time_aggregator_test.dart @@ -0,0 +1,559 @@ +// hrvTimeAggregator (assembled from lawful blocks) must agree with hrvTime() +// for any beat series and any chunking or slicing, and obey the laws. +// +// Contract used here: +// * gapAware (default): every beat carries its time; originMs is the first +// beat's time. run(beats) == hrvTime(nn, nnTimesMs: times).value. +// * gapAware false: times are ignored (they may even be NaN); run(beats) == +// hrvTime(nn).value, so sdann / sdnnIndex are null and seams are not +// skipped. +// * hrvTime returns an absent Metric below 2 beats. The aggregator then +// reports nBeats = the beat count and null for every other field. +// * originMs is needed as soon as a beat is binned; without it that is a +// StateError. Empty input with a null origin is fine. + +import 'package:openstrap_analytics/aggregate.dart'; +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import '../support/checkpoint_support.dart'; +import '../support/aggregator_laws.dart'; +import '../support/hrv_beats.dart'; +import '../support/property.dart' show Rng; + +Aggregator _agg(Beats beats, {bool gapAware = true}) => + hrvTimeAggregator( + originMs: gapAware ? hrvOrigin(beats) : null, gapAware: gapAware); + +/// Every way of evaluating [bounds] against [want]: left-nested chunks (as +/// ranges and as copies), right-nested chunks, and one state sliced. +void _expectAllPaths(Aggregator agg, Beats beats, + List bounds, HrvTime want, String tag) { + expectHrvParity(runChunked(agg, beats, bounds), want, '$tag chunks $bounds'); + expectHrvParity(runChunked(agg, beats, bounds, copy: true), want, + '$tag copies $bounds'); + expectHrvParity( + runChunkedRight(agg, beats, bounds), want, '$tag right $bounds'); + expectHrvParity(runSliced(agg, beats, bounds), want, '$tag sliced $bounds'); +} + +/// [segments] runs of 12 beats alternating 860/800 ms, each starting at 860, +/// with a 5 s hole between runs. Inside a run the diffs alternate -60/+60 (diff +/// acf strongly negative). Run k ends with a -60 diff and run k+1 opens with a +/// -60 diff, so a lag pair formed across the hole would be (-60, -60), a +/// positive product that drags acf1 up. +Beats _alternatingRuns(int segments) { + final out = []; + var t = hrvT0; + for (var s = 0; s < segments; s++) { + for (var i = 0; i < 12; i++) { + final nn = i.isEven ? 860.0 : 800.0; + t += nn + (s > 0 && i == 0 ? 5000 : 0); + out.add((nnMs: nn, timeMs: t)); + } + } + return hrvBeats(out); +} + +void main() { + group('named series', () { + final named = { + 'no beats': hrvBeats([]), + 'one beat': hrvBeats([(nnMs: 800.0, timeMs: hrvT0 + 800)]), + 'two beats': hrvSeries(Rng(1), 2, 0), + 'three beats': hrvSeries(Rng(2), 3, 0), + '30 beats (29 diffs, acf1 absent)': hrvSeries(Rng(3), 30, 0, gapP: 0), + '31 beats (30 diffs, acf1 present)': hrvSeries(Rng(4), 31, 0, gapP: 0), + '60 smooth beats, no gaps': hrvSeries(Rng(5), 60, 0, gapP: 0), + '300 white-noise beats (jitter floor nulls rmssd and pnn50)': + hrvSeries(Rng(6), 300, 1, gapP: 0), + 'constant 1000 ms, 400 beats (zero variance, bin edge at beat 300)': + hrvConstant(400, 1000), + 'constant 2000 ms, 400 beats (3 bins)': hrvConstant(400, 2000), + 'slow beats spanning many bins': hrvSeries(Rng(7), 380, 2, gapP: .02), + 'many holes': hrvSeries(Rng(8), 200, 0, gapP: .3), + 'alternating runs': _alternatingRuns(4), + 'RSA at a slow heart, exempt from the jitter floor': + hrvSeries(Rng(61), 900, 3, gapP: 0), + 'RSA at a slow heart with holes': hrvSeries(Rng(62), 1500, 3, gapP: .004), + 'refused, published from a breathing line steady in Hz': + hrvSeries(Rng(63), 4000, 4, gapP: 0), + 'a hole of several bins': hrvBeats([ + for (var i = 0; i < 40; i++) + (nnMs: 800.0, timeMs: hrvT0 + (i + 1) * 800.0), + (nnMs: 900.0, timeMs: hrvT0 + 40 * 800 + 900000), + (nnMs: 910.0, timeMs: hrvT0 + 40 * 800 + 900910), + (nnMs: 905.0, timeMs: hrvT0 + 40 * 800 + 901815), + ]), + }; + + named.forEach((name, beats) { + test('$name: gap aware matches hrvTime', () { + final want = hrvExpected(beats, gapAware: true); + final a = _agg(beats); + expectHrvParity(a.run(beats), want, name); + final r = Rng(name.hashCode & 0xffff); + for (var t = 0; t < 3; t++) { + _expectAllPaths(a, beats, randomBounds(r, beats.length), want, name); + } + }); + test('$name: gapAware false matches hrvTime without times', () { + final want = hrvExpected(beats, gapAware: false); + final a = _agg(beats, gapAware: false); + expectHrvParity(a.run(beats), want, name); + final r = Rng(name.hashCode & 0xfffe); + _expectAllPaths(a, beats, randomBounds(r, beats.length), want, name); + }); + }); + + test('fewer than two beats: nBeats set, everything else null', () { + for (final n in [0, 1]) { + final beats = hrvSeries(Rng(n), n, 0); + for (final ga in [true, false]) { + final v = _agg(beats, gapAware: ga).run(beats); + expect(v.nBeats, n); + expect(hrvFields(v), everyElement(isNull)); + } + } + }); + + test('hand-worked: nn 800, 810, 790 contiguous', () { + final beats = hrvBeats([ + (nnMs: 800.0, timeMs: 1000.0), + (nnMs: 810.0, timeMs: 1810.0), + (nnMs: 790.0, timeMs: 2600.0), + ]); + final v = hrvTimeAggregator(originMs: 1000.0).run(beats); + expect(v.nBeats, 3); + // diffs 10, -20 -> rmssd sqrt((100+400)/2) + expect(v.rmssd, closeTo(15.811388300841896, 1e-9)); + expect(v.pnn50, 0.0); + expect(v.sdnn, closeTo(10.0, 1e-9)); + expect(v.diffAcf1, isNull); // fewer than 30 diffs + expect(v.sdann, isNull); // a single 5-minute segment + expect(v.sdnnIndex, isNull); + }); + }); + + group('below the jitter floor (the batch reads the difference runs)', () { + test('the series reach the paths they name', () { + final exempt = hrvTime( + hrvSeries(Rng(61), 900, 3, gapP: 0).nnMs.toList(), + nnTimesMs: hrvSeries(Rng(61), 900, 3, gapP: 0).timeMs.toList()); + expect(exempt.value!.diffAcf1, lessThan(kNnDiffAcf1Floor)); + expect(exempt.value!.rmssd, isNotNull); + expect(exempt.note, startsWith('PRV not ECG-HRV')); + final b = hrvSeries(Rng(63), 4000, 4, gapP: 0); + final fallback = hrvTime(b.nnMs.toList(), nnTimesMs: b.timeMs.toList()); + expect(fallback.value!.diffAcf1, lessThan(kNnDiffAcf1Floor)); + expect(fallback.value!.rmssd, isNotNull); + expect(fallback.note, contains('breathing line steady in Hz')); + // Without times the same series is refused (no windows to fall back on). + expect(hrvTime(b.nnMs.toList()).value!.rmssd, isNull); + }); + + test('random slow-heart and breathing-line series: whole, chunked, sliced', + () { + final r = Rng(0x5C14); + for (var t = 0; t < 16; t++) { + final mode = 3 + t % 2; + final n = mode == 3 ? r.intIn(600, 1600) : r.intIn(3800, 4400); + final beats = hrvSeries(r, n, mode, gapP: r.nextBool() ? 0 : .003); + for (final ga in [true, false]) { + final want = hrvExpected(beats, gapAware: ga); + final a = _agg(beats, gapAware: ga); + expectHrvParity(a.run(beats), want, 'case $t n=$n ga=$ga whole'); + _expectAllPaths( + a, beats, randomBounds(r, n), want, 'case $t n=$n ga=$ga'); + } + } + }); + }); + + group('random parity (seeded)', () { + test('gap aware: whole series, chunked, sliced', () { + final r = Rng(0x5C11); + for (var t = 0; t < 40; t++) { + final n = r.nextBool(.1) ? r.intIn(0, 3) : r.intIn(0, 400); + final beats = + hrvSeries(r, n, r.intIn(0, 2), gapP: r.nextBool() ? .02 : .15); + final want = hrvExpected(beats, gapAware: true); + final a = _agg(beats); + expectHrvParity(a.run(beats), want, 'case $t n=$n whole'); + for (var c = 0; c < 2; c++) { + _expectAllPaths(a, beats, randomBounds(r, n), want, 'case $t n=$n'); + } + } + }); + + test('gapAware false (times present but ignored)', () { + final r = Rng(0x5C12); + for (var t = 0; t < 30; t++) { + final n = r.intIn(0, 300); + final beats = hrvSeries(r, n, r.intIn(0, 1)); + final want = hrvExpected(beats, gapAware: false); + final a = _agg(beats, gapAware: false); + expectHrvParity(a.run(beats), want, 'case $t whole'); + _expectAllPaths(a, beats, randomBounds(r, n), want, 'case $t'); + } + }); + + test('gapAware false with no times at all (NaN times)', () { + final r = Rng(0x5C13); + for (var t = 0; t < 20; t++) { + final n = r.intIn(0, 250); + final beats = hrvSeries(r, n, r.intIn(0, 1), withTimes: false); + final want = hrvExpected(beats, gapAware: false); + final a = hrvTimeAggregator(gapAware: false); + _expectAllPaths(a, beats, randomBounds(r, n), want, 'case $t'); + } + }); + + test('every single split point (combine and slicing) of a series with ' + 'holes and several bins', () { + final beats = hrvSeries(Rng(31), 120, 2, gapP: .1); + final want = hrvExpected(beats, gapAware: true); + final a = _agg(beats); + for (var k = 0; k <= beats.length; k++) { + final s = a.combine( + a.fold(beats, 0, k), a.fold(beats, k, beats.length)); + expectHrvParity(a.finish(s), want, 'split at $k'); + expectHrvParity(runSliced(a, beats, [0, k, beats.length]), want, + 'sliced at $k'); + } + }); + + test('splits on and around a 5-minute bin edge', () { + final beats = hrvConstant(400, 1000); + final want = hrvExpected(beats, gapAware: true); + final a = _agg(beats); + for (final k in [1, 2, 298, 299, 300, 301, 399]) { + final s = a.combine(a.fold(beats, 0, k), a.fold(beats, k, 400)); + expectHrvParity(a.finish(s), want, 'split at $k'); + expectHrvParity(runSliced(a, beats, [0, k, 400]), want, 'sliced $k'); + } + }); + + test('one beat per chunk and per slice', () { + final beats = hrvSeries(Rng(41), 80, 0, gapP: .1); + final want = hrvExpected(beats, gapAware: true); + final a = _agg(beats); + final b = singletonBounds(beats.length); + expectHrvParity(runChunked(a, beats, b), want, 'singleton chunks'); + expectHrvParity(runChunked(a, beats, b, copy: true), want, 'copies'); + expectHrvParity(runSliced(a, beats, b), want, 'singleton slices'); + }); + }); + + group('gaps and lags', () { + test('lag pairs are not formed across a gap', () { + final beats = _alternatingRuns(4); // 44 diffs, seams dropped + final want = hrvExpected(beats, gapAware: true); + expect(want.diffAcf1, isNotNull); + // The scenario discriminates: bridging the gap (ignoring times) moves acf1. + final bridged = hrvExpected(beats, gapAware: false); + expect((bridged.diffAcf1! - want.diffAcf1!).abs(), greaterThan(.02)); + final a = _agg(beats); + expect(a.run(beats).diffAcf1, closeTo(want.diffAcf1!, 1e-9)); + // And in chunks and slices cut on and around the seams. + for (final k in [11, 12, 13, 23, 24, 25, 35, 36, 37]) { + final bounds = [0, k, beats.length]; + final s = a.combine(a.fold(beats, 0, k), a.fold(beats, k, beats.length)); + expect(a.finish(s).diffAcf1, closeTo(want.diffAcf1!, 1e-9), + reason: 'split at $k'); + expect(runSliced(a, beats, bounds).diffAcf1, closeTo(want.diffAcf1!, 1e-9), + reason: 'sliced at $k'); + expect(runChunked(a, beats, bounds, copy: true).diffAcf1, + closeTo(want.diffAcf1!, 1e-9), + reason: 'copies at $k'); + } + // Three-way cuts that put whole runs apart. + for (final b in [ + [0, 12, 24, 36, 48], + [0, 11, 13, 25, 48], + [0, 12, 12, 13, 36, 47, 48], + ]) { + _expectAllPaths(a, beats, b, want, 'runs'); + } + }); + + test('the hole rule: nn + 0.5 ms is contiguous, nn + 1.0 ms is a hole', () { + // diffs +100 then -30: contiguous keeps both (pNN50 50 %); a hole drops + // the -30 (pNN50 100 %). + final edge = hrvBeats([ + (nnMs: 800.0, timeMs: 1000.0), + (nnMs: 900.0, timeMs: 1900.5), // dt 900.5 = nn + 0.5 + (nnMs: 870.0, timeMs: 2771.0), // dt 870.5 = nn + 0.5 + ]); + final hole = hrvBeats([ + (nnMs: 800.0, timeMs: 1000.0), + (nnMs: 900.0, timeMs: 1900.5), + (nnMs: 870.0, timeMs: 2772.0), // dt 871.5 > nn + 0.5 + ]); + final a = hrvTimeAggregator(originMs: 1000.0); + expect(a.run(edge).pnn50, 50.0); + expect(a.run(hole).pnn50, 100.0); + expect(a.run(edge).nBeats, 3); + expect(a.run(hole).nBeats, 3); + // The same across every cut, because the gap check reads times at the seam. + for (final bounds in [ + [0, 1, 3], + [0, 2, 3], + [0, 1, 2, 3], + [0, 0, 1, 1, 3, 3] + ]) { + expect(runChunked(a, edge, bounds).pnn50, 50.0, reason: 'edge $bounds'); + expect(runSliced(a, edge, bounds).pnn50, 50.0, reason: 'edge $bounds'); + expect(runChunked(a, hole, bounds, copy: true).pnn50, 100.0, + reason: 'hole $bounds'); + expect(runSliced(a, hole, bounds).pnn50, 100.0, reason: 'hole $bounds'); + } + }); + + test('a contiguous seam with a hole in the gapAware false case is bridged', + () { + final hole = hrvBeats([ + (nnMs: 800.0, timeMs: 1000.0), + (nnMs: 900.0, timeMs: 1900.5), + (nnMs: 870.0, timeMs: 2772.0), + ]); + final a = hrvTimeAggregator(gapAware: false); + expect(a.run(hole).pnn50, 50.0); // times ignored: both diffs count + expect(a.run(hole).sdann, isNull); + expect(a.run(hole).sdnnIndex, isNull); + }); + }); + + group('origin', () { + test('a binned beat without an origin is a StateError', () { + final beats = hrvSeries(Rng(51), 20, 0, gapP: 0); + final a = hrvTimeAggregator(); // gapAware, no origin + expect(() => a.run(beats), throwsStateError); + expect(() => runChunked(a, beats, [0, 7, 20]), throwsStateError); + expect(() => runSliced(a, beats, [0, 7, 20]), throwsStateError); + expect(() => a.run(hrvBeats([(nnMs: 800.0, timeMs: hrvT0 + 800)])), + throwsStateError); + }); + + test('empty input with a null origin is fine', () { + final a = hrvTimeAggregator(); + final v = a.run(hrvBeats([])); + expect(v.nBeats, 0); + expect(hrvFields(v), everyElement(isNull)); + expect(a.finish(a.empty()).nBeats, 0); + expect(a.finish(a.combine(a.empty(), a.empty())).nBeats, 0); + expect(a.finish(a.fold(hrvSeries(Rng(52), 5, 0), 2, 2)).nBeats, 0); + }); + + test('gapAware false never needs an origin', () { + final beats = hrvSeries(Rng(53), 50, 0); + final v = hrvTimeAggregator(gapAware: false).run(beats); + expect(v.nBeats, 50); + }); + }); + + test('persistable: a mid-stream checkpoint resumes to the one-pass result', () { + final b = hrvSeries(Rng(0xC0DE), 90, 1, gapP: .08); + expectCheckpointResumes(hrvTimeAggregator(originMs: hrvOrigin(b)), b, + equals: hrvEqAll); + }); + + group('laws', () { + List cases(int seed, {int count = 8, int maxN = 120}) { + final r = Rng(seed); + return [ + hrvBeats([]), + hrvSeries(Rng(seed), 1, 0), + for (var i = 0; i < count; i++) + hrvSeries(r, r.intIn(0, maxN), r.intIn(0, 2), gapP: .08), + ]; + } + + test('below the jitter floor, gap aware and not, is lawful', () { + final r = Rng(0x5D05); + final group = [ + hrvSeries(Rng(61), 900, 3, gapP: 0), + hrvSeries(r, 1200, 3, gapP: .004), + hrvSeries(Rng(63), 4000, 4, gapP: 0), + ]; + // These series are thousands of beats long (the exemption needs 20 Welch + // segments in a run, the fallback 12 five-minute windows), so the split + // and associativity laws are checked at 10 split points, not 64. + for (final c in group) { + for (final agg in [ + hrvTimeAggregator(originMs: hrvOrigin(c)), + hrvTimeAggregator(gapAware: false), + ]) { + final v = checkLaws(agg, [c], equals: hrvEqAll, maxSplits: 10); + expect(v, isEmpty, + reason: v.map((x) => '${x.law}: ${x.detail}').join('\n')); + } + } + }); + + test('gap aware is lawful (cases grouped by origin)', () { + final byOrigin = >{}; + for (final c in cases(0x5D01)) { + (byOrigin[hrvOrigin(c)] ??= []).add(c); + } + byOrigin.forEach((origin, group) { + expectLawful(hrvTimeAggregator(originMs: origin), group, + equals: hrvEqAll); + }); + }); + + test('gap aware with a shared origin is lawful', () { + final shifted = [for (final c in cases(0x5D02)) hrvShifted(c, 5000.0)]; + expectLawful(hrvTimeAggregator(originMs: 5000.0), shifted, + equals: hrvEqAll); + }); + + test('gapAware false is lawful', () { + expectLawful(hrvTimeAggregator(gapAware: false), cases(0x5D03), + equals: hrvEqAll); + }); + + test('alternating runs are lawful at every split', () { + final beats = _alternatingRuns(4); + expectLawful(hrvTimeAggregator(originMs: hrvOrigin(beats)), [beats], + equals: hrvEqAll); + }); + }); + + group('numerical stability', () { + // The review's series: a steady 5 ms/beat ramp with a tiny odd-beat bump. + // Diffs are ~5 +- 1e-8, so raw sums of d and d^2 cancel catastrophically + // for the lag-1 autocorrelation; centred statistics do not. + Beats ramp(double bump) => hrvBeats([ + for (var i = 0; i <= 100; i++) + (nnMs: 800.0 + 5 * i + (i.isOdd ? bump : 0.0), timeMs: null), + ]); + + test('1e-8 perturbation: acf1 within 1e-6 of hrvTime, same RMSSD nullness', + () { + final beats = ramp(1e-8); + final want = hrvTime(beats.nnMs.toList()).value!; + final got = hrvTimeAggregator(gapAware: false).run(beats); + // why: the batch wrapper shares the aggregator core; use the independent + // two-pass run calculation to expose a shared centred-moment error. + final diffs = [for (var i = 1; i < beats.length; i++) beats.nnMs[i] - beats.nnMs[i - 1]]; + final independent = nnDiffAcf1([diffs]); + expect(independent, isNotNull); + expect(got.diffAcf1, closeTo(independent!, 1e-6)); + expect(want.diffAcf1, isNotNull); + expect(got.diffAcf1, isNotNull, reason: 'aggregator acf1 is null'); + expect((got.diffAcf1! - want.diffAcf1!).abs(), lessThan(1e-6), + reason: 'acf1 ${got.diffAcf1} vs ${want.diffAcf1}'); + expect(got.rmssd == null, want.rmssd == null, + reason: 'rmssd ${got.rmssd} vs ${want.rmssd}'); + }); + + test('1e-4 perturbation: whole vs split at 50 agree to 1e-9', () { + final beats = ramp(1e-4); + final agg = hrvTimeAggregator(gapAware: false); + final whole = agg.run(beats); + expect(whole.diffAcf1, isNotNull); + for (final copy in [false, true]) { + final split = runChunked(agg, beats, [0, 50, beats.length], copy: copy); + expect(split.diffAcf1, isNotNull); + expect((split.diffAcf1! - whole.diffAcf1!).abs(), lessThan(1e-9), + reason: 'copy=$copy split ${split.diffAcf1} whole ${whole.diffAcf1}'); + expect(split.rmssd == null, whole.rmssd == null); + } + }); + }); + + group('checkpoint relationships (beat counts across the children)', () { + final beats = hrvSeries(Rng(0x4A7), 60, 1, gapP: .08); + final agg = hrvTimeAggregator(originMs: hrvOrigin(beats)); + // Inner state: [core, evidence]; core is [[diffs, lags], [levels, + // bins]]; diffs and lags are Lookback states ({length, first, last, + // inner}); levels is a Moments map and bins is {fiveMinuteBin: Moments}; + // evidence is the jitter-gate evidence map. + List core(Object? s) => ((s as Map)['inner'] as List)[0] as List; + Map diffs(Object? s) => (core(s)[0] as List)[0] as Map; + Map lags(Object? s) => (core(s)[0] as List)[1] as Map; + Map levels(Object? s) => (core(s)[1] as List)[0] as Map; + Map bins(Object? s) => (core(s)[1] as List)[1] as Map; + Map evidence(Object? s) => ((s as Map)['inner'] as List)[1] as Map; + + test('both lookbacks must have seen the same number of beats', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + lags(s)['length'] = 61; + return s; + }); + }); + test('more level samples than beats is refused', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + levels(s)['count'] = 70; + return s; + }); + }); + test('five-minute bin counts must add up to the level count', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + final first = bins(s).values.first as Map; + first['count'] = (first['count'] as int) + 1; + return s; + }); + }); + test('more successive differences than beat pairs is refused', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + final inner = diffs(s)['inner'] as Map; + inner['pairs'] = 60; + inner['over50'] = 0; + return s; + }); + }); + test('more lag pairs than beat triples is refused', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + (lags(s)['inner'] as Map)['pairs'] = 59; + return s; + }); + }); + test('a version 1 checkpoint (no jitter evidence) is refused whole', () { + expectDamagedCheckpointRefused(agg, beats, (s) => core(s)); + }); + test('evidence of another type or version is refused', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + evidence(s)['version'] = 2; + return s; + }); + expectDamagedCheckpointRefused(agg, beats, (s) { + evidence(s)['type'] = 'Other'; + return s; + }); + }); + test('evidence counting other beats or differences than the core', () { + expectDamagedCheckpointRefused(agg, beats, (s) { + evidence(s)['n'] = (evidence(s)['n'] as int) - 1; + return s; + }); + expectDamagedCheckpointRefused(agg, beats, (s) { + evidence(s)['nd'] = (evidence(s)['nd'] as int) - 1; + return s; + }); + }); + test('evidence whose parts disagree is refused', () { + for (final damage in [ + (e) => (e['head'] as Map)['len'] = ((e['head'] as Map)['len'] as int) + 1, + (e) => (e['head'] as Map)['anchored'] = true, + (e) => (e['head'] as Map)['phases'] = [], + (e) => ((e['head'] as Map)['cnt'] as List)[0] = 9999, + (e) => (e['wins'] as List).removeLast(), + (e) => e['headRun'] = null, + (e) => e['winNd'] = (e['nd'] as int) + 1, + ]) { + expectDamagedCheckpointRefused(agg, beats, (s) { + damage(evidence(s)); + return s; + }); + } + }); + test('a consistent checkpoint restores', () { + expect( + decodeCheckpoint(agg, checkpointOf(agg, beats), + kind: 'test', version: 1), + isNotNull); + }); + }); +} diff --git a/test/aggregate/prsa_aggregator_test.dart b/test/aggregate/prsa_aggregator_test.dart new file mode 100644 index 0000000..e6668f7 --- /dev/null +++ b/test/aggregate/prsa_aggregator_test.dart @@ -0,0 +1,144 @@ +// prsaAggregator must reproduce decelerationCapacity / accelerationCapacity +// (the references, unchanged) under every slicing / chunking, and obey the laws. +// +// Contract: prsaAggregator({required deceleration, l = 2, anchorRatioCap = 0.05}) +// folds a DoubleColumn of NN intervals (ms); l < 2 is the reference's absent +// Metric for every input and never throws. Persistable. +// +// Equality: anchors, kind, note, tier, inputs exact; capacity and every profile +// entry within 1e-9 (rel and abs); confidence within 1e-12. + +import 'dart:typed_data'; + +import 'package:openstrap_analytics/aggregate.dart'; +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import '../support/aggregator_laws.dart'; +import '../support/checkpoint_support.dart'; +import '../support/property.dart' show Rng; + +List _exact(Metric m) => [ + m.present, m.tier, m.inputs_used.join(','), m.note, + m.value?.anchors, m.value?.kind, m.value?.profile.length, + ]; +List _near(Metric m) => + [m.value?.capacity, ...?m.value?.profile]; + +final _same = (Metric a, Metric b) => + fieldsEqual>(_exact)(a, b) && + fieldsEqual>(_near, relTol: 1e-9, absTol: 1e-9)(a, b) && + (a.confidence - b.confidence).abs() <= 1e-12; + +DoubleColumn _c(Iterable x) => + DoubleColumn(Float64List.fromList([for (final v in x) v.toDouble()])); + +/// Seeded NN series of [n] beats, 700-1100 ms, small steps; [draw] overrides. +List _walk(Rng r, int n) { + var v = 900.0; + return [ + for (var i = 0; i < n; i++) + v = (v + (r.nextDouble() - .5) * 90).clamp(700.0, 1100.0) + ]; +} + +List _cases(int seed) { + final r = Rng(seed); + return [ + _c([]), + _c([800]), + _c(_walk(r, 7)), // short for every l + _c(_walk(r, 14)), + for (var i = 0; i < 4; i++) _c(_walk(r, r.intIn(15, 120))), + _c(_walk(r, 400)), + _c([for (var i = 0; i < 40; i++) 700 + i * 2.0]), // monotone up + _c([for (var i = 0; i < 40; i++) 1100 - i * 2.0]), // monotone down + _c([ + for (final v in _walk(r, 60)) r.nextBool(.15) ? (r.nextBool() ? 0.0 : -v) : v + ]), // zero / negative values + ]; +} + +void main() { + group('parity with the reference', () { + for (final dec in [true, false]) { + for (final l in [2, 3, 5]) { + for (final cap in [0.05, 0.2]) { + test('${dec ? 'DC' : 'AC'}, l=$l, cap=$cap', () { + expectConversion>( + (_) => prsaAggregator( + deceleration: dec, l: l, anchorRatioCap: cap), + _cases(l * 100 + (dec ? 1 : 0)), + (b) { + final x = b.values.toList(); + return dec + ? decelerationCapacity(x, l: l, anchorRatioCap: cap) + : accelerationCapacity(x, l: l, anchorRatioCap: cap); + }, + equals: _same, + seed: l, + ); + }); + } + } + } + }); + + group('absent cases', () { + for (final l in [1, 0, -3]) { + test('l=$l is absent for every input and never throws', () { + final a = prsaAggregator(deceleration: true, l: l); + final b = _c(_walk(Rng(l + 9), 50)); + final want = decelerationCapacity(b.values.toList(), l: l); + expect(want.present, isFalse); + expect(_same(a.run(b), want), isTrue); + expect(_same(a.finish(a.empty()), want), isTrue); + expect(_same(runChunked(a, b, [0, 20, 50]), want), isTrue); + expect(_same(runSliced(a, b, [0, 0, 30, 50]), want), isTrue); + }); + } + }); + + test('persistable: a mid-stream checkpoint resumes to the one-pass result', () { + final agg = prsaAggregator(deceleration: false, l: 3); + expect(agg.persistable, isTrue); + expectCheckpointResumes(agg, _c(_walk(Rng(0xBA0E), 300)), equals: _same); + }); + + group('checkpoint relationships (beat count, window length, anchors)', () { + final agg = prsaAggregator(deceleration: true, l: 2); + final b = _c(_walk(Rng(0xBA0F), 40)); + // State: [n, lookback]; lookback inner is {profile, anchors}. + Map lookback(Object? s) => (s as List)[1] as Map; + Map inner(Object? s) => lookback(s)['inner'] as Map; + + test('the beat count must equal the lookback row count', () { + for (final n in [10, 39, 41]) { + expectDamagedCheckpointRefused(agg, b, (s) { + (s as List)[0] = n; + return s; + }, reason: 'n=$n'); + } + }); + test('more anchors than windows is refused', () { + // 40 beats hold 40 - 2l = 36 windows with l = 2. + expectDamagedCheckpointRefused(agg, b, (s) { + inner(s)['anchors'] = 37; + return s; + }); + }); + test('anchors with fewer beats than one window are refused', () { + final short = _c(_walk(Rng(1), 3)); + expectDamagedCheckpointRefused(agg, short, (s) { + inner(s)['anchors'] = 1; + return s; + }); + }); + test('a consistent checkpoint restores', () { + expect( + decodeCheckpoint(agg, checkpointOf(agg, b), + kind: 'test', version: 1), + isNotNull); + }); + }); +} diff --git a/test/aggregate/rr_sandwich_test.dart b/test/aggregate/rr_sandwich_test.dart new file mode 100644 index 0000000..e0c72e8 --- /dev/null +++ b/test/aggregate/rr_sandwich_test.dart @@ -0,0 +1,509 @@ +// RED: the RR "sandwich" (combined bump, chunk A2 + A3). +// +// Contract (lib/src/onehz/foundations/rr_sandwich.dart): +// RrSandwich(inner, {maxArtifactRun = kMaxArtifactRun, ...}) is an +// Aggregator over RawRr (raw RR ms + beat timestamps). It corrects with the +// capped Lipponen-Tarvainen rule and feeds the settled NN (nnMs) into +// `inner`, each with the ABSOLUTE raw timestamp (`rrTsMs`) of the input beat +// it came from (`nnSourceIndex`): the beat itself, or for a spline-corrected +// beat that beat's own timestamp. Not correctRr's relative clock, so a seam +// needs no clock translation. +// +// * ANY chunking (random cuts, one-beat chunks, empty chunks, chunks as +// ranges or as copies, combined left- or right-nested, or one state sliced) +// finishes to exactly what one pass gives, and that is the capped +// `correctRr` over all the beats: nn bit for bit, paired with the absolute +// timestamps of the source beats. +// * It obeys the aggregator laws (identity, split, associativity, singletons, +// slicing, finishPure, roundTrip) and checkpoints/resumes mid-stream. +// * Its state is bounded: it keeps a raw head and tail, not the series, so an +// endless artifact run does not grow it. +// * A3: the night HRV time-domain and PRSA aggregators fed through it agree +// with the batch functions run on capped correctRr's NN, within their +// documented tolerances (1e-9). +// +// Data are integer RR with whole-second timestamps (synthRr: dropouts, ectopy, +// missed and extra beats, noise runs) and cleanly built long-run series +// (support/rr_runs.dart), so every clock value is an integer and "exactly" is +// attainable across chunk seams. +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:openstrap_analytics/aggregate.dart'; +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import '../onehz/support/rr_compare.dart'; +import '../onehz/support/rr_runs.dart'; +import '../onehz/support/rr_synth.dart'; +import '../support/aggregator_laws.dart'; +import '../support/checkpoint_support.dart'; +import '../support/hrv_beats.dart'; +import '../support/property.dart'; + +/// Test inner: keeps every NN and its time (unbounded on purpose; the point is +/// to see exactly what the sandwich hands down). +class _Acc { + final nn = [], t = []; +} + +class _Collect extends Aggregator<_Acc, Beats, Beats> { + @override + _Acc empty() => _Acc(); + @override + _Acc addSlice(_Acc s, Beats b, int start, int end) { + for (var i = start; i < end; i++) { + s.nn.add(b.nnMs[i]); + s.t.add(b.timeMs[i]); + } + return s; + } + + @override + _Acc combine(_Acc l, _Acc r) { + l.nn.addAll(r.nn); + l.t.addAll(r.t); + return l; + } + + @override + Beats finish(_Acc s) => Beats(Float64List.fromList(s.nn), Float64List.fromList(s.t)); + + @override + bool get persistable => true; + @override + Object? encodeState(_Acc s) => {'nn': s.nn, 't': s.t}; + @override + _Acc decodeState(Object? json) { + final m = json as Map; + return _Acc() + ..nn.addAll([for (final v in m['nn'] as List) (v as num).toDouble()]) + ..t.addAll([for (final v in m['t'] as List) (v as num).toDouble()]); + } +} + +RrSandwich<_Acc, Beats> _sandwich({int? maxArtifactRun = kMaxArtifactRun}) => + RrSandwich<_Acc, Beats>(_Collect(), maxArtifactRun: maxArtifactRun); + +bool _sameBeats(Beats a, Beats b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a.nnMs[i].compareTo(b.nnMs[i]) != 0 || + a.timeMs[i].compareTo(b.timeMs[i]) != 0) { + return false; + } + } + return true; +} + +typedef RrSeriesLike = ({List rr, List ts}); + +/// The oracle: capped (or [cap]ped) correctRr's NN, each paired with the +/// ABSOLUTE timestamp of the input beat it came from. +({List nn, List t}) _oracle(RrSeriesLike s, + {int? cap = kMaxArtifactRun}) { + final c = correctRr(s.rr, rrTsMs: s.ts, maxArtifactRun: cap); + return (nn: c.nn, t: [for (final i in c.nnSourceIndex) s.ts[i]]); +} + +void _expectIsCorrectRr(Beats got, RrSeriesLike s, String why, + {int? cap = kMaxArtifactRun}) { + final want = _oracle(s, cap: cap); + expectBitIdentical(got.nnMs, want.nn, '$why nn'); + expectBitIdentical(got.timeMs, want.t, '$why absolute times'); +} + +RawRr _raw(RrSeriesLike s) => RawRr.of(s.rr, s.ts); + +RrSeriesLike _synth(int seed, double hours, + {double noise = 0.5, double gaps = 12}) { + final d = synthRr(SynthConfig( + seed: seed, + hours: hours, + ectopicPerMin: 2, + missedPerMin: 1, + extraPerMin: 1, + noiseRunPerMin: noise, + gapPerHour: gaps)); + return (rr: d.rr, ts: d.ts); +} + +RrSeriesLike _runs(int seed, List segs, List runs, {bool near = true}) { + final (s, _) = withRuns(seed, segs, runs, nearRun: near); + return (rr: s.rr, ts: s.ts); +} + +void main() { + final cases = { + 'synthetic, dirty': _synth(23, 0.1), + 'synthetic, calm, with dropouts': _synth(5, 0.1, noise: 0.05, gaps: 30), + 'one 40-beat run': _runs(7, [200, 200], [40]), + 'two long runs': _runs(8, [120, 120, 120], [33, 45]), + }; + + group('single pass = capped correctRr', () { + for (final e in cases.entries) { + test(e.key, () { + final agg = _sandwich(); + _expectIsCorrectRr(agg.run(_raw(e.value)), e.value, e.key); + }); + } + + test('empty and tiny inputs', () { + for (final n in [0, 1, 2, 3, 5]) { + final s = _synth(3, 0.05); + final part = (rr: s.rr.sublist(0, n), ts: s.ts.sublist(0, n)); + _expectIsCorrectRr(_sandwich().run(_raw(part)), part, 'n=$n'); + } + }); + + test('maxArtifactRun: null is the uncapped corrector', () { + final s = _runs(9, [150, 150], [40]); + final got = _sandwich(maxArtifactRun: null).run(_raw(s)); + _expectIsCorrectRr(got, s, 'uncapped', cap: null); + }); + }); + + group('any chunking = one pass', () { + forAll('random cuts (empty and one-beat chunks included), every path', + G.intIn(1, 100000), (seed) { + final s = _synth(seed, 0.07); + final batch = _raw(s); + final want = _oracle(s); + final agg = _sandwich(); + final r = Rng(seed); + for (var k = 0; k < 3; k++) { + final bounds = randomBounds(r, batch.length); + for (final (name, got) in [ + ('chunked', runChunked(agg, batch, bounds)), + ('copies', runChunked(agg, batch, bounds, copy: true)), + ('right', runChunkedRight(agg, batch, bounds)), + ('sliced', runSliced(agg, batch, bounds)), + ]) { + expectBitIdentical(got.nnMs, want.nn, '$name $bounds nn'); + expectBitIdentical(got.timeMs, want.t, '$name $bounds t'); + } + } + }, cases: 25, budget: const Duration(seconds: 90)); + + test('one-beat chunks, combined left and right', () { + final s = _synth(31, 0.05); + final batch = _raw(s); + final agg = _sandwich(); + _expectIsCorrectRr( + runChunked(agg, batch, singletonBounds(batch.length)), s, 'left'); + _expectIsCorrectRr( + runChunkedRight(agg, batch, singletonBounds(batch.length)), s, 'right'); + }); + + test('cuts inside, at the edges of and across a long artifact run', () { + final (s, runs) = withRuns(41, [150, 150], [50], nearRun: true); + final batch = RawRr.of(s.rr, s.ts); + final (a, b) = runs.single; + final agg = _sandwich(); + for (final cuts in [ + [a - 1, a, a + 1], + [a + 10, a + 20, a + 33, a + 34], + [b - 1, b, b + 1], + [a - 60, a + 25, b + 60], + [a + 1, a + 2, a + 3, a + 4, b - 2, b - 1], + ]) { + final bounds = [0, ...cuts, batch.length]; + final want = _oracle((rr: s.rr, ts: s.ts)); + for (final got in [ + runChunked(agg, batch, bounds), + runChunkedRight(agg, batch, bounds), + runSliced(agg, batch, bounds), + ]) { + expectBitIdentical(got.nnMs, want.nn, 'cuts $cuts nn'); + expectBitIdentical(got.timeMs, want.t, 'cuts $cuts t'); + } + } + }); + + test('a cut at every beat of a dropout seam (the clock re-anchors)', () { + final original = _synth(51, 0.06, gaps: 60); + // A dropout rate is probabilistic; this seed currently draws none. + // Insert a definite two-minute hole so this seam test always runs. + final at = original.rr.length ~/ 2; + final s = (rr: original.rr, ts: [ + for (var i = 0; i < original.ts.length; i++) + original.ts[i] + (i >= at ? 120000 : 0), + ]); + final batch = _raw(s); + final agg = _sandwich(); + final want = _oracle(s); + var seams = 0; + for (var i = 1; i < s.rr.length; i++) { + if ((s.ts[i] - s.ts[i - 1]) - s.rr[i] > 1000) { + seams++; + final got = runChunked(agg, batch, [0, i - 1, i, i + 1, batch.length]); + expectBitIdentical(got.timeMs, want.t, 'seam at $i t'); + expectBitIdentical(got.nnMs, want.nn, 'seam at $i nn'); + } + } + expect(seams, greaterThan(0), reason: 'the series must have dropouts'); + }); + }); + + // Series much longer than the sandwich's head + tail windows, so most beats + // are settled into `inner` and a seam is re-corrected from the windows alone. + group('long series (settled middle in play)', () { + final long = { + 'synthetic, dirty, long': _synth(11, 0.7), + 'synthetic, calm, long, dropouts': _synth(12, 2.0, noise: 0.05, gaps: 30), + 'runs over the cap, long': _runs(13, [700, 700, 700], [40, 50]), + }; + + for (final e in long.entries) { + test('${e.key}: random cuts, every path', () { + final batch = _raw(e.value); + expect(batch.length, greaterThan(1200), reason: 'longer than the windows'); + final want = _oracle(e.value); + final agg = _sandwich(); + _expectIsCorrectRr(agg.run(batch), e.value, 'one pass'); + final r = Rng(e.key.length); + for (var k = 0; k < 4; k++) { + final bounds = randomBounds(r, batch.length); + for (final (name, got) in [ + ('chunked', runChunked(agg, batch, bounds)), + ('copies', runChunked(agg, batch, bounds, copy: true)), + ('right', runChunkedRight(agg, batch, bounds)), + ('sliced', runSliced(agg, batch, bounds)), + ]) { + expectBitIdentical(got.nnMs, want.nn, '$name $bounds nn'); + expectBitIdentical(got.timeMs, want.t, '$name $bounds t'); + } + } + }); + } + + forAll('dense artifacts (ectopy, runs, dropouts), random cuts', + G.intIn(1, 100000), (seed) { + final s = _synth(seed, 0.5, noise: 1.5, gaps: 20); + final batch = _raw(s); + final want = _oracle(s); + final agg = _sandwich(); + final bounds = randomBounds(Rng(seed), batch.length); + final got = runChunked(agg, batch, bounds); + expectBitIdentical(got.nnMs, want.nn, 'seed $seed $bounds nn'); + expectBitIdentical(got.timeMs, want.t, 'seed $seed $bounds t'); + }, cases: 12, budget: const Duration(seconds: 90)); + + test('even chunks of many sizes, left and right', () { + final s = long['synthetic, dirty, long']!; + final batch = _raw(s); + final agg = _sandwich(); + for (final size in [7, 100, 283, 284, 285, 560, 561, 1000]) { + final bounds = [ + for (var i = 0; i < batch.length; i += size) i, + batch.length, + ]; + _expectIsCorrectRr(runChunked(agg, batch, bounds), s, 'size $size left'); + _expectIsCorrectRr( + runChunkedRight(agg, batch, bounds), s, 'size $size right'); + } + }); + + test('one-beat chunks over a long series', () { + final s = long['runs over the cap, long']!; + final batch = _raw(s); + final agg = _sandwich(); + _expectIsCorrectRr( + runChunked(agg, batch, singletonBounds(batch.length)), s, 'left'); + _expectIsCorrectRr(runSliced(agg, batch, singletonBounds(batch.length)), + s, 'sliced'); + }); + + test('state size does not grow with the series', () { + // a bounded inner (Count), so only the sandwich's own windows are sized + final agg = RrSandwich(const Count()); + int chars(RrSeriesLike s) => + jsonEncode(agg.encodeState(agg.fold(_raw(s)))).length; + final small = _synth(14, 1.4), big = _synth(14, 2.8); + expect(big.rr.length, greaterThan(small.rr.length * 1.5)); + expect(chars(big), lessThan(chars(small) * 1.1 + 200)); + }); + }); + + group('laws and persistence', () { + final lawCases = [ + RawRr.of([], []), + RawRr.of([800], [1.7e12]), + _raw((rr: _synth(3, 0.05).rr.sublist(0, 3), ts: _synth(3, 0.05).ts.sublist(0, 3))), + _raw(_synth(4, 0.04)), + _raw(_runs(5, [100, 100], [36])), + _raw(_runs(6, [300, 300], [36])), + ]; + + test('identity, split, associativity, singletons, slicing, finishPure, ' + 'roundTrip', () { + expectLawful, RawRr, Beats>(_sandwich(), lawCases, + equals: _sameBeats); + }); + + test('is persistable', () { + expect(_sandwich().persistable, isTrue); + }); + + test('checkpoints and resumes mid-stream (through JSON text)', () { + final s = _runs(61, [160, 160], [45]); + final batch = _raw(s); + expectCheckpointResumes, RawRr, Beats>( + _sandwich(), batch, + equals: _sameBeats); + final agg = _sandwich(); + final a = 160; + for (final k in [0, 1, 50, a - 1, a + 1, a + 20, a + 33, a + 45, batch.length - 1, batch.length]) { + final env = jsonDecode(jsonEncode(encodeCheckpoint( + agg, agg.fold(batch, 0, k), + kind: 'rr', version: 1))); + final back = decodeCheckpoint>(agg, env, + kind: 'rr', version: 1); + expect(back, isNotNull, reason: 'restores at $k'); + final got = agg.finish(agg.addSlice(back!, batch, k, batch.length)); + _expectIsCorrectRr(got, s, 'resumed at $k'); + } + }); + + test('a checkpoint of the wrong kind or version is refused (null)', () { + final agg = _sandwich(); + final batch = _raw(_synth(3, 0.05)); + final env = jsonDecode(jsonEncode(encodeCheckpoint( + agg, agg.fold(batch, 0, 40), + kind: 'rr', version: 1))); + expect(decodeCheckpoint>(agg, env, kind: 'rr', version: 2), + isNull); + expect(decodeCheckpoint>(agg, {...env as Map, 'state': 'junk'}, + kind: 'rr', version: 1), isNull); + }); + + test('state is bounded: 10,000 artifact beats do not grow it', () { + final r = Rng(71); + final clean = cleanRr(r, 300); + final rr = [...clean, ...List.filled(10000, 3000.0), ...cleanRr(r, 300)]; + final s = rrSeries(rr); + final batch = RawRr.of(s.rr, s.ts); + // Count: a bounded inner, so only the sandwich's own state is measured. + final agg = RrSandwich(const Count()); + final mid = agg.fold(batch, 0, 300 + 10000); + final text = jsonEncode(agg.encodeState(mid)); + expect(text.length, lessThan(80000), reason: 'state chars inside the run'); + final end = agg.finish(agg.addSlice(mid, batch, 10300, batch.length)); + final want = correctRr(s.rr, rrTsMs: s.ts); + expect(end, want.nn.length); + // combining two halves of the run keeps it bounded too + final left = agg.fold(batch, 0, 5300), right = agg.fold(batch, 5300, batch.length); + final merged = agg.combine(left, right); + expect(jsonEncode(agg.encodeState(merged)).length, lessThan(80000)); + expect(agg.finish(merged), want.nn.length); + }); + }); + + group('A3: night aggregators fed through the sandwich', () { + // The reference: capped correctRr's NN into the batch function. + HrvTime hrvRef(RrSeriesLike s) { + final o = _oracle(s); + return hrvExpected(Beats(Float64List.fromList(o.nn), Float64List.fromList(o.t)), + gapAware: true); + } + + double? originOf(RrSeriesLike s) { + final o = _oracle(s); + return o.t.isEmpty ? null : o.t.first; + } + + for (final e in cases.entries) { + test('hrvTimeAggregator, ${e.key}', () { + final batch = _raw(e.value); + final agg = RrSandwich( + hrvTimeAggregator(originMs: originOf(e.value))); + final want = hrvRef(e.value); + expectHrvParity(agg.run(batch), want, 'one pass'); + final r = Rng(e.key.length); + for (var k = 0; k < 4; k++) { + final bounds = randomBounds(r, batch.length); + expectHrvParity(runChunked(agg, batch, bounds), want, 'chunks $bounds'); + expectHrvParity(runChunkedRight(agg, batch, bounds), want, 'right $bounds'); + expectHrvParity(runSliced(agg, batch, bounds), want, 'sliced $bounds'); + } + }); + } + + List exact(Metric m) => [ + m.present, m.tier, m.inputs_used.join(','), m.note, + m.value?.anchors, m.value?.kind, m.value?.profile.length, + ]; + List near(Metric m) => + [m.value?.capacity, ...?m.value?.profile]; + bool same(Metric a, Metric b) => + fieldsEqual>(exact)(a, b) && + fieldsEqual>(near, relTol: 1e-9, absTol: 1e-9)(a, b) && + (a.confidence - b.confidence).abs() <= 1e-12; + + for (final dec in [true, false]) { + for (final e in cases.entries) { + test('prsaAggregator ${dec ? 'DC' : 'AC'}, ${e.key}', () { + final batch = _raw(e.value); + final agg = RrSandwich>( + NnColumn>( + prsaAggregator(deceleration: dec))); + final nn = correctRr(e.value.rr, rrTsMs: e.value.ts).nn; + final want = + dec ? decelerationCapacity(nn) : accelerationCapacity(nn); + expect(same(agg.run(batch), want), isTrue, reason: 'one pass'); + final r = Rng(e.key.length + 1); + for (var k = 0; k < 4; k++) { + final bounds = randomBounds(r, batch.length); + expect(same(runChunked(agg, batch, bounds), want), isTrue, + reason: 'chunks $bounds'); + expect(same(runChunkedRight(agg, batch, bounds), want), isTrue, + reason: 'right $bounds'); + } + }); + } + } + + // Series longer than the windows: the settled middle is in play. + for (final e in { + 'synthetic, dirty, long': _synth(11, 0.7), + 'runs over the cap, long': _runs(13, [700, 700, 700], [40, 50]), + }.entries) { + test('hrvTimeAggregator and prsaAggregator, ${e.key}', () { + final batch = _raw(e.value); + expect(batch.length, greaterThan(1200)); + final hrv = RrSandwich( + hrvTimeAggregator(originMs: originOf(e.value))); + final prsa = RrSandwich>( + NnColumn>( + prsaAggregator(deceleration: true))); + final wantPrsa = decelerationCapacity(_oracle(e.value).nn); + final r = Rng(e.key.length + 2); + final wantHrv = hrvRef(e.value); + for (var k = 0; k < 3; k++) { + final bounds = randomBounds(r, batch.length); + expectHrvParity(runChunked(hrv, batch, bounds), wantHrv, 'hrv $bounds'); + expectHrvParity( + runChunkedRight(hrv, batch, bounds), wantHrv, 'hrv right $bounds'); + expect(same(runChunked(prsa, batch, bounds), wantPrsa), isTrue, + reason: 'prsa $bounds'); + expect(same(runChunkedRight(prsa, batch, bounds), wantPrsa), isTrue, + reason: 'prsa right $bounds'); + } + }); + } + + test('the sandwiched aggregators checkpoint and resume', () { + final s = cases['one 40-beat run']!; + final batch = _raw(s); + final hrv = RrSandwich( + hrvTimeAggregator(originMs: originOf(s))); + expect(hrv.persistable, isTrue); + expectCheckpointResumes, RawRr, HrvTime>(hrv, batch, + equals: (a, b) => hrvEqAll(a, b)); + final prsa = RrSandwich>( + NnColumn>(prsaAggregator(deceleration: true))); + expect(prsa.persistable, isTrue); + }); + }); +} diff --git a/test/incremental/cr105_tools_test.dart b/test/incremental/cr105_tools_test.dart new file mode 100644 index 0000000..d0f02bd --- /dev/null +++ b/test/incremental/cr105_tools_test.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' hide IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/hrv_incr.dart'; +import '../../tool/incremental/resp_incr.dart'; +import '../onehz/welch_stream_test.dart' show expectSameHrv, expectSameResp; +import 'support.dart'; + +void main() { + // why: an error recorder must fail on non-finite values instead of reporting zero error. + // # catches: comparisons against NaN leave maxAbs and maxRel at zero. + test('Err rejects non-finite operands', () { + for (final bad in [double.nan, double.infinity, double.negativeInfinity]) { + expect(() => Err().add(bad, 1), throwsA(isA())); + expect(() => Err().add(1, bad), throwsA(isA())); + } + final e = Err()..add(3, 2); + expect(e.maxAbs, 1); + expect(e.maxRel, .5); + }); + // why: the stage mask is indexed in beat-clock seconds, including the origin. + // # catches: window 0 at t0=120s reads mask[150] instead of mask[270]. + test('nocturnal stage lookup includes clock origin', () { + final nn = List.filled(20, 800.0); + final ts = List.generate(20, (i) => 120000 + i * 800.0); + final mask = List.filled(500, false)..[270] = true; + final s = NocturnalRmssdState()..fold(nn, ts); + final want = nocturnalRmssd(nn, ts, stageMaskPerSec: mask); + final got = s.evaluate([], [], stageMaskPerSec: mask); + expect(want.present, isTrue); + expect(got.toJson(), want.toJson()); + }); + // why: session refusal covers all raw times, including regressions between folds. + // # catches: negative deltas contribute successive differences and produce RMSSD 0. + test('session refuses regressing raw timestamps across folds', () { + final rr = List.filled(40, 800.0); + final ts = List.generate(40, (i) => 1000000 + i * 800.0)..[39] = 1000100; + final s = SessionRmssdState(1000, 1300) + ..fold(rr.sublist(0, 39), ts.sublist(0, 39)); + s.fold(rr.sublist(39), ts.sublist(39)); + final want = + sleepSessionWindowedRmssd(rr, ts, startSec: 1000, endSec: 1300); + expect(want.note, 'regressing beat time'); + expect(s.evaluate().toJson(), want.toJson()); + }); + // why: retained pNN counts and future counts must use the same screening policy. + // # catches: fromJson silently installs default constructor settings. + test('prototype irregular checkpoint restores custom settings', () { + final s = IrregularScreenState( + sd1sd2Flag: .6, + pnnThresholdMs: 20, + pnnFlagPct: 25, + windowMinutes: 2, + minWindowBeats: 15, + sustainedFraction: .7); + final nn = List.generate(200, (i) => 800.0 + (i.isOdd ? 40 : 0)); + final ts = List.generate(200, (i) => i * 800.0); + s.fold(nn.sublist(0, 100), ts.sublist(0, 100)); + final restored = IrregularScreenState.fromJson( + jsonDecode(jsonEncode(s.toJson())) as Map); + expect(restored.pnnThresholdMs, 20); + expect(restored.sd1sd2Flag, .6); + expect(restored.pnnFlagPct, 25); + expect(restored.windowMs, 120000); + expect(restored.minWindowBeats, 15); + expect(restored.sustainedFraction, .7); + restored.fold(nn.sublist(100), ts.sublist(100)); + s.fold(nn.sublist(100), ts.sublist(100)); + expect(restored.evaluate([], [], minBeats: 40).toJson((v) => v.toJson()), + s.evaluate([], [], minBeats: 40).toJson((v) => v.toJson())); + }); + // why: a fresh state with a provisional night must honor evaluation overrides. + // # catches: the short-record batch fallback forwards only artifactFraction. + test('RSA fallback forwards evaluation options', () { + final nn = List.generate(1000, (i) => 800 + 30 * math.sin(i / 4)); + final ts = List.generate(1000, (i) => i * 800.0); + expectSameResp( + RsaWelchState().evaluate(nn, ts, + artifactFraction: .2, + maxArtifact: .1, + tolBrpm: .4, + minConsensus: .9), + rsaRespRate(nn, ts, + artifactFraction: .2, + maxArtifact: .1, + tolBrpm: .4, + minConsensus: .9), + 'custom options'); + }); + // why: the supported abstaining state must survive persistence. + // # catches: raw infinities/NaNs in tMs, nn, or sorted break jsonEncode. + for (final pair in [(800.0, double.infinity), (double.nan, 0.0)]) { + test('RSA checkpoint encodes non-finite input $pair', () { + final s = RsaWelchState()..fold([pair.$1], [pair.$2]); + final encoded = jsonEncode(s.toJson()); + final restored = + RsaWelchState.fromJson(jsonDecode(encoded) as Map); + expect(jsonEncode(restored.toJson()), encoded); + }); + } + // why: research parity reports must apply the batch omission policy across boundaries. + // # catches: unsorted retained times enter binary search or omitted counts are lost. + test('prototype spectrum preserves regression omission policy', () { + final nn = List.generate(801, (i) => 800 + 20 * math.sin(i / 4)); + final ts = List.generate(801, (i) => i * 800.0)..[410] = 325000; + var s = HrvFreqState()..fold(nn.sublist(0, 410), ts.sublist(0, 410)); + expectSameHrv( + s.evaluate(nn.sublist(410), ts.sublist(410), artifactFraction: 0), + hrvFreq(nn, ts, artifactFraction: 0), + 'tail regression'); + s.fold(nn.sublist(410), ts.sublist(410)); + s = HrvFreqState.fromJson( + jsonDecode(jsonEncode(s.toJson())) as Map); + expectSameHrv(s.evaluate([], [], artifactFraction: 0), + hrvFreq(nn, ts, artifactFraction: 0), 'fold regression'); + }); + // why: infinity cannot be used as the segment-loop clock. + // # catches: Infinity + segment <= Infinity loops forever; validation must be atomic. + test('prototype spectrum refuses non-finite time before mutation', () { + final s = HrvFreqState()..fold([800], [0]); + final before = jsonEncode(s.toJson()); + // The initial red run used NaN to expose the missing guard without hanging. + for (final bad in [double.nan, double.infinity, double.negativeInfinity]) { + expect(() => s.fold([800], [bad]), throwsArgumentError); + expect(jsonEncode(s.toJson()), before); + } + }); +} diff --git a/test/incremental/hrv_boundary_test.dart b/test/incremental/hrv_boundary_test.dart new file mode 100644 index 0000000..2b3702f --- /dev/null +++ b/test/incremental/hrv_boundary_test.dart @@ -0,0 +1,200 @@ +// Boundary / refusal-path oracle tests that RR-correction-driven data rarely +// reaches: beats landing EXACTLY on bin edges, the jitter gate (RMSSD refused), +// thin input => absent exactly where the oracle is. +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/hrv_incr.dart'; +import '../../tool/incremental/oracle_util.dart'; +import '../../tool/incremental/resp_incr.dart'; +import 'support.dart'; + +({List nn, List t}) _nn(int n, + {double step = 1000, double noise = 0, int seed = 1, double start = 0}) { + final r = math.Random(seed); + final nn = [], t = []; + var clock = start; + for (var i = 0; i < n; i++) { + final v = (step + 60 * math.sin(i * 0.21) + noise * (r.nextDouble() - .5)) + .roundToDouble(); + clock += v; + nn.add(v); + t.add(clock); + } + return (nn: nn, t: t); +} + +/// Equal null-ness and within 1e-9 relative (the aggregator's stated +/// tolerance; hrvTime now runs hrvTimeAggregator). +void _near9(double? got, double? want, String reason) { + expect(got == null, want == null, reason: reason); + if (got != null && want != null) { + expect(got, closeTo(want, 1e-9 * want.abs()), reason: reason); + } +} + +void main() { + test('night shape: beats exactly on 30-min edges, incl. last beat on an edge', () { + // 1000 ms steps from 0 => beat times are multiples of 1000, bins at 1.8e6 + for (final lastOnEdge in [false, true]) { + final n = 1800 * 4 + (lastOnEdge ? 0 : 777); + final nn = [], t = []; + for (var i = 1; i <= n; i++) { + nn.add(1000.0 + (i % 17) * 3); + t.add(i * 1000.0); // beat i ends at i*1000 ms + } + // make t0 = t[0] = 1000 => edges at 1000 + k*1.8e6 + for (final chunk in [1, 37, 900, 5000]) { + final st = NightShapeState(); + for (var a = 0; a < n; a += chunk) { + final b = math.min(n, a + chunk); + st.fold(nn.sublist(a, b), t.sublist(a, b)); + final want = nightHrvShape(nn.sublist(0, b), t.sublist(0, b)); + final got = st.evaluate(const [], const []); + sameEnvelope(got, want, why: 'edge n=$b chunk=$chunk'); + if (want.present) { + expect(got.value!.toJson(), want.value!.toJson(), reason: 'n=$b'); + } + } + } + } + }); + + test('night shape: dropout makes empty bins (holes) and a gap straddles a bin', () { + final a = _nn(2500, seed: 3); + final b = _nn(2500, seed: 4, start: a.t.last + 3 * 3600 * 1000.0); // 3 h gap + final nn = [...a.nn, ...b.nn], t = [...a.t, ...b.t]; + final st = NightShapeState(); + final r = math.Random(7); + var at = 0; + for (final cut in randomCuts(r, nn.length, sizes: [50, 700, 3000])) { + st.fold(nn.sublist(at, cut), t.sublist(at, cut)); + at = cut; + final want = nightHrvShape(nn.sublist(0, cut), t.sublist(0, cut)); + final got = st.evaluate(const [], const []); + sameEnvelope(got, want, why: 'cut=$cut'); + if (want.present) expect(got.value!.toJson(), want.value!.toJson()); + } + }); + + test('hrvTime: jitter gate refuses RMSSD/pNN50 exactly like the oracle', () { + final x = _nn(3000, noise: 260, seed: 5); // near-white successive diffs + final want0 = hrvTime(x.nn, nnTimesMs: x.t); + expect(want0.value!.rmssd, isNull, reason: 'fixture must trip the gate'); + for (final chunk in [1, 29, 30, 31, 600]) { + final acc = HrvTimeAcc(); + for (var a = 0; a < x.nn.length; a += chunk) { + final b = math.min(x.nn.length, a + chunk); + acc.fold(x.nn.sublist(a, b), x.t.sublist(a, b)); + final want = hrvTime(x.nn.sublist(0, b), nnTimesMs: x.t.sublist(0, b)); + final got = acc.evaluate(const [], const []); + expect(got.present, want.present); + if (!want.present) continue; + expect(got.value!.rmssd, want.value!.rmssd, reason: 'b=$b'); + expect(got.value!.pnn50, want.value!.pnn50, reason: 'b=$b'); + // 1e-9 relative: hrvTime now runs hrvTimeAggregator (was exact). + _near9(got.value!.sdann, want.value!.sdann, 'sdann b=$b'); + _near9(got.value!.sdnnIndex, want.value!.sdnnIndex, 'sdnnIndex b=$b'); + expect(got.value!.nBeats, want.value!.nBeats); + expect(got.note, want.note, reason: 'b=$b'); + } + } + }); + + test('hrvTime: gaps (seams) between runs, thin series', () { + final a = _nn(400, seed: 8), b = _nn(400, seed: 9, start: 5000000); + final nn = [...a.nn, ...b.nn], t = [...a.t, ...b.t]; + final acc = HrvTimeAcc(); + for (var n = 0; n <= nn.length; n += (n < 40 ? 1 : 97)) { + final acc2 = HrvTimeAcc()..fold(nn.sublist(0, n), t.sublist(0, n)); + final want = hrvTime(nn.sublist(0, n), nnTimesMs: t.sublist(0, n)); + final got = acc2.evaluate(const [], const []); + expect(got.present, want.present, reason: 'n=$n'); + if (want.present) { + expect(got.value!.rmssd, want.value!.rmssd, reason: 'n=$n'); + expect(got.value!.pnn50, want.value!.pnn50); + expect(got.value!.diffAcf1 == null, want.value!.diffAcf1 == null); + } + } + expect(acc.n, 0); + }); + + test('rsa: absent when oracle abstains (slow HR => ceiling below HF band)', () { + // 1500 ms beats: Nyquist 0.333 Hz < 0.40 => oracle refuses; so must we. + final x = _nn(900, step: 1500, noise: 10, seed: 2); + final st = RsaWelchState(); + var at = 0; + for (final cut in [200, 205, 500, 900]) { + st.fold(x.nn.sublist(at, cut), x.t.sublist(at, cut)); + at = cut; + final want = rsaRespRate(x.nn.sublist(0, cut), x.t.sublist(0, cut), + artifactFraction: 0.05); + final got = st.evaluate(const [], const [], artifactFraction: 0.05); + sameEnvelope(got, want, why: 'cut=$cut'); + expect(got.value?.brpm, want.value?.brpm); + } + }); + + test('rsa: artifact gate, thin (<20 beats), spans just below/at 600 s', () { + final x = _nn(1500, step: 800, noise: 30, seed: 6); + for (final af in [0.0, 0.31]) { + final st = RsaWelchState(); + var at = 0; + for (final cut in [10, 19, 20, 400, 749, 750, 751, 752, 900, 1500]) { + st.fold(x.nn.sublist(at, cut), x.t.sublist(at, cut)); + at = cut; + final want = rsaRespRate(x.nn.sublist(0, cut), x.t.sublist(0, cut), + artifactFraction: af); + final got = st.evaluate(const [], const [], artifactFraction: af); + sameEnvelope(got, want, why: 'cut=$cut af=$af'); + expect(got.value?.brpm, want.value?.brpm, reason: 'cut=$cut'); + } + } + }); + + // Under the ACF floor hrvTime reads the difference runs: a respiratory line + // exempts the night, a refused one may publish from a breathing line + // steady in Hz. HrvTimeAcc carries the library's mergeable evidence for both. + for (final (name, mode, n) in [ + ('exempt (RSA at a slow heart)', 3, 1200), + ('breathing line steady in Hz', 4, 4200), + ]) { + test('hrvTime below the floor: $name, chunks + restarts', () { + final r = math.Random(mode); + final nn = [], t = []; + var clock = 1e6; + for (var i = 0; i < n; i++) { + final s = (clock - 1e6) / 1000; + final v = mode == 3 + ? 1350 + 80 * math.sin(2 * math.pi * s / 3600) + + 40 * math.sin(2 * math.pi * .3 * s) + 3 * (r.nextDouble() - .5) + : 900 + 400 * s / 9000 + 10 * math.sin(2 * math.pi * .25 * s) + + 40 * (r.nextDouble() - .5); + clock += v; + nn.add(v); + t.add(clock); + } + final whole = hrvTime(nn, nnTimesMs: t); + expect(whole.value!.diffAcf1, lessThan(kNnDiffAcf1Floor)); + expect(whole.value!.rmssd, isNotNull, reason: 'the batch publishes'); + var acc = HrvTimeAcc(); + var at = 0; + for (final cut in randomCuts(math.Random(9), n, sizes: [37, 400, 1300])) { + acc.fold(nn.sublist(at, cut), t.sublist(at, cut)); + acc = HrvTimeAcc.fromJson(jsonRoundTrip(acc.toJson())); + at = cut; + final want = hrvTime(nn.sublist(0, cut), nnTimesMs: t.sublist(0, cut)); + final got = acc.evaluate(const [], const []); + expect(got.note, want.note, reason: 'cut=$cut'); + expect(got.value!.rmssd == null, want.value!.rmssd == null, reason: 'cut=$cut'); + if (want.value!.rmssd != null) { + expect((got.value!.rmssd! - want.value!.rmssd!).abs(), + lessThan(1e-9 * want.value!.rmssd!), reason: 'cut=$cut'); + } + } + }); + } +} diff --git a/test/incremental/hrv_resp_nn_test.dart b/test/incremental/hrv_resp_nn_test.dart new file mode 100644 index 0000000..d8f4635 --- /dev/null +++ b/test/incremental/hrv_resp_nn_test.dart @@ -0,0 +1,274 @@ +// Oracle tests for every estimator that consumes the corrected NN series: +// hrvTime, edge _hrvTimeline, nightHrvShape, rsaRespRate, _respPerWindow +// (+ breathingRateVariability on top), hrvFreq. +// The incremental states are fed the SETTLED NN from RrCorrector.fold and +// evaluated with the PROVISIONAL tail; the oracle is the batch function on +// correctRr(all beats so far). +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/driver.dart'; +import '../../tool/incremental/edge_oracles.dart'; +import '../../tool/incremental/hrv_incr.dart'; +import '../../tool/incremental/oracle_util.dart'; +import '../../tool/incremental/resp_incr.dart'; +import '../../tool/incremental/synth.dart'; +import 'support.dart'; + +RrData _realFirstHours(double hours) { + final r = realNightRr()!; + final endMs = r.ts.first + hours * 3.6e6; + var n = r.ts.indexWhere((t) => t > endMs); + if (n < 0) n = r.length; + return RrData(r.rr.sublist(0, n), r.ts.sublist(0, n)); +} + +/// Equal null-ness and within 1e-9 relative (the aggregator's stated +/// tolerance; hrvTime now runs hrvTimeAggregator). +void _near9(double? got, double? want, String reason) { + expect(got == null, want == null, reason: reason); + if (got != null && want != null) { + expect(got, closeTo(want, 1e-9 * want.abs()), reason: reason); + } +} + +void main() { + final errSdnn = Err(), errAcf = Err(), errConf = Err(); + var hrvChecks = 0; + + group('hrvTime accumulators', () { + for (final restart in [false, true]) { + for (final name in ['real 3h', 'synthetic dirty 2h', 'synthetic clean 1h']) { + test('$name restart=$restart', () { + final s = name == 'real 3h' + ? _realFirstHours(3) + : name.contains('dirty') + ? synthRr(const SynthConfig( + seed: 61, hours: 2, ectopicPerMin: 1.5, noiseRunPerMin: 0.3)) + : synthRr(const SynthConfig( + seed: 62, + hours: 1, + ectopicPerMin: 0, + missedPerMin: 0, + extraPerMin: 0, + noiseRunPerMin: 0, + gapPerHour: 0)); + final r = math.Random(restart ? 5 : 6); + final cuts = randomCuts(r, s.length, sizes: [90, 181, 400, 1200, 3000]); + var acc = HrvTimeAcc(); + driveRr(s, cuts, (f) { + acc.fold(f.settled.nn, f.settled.nnTimes); + if (restart) acc = HrvTimeAcc.fromJson(jsonRoundTrip(acc.toJson())); + if (f.fold % 3 != 0 && f.fold != cuts.length - 1) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + expect(f.artifactFraction, af); + final want = hrvTime(o.nn, nnTimesMs: o.nnTimesMs, artifactFraction: af); + final got = acc.evaluate(f.snap.tailNn, f.snap.tailNnTimes, + artifactFraction: af); + hrvChecks++; + expect(got.present, want.present); + if (!want.present) return; + final a = got.value!, b = want.value!; + expect(a.nBeats, b.nBeats); + // rmssd / pnn50 bit-identical; sdann / sdnnIndex 1e-9 relative + // (hrvTime now runs hrvTimeAggregator; was exact). + expect(a.rmssd, b.rmssd, reason: 'rmssd'); + expect(a.pnn50, b.pnn50, reason: 'pnn50'); + _near9(a.sdann, b.sdann, 'sdann'); + _near9(a.sdnnIndex, b.sdnnIndex, 'sdnnIndex'); + // bounded error + errSdnn.add(a.sdnn, b.sdnn); + errAcf.add(a.diffAcf1, b.diffAcf1); + errConf.add(got.confidence, want.confidence); + expect(got.note, want.note); + }); + }); + } + } + test('error summary', () { + // ignore: avoid_print + print('hrvTime checks=$hrvChecks\n sdnn: $errSdnn\n acf1: $errAcf\n conf: $errConf'); + expect(errSdnn.maxRel, lessThan(1e-11)); + expect(errAcf.maxAbs, lessThan(1e-11)); + }); + }); + + group('hrv timeline + night shape', () { + for (final restart in [false, true]) { + test('real 8.9 h night, 15-min folds restart=$restart', () { + final s = realNightRr()!; + final origin = s.ts.first - s.rr.first; + var tl = HrvTimelineState(origin); + var shape = NightShapeState(); + final cuts = timeCuts(s.ts, 900); + final checks = {5, 20, 35, cuts.length - 1}; + driveRr(s, cuts, (f) { + tl.fold(f.settled.nn, f.settled.nnTimes); + shape.fold(f.settled.nn, f.settled.nnTimes); + if (!checks.contains(f.fold)) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + // timeline + expect( + tl.curve(f.snap.tailNn, f.snap.tailNnTimes) + .map((m) => '${m['t']}:${m['v']}') + .toList(), + oracleHrvTimeline(o.nn, o.nnTimesMs, origin) + .map((m) => '${m['t']}:${m['v']}') + .toList(), + reason: 'timeline fold ${f.fold}'); + // night shape + final want = nightHrvShape(o.nn, o.nnTimesMs); + final got = shape.evaluate(f.snap.tailNn, f.snap.tailNnTimes); + sameEnvelope(got, want, why: 'shape ${f.fold}'); + if (want.present) { + expect(got.value!.toJson(), want.value!.toJson()); + final gb = got.value!.bins, wb = want.value!.bins; + for (var i = 0; i < wb.length; i++) { + expect(gb[i].rmssdMs, wb[i].rmssdMs); + expect(gb[i].loMs, wb[i].loMs); + expect(gb[i].hiMs, wb[i].hiMs); + expect(gb[i].nBeats, wb[i].nBeats); + } + expect(got.value!.lastOverFirst, want.value!.lastOverFirst); + } + if (restart) { + // json round trip of the HRV timeline ring is covered by day curve; + // here we only re-create from scratch to prove state is the whole story + } + }, restart: restart); + }); + } + }, timeout: const Timeout(Duration(minutes: 10))); + + group('rsaRespRate / respPerWindow / BRV', () { + test('real night 15-min folds (state JSON-restarted each fold)', () { + final s = realNightRr()!; + var rsa = RsaWelchState(); + var wins = RespWindowsState(); + final cuts = timeCuts(s.ts, 900); + final checks = {0, 1, 2, 3, 12, 20, 30, 33, cuts.length - 1}; + var maxBuf = 0; + driveRr(s, cuts, (f) { + rsa.fold(f.settled.nn, f.settled.nnTimes); + rsa = RsaWelchState.fromJson(jsonRoundTrip(rsa.toJson())); + wins.fold(f.settled.nn, f.settled.nnTimes); + maxBuf = math.max(maxBuf, rsa.bufferedBeats); + if (!checks.contains(f.fold)) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final want = rsaRespRate(o.nn, o.nnTimesMs, artifactFraction: af); + final got = rsa.evaluate(f.snap.tailNn, f.snap.tailNnTimes, + artifactFraction: af); + sameEnvelope(got, want, why: 'rsa fold ${f.fold}'); + expect(got.value?.brpm, want.value?.brpm); + expect(got.value?.peakHz, want.value?.peakHz); + expect(got.value?.power, want.value?.power); + // resp windows + BRV + final wantW = oracleRespPerWindow(o.nn, o.nnTimesMs); + final gotW = wins.evaluate(f.snap.tailNn, f.snap.tailNnTimes); + expect(gotW, orderedEquals(wantW), reason: 'windows fold ${f.fold}'); + if (wantW.length >= 3) { + final a = breathingRateVariability(gotW); + final b = breathingRateVariability(wantW); + expect(a.value!.toJson(), b.value!.toJson()); + } + }); + // ignore: avoid_print + print('rsa: max buffered settled beats=$maxBuf (state carries ' + '${maxBuf * 2} doubles + per-segment peaks)'); + }); + + test('synthetic dirty 2 h, random chunks + restart', () { + final s = synthRr(const SynthConfig(seed: 71, hours: 2.2, ectopicPerMin: 1)); + final r = math.Random(3); + final cuts = randomCuts(r, s.length, sizes: [90, 300, 1000, 2500]); + var rsa = RsaWelchState(); + driveRr(s, cuts, (f) { + rsa.fold(f.settled.nn, f.settled.nnTimes); + if (f.fold % 2 == 0) { + rsa = RsaWelchState.fromJson(jsonRoundTrip(rsa.toJson())); + } + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final want = rsaRespRate(o.nn, o.nnTimesMs, artifactFraction: af); + final got = rsa.evaluate(f.snap.tailNn, f.snap.tailNnTimes, + artifactFraction: af); + sameEnvelope(got, want, why: 'rsa fold ${f.fold} n=${f.n}'); + expect(got.value?.brpm, want.value?.brpm); + }); + }); + }, timeout: const Timeout(Duration(minutes: 15))); + + group('hrvFreq (LF/HF)', () { + test('real night 15-min folds, state JSON-restarted each fold', () { + final s = realNightRr()!; + var hf = HrvFreqState(); + final cuts = timeCuts(s.ts, 900); + final checks = {2, 10, 24, cuts.length - 1}; + var maxBuf = 0; + driveRr(s, cuts, (f) { + hf.fold(f.settled.nn, f.settled.nnTimes); + hf = HrvFreqState.fromJson(jsonRoundTrip(hf.toJson())); + maxBuf = math.max(maxBuf, hf.bufferedBeats); + if (!checks.contains(f.fold)) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final want = hrvFreq(o.nn, o.nnTimesMs, artifactFraction: af); + final got = hf.evaluate(f.snap.tailNn, f.snap.tailNnTimes, + artifactFraction: af); + sameEnvelope(got, want, why: 'hrvFreq fold ${f.fold}'); + if (want.present) { + final a = got.value!, b = want.value!; + expect(a.ulf, b.ulf); + expect(a.vlf, b.vlf); + expect(a.lf, b.lf); + expect(a.hf, b.hf); + expect(a.total, b.total); + expect(a.lfhf, b.lfhf); + expect(a.nuLf, b.nuLf); + expect(a.totalBands, b.totalBands); + } + }); + // ignore: avoid_print + print('hrvFreq: max buffered beats=$maxBuf (ULF/VLF keep the record)'); + }); + test('includeUlf:false keeps the buffer small and is exact below 33 333 s', () { + final s = realNightRr()!; // 32 040 s span < ULF segment + var hf = HrvFreqState(includeUlf: false); + final cuts = timeCuts(s.ts, 900); + final checks = {4, 18, cuts.length - 1}; + var maxBuf = 0, maxBytes = 0; + driveRr(s, cuts, (f) { + hf.fold(f.settled.nn, f.settled.nnTimes); + final txt = jsonEncode(hf.toJson()); + maxBytes = math.max(maxBytes, txt.length); + hf = HrvFreqState.fromJson(jsonDecode(txt) as Map, + includeUlf: false); + maxBuf = math.max(maxBuf, hf.bufferedBeats); + if (!checks.contains(f.fold)) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final want = hrvFreq(o.nn, o.nnTimesMs, artifactFraction: af); + final got = hf.evaluate(f.snap.tailNn, f.snap.tailNnTimes, + artifactFraction: af); + sameEnvelope(got, want, why: 'hrvFreq noULF fold ${f.fold}'); + if (want.present) { + expect(got.value!.vlf, want.value!.vlf); + expect(got.value!.lf, want.value!.lf); + expect(got.value!.hf, want.value!.hf); + expect(got.value!.ulf, want.value!.ulf); + expect(got.value!.total, want.value!.total); + } + }); + // ignore: avoid_print + print('hrvFreq(includeUlf:false): max buffered beats=$maxBuf, ' + 'max state JSON bytes=$maxBytes'); + expect(maxBuf, lessThan(8000)); + }); + }, timeout: const Timeout(Duration(minutes: 20))); +} diff --git a/test/incremental/hrv_windows_test.dart b/test/incremental/hrv_windows_test.dart new file mode 100644 index 0000000..1159591 --- /dev/null +++ b/test/incremental/hrv_windows_test.dart @@ -0,0 +1,205 @@ +// Oracle tests: nocturnalRmssd (with/without stage mask), sleepSessionWindowedRmssd +// (raw RR, bounds), irregularBeatScreen (day-long corrected NN). +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/driver.dart'; +import '../../tool/incremental/hrv_incr.dart'; +import '../../tool/incremental/oracle_util.dart'; +import '../../tool/incremental/synth.dart'; +import 'support.dart'; + +void _sameD(Metric a, Metric b, Err e, {String? why}) { + expect(a.present, b.present, reason: 'present $why'); + expect(a.note, b.note, reason: 'note $why'); + expect(a.tier, b.tier); + if (b.present) { + expect(a.value, b.value, reason: 'value $why'); // bit-identical + e.add(a.confidence, b.confidence); + } +} + +void main() { + final confErr = Err(); + + test('nocturnalRmssd: real night, 15-min folds, with and without a stage mask', () { + final s = realNightRr()!; + final spanSec = ((s.ts.last - s.ts.first) / 1000).ceil() + 10; + // blocky mask: asleep (true) ~70% of the time in 5-40 min blocks + final r = math.Random(1); + final mask = List.filled(spanSec, false); + for (var i = 0; i < spanSec;) { + final len = 300 + r.nextInt(2100); + final v = r.nextDouble() < 0.7; + for (var k = 0; k < len && i < spanSec; k++, i++) { + mask[i] = v; + } + } + final st = NocturnalRmssdState(); + final cuts = timeCuts(s.ts, 900); + final checks = {1, 3, 8, 15, 25, cuts.length - 1}; + driveRr(s, cuts, (f) { + st.fold(f.settled.nn, f.settled.nnTimes); + if (!checks.contains(f.fold)) return; + final o = correctRr(s.rr.sublist(0, f.n), rrTsMs: s.ts.sublist(0, f.n)); + for (final m in [null, mask]) { + final want = nocturnalRmssd(o.nn, o.nnTimesMs, stageMaskPerSec: m); + final got = st.evaluate(f.snap.tailNn, f.snap.tailNnTimes, stageMaskPerSec: m); + _sameD(got, want, confErr, why: 'fold ${f.fold} mask=${m != null}'); + } + }); + }); + + test('nocturnalRmssd: jitter gate (white-noise NN) refuses like the oracle', () { + final r = math.Random(4); + final nn = [], t = []; + var clock = 0.0; + for (var i = 0; i < 6000; i++) { + final v = (800 + 300 * (r.nextDouble() - .5)).roundToDouble(); + clock += v; + nn.add(v); + t.add(clock); + } + final st = NocturnalRmssdState(); + var at = 0; + for (final cut in randomCuts(math.Random(2), nn.length, sizes: [37, 400, 2000])) { + st.fold(nn.sublist(at, cut), t.sublist(at, cut)); + at = cut; + final want = nocturnalRmssd(nn.sublist(0, cut), t.sublist(0, cut)); + final got = st.evaluate(const [], const []); + _sameD(got, want, confErr, why: 'cut=$cut'); + } + expect(nocturnalRmssd(nn, t).present, isFalse, reason: 'fixture trips the gate'); + }); + + test('sleepSessionWindowedRmssd: raw RR, 15-min folds, session bounds inside the night', () { + final s = realNightRr()!; + final start = (s.ts.first / 1000).floor() + 1234; + final end = (s.ts.last / 1000).floor() - 987; + final cuts = timeCuts(s.ts, 900); + final st = SessionRmssdState(start, end); + var from = 0; + for (var k = 0; k < cuts.length; k++) { + st.fold(s.rr.sublist(from, cuts[k]), s.ts.sublist(from, cuts[k])); + from = cuts[k]; + if (k % 4 == 0 || k == cuts.length - 1) { + // oracle with the session end clipped to the data seen so far + final seenEnd = math.min(end, (s.ts[cuts[k] - 1] / 1000).round() + 1); + final want = sleepSessionWindowedRmssd( + s.rr.sublist(0, cuts[k]), s.ts.sublist(0, cuts[k]), + startSec: start, endSec: end); + final got = st.evaluate(); + _sameD(got, want, confErr, why: 'k=$k seenEnd=$seenEnd'); + } + } + }); + + test('sleepSessionWindowedRmssd: dirty synthetic night + seams', () { + final s = synthRr(const SynthConfig( + seed: 91, hours: 4, ectopicPerMin: 2, noiseRunPerMin: 0.6, gapPerHour: 10)); + final start = (s.ts.first / 1000).floor() + 100; + final end = (s.ts.last / 1000).floor() - 100; + final st = SessionRmssdState(start, end); + var at = 0; + for (final cut in randomCuts(math.Random(8), s.length, sizes: [3, 200, 1500])) { + st.fold(s.rr.sublist(at, cut), s.ts.sublist(at, cut)); + at = cut; + final want = sleepSessionWindowedRmssd( + s.rr.sublist(0, cut), s.ts.sublist(0, cut), + startSec: start, endSec: end); + _sameD(st.evaluate(), want, confErr, why: 'cut=$cut'); + } + }); + + group('irregularBeatScreen', () { + // AF-like bursts: irregularly irregular RR in 5-30 min blocks. + ({List nn, List t}) fixture(int seed, double afShare) { + final r = math.Random(seed); + final nn = [], t = []; + var clock = 0.0; + var af = false; + var left = 0; + while (clock < 8 * 3600 * 1000.0) { + if (--left <= 0) { + af = r.nextDouble() < afShare; + left = 300 + r.nextInt(2000); + } + final v = af + ? (420 + r.nextInt(700)).toDouble() + : (850 + 40 * math.sin(clock / 9000) + 25 * (r.nextDouble() - .5)).roundToDouble(); + clock += v; + nn.add(v); + t.add(clock); + } + return (nn: nn, t: t); + } + + for (final share in [0.0, 0.35, 0.75, 1.0]) { + for (final restart in [false, true]) { + test('af share=$share restart=$restart', () { + final x = fixture((share * 100).round() + 3, share); + var st = IrregularScreenState(); + var at = 0; + var flags = 0, checks = 0; + for (final cut in randomCuts(math.Random(5), x.nn.length, sizes: [90, 500, 4000])) { + st.fold(x.nn.sublist(at, cut), x.t.sublist(at, cut)); + at = cut; + if (restart) st = IrregularScreenState.fromJson(jsonRoundTrip(st.toJson())); + for (final af in [0.0, 0.31]) { + final want = irregularBeatScreen(x.nn.sublist(0, cut), + nnTimesMs: x.t.sublist(0, cut), artifactFraction: af); + final got = st.evaluate(const [], const [], artifactFraction: af); + expect(got.present, want.present, reason: 'cut=$cut af=$af'); + expect(got.note, want.note); + if (want.present) { + expect(got.value!.flag, want.value!.flag, reason: 'flag cut=$cut'); + expect(got.value!.nBeats, want.value!.nBeats); + expect(got.value!.pnnPct, want.value!.pnnPct); + expect((got.value!.sd1 - want.value!.sd1).abs(), lessThan(1e-9)); + expect((got.value!.sd2 - want.value!.sd2).abs(), lessThan(1e-9)); + confErr.add(got.confidence, want.confidence); + if (want.value!.flag) flags++; + checks++; + } + } + } + // ignore: avoid_print + print('irregular share=$share: checks=$checks flagged=$flags'); + }); + } + } + + test('whole day through RrCorrector -> screen (3 checkpoints)', () { + final day = realShapedDay(); + final st = IrregularScreenState(); + final cuts = timeCuts(day.ts, 900); + final checks = {cuts.length ~/ 3, 2 * cuts.length ~/ 3, cuts.length - 1}; + driveRr(day, cuts, (f) { + st.fold(f.settled.nn, f.settled.nnTimes); + if (!checks.contains(f.fold)) return; + final o = correctRr(day.rr.sublist(0, f.n), rrTsMs: day.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final want = irregularBeatScreen(o.nn, + nnTimesMs: o.nnTimesMs, artifactFraction: af); + final got = st.evaluate(f.snap.tailNn, f.snap.tailNnTimes, artifactFraction: af); + expect(got.present, want.present); + expect(got.note, want.note); + if (want.present) { + expect(got.value!.flag, want.value!.flag); + expect(got.value!.nBeats, want.value!.nBeats); + expect(got.value!.pnnPct, want.value!.pnnPct); + expect((got.value!.sd1 - want.value!.sd1).abs(), lessThan(1e-9)); + expect((got.value!.sd2 - want.value!.sd2).abs(), lessThan(1e-9)); + } + }); + }, timeout: const Timeout(Duration(minutes: 10))); + }); + + test('confidence error summary', () { + // ignore: avoid_print + print('confidence (derived from acf / counts): $confErr'); + }); +} diff --git a/test/incremental/rr_stream_test.dart b/test/incremental/rr_stream_test.dart new file mode 100644 index 0000000..f1f7791 --- /dev/null +++ b/test/incremental/rr_stream_test.dart @@ -0,0 +1,329 @@ +// Oracle test: streaming RrCorrector == correctRr over the series so far. +// BIT-identical (nn, nnTimes, per-beat classes, counts, cleanFraction), for +// random chunkings, restart-from-JSON between chunks, and awkward inputs. +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/oracle_util.dart'; +import '../../tool/incremental/rr_stream.dart'; +import '../../tool/incremental/synth.dart'; + +class _Run { + RrCorrector c; + final settledNn = [], settledT = []; + final classes = {}; + _Run(this.c) { + _hook(); + } + void _hook() => c.debugClassSink = (g, cls) => classes[g] = cls; + void restart() { + c = RrCorrector.fromJson(jsonRoundTrip(c.toJson())); + _hook(); + } + + void fold(List rr, List? ts) { + final s = c.fold(rr, tsMs: ts); + settledNn.addAll(s.nn); + settledT.addAll(s.nnTimes); + } +} + +void _expectSame(_Run run, List rr, List? ts, int n, + {double alpha = 5.2, int win = 91, String? why}) { + final oracle = correctRr(rr.sublist(0, n), + rrTsMs: ts?.sublist(0, n), alpha: alpha, windowBeats: win, + maxArtifactRun: null); + final snap = run.c.snapshot(withClasses: true); + expect(snap.n, n, reason: why); + final nn = [...run.settledNn, ...snap.tailNn]; + final tt = [...run.settledT, ...snap.tailNnTimes]; + expect(nn, orderedEquals(oracle.nn), reason: 'nn $why'); + expect(tt, orderedEquals(oracle.nnTimesMs), reason: 'nnTimes $why'); + expect(snap.cleanFraction, oracle.cleanFraction, reason: 'clean $why'); + expect(snap.droppedCount, oracle.droppedCount, reason: 'dropped $why'); + expect(snap.correctedCount, oracle.correctedCount, reason: 'corrected $why'); + // classes: sink (final) ++ tail (provisional) + final cl = [ + for (var g = 0; g < snap.classifiedBeats; g++) + BeatClass.values[run.classes[g]!], + ...snap.tailClasses ?? const [], + ]; + if (n >= 3) { + expect(cl, orderedEquals(oracle.classes), reason: 'classes $why'); + } +} + +void main() { + // why: the research corrector is uncapped; its oracle must use that same mode. + // # catches: default capped correction introduces gap labels for the hard run. + test('prototype oracle uses uncapped artifact policy', () { + final rr = [...List.filled(200, 800.0), ...List.filled(40, 3000.0), ...List.filled(200, 800.0)]; + final run = _Run(RrCorrector())..fold(rr, null); + _expectSame(run, rr, null, rr.length); + }); + test('every prefix, 1 beat at a time (n = 0..260), with artefacts', () { + for (final seed in [3, 4]) { + final s = synthRr(SynthConfig( + seed: seed, + hours: 0.12, + ectopicPerMin: 2, + missedPerMin: 1, + extraPerMin: 1, + noiseRunPerMin: 0.8, + gapPerHour: 20)); + final n = math.min(260, s.length); + final run = _Run(RrCorrector()); + for (var i = 0; i < n; i++) { + run.fold([s.rr[i]], [s.ts[i]]); + _expectSame(run, s.rr, s.ts, i + 1, why: 'seed=$seed i=$i'); + } + } + }); + + test('every prefix with a JSON restart between EVERY beat', () { + final s = synthRr(SynthConfig( + seed: 11, + hours: 0.1, + ectopicPerMin: 2, + noiseRunPerMin: 0.6, + gapPerHour: 10)); + final n = math.min(230, s.length); + final run = _Run(RrCorrector()); + for (var i = 0; i < n; i++) { + run.fold([s.rr[i]], [s.ts[i]]); + run.restart(); + _expectSame(run, s.rr, s.ts, i + 1, why: 'i=$i'); + } + }); + + test('1-beat folds over 4000 dirty beats: settled output == oracle', () { + // Hits every alignment between a fold boundary and an isolated artefact's + // right-anchor search (the case a coarse chunking rarely lands on). + final s = synthRr(const SynthConfig( + seed: 51, + hours: 1.2, + ectopicPerMin: 3, + missedPerMin: 2, + extraPerMin: 2, + noiseRunPerMin: 1, + gapPerHour: 8)); + final n = math.min(4000, s.length); + final run = _Run(RrCorrector()); + for (var i = 0; i < n; i++) { + run.fold([s.rr[i]], [s.ts[i]]); + if (i % 997 == 0) run.restart(); + } + _expectSame(run, s.rr, s.ts, n, why: 'final'); + }); + + group('random chunking', () { + final cfgs = { + 'clean': const SynthConfig( + seed: 21, + hours: 1.5, + ectopicPerMin: 0, + missedPerMin: 0, + extraPerMin: 0, + noiseRunPerMin: 0, + gapPerHour: 0), + 'default': const SynthConfig(seed: 22, hours: 2), + 'dirty': const SynthConfig( + seed: 23, + hours: 1.5, + ectopicPerMin: 2, + missedPerMin: 1, + extraPerMin: 1, + noiseRunPerMin: 0.5, + gapPerHour: 12), + 'backwards-ts': const SynthConfig( + seed: 24, hours: 1.5, backwardsPerHour: 30, gapPerHour: 6), + }; + for (final e in cfgs.entries) { + for (final restart in [false, true]) { + test('${e.key} restart=$restart', () { + final s = synthRr(e.value); + final r = math.Random(e.value.seed * 7 + (restart ? 1 : 0)); + final cuts = randomCuts(r, s.length); + final run = _Run(RrCorrector()); + var from = 0; + var maxLag = 0; + for (var k = 0; k < cuts.length; k++) { + final to = cuts[k]; + run.fold(s.rr.sublist(from, to), s.ts.sublist(from, to)); + from = to; + maxLag = math.max(maxLag, to - run.c.settledBeats); + if (restart && r.nextBool()) run.restart(); + if (k % 11 == 0 || k == cuts.length - 1) { + _expectSame(run, s.rr, s.ts, to, why: '${e.key} cut=$to'); + } + } + // buffer is bounded: not the whole series + expect(run.c.bufferedBeats, lessThan(2000)); + // ignore: avoid_print + print('${e.key}: beats=${s.length} folds=${cuts.length} ' + 'max unsettled lag=$maxLag beats'); + }); + } + } + }); + + test('sub-second beat timestamps (non-integer ms) stream identically', () { + final s = synthRr(const SynthConfig(seed: 33, hours: 1, gapPerHour: 6)); + final r = math.Random(12); + final ts = [for (final t in s.ts) t + r.nextInt(1000) + r.nextDouble()]; + final run = _Run(RrCorrector()); + var from = 0; + for (final to in randomCuts(r, s.length)) { + run.fold(s.rr.sublist(from, to), ts.sublist(from, to)); + from = to; + if (to % 4 == 0 || to == s.length) _expectSame(run, s.rr, ts, to, why: 'to=$to'); + } + _expectSame(run, s.rr, ts, s.length); + }); + + test('no timestamps (null ts) streams identically', () { + final s = synthRr(const SynthConfig(seed: 31, hours: 1)); + final run = _Run(RrCorrector()); + final r = math.Random(5); + var from = 0; + for (final to in randomCuts(r, s.length)) { + run.fold(s.rr.sublist(from, to), null); + from = to; + if (to % 3 == 0 || to == s.length) { + _expectSame(run, s.rr, null, to, why: 'to=$to'); + } + } + _expectSame(run, s.rr, null, s.length); + }); + + for (final p in [ + (alpha: 5.2, win: 31), + (alpha: 4.0, win: 61), + (alpha: 5.2, win: 121), + (alpha: 6.5, win: 91), + ]) { + test('params alpha=${p.alpha} win=${p.win}', () { + final s = synthRr(const SynthConfig(seed: 41, hours: 1, ectopicPerMin: 1)); + final run = + _Run(RrCorrector(alpha: p.alpha, windowBeats: p.win)); + final r = math.Random(9); + var from = 0; + for (final to in randomCuts(r, s.length)) { + run.fold(s.rr.sublist(from, to), s.ts.sublist(from, to)); + from = to; + if (to % 5 == 0 || to == s.length) { + _expectSame(run, s.rr, s.ts, to, + alpha: p.alpha, win: p.win, why: 'to=$to'); + } + } + _expectSame(run, s.rr, s.ts, s.length, alpha: p.alpha, win: p.win); + }); + } + + group('degenerate inputs', () { + void drive(List rr, List ts, String why) { + final run = _Run(RrCorrector()); + final r = math.Random(3); + var from = 0; + for (final to in randomCuts(r, rr.length, sizes: [1, 2, 3, 50, 200])) { + run.fold(rr.sublist(from, to), ts.sublist(from, to)); + from = to; + _expectSame(run, rr, ts, to, why: '$why to=$to'); + } + } + + test('constant RR (QD = 0, floor governs, massive ties)', () { + final rr = List.filled(500, 800); + drive(rr, [for (var i = 0; i < 500; i++) 1.7e12 + i * 800.0], 'const'); + }); + test('constant with one outlier at start / middle / end', () { + for (final at in [0, 1, 250, 498, 499]) { + final rr = List.filled(500, 800)..[at] = 1700; + drive(rr, [for (var i = 0; i < 500; i++) i * 800.0], 'outlier@$at'); + } + }); + test('long artefact runs (>90 beats) and alternating artefacts', () { + final r = math.Random(5); + final rr = [ + for (var i = 0; i < 700; i++) + (i >= 150 && i < 290) + ? 250 + r.nextInt(2000).toDouble() + : (i >= 400 && i < 500 && i.isEven) + ? 2200 + : 800 + r.nextInt(40).toDouble() + ]; + var t = 0.0; + final ts = [ + for (final v in rr) (t += v) + ]; + drive(rr, ts, 'longrun'); + }); + test('artefact beat 0 and 1 (no left anchors), tail artefact (no right)', () { + final rr = [2500, 800, 810, 790, 805, for (var i = 0; i < 300; i++) 800 + (i % 7) * 3.0, 2500]; + drive(rr, [for (var i = 0; i < rr.length; i++) i * 800.0], 'edges'); + }); + test('n = 1, 2, 3 transitions', () { + final rr = [800, 810, 790, 805, 3000, 800]; + drive(rr, [for (var i = 0; i < rr.length; i++) i * 800.0], 'tiny'); + }); + }); + + test('REAL 8.9 h night: 15-min folds, restart each, 6 checkpoints', () { + final s = realNightRr()!; + final cuts = timeCuts(s.ts, 900); + final run = _Run(RrCorrector()); + var from = 0; + final checks = {0, cuts.length ~/ 5, cuts.length ~/ 2, cuts.length - 2, + cuts.length - 1}; + for (var k = 0; k < cuts.length; k++) { + run.fold(s.rr.sublist(from, cuts[k]), s.ts.sublist(from, cuts[k])); + from = cuts[k]; + run.restart(); + if (checks.contains(k)) { + _expectSame(run, s.rr, s.ts, cuts[k], why: 'real k=$k'); + } + } + }, timeout: const Timeout(Duration(minutes: 5))); + + // why: a chunk with one bad beat must not leave the beats before it applied. + // # catches: fold([800, nan]) keeps beat 1, so retrying the fixed chunk + // counts it twice. + test('a rejected chunk changes nothing and can be retried', () { + final ts = [for (var i = 1; i <= 6; i++) i * 800.0]; + final rr = List.filled(6, 800.0); + final clean = RrCorrector()..fold(rr, tsMs: ts); + final c = RrCorrector(); + expect(() => c.fold([800, double.nan], tsMs: [800, 1600]), + throwsArgumentError); + expect(() => c.fold([800, 800], tsMs: [800, double.nan]), + throwsArgumentError); + expect(c.bufferedBeats, 0); + expect(c.classifiedBeats, 0); + c.fold(rr, tsMs: ts); + expect(c.bufferedBeats, clean.bufferedBeats); + expect(c.classifiedBeats, clean.classifiedBeats); + expect(c.snapshot().tailNn, clean.snapshot().tailNn); + }); + + // why: the first fold decides whether the stream is timestamped; a rejected + // first chunk must not decide it. + test('a rejected first chunk does not fix the timestamp mode', () { + final c = RrCorrector(); + expect(() => c.fold([800, double.nan]), throwsArgumentError); + c.fold([800, 800, 800], tsMs: [800, 1600, 2400]); + expect(c.bufferedBeats, 3); + }); + + // why: a non-positive step never advances the cut position. + // # catches: sec = 0 or a negative sec loops forever in timeCuts. + test('timeCuts rejects a non-positive or non-finite step', () { + final ts = [1000.0, 2000.0, 3000.0]; + for (final bad in [0.0, -1.0, double.nan, double.infinity]) { + expect(() => timeCuts(ts, bad), throwsArgumentError, reason: '$bad'); + } + expect(timeCuts(ts, 1), [1, 2, 3]); + }); +} diff --git a/test/incremental/support.dart b/test/incremental/support.dart new file mode 100644 index 0000000..7573dab --- /dev/null +++ b/test/incremental/support.dart @@ -0,0 +1,33 @@ +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +/// Max-error recorder for approximate fields. +class Err { + double maxAbs = 0, maxRel = 0; + int n = 0; + void add(double? a, double? b) { + if (a == null || b == null) { + expect(a, b); + return; + } + expect(a.isFinite, isTrue, reason: 'non-finite actual: $a'); + expect(b.isFinite, isTrue, reason: 'non-finite expected: $b'); + n++; + final d = (a - b).abs(); + if (d > maxAbs) maxAbs = d; + final rel = b == 0 ? d : d / b.abs(); + if (rel > maxRel) maxRel = rel; + } + + @override + String toString() => 'n=$n maxAbs=${maxAbs.toStringAsExponential(2)} maxRel=${maxRel.toStringAsExponential(2)}'; +} + +void sameEnvelope(Metric a, Metric b, {String? why, bool exactConf = true}) { + expect(a.present, b.present, reason: 'present $why'); + expect(a.tier, b.tier, reason: 'tier $why'); + expect(a.inputs_used, b.inputs_used, reason: 'inputs $why'); + expect(a.note, b.note, reason: 'note $why'); + if (exactConf) expect(a.confidence, b.confidence, reason: 'conf $why'); +} diff --git a/test/incremental/thin_input_test.dart b/test/incremental/thin_input_test.dart new file mode 100644 index 0000000..2448448 --- /dev/null +++ b/test/incremental/thin_input_test.dart @@ -0,0 +1,117 @@ +// "Never fabricate": at EVERY short prefix (1-beat folds, n = 0..~700) every +// incremental estimator is absent exactly where its oracle is absent, present +// exactly where it is present, with the same note. +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:test/test.dart'; + +import '../../tool/incremental/driver.dart'; +import '../../tool/incremental/edge_oracles.dart'; +import '../../tool/incremental/hrv_incr.dart'; +import '../../tool/incremental/resp_incr.dart'; +import '../../tool/incremental/synth.dart'; +import 'support.dart'; + +void main() { + for (final name in ['clean', 'dirty']) { + test('thin-input sweep, 1-beat folds ($name)', () { + final s = name == 'clean' + ? synthRr(const SynthConfig( + seed: 101, + hours: 0.4, + ectopicPerMin: 0, + missedPerMin: 0, + extraPerMin: 0, + noiseRunPerMin: 0, + gapPerHour: 0)) + : synthRr(const SynthConfig( + seed: 102, hours: 0.4, ectopicPerMin: 3, noiseRunPerMin: 1, gapPerHour: 20)); + final n = s.length < 760 ? s.length : 760; + final sub = RrData(s.rr.sublist(0, n), s.ts.sublist(0, n)); + final cuts = [for (var i = 1; i <= n; i++) i]; + final acc = HrvTimeAcc(); + final noc = NocturnalRmssdState(); + final shape = NightShapeState(binMin: 2, minBeatsPerBin: 20); + final rsa = RsaWelchState(); + final hf = HrvFreqState(); + final irr = IrregularScreenState(); + final origin = sub.ts.first - sub.rr.first; + final tl = HrvTimelineState(origin); + final wins = RespWindowsState(windowMs: 120000, minBeats: 20); + var presentCount = {}; + void tally(String k, bool p) => presentCount[k] = (presentCount[k] ?? 0) + (p ? 1 : 0); + driveRr(sub, cuts, (f) { + acc.fold(f.settled.nn, f.settled.nnTimes); + noc.fold(f.settled.nn, f.settled.nnTimes); + shape.fold(f.settled.nn, f.settled.nnTimes); + rsa.fold(f.settled.nn, f.settled.nnTimes); + hf.fold(f.settled.nn, f.settled.nnTimes); + irr.fold(f.settled.nn, f.settled.nnTimes); + tl.fold(f.settled.nn, f.settled.nnTimes); + wins.fold(f.settled.nn, f.settled.nnTimes); + if (!(f.n <= 130 || f.n % 9 == 0)) return; + final o = correctRr(sub.rr.sublist(0, f.n), rrTsMs: sub.ts.sublist(0, f.n)); + final af = (1.0 - o.cleanFraction).clamp(0.0, 1.0).toDouble(); + final why = '$name n=${f.n}'; + // hrvTime + var w = hrvTime(o.nn, nnTimesMs: o.nnTimesMs, artifactFraction: af); + var g = acc.evaluate(f.snap.tailNn, f.snap.tailNnTimes, artifactFraction: af); + expect(g.present, w.present, reason: 'hrvTime $why'); + expect(g.note, w.note); + tally('hrvTime', w.present); + if (w.present) { + expect(g.value!.rmssd == null, w.value!.rmssd == null, reason: why); + expect(g.value!.sdann == null, w.value!.sdann == null, reason: why); + expect(g.value!.sdnnIndex == null, w.value!.sdnnIndex == null, reason: why); + expect(g.value!.diffAcf1 == null, w.value!.diffAcf1 == null, reason: why); + } + // nocturnal + final wn = nocturnalRmssd(o.nn, o.nnTimesMs); + final gn = noc.evaluate(f.snap.tailNn, f.snap.tailNnTimes); + expect(gn.present, wn.present, reason: 'nocturnal $why'); + expect(gn.note, wn.note); + tally('nocturnalRmssd', wn.present); + // shape (2-min bins so the 3-bin gate is reachable at this size) + final ws = nightHrvShape(o.nn, o.nnTimesMs, minBeatsPerBin: 20, binMin: 2); + final gs = shape.evaluate(f.snap.tailNn, f.snap.tailNnTimes); + sameEnvelope(gs, ws, why: 'shape $why'); + if (ws.present) expect(gs.value!.toJson(), ws.value!.toJson(), reason: why); + tally('nightShape', ws.present); + // rsa + final wr = rsaRespRate(o.nn, o.nnTimesMs, artifactFraction: af); + final gr = rsa.evaluate(f.snap.tailNn, f.snap.tailNnTimes, artifactFraction: af); + expect(gr.present, wr.present, reason: 'rsa $why'); + expect(gr.note, wr.note); + expect(gr.value?.brpm, wr.value?.brpm); + tally('rsa', wr.present); + // hrvFreq + final wf = hrvFreq(o.nn, o.nnTimesMs, artifactFraction: af); + final gf = hf.evaluate(f.snap.tailNn, f.snap.tailNnTimes, artifactFraction: af); + expect(gf.present, wf.present, reason: 'hrvFreq $why'); + expect(gf.note, wf.note); + tally('hrvFreq', wf.present); + if (wf.present) { + expect(gf.value!.lf, wf.value!.lf, reason: why); + expect(gf.value!.hf, wf.value!.hf, reason: why); + expect(gf.value!.vlf, wf.value!.vlf, reason: why); + } + // irregular (500-beat floor: absent until then, exactly) + final wi = irregularBeatScreen(o.nn, nnTimesMs: o.nnTimesMs, artifactFraction: af); + final gi = irr.evaluate(f.snap.tailNn, f.snap.tailNnTimes, artifactFraction: af); + expect(gi.present, wi.present, reason: 'irregular $why'); + expect(gi.note, wi.note); + tally('irregular', wi.present); + // timeline + final wt = oracleHrvTimeline(o.nn, o.nnTimesMs, origin); + expect(tl.curve(f.snap.tailNn, f.snap.tailNnTimes).length, wt.length, + reason: 'timeline $why'); + // resp windows + final ww = oracleRespPerWindow(o.nn, o.nnTimesMs, windowMs: 120000, minBeats: 20); + expect(wins.evaluate(f.snap.tailNn, f.snap.tailNnTimes), orderedEquals(ww), + reason: 'respWindows $why'); + }); + // ignore: avoid_print + print('$name: present-at-checked-prefix counts $presentCount'); + }); + } +} diff --git a/test/onehz/cr105_welch_test.dart b/test/onehz/cr105_welch_test.dart new file mode 100644 index 0000000..b9b22e2 --- /dev/null +++ b/test/onehz/cr105_welch_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import 'welch_stream_test.dart' show expectSameHrv, expectSameResp; + +void main() { + // why: compaction must retain powers of completed segments, including ULF. + // # catches: a retained span shorter than segSec hides previously accumulated power. + test('ULF stays present after ten-hour compaction', () { + final nn = List.generate(18001, (i) => 1900 + 20 * math.sin(i / 1000)); + final ts = List.generate(nn.length, (i) => i * 2000.0); + final s = HrvFreqStream()..fold(nn, ts); + final want = hrvFreq(nn, ts, artifactFraction: 0); + expect(want.value!.ulf, isNotNull); + expectSameHrv(s.snapshot(artifactFraction: 0), want, 'ten hours'); + }); + // why: capped-reset clocks may regress slightly; batch drops at most 5%. + // # catches: throwing at the first regression or forgetting omissions at checkpoint. + test('spectral stream follows batch regression policy at prefixes and tails', + () { + final nn = List.generate(801, (i) => 800 + 20 * math.sin(i / 4)); + final ts = List.generate(nn.length, (i) => i * 800.0); + ts[410] = ts[409] - 500; + var s = HrvFreqStream(); + s.fold(nn.sublist(0, 410), ts.sublist(0, 410)); + expectSameHrv( + s.snapshot( + tailNnMs: nn.sublist(410), + tailNnTimesMs: ts.sublist(410), + artifactFraction: 0), + hrvFreq(nn, ts, artifactFraction: 0), + 'tail'); + s.fold(nn.sublist(410), ts.sublist(410)); + s = HrvFreqStream.fromJson( + jsonDecode(jsonEncode(s.toJson())) as Map); + expectSameHrv(s.snapshot(artifactFraction: 0), + hrvFreq(nn, ts, artifactFraction: 0), 'fold'); + final bad = HrvFreqStream() + ..fold(List.filled(40, 800.0), + [for (var i = 0; i < 40; i++) i < 20 ? i * 800.0 : 1000.0]); + expect(bad.snapshot(artifactFraction: 0).note, 'regressing beat time'); + }); + // why: non-finite provisional clocks must abstain, just like batch, before order checks. + // # catches: -Infinity is treated as a finite backward timestamp and throws. + test('RSA and CVHR abstain on a non-finite tail', () { + final nn = List.filled(70, 800.0); + final ts = List.generate(70, (i) => i * 800.0); + final rsa = RsaRespStream()..fold(nn, ts); + final cvhr = CvhrStream()..fold(nn, ts); + for (final bad in [double.negativeInfinity, double.infinity, double.nan]) { + final r = rsa + .snapshot(tailNnMs: [800], tailNnTimesMs: [bad], artifactFraction: 0); + expectSameResp( + r, + rsaRespRate([...nn, 800], [...ts, bad], artifactFraction: 0), + 'RSA $bad'); + final c = cvhr + .snapshot(tailNnMs: [800], tailNnTimesMs: [bad], artifactFraction: 0); + expect(c.present, isFalse); + expect(c.note, 'non-finite beat time'); + } + }); +} diff --git a/test/onehz/cvhr_nan_test.dart b/test/onehz/cvhr_nan_test.dart new file mode 100644 index 0000000..f120b1a --- /dev/null +++ b/test/onehz/cvhr_nan_test.dart @@ -0,0 +1,98 @@ +// cvhrApneaScreen used to loop forever on a non-finite NN value: a NaN +// excursion made both `exc[i] <= prom` and `exc[i] > prom` false in +// _scoreSegment, so the scan index never advanced. +// +// A busy loop on the main isolate cannot be interrupted by the test timeout, +// so each call runs in a spawned isolate that is killed after 5 s. A hang +// therefore shows up as a clean test failure, not a stuck test run. +// +// Assertions, and why they hold: +// * The baseline series is constant NN (800 ms), so it has no dip at all. +// Every finite excursion is exactly 0, so no finite stretch can start a +// cycle. Non-finite samples must not start one either (the intended +// behaviour: a non-finite excursion is never above the prominence +// threshold). So the cycle count must be 0, which is also the count for +// the same series with the bad beats removed. +// * The series are 400 beats (~320 s), long enough to pass the >=60 beat and +// >=60 s gates and to be one gap-free segment, so the result is present. +import 'dart:isolate'; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +const _n = 400; +const _nn = 800.0; + +/// Entry point for the spawned isolate: msg = [SendPort, nn, tMs]. +void _entry(List msg) { + final port = msg[0] as SendPort; + final nn = (msg[1] as List).cast(); + final t = (msg[2] as List).cast(); + final m = cvhrApneaScreen(nn, t, artifactFraction: 0.0); + port.send(m.value?.cycleCount ?? -1); +} + +/// Cycle count, -1 when the metric is absent. Fails the test on a hang. +Future _count(List nn) async { + final t = [for (var i = 0; i < nn.length; i++) i * _nn]; + final rp = ReceivePort(); + final iso = await Isolate.spawn(_entry, [rp.sendPort, nn, t]); + try { + final r = await rp.first.timeout(const Duration(seconds: 5)); + return r as int; + } finally { + iso.kill(priority: Isolate.immediate); + rp.close(); + } +} + +List _flat() => List.filled(_n, _nn); + +List _with(Map bad) { + final x = _flat(); + bad.forEach((i, v) => x[i] = v); + return x; +} + +void main() { + const to = Timeout(Duration(seconds: 15)); + const nan = double.nan; + const inf = double.infinity; + + test('control: flat series has no cycles', () async { + expect(await _count(_flat()), 0); + }, timeout: to); + + test('single NaN mid-series returns, no cycles', () async { + expect(await _count(_with({200: nan})), 0); + }, timeout: to); + + test('NaN run mid-series returns, no cycles', () async { + expect( + await _count(_with({for (var i = 150; i < 190; i++) i: nan})), 0); + }, timeout: to); + + test('NaN at the start returns, no cycles', () async { + expect(await _count(_with({0: nan, 1: nan, 2: nan})), 0); + }, timeout: to); + + test('NaN at the end returns, no cycles', () async { + expect(await _count(_with({_n - 1: nan, _n - 2: nan})), 0); + }, timeout: to); + + test('all-NaN segment returns, no cycles', () async { + expect(await _count(List.filled(_n, nan)), 0); + }, timeout: to); + + test('+Infinity returns, no cycles', () async { + expect(await _count(_with({200: inf})), 0); + }, timeout: to); + + test('-Infinity returns, no cycles', () async { + expect(await _count(_with({200: -inf})), 0); + }, timeout: to); + + test('mixed NaN and +-Infinity returns, no cycles', () async { + expect(await _count(_with({50: nan, 120: inf, 260: -inf, 330: nan})), 0); + }, timeout: to); +} diff --git a/test/onehz/cvhr_stream_test.dart b/test/onehz/cvhr_stream_test.dart new file mode 100644 index 0000000..039f810 --- /dev/null +++ b/test/onehz/cvhr_stream_test.dart @@ -0,0 +1,599 @@ +// RED (round 22): exact streaming CVHR. +// +// CvhrStream.snapshot(...) must equal cvhrApneaScreen(folded ++ tail, ...) BIT +// FOR BIT at every fold: == on doubles, same NaN-ness and sign of zero, same +// note, confidence, quartiles. No tolerance. The batch function is the +// reference and does not change in this round. +// +// Locality facts the tests lean on (checked against cvhr_apnea.dart): +// * 1 Hz resample at grid g needs the beat bracketing g. A beat AT g that is +// the last beat clamps to y.last, but once a later beat exists the same +// point is y[j] + (y[j+1]-y[j]) * 1.0, which can differ in the last bit, +// so the on-the-grid tests below feed beats exactly on whole seconds. +// * Savitzky-Golay reads +-7 grid points, the median +-65 s (clipped at the +// segment ends); P75, prominence and run detection read the whole segment. +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +// ───────────────────────── synthetic nights ───────────────────────────────── + +class Night { + final List nn = [], times = []; +} + +/// A seeded night of [hours]: ~1000 ms NN with breathing and noise. With +/// [apnea], 30-60 s cyclic bradycardia (+80 ms half-sine bumps, 20 s wide, +/// every 45 s; the period wobbles per stretch) over alternating stretches of +/// the night. [jumps] are (atSec, lengthSec) holes in the recording clock. +/// [fast] = (fromSec, toSec) runs 500 ms NN there (>= 60 beats in < 60 s). +Night synth(int seed, double hours, + {bool apnea = true, + List<(double, double)> jumps = const [], + (double, double)? fast}) { + final rnd = math.Random(seed); + final n = Night(); + final endS = hours * 3600; + var s = 0.0; // recording clock, seconds + var t = 1.7e12; // beat clock, ms + final phase = rnd.nextDouble() * 6.28; + final jl = [...jumps]..sort((a, b) => a.$1.compareTo(b.$1)); + var ji = 0; + while (s < endS) { + while (ji < jl.length && s >= jl[ji].$1) { + t += jl[ji].$2 * 1000; + s += jl[ji].$2; + ji++; + } + var v = 1000.0 + + 25 * math.sin(2 * math.pi * 0.24 * s + phase) + + 30 * math.sin(s / 900 + seed) + + 6 * (rnd.nextDouble() - .5) * 2; + if (apnea) { + // Stretches of 12 min with events, 8 min without. + final stretch = (s % 1200); + if (stretch < 720) { + final period = 45.0 + 5 * (s ~/ 1200 % 3); + final u = s % period; + if (u < 20) v += 80 * math.sin(math.pi * u / 20); + } + } + if (fast != null && s >= fast.$1 && s < fast.$2) v = 500; + v = v.roundToDouble(); + t += v; + s += v / 1000; + n.nn.add(v); + n.times.add(t); + } + return n; +} + +// ───────────────────────── bit-for-bit comparison ─────────────────────────── + +bool sameD(double? a, double? b) { + if (a == null || b == null) return a == null && b == null; + if (a.isNaN || b.isNaN) return a.isNaN && b.isNaN; + return a == b && a.isNegative == b.isNegative; +} + +/// Empty string when equal, else the first difference. (A String so it can +/// cross an isolate boundary.) +String diffD(double? a, double? b, String what) => + sameD(a, b) ? '' : '$what: $a vs $b; '; + +String diffList(List? a, List? b, String what) { + if (a == null || b == null) return a == null && b == null ? '' : '$what null; '; + if (a.length != b.length) return '$what length; '; + final sb = StringBuffer(); + for (var i = 0; i < a.length; i++) { + sb.write(diffD(a[i], b[i], '$what[$i]')); + } + return sb.toString(); +} + +String diffCvhr(Metric a, Metric b) { + final sb = StringBuffer(); + if ((a.value == null) != (b.value == null)) return 'value presence; '; + sb.write(diffD(a.confidence, b.confidence, 'confidence')); + if (a.tier != b.tier) sb.write('tier; '); + if ('${a.inputs_used}' != '${b.inputs_used}') sb.write('inputs; '); + if (a.note != b.note) sb.write('note "${a.note}" vs "${b.note}"; '); + if ((a.drivers == null) != (b.drivers == null)) sb.write('drivers; '); + final x = a.value, y = b.value; + if (x == null || y == null) return sb.toString(); + if (x.cycleCount != y.cycleCount) { + sb.write('cycleCount ${x.cycleCount} vs ${y.cycleCount}; '); + } + sb.write(diffD(x.cvhrPerHour, y.cvhrPerHour, 'cvhrPerHour')); + sb.write(diffD(x.analyzedHours, y.analyzedHours, 'analyzedHours')); + sb.write(diffD(x.meanDepthMs, y.meanDepthMs, 'meanDepthMs')); + sb.write(diffD(x.meanWidthSec, y.meanWidthSec, 'meanWidthSec')); + sb.write(diffList(x.depthQuartilesMs, y.depthQuartilesMs, 'depthQ')); + sb.write(diffList(x.widthQuartilesSec, y.widthQuartilesSec, 'widthQ')); + return sb.toString(); +} + +void expectSame(Metric a, Metric b, String at) => + expect(diffCvhr(a, b), '', reason: at); + +// ───────────────────────── drivers ────────────────────────────────────────── + +/// Chunk boundaries (beat counts) with random sizes in [lo, hi], starting small +/// so the first folds sit below the 60-beat gate. +List chunkEnds(int n, math.Random rnd, int lo, int hi, {int first = 20}) { + final ends = []; + var at = first; + while (at < n) { + ends.add(at); + at += lo + rnd.nextInt(hi - lo + 1); + } + ends.add(n); + return ends; +} + +Metric batch(List nn, List t, int end, double af, + {double minW = 10, + double maxW = 120, + double ratio = 0.7, + double env = 130, + double maxA = 0.30, + double gap = 30}) => + cvhrApneaScreen(nn.sublist(0, end), t.sublist(0, end), + artifactFraction: af, + minWidthSec: minW, + maxWidthSec: maxW, + depthWidthRatioMin: ratio, + envWindowSec: env, + maxArtifact: maxA, + maxGapSec: gap); + +/// Fold [nn]/[t] by [ends]; after each fold compare an empty-tail and a +/// [tailBeats]-tail snapshot against the batch. Returns the first mismatch +/// ('' when none) so it can run inside an isolate. +String parity(List nn, List t, List ends, double af, + {int tailBeats = 100, + double minW = 10, + double maxW = 120, + double ratio = 0.7, + double env = 130, + double maxA = 0.30, + double gap = 30}) { + final s = CvhrStream( + minWidthSec: minW, + maxWidthSec: maxW, + depthWidthRatioMin: ratio, + envWindowSec: env, + maxGapSec: gap); + var from = 0; + for (final end in ends) { + s.fold(nn.sublist(from, end), t.sublist(from, end)); + from = end; + var d = diffCvhr( + s.snapshot(artifactFraction: af, maxArtifact: maxA), + batch(nn, t, end, af, + minW: minW, + maxW: maxW, + ratio: ratio, + env: env, + maxA: maxA, + gap: gap)); + if (d.isNotEmpty) return 'prefix $end: $d'; + final tEnd = math.min(nn.length, end + tailBeats); + d = diffCvhr( + s.snapshot( + tailNnMs: nn.sublist(end, tEnd), + tailNnTimesMs: t.sublist(end, tEnd), + artifactFraction: af, + maxArtifact: maxA), + batch(nn, t, tEnd, af, + minW: minW, + maxW: maxW, + ratio: ratio, + env: env, + maxA: maxA, + gap: gap)); + if (d.isNotEmpty) return 'prefix $end + tail ${tEnd - end}: $d'; + } + return ''; +} + +void expectParity(Night n, List ends, double af, + {int tailBeats = 100, + double minW = 10, + double maxW = 120, + double ratio = 0.7, + double env = 130, + double maxA = 0.30, + double gap = 30}) => + expect( + parity(n.nn, n.times, ends, af, + tailBeats: tailBeats, + minW: minW, + maxW: maxW, + ratio: ratio, + env: env, + maxA: maxA, + gap: gap), + ''); + +/// Isolate entry: msg = [SendPort, nn, t, ends, af]. A hang cannot be +/// interrupted by the test timeout, so the run is killed after 20 s. +void _entry(List msg) { + final port = msg[0] as SendPort; + final nn = (msg[1] as List).cast(); + final t = (msg[2] as List).cast(); + final ends = (msg[3] as List).cast(); + port.send(parity(nn, t, ends, msg[4] as double, tailBeats: 60)); +} + +Future parityIsolated( + List nn, List t, List ends, double af) async { + final rp = ReceivePort(); + final iso = + await Isolate.spawn(_entry, [rp.sendPort, nn, t, ends, af]); + try { + return await rp.first.timeout(const Duration(seconds: 20)) as String; + } finally { + iso.kill(priority: Isolate.immediate); + rp.close(); + } +} + +Object? wire(Map j) => jsonDecode(jsonEncode(j)); + +void expectMalformedRejected( + Map good, Object? Function(Map) revive) { + for (final k in good.keys) { + final missing = Map.from(good)..remove(k); + expect(() => revive(missing), throwsFormatException, reason: 'missing $k'); + final wrong = Map.from(good)..[k] = {'bogus': true}; + expect(() => revive(wrong), throwsFormatException, reason: 'wrong $k'); + } + expect(() => revive({}), throwsFormatException); + expect(() => revive({...good, 'type': 'NotAStream'}), throwsFormatException); + expect(() => revive({...good, 'version': 9999}), throwsFormatException); + expect(() => revive({...good, 'version': 'one'}), throwsFormatException); +} + +// Holes: two > 30 s gaps, a 45 s island (< 60 beats), a 40 s island of 500 ms +// beats (>= 60 beats, span < 60 s), then a long tail segment. +const _gappy = <(double, double)>[ + (2400, 90), + (3000, 60), + (3045, 60), + (5000, 45), + (5040, 70), + (7000, 31), +]; + +void main() { + group('guards: the generator really produces CVHR cycles', () { + test('batch finds cycles on the apnea night and few on the plain one', () { + final a = synth(1, 3); + final r = batch(a.nn, a.times, a.nn.length, 0.02).value!; + expect(r.cycleCount, greaterThan(20)); + expect(r.depthQuartilesMs, isNotNull); + final p = synth(1, 3, apnea: false); + final q = batch(p.nn, p.times, p.nn.length, 0.02).value!; + expect(q.cycleCount, lessThan(r.cycleCount ~/ 4)); + }); + + test('gappy night: several segments, cycles found, short islands skipped', + () { + final g = synth(2, 3, jumps: _gappy, fast: (5000, 5040)); + final r = batch(g.nn, g.times, g.nn.length, 0.02).value!; + expect(r.cycleCount, greaterThan(10)); + final whole = synth(2, 3, fast: (5000, 5040)); + expect(r.analyzedHours, + lessThan(batch(whole.nn, whole.times, whole.nn.length, 0.02) + .value! + .analyzedHours)); + }); + }); + + group('CvhrStream with nothing (or almost nothing) folded', () { + Metric viaTail(List nn, List t, int folded, + int end, double af) { + final s = CvhrStream(); + if (folded > 0) s.fold(nn.sublist(0, folded), t.sublist(0, folded)); + return s.snapshot( + tailNnMs: nn.sublist(folded, end), + tailNnTimesMs: t.sublist(folded, end), + artifactFraction: af); + } + + void expectEmptyPrefix(Night n, List ks, {int folded = 0}) { + for (final k in ks) { + final end = math.min(k, n.nn.length); + if (end <= folded) continue; + expectSame(viaTail(n.nn, n.times, folded, end, 0.02), + batch(n.nn, n.times, end, 0.02), 'folded $folded tail ${end - folded}'); + } + } + + test('fresh stream, tail across the 60-beat gate', () { + final n = synth(81, 1); + expectEmptyPrefix(n, [1, 2, 3, 30, 58, 59, 60, 61, 62, 100, 400]); + }); + + test('fresh stream, tail is a full 2 h night', () { + final n = synth(82, 2); + expectEmptyPrefix(n, [n.nn.length]); + }); + + test('fresh stream, tail with gaps > maxGapSec and short islands', () { + final n = synth(83, 3, jumps: _gappy, fast: (5000, 5040)); + expectEmptyPrefix(n, [200, 1500, 3000, 6000, n.nn.length]); + // gaps right at the tail edge + final cuts = []; + for (var i = 1; i < n.times.length; i++) { + if (n.times[i] - n.times[i - 1] > 30000) cuts.addAll([i - 1, i, i + 1]); + } + expectEmptyPrefix(n, cuts.where((c) => c > 0).toList()); + }); + + test('a few folded beats (below any segment) plus a long tail', () { + final n = synth(84, 2, jumps: const [(2400, 90)]); + for (final f in [1, 5, 30, 59, 60]) { + expectEmptyPrefix(n, [f + 1, f + 30, f + 61, n.nn.length], folded: f); + } + }); + }); + + group('CvhrStream parity', () { + test('no gaps, apnea cycles present (3 h), random chunks', () { + final n = synth(3, 3); + expectParity(n, chunkEnds(n.nn.length, math.Random(31), 300, 1400), 0.02); + }); + + test('no gaps, cycles absent (2 h)', () { + final n = synth(4, 2, apnea: false); + expectParity(n, chunkEnds(n.nn.length, math.Random(41), 300, 1400), 0.02); + }); + + test('several >30 s gaps and short islands (3 h)', () { + final n = synth(5, 3, jumps: _gappy, fast: (5000, 5040)); + final ends = chunkEnds(n.nn.length, math.Random(51), 150, 900); + expectParity(n, ends, 0.02); + }); + + test('folds that end exactly at, just before and just after each gap', () { + final n = synth(6, 1.5, jumps: const [(1500, 40), (3000, 35)]); + final ends = {n.nn.length}; + for (var i = 1; i < n.times.length; i++) { + if (n.times[i] - n.times[i - 1] > 30000) { + for (final d in [-2, -1, 0, 1, 2]) { + ends.add(i + d); + } + } + } + final sorted = ends.where((e) => e > 20).toList()..sort(); + expect(sorted.length, greaterThan(6)); + expectParity(n, sorted, 0.02, tailBeats: 30); + }); + + test('gap of exactly maxGapSec stays one segment, +1 ms splits it', () { + for (final extra in [0.0, 1.0]) { + final nn = [], t = []; + var clock = 0.0; + final rnd = math.Random(61); + for (var i = 0; i < 500; i++) { + nn.add(1000 + 40 * math.sin(i / 7) + 10 * rnd.nextDouble()); + clock += 1000; + if (i == 250) clock += 29000 + extra; // 30 000 (+1) ms between beats + t.add(clock); + } + final ends = [30, 200, 250, 251, 252, 400, 500]; + expect(parity(nn, t, ends, 0.02), '', reason: 'extra=$extra'); + expect(parity(nn, t, ends, 0.02, gap: 10), '', reason: 'gap 10'); + } + }); + + test('beats exactly on whole seconds, beat by beat (resample edge)', () { + // nn is independent of the times, so every grid point lands exactly on a + // beat; duplicate times (non-decreasing) are mixed in. + final rnd = math.Random(71); + final nn = [], t = []; + var clock = 5000.0; + for (var i = 0; i < 420; i++) { + nn.add(700 + 300 * rnd.nextDouble() + 90 * math.sin(i / 9)); + if (i % 37 != 36) clock += 1000; + t.add(clock); + } + expect(parity(nn, t, [for (var e = 61; e <= 420; e++) e], 0.02, + tailBeats: 3), + ''); + }); + + test('single-beat folds across 70 min of a segment (median window edges)', + () { + final n = synth(8, 1.2); + final ends = [for (var e = 40; e <= 400; e += 1) e] + + [for (var e = 400; e <= n.nn.length; e += 97) e]; + expectParity(n, ends, 0.02, tailBeats: 0); + }); + + test('artifact gate on / off and near the threshold', () { + final n = synth(9, 1.5); + final ends = chunkEnds(n.nn.length, math.Random(91), 400, 1200); + for (final af in [0.0, 0.1, 0.30, 0.3000001, 0.5]) { + expectParity(n, ends, af); + } + expectParity(n, ends, 0.2, maxA: 0.1); + expectParity(n, ends, 0.2, maxA: 0.25); + }); + + test('non-default options', () { + final n = synth(10, 1.5); + final ends = chunkEnds(n.nn.length, math.Random(101), 300, 1000); + expectParity(n, ends, 0.02, minW: 15, maxW: 90); + expectParity(n, ends, 0.02, ratio: 0.4); + expectParity(n, ends, 0.02, env: 90); // half = 45 + expectParity(n, ends, 0.02, env: 131); // half = round(65.5) = 66 + expectParity(n, ends, 0.02, gap: 5); + }); + + test('absent paths: fresh stream, <60 beats, degenerate times', () { + final s = CvhrStream(); + expectSame(s.snapshot(artifactFraction: 0), + cvhrApneaScreen(const [], const [], artifactFraction: 0), 'fresh'); + final n = synth(11, 0.3); + s.fold(n.nn.sublist(0, 40), n.times.sublist(0, 40)); + expectSame(s.snapshot(artifactFraction: 0), + batch(n.nn, n.times, 40, 0), '40 beats'); + expectSame( + s.snapshot( + tailNnMs: n.nn.sublist(40, 59), + tailNnTimesMs: n.times.sublist(40, 59), + artifactFraction: 0), + batch(n.nn, n.times, 59, 0), + '59 beats'); + final flat = CvhrStream() + ..fold(List.filled(80, 800.0), List.filled(80, 5000.0)); + expectSame( + flat.snapshot(artifactFraction: 0), + cvhrApneaScreen(List.filled(80, 800.0), List.filled(80, 5000.0), + artifactFraction: 0), + 'degenerate times'); + // 60 beats but a 59 s span, then 60 beats over 80 s. + for (final step in [1000.0, 1300.0]) { + final nn = List.filled(60, 900.0); + final t = [for (var i = 0; i < 60; i++) 1000 + i * step]; + final f = CvhrStream()..fold(nn, t); + expectSame(f.snapshot(artifactFraction: 0), + cvhrApneaScreen(nn, t, artifactFraction: 0), 'span step $step'); + } + }); + + test('tail that starts a new segment, or only extends the open one', () { + final n = synth(12, 1, jumps: const [(1800, 60)]); + var cut = 0; + for (var i = 1; i < n.times.length; i++) { + if (n.times[i] - n.times[i - 1] > 30000) cut = i; + } + final s = CvhrStream()..fold(n.nn.sublist(0, cut), n.times.sublist(0, cut)); + for (final k in [0, 1, 5, 59, 60, 61, 300]) { + final e = math.min(n.nn.length, cut + k); + expectSame( + s.snapshot( + tailNnMs: n.nn.sublist(cut, e), + tailNnTimesMs: n.times.sublist(cut, e), + artifactFraction: 0.02), + batch(n.nn, n.times, e, 0.02), + 'tail $k after the gap'); + } + }); + + test('NaN / +-inf NN: no hang, matches batch (with cycles in the night)', + () async { + final n = synth(13, 1.5); + final nn = [...n.nn]; + nn[300] = double.nan; + for (var i = 900; i < 940; i++) { + nn[i] = double.nan; + } + nn[1500] = double.infinity; + nn[2000] = double.negativeInfinity; + nn[0] = double.nan; + nn[nn.length - 1] = double.nan; + final ends = chunkEnds(nn.length, math.Random(131), 150, 700); + expect(await parityIsolated(nn, n.times, ends, 0.02), ''); + final all = List.filled(500, double.nan); + final t = [for (var i = 0; i < 500; i++) i * 800.0]; + expect( + await parityIsolated(all, t, [100, 300, 500], 0.0), ''); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('8 h night with gaps: parity at checkpoints', () { + final n = synth(14, 8, jumps: _gappy + const [(15000, 45), (22000, 100)], + fast: (5000, 5040)); + expect(n.nn.length, greaterThan(25000)); + final ends = chunkEnds(n.nn.length, math.Random(141), 2500, 4000); + expectParity(n, ends, 0.02); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('CvhrStream state', () { + test('snapshot never mutates: different tails, then folding, still exact', + () { + final n = synth(15, 1.5, jumps: const [(2000, 50)]); + final len = n.nn.length; + final s = CvhrStream()..fold(n.nn.sublist(0, 2000), n.times.sublist(0, 2000)); + final json1 = jsonEncode(s.toJson()); + final base = s.snapshot(artifactFraction: 0.02); + s.snapshot( + tailNnMs: n.nn.sublist(2000, 2400), + tailNnTimesMs: n.times.sublist(2000, 2400), + artifactFraction: 0.02); + s.snapshot( + tailNnMs: [800, 800], + tailNnTimesMs: [n.times[1999] + 90000, n.times[1999] + 90800], + artifactFraction: 0.5); + expect(jsonEncode(s.toJson()), json1, reason: 'toJson unchanged'); + expectSame(s.snapshot(artifactFraction: 0.02), base, 'repeat snapshot'); + expectSame(base, batch(n.nn, n.times, 2000, 0.02), 'base'); + s.fold(n.nn.sublist(2000, len), n.times.sublist(2000, len)); + expectSame(s.snapshot(artifactFraction: 0.02), + batch(n.nn, n.times, len, 0.02), 'after tail snapshots'); + }); + + test('JSON round trip mid-night resumes bit-identically', () { + final n = synth(16, 2, jumps: _gappy, fast: (5000, 5040)); + final len = n.nn.length; + // cut inside the open segment, right at a gap, and in a closed island. + var gapAt = 0; + for (var i = 1; i < n.times.length; i++) { + if (n.times[i] - n.times[i - 1] > 30000) { + gapAt = i; + break; + } + } + final cuts = ({30, gapAt, gapAt + 1, len ~/ 3, (len * 2) ~/ 3, len}.toList() + ..sort()); + var s = CvhrStream(); + var from = 0; + for (final end in cuts) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + if (end != len) { + final text = jsonEncode(s.toJson()); + s = CvhrStream.fromJson(jsonDecode(text) as Map); + expect(jsonEncode(s.toJson()), text, reason: 'lossless at $end'); + } + expectSame(s.snapshot(artifactFraction: 0.02), + batch(n.nn, n.times, end, 0.02), 'resumed prefix $end'); + } + // Options survive a round trip. + final f = CvhrStream(envWindowSec: 90, maxGapSec: 12, minWidthSec: 14); + final f2 = CvhrStream.fromJson(wire(f.toJson()) as Map); + f2.fold(n.nn.sublist(0, 1500), n.times.sublist(0, 1500)); + expectSame( + f2.snapshot(artifactFraction: 0.02), + batch(n.nn, n.times, 1500, 0.02, env: 90, gap: 12, minW: 14), + 'options survive round trip'); + }); + + test('malformed JSON is a FormatException', () { + final n = synth(17, 1, jumps: const [(1200, 40)]); + final s = CvhrStream()..fold(n.nn, n.times); + final good = wire(s.toJson()) as Map; + expect(good['type'], 'CvhrStream'); + expect(good['version'], 1); + expectMalformedRejected(good, (j) => CvhrStream.fromJson(j)); + // A fresh stream's checkpoint is rejected the same way. + final fresh = wire(CvhrStream().toJson()) as Map; + expectMalformedRejected(fresh, (j) => CvhrStream.fromJson(j)); + }); + + test('non-monotone fold is a StateError', () { + final s = CvhrStream()..fold([800, 810, 820], [1000, 1810, 2630]); + expect(() => s.fold([800], [2000]), throwsStateError); + expect(() => CvhrStream().fold([800, 800], [5000, 4000]), throwsStateError); + final eq = CvhrStream()..fold([800], [1000]); + expect(() => eq.fold([800], [1000]), returnsNormally); + }); + }); +} diff --git a/test/onehz/incremental/irregular_screen_state_test.dart b/test/onehz/incremental/irregular_screen_state_test.dart index 6b118f4..82e6030 100644 --- a/test/onehz/incremental/irregular_screen_state_test.dart +++ b/test/onehz/incremental/irregular_screen_state_test.dart @@ -294,6 +294,23 @@ void main() { _expectSameScreen(got, want, 'flat'); }); + // why: 800.1 is not representable, so the batch mean (a repeated sum) lands + // a hair off it and its SDNN is ~1e-11 instead of 0, while the running + // moments stay at exactly 0. Both must abstain. + // # catches: batch publishes ratio 0 with a rounding-sized SD2 while the + // stream abstains. + test('flat series at 800.1 abstains in both', () { + final nn = List.filled(600, 800.1); + var acc = 0.0; + final t = [for (var i = 0; i < 600; i++) acc += nn[i]]; + final got = (IrregularScreenState()..fold(nn, t)) + .evaluate(const [], const []); + final want = _batch(nn, t, 600); + expect(want.present, isFalse, reason: 'the batch abstains'); + expect(got.present, isFalse, reason: 'the stream abstains'); + _expectSameScreen(got, want, 'flat 800.1'); + }); + test('no two adjacent clean beats: nothing to build a Poincare plot from', () { // every other beat out of range: 300 clean beats, zero adjacent pairs. diff --git a/test/onehz/rsa_floor_stream_test.dart b/test/onehz/rsa_floor_stream_test.dart new file mode 100644 index 0000000..b5cdc29 --- /dev/null +++ b/test/onehz/rsa_floor_stream_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +void main() { + // Same slow-breathing model as upstream respiration_test.dart. Its true + // peak is below 9 br/min; the in-band skirt must never become a measurement. + test('RSA floor outcomes survive chunks, JSON restart and provisional tails', () { + final random = math.Random(1); + final nn = [], times = []; + var clock = 0.0; + while (clock < 1800e3) { + final sec = clock / 1000; + final interval = 1000 + + 30 * math.sin(2 * math.pi * 8 / 60 * sec) + + 10 * math.sin(2 * math.pi * 0.1 * sec) + + (random.nextDouble() * 20 - 10); + clock += interval; + nn.add(interval); + times.add(clock); + } + final batch = rsaRespRate(nn, times, artifactFraction: 0); + expect(batch.present, isFalse); + expect(batch.note, contains('floor')); + var stream = RsaRespStream(); + const split = 1000; + stream.fold(nn.sublist(0, split), times.sublist(0, split)); + final saved = stream.toJson(); + expect((saved['closed'] as List).where((r) => r['kind'] == 5), isNotEmpty); + stream = RsaRespStream.fromJson(jsonDecode(jsonEncode(saved)) as Map); + final withTail = stream.snapshot(artifactFraction: 0, + tailNnMs: nn.sublist(split), tailNnTimesMs: times.sublist(split)); + expect(withTail.toJson(), batch.toJson()); + stream.fold(nn.sublist(split), times.sublist(split)); + expect(stream.snapshot(artifactFraction: 0).toJson(), batch.toJson()); + for (final old in [1, 2]) { + expect(() => RsaRespStream.fromJson({...saved, 'version': old}), + throwsFormatException); + } + }); +} diff --git a/test/onehz/welch_stream_test.dart b/test/onehz/welch_stream_test.dart new file mode 100644 index 0000000..6554260 --- /dev/null +++ b/test/onehz/welch_stream_test.dart @@ -0,0 +1,636 @@ +// RED (round 18): exact streaming hrvFreq and RSA respiration. +// +// HrvFreqStream / RsaRespStream must equal the batch hrvFreq / rsaRespRate on +// (folded ++ tail) BIT FOR BIT at every fold: == on doubles, identical +// NaN-ness and sign of zero, same notes, same totalBands. No tolerance. +// +// The batch functions are the reference (they do not change in this round). +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +// ───────────────────────── realistic NN night ─────────────────────────────── + +class Night { + final List nn = [], times = []; +} + +/// A seeded sleep-like night of [hours] with ectopic/missed/extra beats, short +/// garbage runs and one 2-min dropout; cleaned by the production [correctRr] +/// (with timestamps), so the NN carries the same shapes as production input. +/// Breathing at 0.24 Hz, slow HR (~57 bpm), 0.1 Hz and VLF modulation. +Night synthNight(int seed, double hours, + {double bpmBase = 57, bool dropout = true}) { + final rnd = math.Random(seed); + const startMs = 1.7e12; + final endT = hours * 3600e3; + final rr = [], ts = []; + var t = 0.0; + final phase = rnd.nextDouble() * 6.28; + final rate = 0.005 + 0.01 * rnd.nextDouble(); + final dropAt = endT * (0.3 + 0.3 * rnd.nextDouble()); + var dropped = !dropout; + void emit(double v) { + v = v.roundToDouble(); + t += v; + rr.add(v); + ts.add(((startMs + t) / 1000).floorToDouble() * 1000); + } + + while (t < endT) { + final s = t / 1000; + final bpm = bpmBase + 7 * math.sin(s / 5400 + seed); + final v = 60000 / bpm + + 28 * math.sin(2 * math.pi * 0.24 * s + phase) + + 18 * math.sin(2 * math.pi * 0.1 * s) + + 22 * math.sin(2 * math.pi * 0.01 * s) + + 6 * (rnd.nextDouble() - .5) * 2; + if (!dropped && t >= dropAt) { + dropped = true; + t += 120e3; + continue; + } + if (rnd.nextDouble() < rate) { + final u = rnd.nextDouble(); + if (u < 0.4) { + emit(v * (0.55 + 0.15 * rnd.nextDouble())); + emit(v * (1.3 + 0.2 * rnd.nextDouble())); + } else if (u < 0.6) { + emit(v * (1.9 + 0.2 * rnd.nextDouble())); + } else if (u < 0.85) { + final f = 0.35 + 0.3 * rnd.nextDouble(); + emit(v * f); + emit(v * (1 - f)); + } else { + emit(250 + rnd.nextInt(2100).toDouble()); + } + continue; + } + emit(v); + } + final c = correctRr(rr, rrTsMs: ts); + return Night() + ..nn.addAll(c.nn) + ..times.addAll(c.nnTimesMs); +} + +// ───────────────────────── bit-for-bit comparison ─────────────────────────── + +bool sameD(double? a, double? b) { + if (a == null || b == null) return a == null && b == null; + if (a.isNaN || b.isNaN) return a.isNaN && b.isNaN; + return a == b && a.isNegative == b.isNegative; +} + +void expectSameD(double? a, double? b, String what) => + expect(sameD(a, b), isTrue, reason: '$what: $a vs $b'); + +void expectSameMetric(Metric a, Metric b, String at) { + expect(a.value == null, b.value == null, reason: '$at value presence'); + expectSameD(a.confidence, b.confidence, '$at confidence'); + expect(a.tier, b.tier, reason: at); + expect(a.inputs_used, b.inputs_used, reason: at); + expect(a.note, b.note, reason: '$at note'); + expect(a.drivers == null, b.drivers == null, reason: at); +} + +void expectSameHrv(Metric a, Metric b, String at) { + expectSameMetric(a, b, at); + final x = a.value, y = b.value; + if (x == null || y == null) return; + expectSameD(x.ulf, y.ulf, '$at ulf'); + expectSameD(x.vlf, y.vlf, '$at vlf'); + expectSameD(x.lf, y.lf, '$at lf'); + expectSameD(x.hf, y.hf, '$at hf'); + expectSameD(x.total, y.total, '$at total'); + expect(x.totalBands, y.totalBands, reason: '$at totalBands'); + expectSameD(x.lfhf, y.lfhf, '$at lfhf'); + expectSameD(x.nuLf, y.nuLf, '$at nuLf'); + expectSameD(x.nuHf, y.nuHf, '$at nuHf'); + expect(x.hfGated, y.hfGated, reason: at); +} + +void expectSameResp( + Metric a, Metric b, String at) { + expectSameMetric(a, b, at); + final x = a.value, y = b.value; + if (x == null || y == null) return; + expectSameD(x.brpm, y.brpm, '$at brpm'); + expectSameD(x.peakHz, y.peakHz, '$at peakHz'); + expectSameD(x.power, y.power, '$at power'); + expect(x.source, y.source, reason: at); +} + +// ───────────────────────── drivers ────────────────────────────────────────── + +/// Chunk boundaries (beat counts) with random sizes in [lo, hi], starting small +/// so the first folds sit below every minimum-beats / minimum-span gate. +List chunkEnds(int n, math.Random rnd, int lo, int hi, {int first = 8}) { + final ends = []; + var at = first; + while (at < n) { + ends.add(at); + at += lo + rnd.nextInt(hi - lo + 1); + } + ends.add(n); + return ends; +} + +Metric batchHrv(Night n, int end, double af, + {double gate = 0.15, double os = 4.0}) => + hrvFreq(n.nn.sublist(0, end), n.times.sublist(0, end), + artifactFraction: af, hfArtifactGate: gate, oversample: os); + +Metric batchRsa(Night n, int end, double af, + {double tol = 2.0, double cons = 0.5, double maxA = 0.30}) => + rsaRespRate(n.nn.sublist(0, end), n.times.sublist(0, end), + artifactFraction: af, + tolBrpm: tol, + minConsensus: cons, + maxArtifact: maxA); + +void hrvParity(Night n, List ends, double af, {int tailBeats = 100}) { + final s = HrvFreqStream(); + var from = 0; + for (final end in ends) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + expectSameHrv(s.snapshot(artifactFraction: af), batchHrv(n, end, af), + 'hrv prefix $end'); + final tEnd = math.min(n.nn.length, end + tailBeats); + expectSameHrv( + s.snapshot( + tailNnMs: n.nn.sublist(end, tEnd), + tailNnTimesMs: n.times.sublist(end, tEnd), + artifactFraction: af), + batchHrv(n, tEnd, af), + 'hrv prefix $end + tail ${tEnd - end}'); + } +} + +void rsaParity(Night n, List ends, double af, {int tailBeats = 100}) { + final s = RsaRespStream(); + var from = 0; + for (final end in ends) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + expectSameResp(s.snapshot(artifactFraction: af), batchRsa(n, end, af), + 'rsa prefix $end'); + final tEnd = math.min(n.nn.length, end + tailBeats); + expectSameResp( + s.snapshot( + tailNnMs: n.nn.sublist(end, tEnd), + tailNnTimesMs: n.times.sublist(end, tEnd), + artifactFraction: af), + batchRsa(n, tEnd, af), + 'rsa prefix $end + tail ${tEnd - end}'); + } +} + +Object? wire(Map j) => jsonDecode(jsonEncode(j)); + +/// Every top-level key removed, and every top-level value swapped for a wrong +/// type, must be a FormatException (never a TypeError / RangeError / success). +void expectMalformedRejected( + Map good, Object? Function(Map) revive) { + for (final k in good.keys) { + final missing = Map.from(good)..remove(k); + expect(() => revive(missing), throwsFormatException, reason: 'missing $k'); + final wrong = Map.from(good)..[k] = {'bogus': true}; + expect(() => revive(wrong), throwsFormatException, reason: 'wrong $k'); + } + expect(() => revive({}), throwsFormatException); + expect(() => revive({...good, 'type': 'NotAStream'}), throwsFormatException); + expect(() => revive({...good, 'version': 9999}), throwsFormatException); + expect(() => revive({...good, 'version': 'one'}), throwsFormatException); +} + +void main() { + group('fresh and short-prefix streams equal the batch on the tail', () { + final ks = [1, 2, 8, 15, 16, 17, 19, 20, 21, 29, 30, 31, 60, 200, 1000]; + + void check(Night n, List tails, {int folded = 0}) { + for (final k in tails) { + final end = math.min(folded + k, n.nn.length); + if (end <= folded) continue; + final h = HrvFreqStream(); + final r = RsaRespStream(); + if (folded > 0) { + h.fold(n.nn.sublist(0, folded), n.times.sublist(0, folded)); + r.fold(n.nn.sublist(0, folded), n.times.sublist(0, folded)); + } + final tn = n.nn.sublist(folded, end), tt = n.times.sublist(folded, end); + expectSameHrv( + h.snapshot(tailNnMs: tn, tailNnTimesMs: tt, artifactFraction: 0.02), + batchHrv(n, end, 0.02), + 'hrv folded $folded tail $k'); + expectSameResp( + r.snapshot(tailNnMs: tn, tailNnTimesMs: tt, artifactFraction: 0.02), + batchRsa(n, end, 0.02), + 'rsa folded $folded tail $k'); + } + } + + test('fresh stream, tails across the 16/20/30 gates', () { + check(synthNight(91, 1), ks); + }); + + test('fresh stream, tail is a full 2 h night (with a dropout)', () { + final n = synthNight(92, 2); + check(n, [n.nn.length]); + }); + + test('a few folded beats plus a long tail', () { + final n = synthNight(93, 2); + for (final f in [1, 5, 8, 15, 16, 19, 20]) { + check(n, [1, 5, 30, 200, n.nn.length], folded: f); + } + }); + }); + + group('HrvFreqStream', () { + test('prefix parity at every fold, empty and non-empty tail (2 h night)', + () { + final n = synthNight(1, 2); + final ends = chunkEnds(n.nn.length, math.Random(11), 700, 1500); + hrvParity(n, ends, 0.02); + }); + + test('HF gate path and non-default gate / oversample are bit-identical', + () { + final n = synthNight(2, 1.0); + final s = HrvFreqStream(oversample: 2.0); + final ends = chunkEnds(n.nn.length, math.Random(5), 700, 1300); + var from = 0; + for (final end in ends) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + for (final af in [0.02, 0.2]) { + expectSameHrv(s.snapshot(artifactFraction: af), + batchHrv(n, end, af, os: 2.0), 'af $af prefix $end'); + } + expectSameHrv( + s.snapshot(artifactFraction: 0.1, hfArtifactGate: 0.05), + batchHrv(n, end, 0.1, gate: 0.05, os: 2.0), + 'gate 0.05 prefix $end'); + } + }); + + test('absent paths: <16 beats, degenerate times, fresh stream', () { + final fresh = HrvFreqStream(); + expectSameHrv( + fresh.snapshot(artifactFraction: 0), + hrvFreq(const [], const [], artifactFraction: 0), + 'fresh stream, no beats'); + // 15 beats: below the 16-beat floor. + final s = HrvFreqStream(); + final nn = List.generate(15, (i) => 800.0 + i); + final ts = List.generate(15, (i) => 1000.0 * i); + s.fold(nn, ts); + expectSameHrv(s.snapshot(artifactFraction: 0), + hrvFreq(nn, ts, artifactFraction: 0), '15 beats'); + // The 16th arrives as a tail only. + expectSameHrv( + s.snapshot( + tailNnMs: [900], + tailNnTimesMs: [15000], + artifactFraction: 0), + hrvFreq([...nn, 900], [...ts, 15000], artifactFraction: 0), + '15 + tail 1'); + // 20 beats at one instant: span 0 -> 'degenerate beat times'. + final d = HrvFreqStream(); + final dn = List.filled(20, 800); + final dt = List.filled(20, 5000); + d.fold(dn, dt); + expectSameHrv(d.snapshot(artifactFraction: 0), + hrvFreq(dn, dt, artifactFraction: 0), 'degenerate'); + // A short record: 20 min, no band but HF/LF resolvable? whatever the + // batch says (including 'record too short to resolve any HRV band'). + final short = synthNight(3, 0.05); + final sh = HrvFreqStream()..fold(short.nn, short.times); + expectSameHrv(sh.snapshot(artifactFraction: 0.01), + hrvFreq(short.nn, short.times, artifactFraction: 0.01), 'short'); + }); + + test('segments close exactly at start+seg: beat on / just before the edge', + () { + // 1 s beats: HF segment is 66.67 s, 50 % overlap. Probe every prefix + // length around several segment ends with single-beat folds. + final nn = List.filled(400, 1000); + final ts = List.generate(400, (i) => 1000.0 * i); + final n = Night() + ..nn.addAll(nn) + ..times.addAll(ts); + final s = HrvFreqStream(); + for (var i = 0; i < n.nn.length; i++) { + s.fold([n.nn[i]], [n.times[i]]); + if (i >= 60 && i <= 140 || i >= 190 && i <= 210) { + expectSameHrv(s.snapshot(artifactFraction: 0.01), + batchHrv(n, i + 1, 0.01), 'single-beat fold $i'); + } + } + }); + + test('8 h night: VLF segments close and stay exact', () { + final n = synthNight(4, 8); + final len = n.nn.length; + final ends = [len ~/ 3, (len * 2) ~/ 3, len]; + final s = HrvFreqStream(); + var from = 0; + for (final end in ends) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + final got = s.snapshot(artifactFraction: 0.02); + expectSameHrv(got, batchHrv(n, end, 0.02), '8h prefix $end'); + if (end == len) { + expect(got.value?.vlf, isNotNull, reason: 'VLF resolved at 8 h'); + } + } + }); + + test('snapshot never mutates: different tails, then folding, still exact', + () { + final n = synthNight(5, 1.5); + final len = n.nn.length; + final mid = len ~/ 2; + final s = HrvFreqStream()..fold(n.nn.sublist(0, mid), n.times.sublist(0, mid)); + final before = s.snapshot(artifactFraction: 0.03); + // A long tail that would close many segments, then a different short one. + s.snapshot( + tailNnMs: n.nn.sublist(mid, len), + tailNnTimesMs: n.times.sublist(mid, len), + artifactFraction: 0.03); + s.snapshot( + tailNnMs: [700, 710, 720], + tailNnTimesMs: [ + n.times[mid - 1] + 700, + n.times[mid - 1] + 1410, + n.times[mid - 1] + 2130 + ], + artifactFraction: 0.5); + expectSameHrv(s.snapshot(artifactFraction: 0.03), before, 'repeat'); + expectSameHrv(before, batchHrv(n, mid, 0.03), 'before'); + // The real continuation overlaps what the tails pretended. + s.fold(n.nn.sublist(mid, len), n.times.sublist(mid, len)); + expectSameHrv(s.snapshot(artifactFraction: 0.03), batchHrv(n, len, 0.03), + 'after folding'); + // A tail may also be folded afterwards with the very same beats. + final json1 = jsonEncode(s.toJson()); + s.snapshot( + tailNnMs: [800], tailNnTimesMs: [n.times.last + 800], artifactFraction: 0); + expect(jsonEncode(s.toJson()), json1, reason: 'toJson unchanged'); + }); + + test('JSON round trip mid-night resumes bit-identically', () { + final n = synthNight(6, 1.5); + final len = n.nn.length; + final cuts = [len ~/ 5, (len * 2) ~/ 5, (len * 3) ~/ 5, len]; + var s = HrvFreqStream(); + var from = 0; + for (final end in cuts) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + if (end != len) { + final text = jsonEncode(s.toJson()); + s = HrvFreqStream.fromJson( + jsonDecode(text) as Map); + // lossless: re-serializing the revived stream is byte-identical. + expect(jsonEncode(s.toJson()), text); + } + expectSameHrv(s.snapshot(artifactFraction: 0.02), + batchHrv(n, end, 0.02), 'resumed prefix $end'); + } + // A fresh stream also round-trips and keeps the oversample setting. + final f = HrvFreqStream(oversample: 3.0); + final f2 = HrvFreqStream.fromJson(wire(f.toJson()) as Map); + f2.fold(n.nn.sublist(0, 800), n.times.sublist(0, 800)); + expectSameHrv( + f2.snapshot(artifactFraction: 0.02), + batchHrv(n, 800, 0.02, os: 3.0), + 'oversample survives round trip'); + }); + + test('malformed JSON is a FormatException', () { + final n = synthNight(7, 1.2); + final s = HrvFreqStream()..fold(n.nn, n.times); + final good = wire(s.toJson()) as Map; + expectMalformedRejected(good, + (j) => HrvFreqStream.fromJson(j)); + }); + + test('non-monotone folds omit backward beats as batch does', () { + final s = HrvFreqStream() + ..fold([800, 810, 820], [1000, 1810, 2630]); + s.fold([800], [2000]); + expect(s.toJson()['n'], 4); + expect(s.toJson()['dropped'], 1); + expect(s.toJson()['times'], [1.0, 1.81, 2.63]); + final within = HrvFreqStream()..fold([800, 800], [5000, 4000]); + expect(within.toJson()['dropped'], 1); + // Equal times are retained by the batch policy. + final eq = HrvFreqStream()..fold([800], [1000]); + eq.fold([800], [1000]); + expect(eq.toJson()['dropped'], 0); + expect(eq.toJson()['times'], [1.0, 1.0]); + }); + }); + + group('RsaRespStream', () { + test('prefix parity at every fold, empty and non-empty tail (2 h night)', + () { + final n = synthNight(11, 2); + // First folds are tiny: < 20 beats, then span < 600 s where the batch + // shortens its segment to span/2. + final ends = chunkEnds(n.nn.length, math.Random(21), 400, 1300, first: 12); + rsaParity(n, ends, 0.02); + }); + + test('short-span prefixes (segment = span/2 regime) fold beat by beat', + () { + final n = synthNight(12, 0.4); + final s = RsaRespStream(); + for (var i = 0; i < n.nn.length && i < 900; i++) { + s.fold([n.nn[i]], [n.times[i]]); + if (i > 15 && i % 7 == 0) { + expectSameResp(s.snapshot(artifactFraction: 0.02), + batchRsa(n, i + 1, 0.02), 'single-beat fold $i'); + } + } + }); + + test('gates and options: artifact gate, tolerance, consensus', () { + final n = synthNight(13, 0.8); + final s = RsaRespStream(); + final ends = chunkEnds(n.nn.length, math.Random(2), 500, 900); + var from = 0; + for (final end in ends) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + expectSameResp(s.snapshot(artifactFraction: 0.4), + batchRsa(n, end, 0.4), 'artifact 0.4 prefix $end'); + expectSameResp( + s.snapshot(artifactFraction: 0.4, maxArtifact: 0.5), + batchRsa(n, end, 0.4, maxA: 0.5), + 'maxArtifact 0.5 prefix $end'); + expectSameResp( + s.snapshot(artifactFraction: 0.05, tolBrpm: 0.1, minConsensus: 0.9), + batchRsa(n, end, 0.05, tol: 0.1, cons: 0.9), + 'strict consensus prefix $end'); + } + }); + + test('slow heart (NN 1400 ms): window ceiling below HF band -> withheld', + () { + final nn = List.generate(500, (i) => 1400.0 + 20 * math.sin(i / 3)); + final ts = []; + var t = 0.0; + for (final v in nn) { + t += v; + ts.add(t); + } + final n = Night() + ..nn.addAll(nn) + ..times.addAll(ts); + final s = RsaRespStream(); + for (var from = 0; from < 500; from += 100) { + s.fold(nn.sublist(from, from + 100), ts.sublist(from, from + 100)); + expectSameResp(s.snapshot(artifactFraction: 0.01), + batchRsa(n, from + 100, 0.01), 'slow HR prefix ${from + 100}'); + } + }); + + test('absent paths: fresh stream, <20 beats, degenerate times', () { + expectSameResp( + RsaRespStream().snapshot(artifactFraction: 0), + rsaRespRate(const [], const [], artifactFraction: 0), + 'fresh'); + final nn = List.generate(19, (i) => 800.0 + i); + final ts = List.generate(19, (i) => 1000.0 * i); + final s = RsaRespStream()..fold(nn, ts); + expectSameResp(s.snapshot(artifactFraction: 0), + rsaRespRate(nn, ts, artifactFraction: 0), '19 beats'); + expectSameResp( + s.snapshot( + tailNnMs: [900], tailNnTimesMs: [19000], artifactFraction: 0), + rsaRespRate([...nn, 900], [...ts, 19000], artifactFraction: 0), + '19 + tail 1'); + final dn = List.filled(25, 800); + final dt = List.filled(25, 5000); + final d = RsaRespStream()..fold(dn, dt); + expectSameResp(d.snapshot(artifactFraction: 0), + rsaRespRate(dn, dt, artifactFraction: 0), 'degenerate'); + }); + + test('segments close exactly at start+300 s: single-beat folds at the edge', + () { + // 1 s beats, 300 s segments, 150 s step: probe prefixes around ends. + final nn = List.filled(1000, 1000); + for (var i = 0; i < nn.length; i++) { + nn[i] += 40 * math.sin(2 * math.pi * 0.24 * i); + } + final ts = []; + for (var i = 0; i < nn.length; i++) { + ts.add(1000.0 * i); + } + final n = Night() + ..nn.addAll(nn) + ..times.addAll(ts); + final s = RsaRespStream(); + for (var i = 0; i < n.nn.length; i++) { + s.fold([n.nn[i]], [n.times[i]]); + final near = [299, 449, 599, 749, 899] + .any((e) => i >= e - 3 && i <= e + 3); + if (near || i == 119 || i == 120 || i == 121) { + expectSameResp(s.snapshot(artifactFraction: 0.01), + batchRsa(n, i + 1, 0.01), 'single-beat fold $i'); + } + } + }); + + test('night with a dropout: thin segments counted the same', () { + final n = synthNight(14, 1.5, dropout: true); + rsaParity(n, chunkEnds(n.nn.length, math.Random(3), 500, 1200), 0.01); + }); + + test('8 h night: parity at checkpoints', () { + final n = synthNight(15, 8); + final len = n.nn.length; + final s = RsaRespStream(); + var from = 0; + for (final end in [len ~/ 3, (len * 2) ~/ 3, len]) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + expectSameResp(s.snapshot(artifactFraction: 0.02), + batchRsa(n, end, 0.02), '8h prefix $end'); + } + }); + + test('snapshot never mutates: different tails, then folding, still exact', + () { + final n = synthNight(16, 1.5); + final len = n.nn.length; + final mid = len ~/ 2; + final s = RsaRespStream()..fold(n.nn.sublist(0, mid), n.times.sublist(0, mid)); + final before = s.snapshot(artifactFraction: 0.03); + s.snapshot( + tailNnMs: n.nn.sublist(mid, len), + tailNnTimesMs: n.times.sublist(mid, len), + artifactFraction: 0.03); + s.snapshot( + tailNnMs: [700, 710, 720], + tailNnTimesMs: [ + n.times[mid - 1] + 700, + n.times[mid - 1] + 1410, + n.times[mid - 1] + 2130 + ], + artifactFraction: 0.5); + expectSameResp(s.snapshot(artifactFraction: 0.03), before, 'repeat'); + expectSameResp(before, batchRsa(n, mid, 0.03), 'before'); + s.fold(n.nn.sublist(mid, len), n.times.sublist(mid, len)); + expectSameResp(s.snapshot(artifactFraction: 0.03), + batchRsa(n, len, 0.03), 'after folding'); + final json1 = jsonEncode(s.toJson()); + s.snapshot( + tailNnMs: [800], tailNnTimesMs: [n.times.last + 800], artifactFraction: 0); + expect(jsonEncode(s.toJson()), json1, reason: 'toJson unchanged'); + }); + + test('JSON round trip mid-night resumes bit-identically', () { + final n = synthNight(17, 1.5); + final len = n.nn.length; + // Cuts include one inside the span<600 s regime. + final cuts = [60, len ~/ 5, (len * 2) ~/ 5, (len * 3) ~/ 5, len]; + var s = RsaRespStream(); + var from = 0; + for (final end in cuts) { + s.fold(n.nn.sublist(from, end), n.times.sublist(from, end)); + from = end; + if (end != len) { + final text = jsonEncode(s.toJson()); + s = RsaRespStream.fromJson(jsonDecode(text) as Map); + expect(jsonEncode(s.toJson()), text); + } + expectSameResp(s.snapshot(artifactFraction: 0.02), + batchRsa(n, end, 0.02), 'resumed prefix $end'); + } + }); + + test('malformed JSON is a FormatException', () { + final n = synthNight(18, 1.2); + final s = RsaRespStream()..fold(n.nn, n.times); + final good = wire(s.toJson()) as Map; + expectMalformedRejected(good, (j) => RsaRespStream.fromJson(j)); + }); + + test('non-monotone fold is a StateError', () { + final s = RsaRespStream()..fold([800, 810, 820], [1000, 1810, 2630]); + expect(() => s.fold([800], [2000]), throwsStateError); + expect(() => RsaRespStream().fold([800, 800], [5000, 4000]), + throwsStateError); + final eq = RsaRespStream()..fold([800], [1000]); + expect(() => eq.fold([800], [1000]), returnsNormally); + }); + }); +} diff --git a/test/properties/welch_stream_laws_test.dart b/test/properties/welch_stream_laws_test.dart new file mode 100644 index 0000000..da95aa5 --- /dev/null +++ b/test/properties/welch_stream_laws_test.dart @@ -0,0 +1,1140 @@ +// LAWS of the append-only beat streams behind the night's spectral and CVHR +// estimators: `RsaRespStream` (respiration/resp_rate.dart, checkpoint v3), +// `HrvFreqStream` (clinical/hrv_freq_stream.dart, v2) and `CvhrStream` +// (respiration/cvhr_apnea.dart, v1). Each folds settled NN with their beat +// times and answers `snapshot` with a provisional tail; the batch functions +// (`rsaRespRate`, `hrvFreq`, `cvhrApneaScreen`) are the reference. +// +// L1 chunk invariance: any chunking of the folds (empty chunks and a +// save/restore through real JSON text between chunks included) gives +// the checkpoint TEXT of one fold of the whole input and the same +// snapshot under every evaluation. A law of the append transition, not +// a monoid claim (no stream has a `combine`). +// L1b streamed vs batch: at every checked seam `snapshot(tail)` is the +// batch of settled ++ tail BIT FOR BIT (doubles compared by their +// shortest round-trip text: NaN-ness and the sign of zero included), +// notes, confidence and every value field; and the snapshot did not +// write. +// L2 checkpoint round trip: `toJson` -> real JSON text -> `fromJson` -> +// `toJson` is the same text, the restored stream reads what the live +// one reads, is not aliased to the map it came from, and goes on +// identically. +// L3 a checkpoint of another type or version (RsaRespStream version 1, the +// span-based ceiling without gappy sub-windows, included), a missing or +// mistyped part, parts that disagree in a way the reader checks: +// refused whole with a FormatException, never another error type. A +// number changed where the reader cannot tell: refused, or held exactly +// as written. An unknown key: ignored. +// L4 identity: an empty fold changes nothing (on a fresh stream it records +// nothing), `snapshot` never writes, and a refused fold (lengths that +// differ or a non-finite beat time) leaves the stream unchanged. A +// backward beat is refused by RSA/CVHR; spectral HRV counts its omission +// and keeps the retained samples and closed powers unchanged. +// L5 conservation, from counts made HERE from the input: the stream counts +// every folded beat once; RsaRespStream keeps exactly the sorted +// multiset of the folded NN and has closed exactly the sub-windows the +// documented walk has finished, and a reported rate rests on as many +// sub-windows as that walk visits; HrvFreqStream's bands have used no +// more segments than they have stepped over; CvhrStream's open stretch +// holds exactly the beats since the last gap over maxGapSec. +// +// A failing law is first checked against the module's contract; a law that +// fails because the library breaks it is fixed in lib/ with a rationale. +// +// A series is a RECIPE (flavour, beats, seed) expanded deterministically. +// Replay a failure with the command in its report, e.g. +// PROPERTY_SEED= PROPERTY_CASE= TZ=UTC dart test \ +// test/properties/welch_stream_laws_test.dart --plain-name '' + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import '../support/fold_law_support.dart'; +import '../support/law_registry.dart'; +import '../support/property.dart'; + +final _laws = LawSet(); + +typedef Json = Map; + +// ── the beats ─────────────────────────────────────────────────────────────── + +const _flavours = [ + 'sleep', // ~57 bpm, breathing at 0.24 Hz, LF and VLF waves, a little noise + 'slow heart', // NN ~1420 ms: the RSA ceiling sits under the HF band + 'gappy', // a 40 s dropout every 400 beats, a 150 s one every 700 + 'apnea', // +80 ms half-sine bumps, 20 s wide, every 45 s (CVHR cycles) + 'whole seconds', // beat times floored to whole seconds, ties included + 'outliers', // out-of-range NN (250 / 2600 ms) and an occasional NaN NN +]; +const int _nFlavours = 6; + +/// 2023-11-14 22:13:20 UTC in ms. +const double _t0 = 1.7e12; + +class _Night { + _Night(this.nn, this.t); + final List nn, t; + int get n => nn.length; +} + +final Map<(int, int, int), _Night> _cache = {}; + +_Night _night(int flavour, int n, int seed) => + _cache.putIfAbsent((flavour, n, seed), () { + if (_cache.length > 300) _cache.clear(); + return _expand(flavour, n, seed); + }); + +_Night _expand(int flavour, int n, int seed) { + final r = Rng(seed * 7919 + flavour * 131 + 7); + final phase = r.nextDouble() * 6.28; + final nn = [], t = []; + var clock = _t0; + for (var i = 0; i < n; i++) { + final s = (clock - _t0) / 1000; + final noise = 6 * (r.nextDouble() - .5) * 2; + final sleep = 1050 + + 60 * math.sin(s / 1500 + seed) + + 28 * math.sin(2 * math.pi * .24 * s + phase) + + 18 * math.sin(2 * math.pi * .1 * s) + + 22 * math.sin(2 * math.pi * .01 * s) + + noise; + var v = switch (flavour) { + 1 => 1420 + 30 * math.sin(2 * math.pi * .2 * s + phase) + noise, + 3 => 1000 + + 25 * math.sin(2 * math.pi * .25 * s + phase) + + (s % 45 < 20 ? 80 * math.sin(math.pi * (s % 45) / 20) : 0) + + noise, + _ => sleep, + }; + if (flavour == 5) { + final u = r.nextDouble(); + if (u < .01) { + v = double.nan; + } else if (u < .03) { + v = r.nextBool() ? 250 : 2600; + } + } + v = v.isNaN ? v : v.roundToDouble(); + if (flavour == 2 && i > 0 && i % 400 == 0) clock += 40000; + if (flavour == 2 && i > 0 && i % 700 == 0) clock += 150000; + clock += v.isFinite ? v : 800; + nn.add(v); + t.add(flavour == 4 ? (clock / 1000).floorToDouble() * 1000 : clock); + } + return _Night(nn, t); +} + +// ── exact text of an output ───────────────────────────────────────────────── + +/// A double as its shortest round-trip text: equal text is equal bits up to +/// the NaN payload (NaN and -0.0 are spelled out). +String _d(double? x) => x == null ? 'null' : x.toString(); + +String _envelope(Metric m) => + '${m.present}|${_d(m.confidence)}|${m.tier}|${m.inputs_used}|${m.note}|' + '${m.drivers}'; + +// ── one stream under the laws ─────────────────────────────────────────────── + +abstract class _Ops { + String get name; + String get type; + + /// Constructor configurations (index 0 is the default) and evaluations. + int get nCfg; + int get nEv; + + S fresh(int cfg); + void fold(S s, List nn, List t); + Metric snapshot(S s, int ev, List tailNn, List tailT); + Metric batch(List nn, List t, int cfg, int ev); + String showValue(Object v); + Json toJson(S s); + S fromJson(Json j); + + /// Every version but the current one is refused. + List get badVersions; + + /// Breaks of a reachable checkpoint the reader checks: must be refused. + List get refuseMutations; + + /// Numbers the reader cannot tell from legal ones: refused or held exactly. + List get faithfulMutations; + + /// Whether a fold with a non-finite beat time is refused. + bool get refusesNonFiniteTimes => true; + + /// Spectral HRV omits backward beats; RSA and CVHR refuse them at fold. + bool get omitsRegressingTimes => false; + + /// Counts made from the input ([nn] / [t], everything folded so far). + void conserved(Json cp, List nn, List t, int cfg, String why); + + /// The output as exact text. + String show(Metric m) => + '${_envelope(m)}|${m.value == null ? '-' : showValue(m.value!)}'; + + Map get shares; + void observe(_Case c, void Function(String) bump); + List<_Case> get forced; +} + +/// ((flavour, beats, seed, config), (splits, cut seed, restart mask, evaluation)). +typedef _Case = ((int, int, int, int), (int, int, int, int)); + +class _NGen extends Gen { + _NGen(this.maxN, this.pool); + final int maxN; + final List pool; + @override + int generate(Rng r, int size) { + final p = r.nextDouble(); + if (p < .07) return 0; + if (p < .35) { + final ok = [for (final x in pool) if (x <= maxN) x]; + return ok[r.nextInt(ok.length)]; + } + if (p < .55) return r.intIn(math.min(700, maxN), maxN); + return r.intIn(0, maxN); + } + + @override + Iterable shrink(int v) => G.intIn(0, maxN).shrink(v); +} + +Gen<_Case> _caseGen(_Ops ops, int maxN, List pool) => G.pair( + G.quad(G.intIn(0, _nFlavours - 1), _NGen(maxN, pool), + G.intIn(0, 1 << 16), G.intIn(0, ops.nCfg - 1)), + G.quad(G.intIn(0, 5), G.intIn(0, 1 << 20), G.intIn(0, 127), + G.intIn(0, ops.nEv - 1)), + ); + +String _tag(_Ops ops, _Case c) { + final ((f, n, seed, cfg), (splits, cut, restart, ev)) = c; + return '${ops.name} ${_flavours[f]} n=$n seed=$seed cfg=$cfg ' + 'splits/cut=$splits/$cut restart=$restart ev=$ev'; +} + +/// Beat counts where the streams change behaviour: the batch minimums (16, 20, +/// 60) and the first spans that settle anything. +const _edges = [0, 1, 2, 15, 16, 17, 19, 20, 21, 59, 60, 61]; + +List _boundsOf(_Case c) { + final ((_, n, _, _), (splits, cut, _, _)) = c; + return foldBounds(n, splits, cut, special: _edges); +} + +S _restore(_Ops ops, S s) => + ops.fromJson((jsonDecode(jsonEncode(ops.toJson(s))) as Map).cast()); + +String _text(_Ops ops, S s) => jsonEncode(ops.toJson(s)); + +/// Folds [bounds] chunk by chunk, restoring through JSON text where [restart] +/// says, calling [seam] after each chunk. +S _drive(_Ops ops, _Night x, int cfg, List bounds, int restart, + [void Function(S s, int k, int at)? seam]) { + var s = ops.fresh(cfg); + for (var k = 0; k + 1 < bounds.length; k++) { + ops.fold(s, x.nn.sublist(bounds[k], bounds[k + 1]), + x.t.sublist(bounds[k], bounds[k + 1])); + if ((restart >> (k % 7)) & 1 == 1) s = _restore(ops, s); + seam?.call(s, k, bounds[k + 1]); + } + return s; +} + +// ── the laws, written once ────────────────────────────────────────────────── + +void _l1(_Ops ops, _Case c) { + final ((f, n, seed, cfg), (_, _, restart, _)) = c; + final x = _night(f, n, seed); + final tag = _tag(ops, c); + final whole = ops.fresh(cfg); + ops.fold(whole, x.nn, x.t); + final bounds = _boundsOf(c); + final got = _drive(ops, x, cfg, bounds, restart); + expect(_text(ops, got), _text(ops, whole), + reason: '$tag bounds=$bounds: the checkpoint of one fold'); + for (var e = 0; e < ops.nEv; e++) { + expect(ops.show(ops.snapshot(got, e, const [], const [])), + ops.show(ops.snapshot(whole, e, const [], const [])), + reason: '$tag: evaluation $e reads the same'); + } +} + +void _l1b(_Ops ops, _Case c) { + final ((f, n, seed, cfg), (_, _, restart, ev)) = c; + final x = _night(f, n, seed); + final tag = _tag(ops, c); + final bounds = _boundsOf(c); + final picks = pickSeams(bounds.length - 1); + _drive(ops, x, cfg, bounds, restart, (s, k, at) { + if (!picks.contains(k)) return; + // The tail is what a corrector would still hold provisional. + final end = math.min(n, at + (at * 7 + k * 13) % 400); + final before = _text(ops, s); + final got = ops.snapshot(s, ev, x.nn.sublist(at, end), x.t.sublist(at, end)); + expect(_text(ops, s), before, reason: '$tag: snapshot reads, never writes'); + final want = ops.batch(x.nn.sublist(0, end), x.t.sublist(0, end), cfg, ev); + expect(ops.show(got), ops.show(want), + reason: '$tag seam $k at $at tail to $end: the batch, bit for bit'); + }); +} + +String _project(_Ops ops, S s) => [ + for (var e = 0; e < ops.nEv; e++) ops.show(ops.snapshot(s, e, const [], const [])), + ].join('\n'); + +void _l2(_Ops ops, _Case c) { + final ((f, n, seed, cfg), _) = c; + final x = _night(f, n, seed); + final tag = _tag(ops, c); + final bounds = _boundsOf(c); + final at = bounds.length > 2 ? bounds[1] : n ~/ 2; + final live = ops.fresh(cfg); + ops.fold(live, x.nn.sublist(0, at), x.t.sublist(0, at)); + final text = _text(ops, live); + final map = (jsonDecode(text) as Map).cast(); + final back = ops.fromJson(map); + expect(_text(ops, back), text, reason: '$tag: write(read(b)) == b'); + expect(_project(ops, back), _project(ops, live), + reason: '$tag: the restored stream reads what the live one reads'); + for (final v in map.values) { + if (v is List) v.clear(); + } + expect(_text(ops, back), text, reason: '$tag: not aliased to the map'); + ops.fold(live, x.nn.sublist(at), x.t.sublist(at)); + ops.fold(back, x.nn.sublist(at), x.t.sublist(at)); + expect(_text(ops, back), _text(ops, live), reason: '$tag: goes on identically'); + expect(_project(ops, back), _project(ops, live), reason: tag); +} + +(Json, Must) _mutate(_Ops ops, Json src, Mut m) { + final j = (deepCopy(src) as Map).cast(); + final (kind, a, b) = m; + final keys = j.keys.toList(); + switch (kind % 7) { + case 0: + j['version'] = ops.badVersions[a % ops.badVersions.length]; + return (j, Must.refuse); + case 1: + const types = ['Other', null, '', 1, 'IrregularScreenState']; + j['type'] = types[a % types.length]; + return (j, Must.refuse); + case 2: + j.remove(keys[a % keys.length]); + return (j, Must.refuse); + case 3: + const junk = ['x', {}, [[]], true]; + j[keys[a % keys.length]] = junk[b % junk.length]; + return (j, Must.refuse); + case 4: + final before = jsonEncode(j); + ops.refuseMutations[a % ops.refuseMutations.length](j); + // A break that happens to be a no-op on this checkpoint is no break. + return (j, jsonEncode(j) == before ? Must.faithful : Must.refuse); + case 5: + ops.faithfulMutations[a % ops.faithfulMutations.length](j); + return (j, Must.faithful); + default: + j['futureField${a % 3}'] = const [1, 'x', [1]][b % 3]; + return (j, Must.ignored); + } +} + +String _outcome(_Ops ops, Json j) { + try { + return 'accepted:${_text(ops, ops.fromJson((deepCopy(j) as Map).cast()))}'; + } on FormatException { + return 'refused'; + } catch (e) { + return 'WRONG ERROR TYPE: ${e.runtimeType}: $e'; + } +} + +/// The checkpoint of one fold of a recipe (folding is not the cost under test). +final Map _foldedText = {}; + +void _l3(_Ops ops, (_Case, Mut) arg) { + final (c, m) = arg; + final ((f, n, seed, cfg), _) = c; + final tag = '${_tag(ops, c)} mutation=${m.$1 % 7}(${m.$2},${m.$3})'; + if (_foldedText.length > 300) _foldedText.clear(); + final text = _foldedText.putIfAbsent('${ops.name}|${c.$1}', () { + final x = _night(f, n, seed); + final s = ops.fresh(cfg); + ops.fold(s, x.nn, x.t); + return _text(ops, s); + }); + final src = (jsonDecode(text) as Map).cast(); + final (bad, must) = _mutate(ops, src, m); + expectMutationOutcome( + _outcome(ops, bad), must, jsonEncode(bad), jsonEncode(src), tag); +} + +void _l4(_Ops ops, _Case c) { + final ((f, n, seed, cfg), (_, _, _, ev)) = c; + final x = _night(f, n, seed); + final tag = _tag(ops, c); + final freshText = _text(ops, ops.fresh(cfg)); + final empty = ops.fresh(cfg); + ops.fold(empty, const [], const []); + expect(_text(ops, empty), freshText, reason: '$tag: an empty first fold records nothing'); + final cut = n ~/ 2; + final s = ops.fresh(cfg); + ops.fold(s, x.nn.sublist(0, cut), x.t.sublist(0, cut)); + final before = _text(ops, s); + ops.fold(s, const [], const []); + expect(_text(ops, s), before, reason: '$tag: an empty fold is the identity'); + final one = ops.show(ops.snapshot(s, ev, const [], const [])); + final end = math.min(n, cut + 50); + ops.snapshot(s, ev, x.nn.sublist(cut, end), x.t.sublist(cut, end)); + expect(_text(ops, s), before, reason: '$tag: snapshot never writes'); + expect(ops.show(ops.snapshot(s, ev, const [], const [])), one, + reason: '$tag: twice is equal'); + // Refused folds change nothing. + void refused(String what, void Function() call, Matcher throws) { + expect(call, throws, reason: '$tag: $what is refused'); + expect(_text(ops, s), before, reason: '$tag: $what leaves the stream unchanged'); + } + + refused('lengths that differ', + () => ops.fold(s, const [800.0, 810.0], [_t0 + 9e9]), throwsArgumentError); + if (ops.refusesNonFiniteTimes) { + final last = cut == 0 ? _t0 : x.t[cut - 1]; + for (final bad in [double.nan, double.infinity]) { + refused('a beat time of $bad', + () => ops.fold(s, const [800.0, 810.0], [last + 800, bad]), + throwsArgumentError); + } + } + if (cut > 0) { + final backward = x.t[cut - 1] - 1000; + if (ops.omitsRegressingTimes) { + // why: the batch spectral policy drops backward beats and counts them; + // this fold must leave the retained samples and band sums unchanged. + final expected = (jsonDecode(before) as Map).cast() + ..['n'] = cut + 1 + ..['dropped'] = 1; + ops.fold(s, const [800.0], [backward]); + expect(_text(ops, s), jsonEncode(expected), reason: '$tag: omission only'); + expect(ops.show(ops.snapshot(s, ev, const [], const [])), + ops.show(ops.batch([...x.nn.take(cut), 800.0], + [...x.t.take(cut), backward], cfg, ev)), + reason: '$tag: backward beat follows batch policy'); + } else { + refused('a time that steps back', + () => ops.fold(s, const [800.0], [backward]), throwsStateError); + } + } +} + +void _l5(_Ops ops, _Case c) { + final ((f, n, seed, cfg), (_, _, restart, _)) = c; + final x = _night(f, n, seed); + final tag = _tag(ops, c); + _drive(ops, x, cfg, _boundsOf(c), restart, (s, k, at) { + final cp = (jsonDecode(_text(ops, s)) as Map).cast(); + expect(cp['n'], at, reason: '$tag at $at: every folded beat counted once'); + ops.conserved(cp, x.nn.sublist(0, at), x.t.sublist(0, at), cfg, '$tag at $at'); + }); +} + +Map _sharesOf(_Ops ops) => { + for (final f in _flavours) 'flavour: $f': .05, + 'beats: under 20': .02, + 'beats: past 700': .25, + 'config: not the default': ops.nCfg > 1 ? .2 : 0, + 'chunks: three or more': .3, + 'an empty chunk': .03, + 'a restore between chunks': .2, + for (var e = 0; e < ops.nEv; e++) 'evaluation: $e': .04, + }..removeWhere((_, v) => v == 0); + +void _observe(_Ops ops, _Case c, void Function(String) bump) { + final ((f, n, _, cfg), (_, _, restart, ev)) = c; + bump('flavour: ${_flavours[f]}'); + if (n > 0 && n < 20) bump('beats: under 20'); + if (n > 700) bump('beats: past 700'); + if (cfg != 0) bump('config: not the default'); + final b = _boundsOf(c); + if (b.length >= 4) bump('chunks: three or more'); + if (hasEmptyChunk(b)) bump('an empty chunk'); + if (restart != 0 && b.length > 2) bump('a restore between chunks'); + bump('evaluation: $ev'); + ops.observe(c, bump); +} + +void _register(_Ops ops, {int maxN = 1200, int cases = 60}) { + const pool = [0, 1, 15, 16, 19, 20, 59, 60, 61, 300, 700, 1200]; + final shares = {..._sharesOf(ops), ...ops.shares}; + final reach = Reach<_Case>(shares, (c, bump) => _observe(ops, c, bump)); + final noEval = Reach<_Case>({ + for (final e in shares.entries) + if (!e.key.startsWith('evaluation: ')) e.key: e.value + }, (c, bump) => _observe(ops, c, bump)); + group(ops.name, () { + _laws.law<_Case>( + 'L1 ${ops.name}: any chunking, empty chunks and restores included, gives ' + 'the checkpoint text and every snapshot of one fold', + _caseGen(ops, maxN, pool), + (c) => _l1(ops, c), + examples: ops.forced, + cases: cases, + reach: noEval, + ); + _laws.law<_Case>( + 'L1b ${ops.name}: snapshot(tail) at a seam is the batch of settled ++ ' + 'tail, bit for bit', + _caseGen(ops, maxN, pool), + (c) => _l1b(ops, c), + examples: ops.forced, + cases: cases, + reach: reach, + ); + _laws.law<_Case>( + 'L2 ${ops.name}: write(read(b)) == b, the restored stream reads the same ' + 'and goes on identically', + _caseGen(ops, maxN, pool), + (c) => _l2(ops, c), + examples: ops.forced, + cases: cases, + reach: noEval, + ); + _laws.law<(_Case, Mut)>( + 'L3 ${ops.name}: another version or type, a missing, mistyped or ' + 'inconsistent part is refused whole; a changed number is held exactly', + G.pair(_caseGen(ops, 1200, pool), mutGen(7)), + (arg) => _l3(ops, arg), + examples: [ + for (var i = 0; i < ops.forced.length; i++) + for (var k = 0; k < 7; k++) (ops.forced[i], (k, 3 * i + k, 5 * i + 2 * k)), + for (var a = 0; a < ops.badVersions.length; a++) (ops.forced.last, (0, a, 0)), + for (var a = 0; a < 40; a++) (ops.forced.last, (2, a, 0)), + for (var a = 0; a < ops.refuseMutations.length; a++) (ops.forced.last, (4, a, 0)), + for (var a = 0; a < ops.faithfulMutations.length; a++) (ops.forced.last, (5, a, 0)), + ], + cases: 100, + reach: Reach<(_Case, Mut)>({ + for (var k = 0; k < 7; k++) 'kind: $k': .05, + }, (arg, bump) => bump('kind: ${arg.$2.$1 % 7}')), + ); + _laws.law<_Case>( + 'L4 ${ops.name}: an empty fold is the identity, snapshot never writes, a ' + 'refused fold leaves the stream unchanged', + _caseGen(ops, maxN, pool), + (c) => _l4(ops, c), + examples: ops.forced, + cases: cases, + reach: reach, + ); + _laws.law<_Case>( + 'L5 ${ops.name}: every folded beat is counted once, and what the stream ' + 'kept and closed is what the input says', + _caseGen(ops, maxN, pool), + (c) => _l5(ops, c), + examples: ops.forced, + cases: cases, + reach: noEval, + ); + }); +} + +_Case _c(int flavour, int n, + {int seed = 3, int cfg = 0, int splits = 4, int cut = 1, int restart = 0, int ev = 0}) => + ((flavour, n, seed, cfg), (splits, cut, restart, ev)); + +// ═════════════════════════════════════════════════════════════════════════════ +// RsaRespStream +// ═════════════════════════════════════════════════════════════════════════════ + +/// (artifactFraction, tolBrpm, minConsensus, maxArtifact). +const List<(double, double, double, double)> _rsaEvals = [ + (.02, 2.0, .5, .30), + (0.0, 2.0, .5, .30), + (.31, 2.0, .5, .30), // over the artifact gate + (.30, 2.0, .5, .30), // exactly at it: passes + (.10, 1.0, .7, .30), // a tighter agreement test + (.05, 3.0, .3, .50), +]; + +/// The sub-window starts of the batch walk over [t] (seconds), and the +/// segment length it uses. +(int, double) _rsaWalk(List tSec) { + final span = tSec.last - tSec.first; + var segSec = rsaSegmentSec; + if (span < 2 * segSec) segSec = span / 2; + var k = 0; + for (var i = 0;; i++) { + final s = tSec.first + i * (segSec / 2); + if (s + segSec > tSec.last + 1e-9) break; + k++; + } + return (k, segSec); +} + +class _RsaOps extends _Ops { + @override + String get name => 'RsaRespStream'; + @override + String get type => 'RsaRespStream'; + @override + int get nCfg => 1; + @override + int get nEv => _rsaEvals.length; + @override + RsaRespStream fresh(int cfg) => RsaRespStream(); + @override + void fold(RsaRespStream s, List nn, List t) => s.fold(nn, t); + @override + Metric snapshot(RsaRespStream s, int ev, List tailNn, List tailT) { + final e = _rsaEvals[ev]; + return s.snapshot( + tailNnMs: tailNn, + tailNnTimesMs: tailT, + artifactFraction: e.$1, + tolBrpm: e.$2, + minConsensus: e.$3, + maxArtifact: e.$4); + } + + @override + Metric batch(List nn, List t, int cfg, int ev) { + final e = _rsaEvals[ev]; + return rsaRespRate(nn, t, + artifactFraction: e.$1, tolBrpm: e.$2, minConsensus: e.$3, maxArtifact: e.$4); + } + + @override + String showValue(Object v) { + final r = v as RespEstimate; + return '${_d(r.brpm)}|${_d(r.peakHz)}|${_d(r.power)}|${r.source}|' + '${r.usableSubwindows}|${r.subwindows}'; + } + + @override + Json toJson(RsaRespStream s) => s.toJson(); + @override + RsaRespStream fromJson(Json j) => RsaRespStream.fromJson(j); + + @override + List get badVersions => + const [1, 0, 2, -1, '3', null, true, 3.5, 1 << 40]; + + @override + List get refuseMutations => [ + (j) => (j['nn'] as List).add(800.0), // nn and times disagree + (j) => (j['sorted'] as List).add(800.0), // more sorted values than beats + (j) { + final s = j['sorted'] as List; + if (s.length >= 2 && s.first != s.last) { + j['sorted'] = s.reversed.toList(); // not sorted + } + }, + (j) { + if ((j['n'] as int) > 0) j['first'] = null; // beats without a start + }, + (j) { + if ((j['n'] as int) == 0) j['first'] = 0.0; // a start without beats + }, + (j) => (j['closed'] as List).add({ + 'kind': 6, // no such sub-window outcome + 'brpm': null, + 'peakHz': null, + 'power': null, + 'ceiling': null + }), + (j) => (j['closed'] as List).add({ + 'kind': 0, // a usable peak without its numbers + 'brpm': null, + 'peakHz': null, + 'power': null, + 'ceiling': null + }), + (j) => (j['closed'] as List).add({ + 'kind': 1, // at the ceiling, without the ceiling + 'brpm': null, + 'peakHz': null, + 'power': null, + 'ceiling': null + }), + (j) { + final t = j['times'] as List; + if (t.length >= 2 && t.first != t.last) { + j['times'] = t.reversed.toList(); // times that step back + } + }, + ]; + + @override + List get faithfulMutations => [ + (j) { + for (final c in j['closed'] as List) { + final m = c as Map; + if (m['kind'] == 0) { + m['brpm'] = (m['brpm'] as num) + 1.0; + return; + } + } + }, + (j) { + if (j['nextStart'] != null) j['nextStart'] = (j['nextStart'] as num) + 1.0; + }, + (j) { + final nn = j['nn'] as List; + if (nn.isNotEmpty && nn[0] is num) nn[0] = (nn[0] as num) + 1.0; + }, + (j) => (j['closed'] as List).add({ + 'kind': 3, // one more thin sub-window + 'brpm': null, + 'peakHz': null, + 'power': null, + 'ceiling': null + }), + ]; + + @override + void conserved(Json cp, List nn, List t, int cfg, String why) { + // The sorted multiset of every folded NN, in double.compareTo order. + final sorted = [...nn]..sort(); + expect(jsonEncode(cp['sorted']), + jsonEncode([for (final v in sorted) v.isFinite ? v : v.toString()]), + reason: 'the sorted NN are every folded NN once $why'); + // Closed sub-windows: the starts first + i*150 s whose window ends before + // the last beat (by more than 1e-9 s), once the span reaches 600 s. + final closed = (cp['closed'] as List).length; + var want = 0; + if (t.isNotEmpty) { + final first = t.first / 1000, last = t.last / 1000; + if (last - first >= 2 * rsaSegmentSec) { + while (first + want * (rsaSegmentSec / 2) + rsaSegmentSec < last - 1e-9) { + want++; + } + } + } + expect(closed, want, reason: 'closed sub-windows $why'); + // A reported rate rests on the sub-windows the batch walk visits. + final m = snapshot(fromJson(cp), 1, const [], const []); + if (m.present) { + final r = m.value as RespEstimate; + final (k, _) = _rsaWalk([for (final x in t) x / 1000]); + expect(r.subwindows, k, reason: 'sub-windows tried $why'); + expect(r.usableSubwindows! >= 3 && r.usableSubwindows! <= k, isTrue, + reason: 'usable sub-windows $why'); + } + } + + @override + Map get shares => { + 'a rate': .05, + 'withheld below the HF band': .02, + 'a gap that empties a sub-window': .01, + }; + + @override + void observe(_Case c, void Function(String) bump) { + final ((f, n, _, _), _) = c; + if (n > 700 && (f == 0 || f == 4)) bump('a rate'); + if (n > 30 && f == 1) bump('withheld below the HF band'); + if (n > 750 && f == 2) bump('a gap that empties a sub-window'); + } + + @override + List<_Case> get forced => [ + _c(0, 0), + _c(0, 19, ev: 1), + _c(0, 20, ev: 1), + _c(0, 700, splits: 5, cut: 2, restart: 85), + _c(0, 1600, splits: 6, cut: 3, restart: 127, ev: 1), + _c(1, 900, ev: 1), // withheld: the ceiling is under the band + _c(2, 1600, splits: 6, cut: 4, restart: 42, ev: 1), // gappy + _c(4, 1800, splits: 5, cut: 5, ev: 4), // whole seconds + _c(5, 1500, splits: 4, cut: 6, restart: 3, ev: 5), // outliers and NaN + _c(3, 1500, ev: 2), // over the artifact gate + _c(0, 1500, splits: 6, cut: 7, ev: 3), // exactly at it + ]; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// HrvFreqStream +// ═════════════════════════════════════════════════════════════════════════════ + +const _oversamples = [4.0, 2.0, 8.0]; + +/// (artifactFraction, hfArtifactGate). +const List<(double, double)> _hrvEvals = [ + (.02, .15), + (.20, .15), // HF gated + (.15, .15), // exactly at the gate: not gated + (0.0, .05), +]; + +/// Band lower edges: the segment of a band is 10 / lo seconds. +const _bandLo = [0.0003, 0.003, 0.04, 0.15]; + +class _HrvOps extends _Ops { + @override + bool get omitsRegressingTimes => true; + @override + String get name => 'HrvFreqStream'; + @override + String get type => 'HrvFreqStream'; + @override + int get nCfg => _oversamples.length; + @override + int get nEv => _hrvEvals.length; + @override + HrvFreqStream fresh(int cfg) => HrvFreqStream(oversample: _oversamples[cfg]); + @override + void fold(HrvFreqStream s, List nn, List t) => s.fold(nn, t); + @override + Metric snapshot(HrvFreqStream s, int ev, List tailNn, List tailT) { + final e = _hrvEvals[ev]; + return s.snapshot( + tailNnMs: tailNn, + tailNnTimesMs: tailT, + artifactFraction: e.$1, + hfArtifactGate: e.$2); + } + + @override + Metric batch(List nn, List t, int cfg, int ev) { + final e = _hrvEvals[ev]; + return hrvFreq(nn, t, + artifactFraction: e.$1, hfArtifactGate: e.$2, oversample: _oversamples[cfg]); + } + + @override + String showValue(Object v) { + final h = v as HrvFreq; + return [ + for (final x in [h.ulf, h.vlf, h.lf, h.hf, h.total, h.lfhf, h.nuLf, h.nuHf]) _d(x), + h.totalBands, + h.hfGated, + ].join('|'); + } + + @override + Json toJson(HrvFreqStream s) => s.toJson(); + @override + HrvFreqStream fromJson(Json j) => HrvFreqStream.fromJson(j); + + @override + List get badVersions => const [0, 1, 3, -1, '2', null, true, 2.5, 1 << 40]; + + @override + List get refuseMutations => [ + (j) => (j['nn'] as List).add(800.0), // nn and times disagree + (j) => (j['bands'] as List).removeLast(), // three bands + (j) => j['oversample'] = 0.0, + (j) => j['oversample'] = -4.0, + (j) { + if ((j['n'] as int) > 0) j['last'] = null; + }, + (j) { + if ((j['n'] as int) == 0) j['first'] = 0.0; + }, + (j) => ((j['bands'] as List).first as Map)['count'] = -1, + (j) { + final t = j['times'] as List; + if (t.length >= 2 && t.first != t.last) j['times'] = t.reversed.toList(); + }, + ]; + + @override + List get faithfulMutations => [ + (j) { + final b = (j['bands'] as List)[2] as Map; + b['sum'] = (b['sum'] as num) + 1.0; + }, + (j) { + final b = (j['bands'] as List)[3] as Map; + b['count'] = (b['count'] as int) + 1; + }, + (j) { + final b = (j['bands'] as List)[1] as Map; + b['nextStart'] = (b['nextStart'] as num) + 1.0; + }, + (j) => j['oversample'] = 3.0, + ]; + + @override + void conserved(Json cp, List nn, List t, int cfg, String why) { + // A band has used at most the segments it has stepped over, and has stepped + // only over segments that end by the last beat. + final bands = cp['bands'] as List; + for (var i = 0; i < 4; i++) { + final b = bands[i] as Map; + final count = b['count'] as int; + if (t.isEmpty) { + expect(count, 0, reason: 'no beats, no segments $why'); + continue; + } + final segSec = 10.0 / _bandLo[i]; + final first = t.first / 1000, last = t.last / 1000; + var stepped = 0; + for (var s = first; s + segSec <= last; s += segSec / 2) { + stepped++; + } + final next = (b['nextStart'] as num).toDouble(); + var walked = 0; + for (var s = first; s < next; s += segSec / 2) { + walked++; + } + expect(walked, stepped, reason: 'band $i stepped over every ended segment $why'); + expect(count <= stepped, isTrue, reason: 'band $i used segments $why'); + } + } + + @override + Map get shares => { + 'LF and HF resolved': .05, + 'gated HF': .02, + }; + + @override + void observe(_Case c, void Function(String) bump) { + final ((f, n, _, _), (_, _, _, ev)) = c; + if (n > 700 && f != 5) bump('LF and HF resolved'); + if (n > 700 && ev == 1) bump('gated HF'); + } + + @override + List<_Case> get forced => [ + _c(0, 0), + _c(0, 15, ev: 1), + _c(0, 16, ev: 1), + _c(0, 700, splits: 5, cut: 2, restart: 85), + _c(0, 1600, splits: 6, cut: 3, restart: 127, cfg: 1), + _c(2, 1600, splits: 6, cut: 4, restart: 42, cfg: 2, ev: 1), // gappy, gated + _c(4, 1800, splits: 5, cut: 5, ev: 2), // whole seconds, at the gate + _c(5, 1500, splits: 4, cut: 6, restart: 3, ev: 3), // outliers and NaN + _c(1, 1500, ev: 0), + _c(3, 1500, splits: 6, cut: 7, ev: 0), + ]; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// CvhrStream +// ═════════════════════════════════════════════════════════════════════════════ + +/// (minWidthSec, maxWidthSec, depthWidthRatioMin, envWindowSec, maxGapSec). +const List<(double, double, double, double, double)> _cvhrCfgs = [ + (10, 120, .7, 130, 30), + (5, 60, .5, 90, 10), + (10, 120, 1.2, 200, 60), +]; + +/// (artifactFraction, maxArtifact). +const List<(double, double)> _cvhrEvals = [ + (.02, .30), + (.31, .30), // over the gate + (.30, .30), // exactly at it + (0.0, .10), +]; + +class _CvhrOps extends _Ops { + @override + String get name => 'CvhrStream'; + @override + String get type => 'CvhrStream'; + @override + int get nCfg => _cvhrCfgs.length; + @override + int get nEv => _cvhrEvals.length; + @override + CvhrStream fresh(int cfg) { + final c = _cvhrCfgs[cfg]; + return CvhrStream( + minWidthSec: c.$1, + maxWidthSec: c.$2, + depthWidthRatioMin: c.$3, + envWindowSec: c.$4, + maxGapSec: c.$5); + } + + @override + void fold(CvhrStream s, List nn, List t) => s.fold(nn, t); + @override + Metric snapshot(CvhrStream s, int ev, List tailNn, List tailT) { + final e = _cvhrEvals[ev]; + return s.snapshot( + tailNnMs: tailNn, tailNnTimesMs: tailT, artifactFraction: e.$1, maxArtifact: e.$2); + } + + @override + Metric batch(List nn, List t, int cfg, int ev) { + final c = _cvhrCfgs[cfg]; + final e = _cvhrEvals[ev]; + return cvhrApneaScreen(nn, t, + artifactFraction: e.$1, + maxArtifact: e.$2, + minWidthSec: c.$1, + maxWidthSec: c.$2, + depthWidthRatioMin: c.$3, + envWindowSec: c.$4, + maxGapSec: c.$5); + } + + @override + String showValue(Object v) { + final r = v as CvhrResult; + return '${r.cycleCount}|${_d(r.cvhrPerHour)}|${_d(r.analyzedHours)}|' + '${_d(r.meanDepthMs)}|${_d(r.meanWidthSec)}|' + '${r.depthQuartilesMs?.map(_d).toList()}|${r.widthQuartilesSec?.map(_d).toList()}'; + } + + @override + Json toJson(CvhrStream s) => s.toJson(); + @override + CvhrStream fromJson(Json j) => CvhrStream.fromJson(j); + + @override + List get badVersions => const [0, 2, -1, '1', null, true, 1.5, 1 << 40]; + + @override + List get refuseMutations => [ + (j) => (j['rawNn'] as List).add(800.0), // rawNn and rawTimes disagree + (j) => (j['exc'] as List).add(0.0), // exc and base disagree + (j) => j['openCount'] = (j['n'] as int) + 1, // more open beats than beats + (j) { + if ((j['n'] as int) > 0) j['openStart'] = null; + }, + (j) { + if ((j['n'] as int) == 0) j['first'] = 0.0; + }, + (j) => j['maxGapSec'] = 'Infinity', + (j) => (j['closed'] as List).add({ + 'hours': -1.0, // negative observed hours + 'depths': [], + 'widths': [] + }), + (j) => (j['closed'] as List).add({ + 'hours': 1.0, // depths and widths disagree + 'depths': [1.0], + 'widths': [] + }), + ]; + + @override + List get faithfulMutations => [ + (j) { + final g = j['grid'] as List; + if (g.isNotEmpty) g[0] = (g[0] as num) + 1.0; + }, + (j) => (j['closed'] as List).add({ + 'hours': 0.5, + 'depths': [12.0], + 'widths': [20.0] + }), + (j) => j['envWindowSec'] = (j['envWindowSec'] as num) + 2.0, + ]; + + @override + void conserved(Json cp, List nn, List t, int cfg, String why) { + // The open stretch holds exactly the beats since the last gap longer than + // maxGapSec (all of them when there is none). + final gap = _cvhrCfgs[cfg].$5; + var open = 0; + for (var i = 0; i < t.length; i++) { + if (i > 0 && t[i] / 1000 - t[i - 1] / 1000 > gap) open = 0; + open++; + } + expect(cp['openCount'], open, reason: 'beats of the open stretch $why'); + // Closed stretches observe no more hours than the input spans. + var hours = 0.0; + for (final c in cp['closed'] as List) { + hours += ((c as Map)['hours'] as num).toDouble(); + } + final span = t.isEmpty ? 0.0 : (t.last - t.first) / 3.6e6; + expect(hours <= span + 1e-9, isTrue, reason: 'closed hours within the span $why'); + } + + @override + Map get shares => { + 'cycles to find': .02, + 'several stretches': .03, + }; + + @override + void observe(_Case c, void Function(String) bump) { + final ((f, n, _, _), _) = c; + if (n > 300 && f == 3) bump('cycles to find'); + if (n > 400 && f == 2) bump('several stretches'); + } + + @override + List<_Case> get forced => [ + _c(0, 0), + _c(3, 59, ev: 0), + _c(3, 60, ev: 0), + _c(3, 700, splits: 5, cut: 2, restart: 85), + _c(3, 1600, splits: 6, cut: 3, restart: 127, cfg: 1), + _c(2, 1600, splits: 6, cut: 4, restart: 42, cfg: 2), // gappy + _c(4, 1800, splits: 5, cut: 5, ev: 2), // whole seconds, at the gate + _c(5, 1500, splits: 4, cut: 6, restart: 3, ev: 3), // outliers and NaN + _c(3, 1500, ev: 1), // over the gate + _c(0, 1500, splits: 6, cut: 7), + ]; +} + +void main() { + _register(_RsaOps()); + _register(_HrvOps()); + _register(_CvhrOps()); + + group('the forced cases say what they claim', () { + test('RSA: a rate, a withheld slow heart, gappy sub-windows', () { + final ops = _RsaOps(); + final notes = []; + var rate = false; + for (final c in ops.forced) { + final ((f, n, seed, cfg), (_, _, _, ev)) = c; + final x = _night(f, n, seed); + final m = ops.batch(x.nn, x.t, cfg, ev); + if (m.present) rate = true; + notes.add(m.note ?? ''); + } + expect(rate, isTrue, reason: 'a rate among the forced nights'); + expect(notes.any((s) => s.startsWith('sleeping heart rate')), isTrue, + reason: 'a night withheld below the HF band'); + expect(notes.any((s) => s.contains('artifact fraction')), isTrue); + }); + + test('HrvFreq: resolved bands, a gated HF', () { + final ops = _HrvOps(); + var resolved = false, gated = false; + for (final c in ops.forced) { + final ((f, n, seed, cfg), (_, _, _, ev)) = c; + final x = _night(f, n, seed); + final m = ops.batch(x.nn, x.t, cfg, ev); + final v = m.value as HrvFreq?; + if (v != null && v.lf != null && v.hf != null) resolved = true; + if (v != null && v.hfGated) gated = true; + } + expect(resolved, isTrue); + expect(gated, isTrue); + }); + + test('CVHR: cycles found, an artifact abstention', () { + final ops = _CvhrOps(); + var cycles = false, gate = false; + for (final c in ops.forced) { + final ((f, n, seed, cfg), (_, _, _, ev)) = c; + final x = _night(f, n, seed); + final m = ops.batch(x.nn, x.t, cfg, ev); + if (m.present && (m.value as CvhrResult).cycleCount > 0) cycles = true; + if (!m.present && (m.note ?? '').contains('artifact fraction')) gate = true; + } + expect(cycles, isTrue); + expect(gate, isTrue); + }); + }); + + _laws.registerReachTest(); +} diff --git a/test/support/hrv_beats.dart b/test/support/hrv_beats.dart new file mode 100644 index 0000000..ea9839d --- /dev/null +++ b/test/support/hrv_beats.dart @@ -0,0 +1,134 @@ +// Shared HRV test helpers: Beats builders, beat-series generators and the +// hrvTime parity check. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:openstrap_analytics/aggregate.dart'; +import 'package:openstrap_analytics/onehz.dart'; +import 'package:test/test.dart'; + +import 'property.dart' show Rng; + +typedef Beat = ({double nnMs, double? timeMs}); + +const double hrvT0 = 1000000.0; +const double hrvTol = 1e-9; + +List hrvFields(HrvTime v) => + [v.rmssd, v.sdnn, v.sdann, v.sdnnIndex, v.pnn50, v.diffAcf1]; + +final hrvEqAll = fieldsEqual((v) => [...hrvFields(v), v.nBeats], + relTol: hrvTol, absTol: hrvTol); + +/// Beats from records. A null time becomes NaN (missing double). +Beats hrvBeats(List xs) => Beats( + Float64List.fromList([for (final b in xs) b.nnMs]), + Float64List.fromList([for (final b in xs) b.timeMs ?? double.nan]), + ); + +/// The beat time of the first row, or null for an empty batch. This is the +/// origin the tests give the aggregator. +double? hrvOrigin(Beats b) => b.length == 0 ? null : b.timeMs[0]; + +/// [b] with every time moved so the first beat sits at [origin]. +Beats hrvShifted(Beats b, double origin) { + if (b.length == 0) return b; + final d = origin - b.timeMs[0]; + return Beats(Float64List.fromList(b.nnMs), + Float64List.fromList([for (final t in b.timeMs) t + d])); +} + +/// What hrvTime says, mapped to the aggregator's expectation. +HrvTime hrvExpected(Beats beats, {required bool gapAware}) { + final nn = beats.nnMs.toList(); + final times = [for (final t in beats.timeMs) t.isNaN ? 0.0 : t]; + final m = hrvTime(nn, nnTimesMs: gapAware ? times : null); + return m.value ?? HrvTime(nBeats: beats.length); +} + +void expectHrvParity(HrvTime got, HrvTime want, String tag) { + expect(got.nBeats, want.nBeats, reason: '$tag: nBeats'); + for (final (name, a, b) in [ + ('rmssd', got.rmssd, want.rmssd), + ('sdnn', got.sdnn, want.sdnn), + ('sdann', got.sdann, want.sdann), + ('sdnnIndex', got.sdnnIndex, want.sdnnIndex), + ('pnn50', got.pnn50, want.pnn50), + ('diffAcf1', got.diffAcf1, want.diffAcf1), + ]) { + expect(a == null, b == null, reason: '$tag: $name null-ness ($a vs $b)'); + if (a != null && b != null) { + expect((a - b).abs() <= hrvTol + hrvTol * b.abs(), isTrue, + reason: '$tag: $name $a vs $b'); + } + } +} + +/// Builds a beat series with times. Values are multiples of 0.5 so the gap +/// rule's float arithmetic is exact in both implementations. +/// mode 0: smooth (random walk), mode 1: white noise (jittery, so the +/// RMSSD/pNN50 floor nulls them), mode 2: slow beats (spans several 5-minute +/// bins in few beats). +/// Below the jitter floor, where hrvTime reads the difference runs: +/// mode 3: RSA at a slow heart, ~44 bpm breathing at 0.3 Hz with a drifting +/// heart rate (ACF1 ~ -0.8, but a respiratory line on a low floor exempts +/// it once the runs hold 20 Welch segments); mode 4: a heart rate drifting +/// from ~67 to ~46 bpm under heavy beat-time jitter with a weak breathing +/// line at 0.25 Hz (refused, then published from the 5-min windows on the +/// line steady in Hz once there are 12 of them, ~1 h). +/// [gapP] is the chance a beat is preceded by a hole; [withTimes] false leaves +/// every time NaN. +Beats hrvSeries(Rng r, int n, int mode, + {double gapP = .05, bool withTimes = true}) { + final out = []; + var t = hrvT0; + var nn = mode == 2 ? 1700.0 : 800.0; + for (var i = 0; i < n; i++) { + final s = (t - hrvT0) / 1000; + switch (mode) { + case 3: + nn = (2 * + (1350 + + 80 * math.sin(2 * math.pi * s / 3600) + + 40 * math.sin(2 * math.pi * .3 * s) + + 3 * (r.nextDouble() - .5))) + .roundToDouble() / + 2; + case 4: + nn = (2 * + (900 + + 400 * s / 9000 + + 10 * math.sin(2 * math.pi * .25 * s) + + 40 * (r.nextDouble() - .5))) + .roundToDouble() / + 2; + case 1: + nn = 600 + r.intIn(0, 800) / 1.0; + case 2: + nn = (nn + r.intIn(-60, 60)).clamp(1200, 2000).toDouble(); + default: + nn = (nn + r.intIn(-40, 40)).clamp(450, 1300).toDouble(); + } + if (mode < 3 && r.nextBool(.04)) nn += r.intIn(40, 200); // an occasional big step + var extra = 0.0; + if (i > 0) { + final p = r.nextDouble(); + if (p < gapP) { + // a hole; sometimes a whole 5-minute bin or more + extra = r.nextBool(.15) + ? r.intIn(300000, 700000).toDouble() + : r.intIn(600, 30000).toDouble(); + } else if (mode < 3 && p < gapP + .04) { + extra = [0.5, 1.0, 0.0][r.nextInt(3)]; // right at the 0.5 ms tolerance + } + } + t += nn + extra; + out.add((nnMs: nn, timeMs: withTimes ? t : null)); + } + return hrvBeats(out); +} + +/// Contiguous series with exact constant nn (acf1 undefined: zero variance). +Beats hrvConstant(int n, double nn) => + hrvBeats([for (var i = 1; i <= n; i++) (nnMs: nn, timeMs: hrvT0 + i * nn)]); diff --git a/tool/incremental/bench_rr.dart b/tool/incremental/bench_rr.dart index 61ff942..81667fc 100644 --- a/tool/incremental/bench_rr.dart +++ b/tool/incremental/bench_rr.dart @@ -16,18 +16,44 @@ void main() { // warm correctRr(day.rr.sublist(0, 4000), rrTsMs: day.ts.sublist(0, 4000)); - final sw = Stopwatch()..start(); - final oracle = correctRr(day.rr, rrTsMs: day.ts); - print('oracle one-shot: ${ms(sw).toStringAsFixed(0)} ms ' - '(${(ms(sw) * 1000 / n).toStringAsFixed(1)} us/beat)'); + // The two one-shot measurements alternate order across rounds (oracle first, + // stream first, ...) so a fixed order cannot favour either; the median of the + // rounds is reported. + const rounds = 5; + final sw = Stopwatch(); + final oracleMs = [], streamMs = []; + late RrCorrectionResult oracle; + late RrSettled s1; + late RrSnapshot snap1; + void runOracle() { + sw..reset()..start(); + oracle = correctRr(day.rr, rrTsMs: day.ts); + oracleMs.add(ms(sw)); + } + + void runStream() { + sw..reset()..start(); + final one = RrCorrector(); + s1 = one.fold(day.rr, tsMs: day.ts); + snap1 = one.snapshot(); + streamMs.add(ms(sw)); + } - // streaming, one fold of the whole day - sw..reset()..start(); - final one = RrCorrector(); - final s1 = one.fold(day.rr, tsMs: day.ts); - final snap1 = one.snapshot(); - print('stream one-shot fold+snapshot: ${ms(sw).toStringAsFixed(0)} ms ' - '(${(ms(sw) * 1000 / n).toStringAsFixed(1)} us/beat)'); + for (var r = 0; r < rounds; r++) { + if (r.isEven) { + runOracle(); + runStream(); + } else { + runStream(); + runOracle(); + } + } + final oMs = _median(oracleMs), sMs = _median(streamMs); + print('oracle one-shot (median of $rounds): ${oMs.toStringAsFixed(0)} ms ' + '(${(oMs * 1000 / n).toStringAsFixed(1)} us/beat)'); + print('stream one-shot fold+snapshot (median of $rounds): ' + '${sMs.toStringAsFixed(0)} ms ' + '(${(sMs * 1000 / n).toStringAsFixed(1)} us/beat)'); final same = [...s1.nn, ...snap1.tailNn]; print(' identical nn: ${same.length == oracle.nn.length && _eq(same, oracle.nn)}'); @@ -78,6 +104,11 @@ bool _eq(List a, List b) { return true; } +double _median(List x) { + final v = [...x]..sort(); + return v.isEmpty ? 0 : v[v.length ~/ 2]; +} + double _mean(List x) => x.isEmpty ? 0 : _sum(x) / x.length; double _sum(List x) => x.fold(0.0, (a, b) => a + b); double _max(List x) => x.fold(0.0, (a, b) => a > b ? a : b); diff --git a/tool/incremental/edge_oracles.dart b/tool/incremental/edge_oracles.dart new file mode 100644 index 0000000..e4f6348 --- /dev/null +++ b/tool/incremental/edge_oracles.dart @@ -0,0 +1,260 @@ +// VERBATIM PORTS of the app-side (edge) functions that are not in analytics: +// dayHrvCurve, dayRespCurve, _daytimeHrv, _hrvTimeline, _respPerWindow. +// Source: edge-bgmem @ 26762fb5, lib/compute/derivation_engine.dart (~8532, +// 8641, 8866) and lib/compute/onehz_pipeline.dart (_hrvTimeline ~1926, +// _respPerWindow ~1692). Only change: `Substrate` -> the plain [DaySub] below +// and the CalculationCache plumbing removed (state == null path). They are the +// ORACLES the incremental states are compared with. +import 'dart:math' as math; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; + +/// edge `accelPlausible` (substrate.dart:100). +bool accelPlausible(double ax, double ay, double az) { + final magSq = ax * ax + ay * ay + az * az; + return magSq > 0 && magSq <= 4.0 * 4.0; +} + +const double kQuietCutG = 0.02; // _quietEnmoCutG gen4 / gen5 + +class DaySub { + final List rrMs, rrTsMs; + final List tsSec; + final List ax, ay, az; + DaySub(this.rrMs, this.rrTsMs, this.tsSec, this.ax, this.ay, this.az); + int get length => tsSec.length; + bool accelPresentAt(int i) => accelPlausible(ax[i], ay[i], az[i]); +} + +List> oracleDayHrvCurve(DaySub s) { + final ts = [], rr = []; + for (var i = 0; i < s.rrMs.length; i++) { + final v = s.rrMs[i]; + if (v >= 300 && v <= 2000) { + ts.add(s.rrTsMs[i]); + rr.add(v); + } + } + if (rr.length < 10) return const []; + const winMs = 300000.0; + final out = >[]; + var lo = 0; + var lastEmit = -1e18; + for (var i = 0; i < rr.length; i++) { + while (ts[i] - ts[lo] > winMs) { + lo++; + } + if (i - lo >= 10 && ts[i] - lastEmit > 60000) { + double? calculate() { + var ssd = 0.0; + var nd = 0; + for (var k = lo + 1; k <= i; k++) { + final d = rr[k] - rr[k - 1]; + if (d.abs() > 0.20 * rr[k - 1] || d.abs() > 200) continue; + ssd += d * d; + nd++; + } + if (nd < 8) return null; + final rmssd = math.sqrt(ssd / nd); + return rmssd <= 220 ? double.parse(rmssd.toStringAsFixed(1)) : null; + } + + final value = calculate(); + lastEmit = ts[i]; + if (value != null) out.add({'t': (ts[i] / 1000).round(), 'v': value}); + } + } + return out; +} + +const double kRespQuietFraction = 0.9; + +List> oracleDayRespCurve(DaySub s, + {double? cut = kQuietCutG, + double? Function(List nn, List nnt)? estimator}) { + if (cut == null) return const []; + final quietPrefix = List.filled(s.length + 1, 0); + for (var i = 0; i < s.length; i++) { + var q = 0; + if (s.accelPresentAt(i)) { + final mag = math.sqrt( + s.ax[i] * s.ax[i] + s.ay[i] * s.ay[i] + s.az[i] * s.az[i], + ); + if ((mag - 1.0).abs() <= cut) q = 1; + } + quietPrefix[i + 1] = quietPrefix[i] + q; + } + final ts = [], rr = []; + for (var i = 0; i < s.rrMs.length; i++) { + final v = s.rrMs[i]; + if (v >= 300 && v <= 2000) { + ts.add(s.rrTsMs[i]); + rr.add(v); + } + } + if (rr.length < 60) return const []; + const winMs = 180000.0; + final out = >[]; + var lo = 0; + var lastEmit = -1e18; + var qLo = 0, qHi = 0; + for (var i = 0; i < rr.length; i++) { + while (ts[i] - ts[lo] > winMs) { + lo++; + } + if (i - lo >= 30 && ts[i] - lastEmit > 300000) { + final loSec = (ts[lo] / 1000).floor(); + final hiSec = (ts[i] / 1000).ceil(); + while (qLo < s.length && s.tsSec[qLo] < loSec) { + qLo++; + } + if (qHi < qLo) qHi = qLo; + while (qHi < s.length && s.tsSec[qHi] < hiSec) { + qHi++; + } + final spanSec = hiSec - loSec; + final stillSec = quietPrefix[qHi] - quietPrefix[qLo]; + final nn = rr.sublist(lo, i + 1); + final t0 = ts[lo]; + final nnt = [for (var k = lo; k <= i; k++) ts[k] - t0]; + double? calculate() { + if (spanSec <= 0 || stillSec < kRespQuietFraction * spanSec) { + return null; + } + if (estimator != null) return estimator(nn, nnt); + final est = rsaRespRate(nn, nnt, artifactFraction: 0.15); + return est.present ? est.value!.brpm : null; + } + + final brpm = calculate(); + lastEmit = ts[i]; + if (brpm != null) { + out.add({ + 't': (ts[i] / 1000).round(), + 'v': double.parse(brpm.toStringAsFixed(1)), + }); + } + } + } + return out; +} + +Map oracleDaytimeHrv(DaySub s, int onsetSec, int offsetSec, + {double? cut = kQuietCutG}) { + const binSec = 300; + if (cut == null) { + return { + 'timeline': const >[], + 'mean_rmssd': null, + 'n_buckets': 0, + }; + } + final quiet = {}; + for (var i = 0; i < s.length; i++) { + if (!s.accelPresentAt(i)) continue; + final mag = + math.sqrt(s.ax[i] * s.ax[i] + s.ay[i] * s.ay[i] + s.az[i] * s.az[i]); + if ((mag - 1.0).abs() <= cut) quiet.add(s.tsSec[i]); + } + final bins = >{}; + double? prev; + for (var k = 0; k < s.rrMs.length; k++) { + final tSec = s.rrTsMs[k] ~/ 1000; + if (offsetSec > onsetSec && tSec >= onsetSec && tSec < offsetSec) { + prev = null; + continue; + } + if (!quiet.contains(tSec)) { + prev = null; + continue; + } + final v = s.rrMs[k]; + if (v < 300 || v > 2000) { + prev = null; + continue; + } + if (prev != null) { + final d = v - prev; + if (d.abs() <= 200) (bins[tSec ~/ binSec] ??= []).add(d * d); + } + prev = v; + } + final timeline = >[]; + final means = []; + final keys = bins.keys.toList()..sort(); + for (final b in keys) { + final sq = bins[b]!; + if (sq.length < 5) continue; + final rmssd = math.sqrt(sq.reduce((a, c) => a + c) / sq.length); + timeline.add({ + 't': b * binSec, + 'rmssd': (rmssd * 10).round() / 10.0, + 'n': sq.length, + }); + means.add(rmssd); + } + final mean = means.isEmpty ? null : means.reduce((a, c) => a + c) / means.length; + return { + 'timeline': timeline, + 'mean_rmssd': mean == null ? null : (mean * 10).round() / 10.0, + 'n_buckets': timeline.length, + }; +} + +double _round(double v, int dp) { + final p = math.pow(10, dp); + return (v * p).round() / p; +} + +List> oracleHrvTimeline( + List nn, List nnTimes, double? originMs) { + if (nn.length < 10 || nnTimes.length != nn.length || originMs == null) { + return const []; + } + const winMs = 300000.0; + final out = >[]; + var lo = 0; + for (var i = 0; i < nn.length; i++) { + while (nnTimes[i] - nnTimes[lo] > winMs) { + lo++; + } + if (nnTimes[i] - nnTimes[0] < winMs) continue; + if (i - lo >= 10) { + var ssd = 0.0; + for (var k = lo + 1; k <= i; k++) { + final diff = nn[k] - nn[k - 1]; + ssd += diff * diff; + } + final rmssd = math.sqrt(ssd / (i - lo)); + final tSec = ((originMs + nnTimes[i]) / 1000).round(); + if (out.isEmpty || tSec - out.last['t']! > 60) { + out.add({'t': tSec, 'v': _round(rmssd, 1)}); + } + } + } + return out; +} + +List oracleRespPerWindow(List nn, List nnTimes, + {double windowMs = 1800000.0, int minBeats = 60}) { + if (nn.isEmpty || nn.length != nnTimes.length) return const []; + final t0 = nnTimes.first; + final binsNn = >{}; + final binsTs = >{}; + for (var i = 0; i < nn.length; i++) { + final idx = ((nnTimes[i] - t0) / windowMs).floor(); + (binsNn[idx] ??= []).add(nn[i]); + (binsTs[idx] ??= []).add(nnTimes[i]); + } + final out = []; + final idxs = binsNn.keys.toList()..sort(); + for (final idx in idxs) { + final segNn = binsNn[idx]!; + if (segNn.length < minBeats) continue; + final r = rsaRespRate(segNn, binsTs[idx]!, artifactFraction: 0.0); + final b = r.present ? r.value!.brpm : null; + if (b != null) out.add(b); + } + return out; +} diff --git a/tool/incremental/err_probe.dart b/tool/incremental/err_probe.dart new file mode 100644 index 0000000..e2efacd --- /dev/null +++ b/tool/incremental/err_probe.dart @@ -0,0 +1,39 @@ +// Max abs/rel error of the Welford-based fields (irregular SD1/SD2, hrvTime SDNN) +// over many random prefixes. dart run tool/incremental/err_probe.dart +import 'dart:math' as math; +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'hrv_incr.dart'; +import 'synth.dart'; + +void main() { + final s = realNightRr()!; + final o = correctRr(s.rr, rrTsMs: s.ts); + var maxRelSdnn = 0.0, maxRelSd1 = 0.0, maxRelSd2 = 0.0, maxAcf = 0.0; + var checks = 0; + final r = math.Random(1); + final acc = HrvTimeAcc(); + final irr = IrregularScreenState(); + var at = 0; + while (at < o.nn.length) { + final to = math.min(o.nn.length, at + 1 + r.nextInt(2500)); + acc.fold(o.nn.sublist(at, to), o.nnTimesMs.sublist(at, to)); + irr.fold(o.nn.sublist(at, to), o.nnTimesMs.sublist(at, to)); + at = to; + if (to < 600) continue; + final nn = o.nn.sublist(0, to), t = o.nnTimesMs.sublist(0, to); + final w = hrvTime(nn, nnTimesMs: t); + final g = acc.evaluate(const [], const []); + maxRelSdnn = math.max(maxRelSdnn, (g.value!.sdnn! - w.value!.sdnn!).abs() / w.value!.sdnn!); + maxAcf = math.max(maxAcf, (g.value!.diffAcf1! - w.value!.diffAcf1!).abs()); + final wi = irregularBeatScreen(nn, nnTimesMs: t); + final gi = irr.evaluate(const [], const []); + if (wi.present) { + maxRelSd1 = math.max(maxRelSd1, (gi.value!.sd1 - wi.value!.sd1).abs() / wi.value!.sd1); + maxRelSd2 = math.max(maxRelSd2, (gi.value!.sd2 - wi.value!.sd2).abs() / wi.value!.sd2); + } + checks++; + } + print('checks=$checks hrvTime sdnn maxRel=$maxRelSdnn acf1 maxAbs=$maxAcf ' + 'irregular sd1 maxRel=$maxRelSd1 sd2 maxRel=$maxRelSd2'); +} diff --git a/tool/incremental/hrv_incr.dart b/tool/incremental/hrv_incr.dart new file mode 100644 index 0000000..fc6a95a --- /dev/null +++ b/tool/incremental/hrv_incr.dart @@ -0,0 +1,1619 @@ +// RESEARCH PROTOTYPES — incremental HRV scalars and curves. +// +// Common shape: `fold(settled input)` mutates the carried state; `evaluate` +// / `curve` answers for "settled + provisional tail" WITHOUT mutating it (the +// tail is re-evaluated on a copy). The settled input is what RrCorrector.fold +// returns; the provisional tail is RrSnapshot.tailNn/tailNnTimes. +import 'dart:math' as math; + +import 'dart:typed_data'; + +import 'package:openstrap_analytics/aggregate.dart' show Beats; +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; +import 'package:openstrap_analytics/src/onehz/clinical/hrv_jitter_evidence.dart'; + +// =========================================================================== +// 1. hrvTime scalars: RMSSD, pNN50, SDNN, SDANN, SDNN-index, diffAcf1 +// =========================================================================== +// +// RMSSD/pNN50/pairs: sums of d*d, count of |d|>50 taken in beat order are the +// SAME float sequence as the batch loop, so they are BIT-identical. +// SDNN: Welford instead of the batch's two-pass (mean, then sum (x-m)^2) — +// agrees to ~1e-13 relative, NOT bit-identical (a two-pass over the whole +// series cannot be folded). +// ACF1: algebraic form from (n, S1, S2, P, E, L); ~1e-14 absolute. The jitter +// gate (acf1 < -0.35) can only differ when |acf1 + 0.35| < ~1e-12. +// SDANN/SDNN-index: the 5-min segments close for good once a later beat +// arrives; their (mean, sd) are computed ONCE with the batch's own +// mean()/stddev() => bit-identical, and the final stddev(means)/mean(sds) +// run over <= 288 numbers with the batch's own functions. +class HrvTimeAcc { + int n = 0; + double prevNn = 0, prevT = 0, t0 = 0; + double wMean = 0, wM2 = 0; + int pairs = 0, over50 = 0, lagPairs = 0; + double ssd = 0, s1 = 0, s2 = 0, prod = 0, endp = 0; + double? lastDiff; + int segIdx = 0; + List cur = []; + List segMeans = [], segSds = []; + // Under the ACF floor hrvTime reads the difference runs (noise-share + // exemption, breathing-line fallback): the library's mergeable evidence for + // them, fed beat by beat (origin = the first beat). + JitterState ev = JitterState(); + JitterEvidenceAggregator? _evAgg; + + HrvTimeAcc(); + + HrvTimeAcc copy() { + final c = HrvTimeAcc() + ..n = n + ..prevNn = prevNn + ..prevT = prevT + ..t0 = t0 + ..wMean = wMean + ..wM2 = wM2 + ..pairs = pairs + ..over50 = over50 + ..lagPairs = lagPairs + ..ssd = ssd + ..s1 = s1 + ..s2 = s2 + ..prod = prod + ..endp = endp + ..lastDiff = lastDiff + ..segIdx = segIdx + ..cur = List.of(cur) + ..segMeans = List.of(segMeans) + ..segSds = List.of(segSds) + ..ev = ev.copy() + .._evAgg = _evAgg; + return c; + } + + void add(double v, double t) { + _evAgg ??= JitterEvidenceAggregator(originMs: t, gapAware: true); + ev = _evAgg!.addSlice( + ev, Beats(Float64List.fromList([v]), Float64List.fromList([t])), 0, 1); + if (n == 0) { + t0 = t; + } else if (t - prevT > v + .5) { + lastDiff = null; // seam: ends a diff run + } else { + final d = v - prevNn; + pairs++; + ssd += d * d; + s1 += d; + s2 += d * d; + if (d.abs() > 50) over50++; + if (lastDiff != null) { + lagPairs++; + prod += lastDiff! * d; + endp += lastDiff! + d; + } + lastDiff = d; + } + n++; + final dl = v - wMean; + wMean += dl / n; + wM2 += dl * (v - wMean); + final idx = ((t - t0) / 300000.0).floor(); + if (idx != segIdx) { + if (cur.length >= 2) { + segMeans.add(mean(cur)!); + segSds.add(stddev(cur)!); + } + cur = []; + segIdx = idx; + } + cur.add(v); + prevNn = v; + prevT = t; + } + + void fold(List nn, List t) { + for (var i = 0; i < nn.length; i++) { + add(nn[i], t[i]); + } + } + + /// `hrvTime(settled ++ tail, nnTimesMs: ..., artifactFraction)`. + Metric evaluate(List tailNn, List tailT, + {double artifactFraction = 0.0}) { + final a = copy()..fold(tailNn, tailT); + return a._finish(artifactFraction); + } + + Metric _finish(double artifactFraction) { + const inputs = ['rr_cleaned']; + if (n < 2) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few NN intervals', + ); + } + double? acf1; + if (pairs >= 30) { + final m = s1 / pairs; + final varSum = s2 - m * s1; + final cov = prod - m * endp + lagPairs * m * m; + acf1 = varSum > 0 ? cov / varSum : null; + } + final evidence = acf1 != null && acf1 < kNnDiffAcf1Floor + ? _evAgg!.finish(ev) + : null; + final jittery = evidence != null && evidence.refused(); + var rmssd = (pairs > 0 && !jittery) ? math.sqrt(ssd / pairs) : null; + final pnn50 = (pairs > 0 && !jittery) ? 100.0 * over50 / pairs : null; + if (jittery) rmssd = evidence.breathingRmssd(); + final byBreathing = jittery && rmssd != null; + final sdnn = n >= 2 ? math.sqrt(wM2 / (n - 1)) : null; + double? sdann, sdnnIndex; + final means = [...segMeans], sds = [...segSds]; + if (cur.length >= 2) { + means.add(mean(cur)!); + sds.add(stddev(cur)!); + } + if (means.length >= 2) { + sdann = stddev(means); + sdnnIndex = sds.isEmpty ? null : mean(sds); + } + final q = acf1 == null ? 1.0 : (1 - acf1 / kNnDiffAcf1Floor).clamp(0.0, 1.0); + final conf = byBreathing + ? 0.3 + : ((n / 250.0).clamp(0.0, 1.0) * q * (1 - artifactFraction)) + .clamp(0.3, 0.95); + return Metric( + value: HrvTime( + rmssd: rmssd, + sdnn: sdnn, + sdann: sdann, + sdnnIndex: sdnnIndex, + pnn50: pnn50, + nBeats: n, + diffAcf1: acf1, + ), + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: jittery + ? 'rmssd_refused:acf1=${acf1!.toStringAsFixed(3)} — the NN successive ' + 'differences are essentially differenced white noise (−0.5 = pure, floor ' + '$kNnDiffAcf1Floor), so RMSSD/pNN50 would measure beat-timing jitter, not ' + 'vagal tone. SDNN/SDANN survive it and are the lead here. ' + '${byBreathing ? 'RMSSD only from 5-min windows on a breathing ' + 'line steady in Hz, at floor confidence. ' : ''}PRV not ECG-HRV.' + : 'PRV not ECG-HRV; RMSSD/pNN50 are quantization-sensitive at 1 Hz ' + '— lead with SDNN/SDANN', + ); + } + + Map toJson() => { + 'n': n, + 'prevNn': prevNn, + 'prevT': prevT, + 't0': t0, + 'wMean': wMean, + 'wM2': wM2, + 'pairs': pairs, + 'over50': over50, + 'lagPairs': lagPairs, + 'ssd': ssd, + 's1': s1, + 's2': s2, + 'prod': prod, + 'endp': endp, + 'lastDiff': lastDiff, + 'segIdx': segIdx, + 'cur': cur, + 'segMeans': segMeans, + 'segSds': segSds, + 'ev': _evAgg == null ? null : _evAgg!.encodeState(ev), + }; + + factory HrvTimeAcc.fromJson(Map j) { + List dl(Object? o) => [for (final x in o as List) (x as num).toDouble()]; + return HrvTimeAcc() + ..n = j['n'] as int + ..prevNn = (j['prevNn'] as num).toDouble() + ..prevT = (j['prevT'] as num).toDouble() + ..t0 = (j['t0'] as num).toDouble() + ..wMean = (j['wMean'] as num).toDouble() + ..wM2 = (j['wM2'] as num).toDouble() + ..pairs = j['pairs'] as int + ..over50 = j['over50'] as int + ..lagPairs = j['lagPairs'] as int + ..ssd = (j['ssd'] as num).toDouble() + ..s1 = (j['s1'] as num).toDouble() + ..s2 = (j['s2'] as num).toDouble() + ..prod = (j['prod'] as num).toDouble() + ..endp = (j['endp'] as num).toDouble() + ..lastDiff = (j['lastDiff'] as num?)?.toDouble() + ..segIdx = j['segIdx'] as int + ..cur = dl(j['cur']) + ..segMeans = dl(j['segMeans']) + ..segSds = dl(j['segSds']) + .._restoreEvidence(j['ev']); + } + + void _restoreEvidence(Object? e) { + if (e == null || n == 0) return; + _evAgg = JitterEvidenceAggregator(originMs: t0, gapAware: true); + ev = _evAgg!.decodeState(e); + } +} + +// =========================================================================== +// 2. edge `_hrvTimeline`: trailing 5-min RMSSD of the corrected NN, >60 s apart +// =========================================================================== +// Right-context 0 over the NN list, so a point is final the moment its NN beat +// is settled. State = ring of the last 5 min of NN, first time, last emitted t. +class HrvTimelineState { + final double originMs; + List _nn = [], _t = []; + int _head = 0; + double? _t0; + int? _lastT; + final List> out = []; + + HrvTimelineState(this.originMs); + + static double _round(double v, int dp) { + final p = math.pow(10, dp); + return (v * p).round() / p; + } + + void _step(double v, double tm, List> sink) { + _t0 ??= tm; + _nn.add(v); + _t.add(tm); + while (tm - _t[_head] > 300000.0) { + _head++; + } + if (tm - _t0! < 300000.0) return; + final cnt = _nn.length - 1 - _head; + if (cnt >= 10) { + var ssd = 0.0; + for (var k = _head + 1; k < _nn.length; k++) { + final diff = _nn[k] - _nn[k - 1]; + ssd += diff * diff; + } + final rmssd = math.sqrt(ssd / cnt); + final tSec = ((originMs + tm) / 1000).round(); + if (_lastT == null || tSec - _lastT! > 60) { + sink.add({'t': tSec, 'v': _round(rmssd, 1)}); + _lastT = tSec; + } + } + } + + void _trim() { + if (_head > 512) { + _nn = _nn.sublist(_head); + _t = _t.sublist(_head); + _head = 0; + } + } + + void fold(List nn, List t) { + for (var i = 0; i < nn.length; i++) { + _step(nn[i], t[i], out); + } + _trim(); + } + + /// settled points ++ provisional points from [tailNn]. + List> curve(List tailNn, List tailT) { + final c = HrvTimelineState(originMs) + .._nn = List.of(_nn) + .._t = List.of(_t) + .._head = _head + .._t0 = _t0 + .._lastT = _lastT; + final extra = >[]; + for (var i = 0; i < tailNn.length; i++) { + c._step(tailNn[i], tailT[i], extra); + } + return [...out, ...extra]; + } +} + +// =========================================================================== +// 3. nightHrvShape: per-30-min-bin RMSSD (+ band) over the night +// =========================================================================== +class NightShapeState { + final double binMin; + final int minBeatsPerBin; + double? _t0; + int _bin = 0; + List _nn = [], _t = []; + int _count = 0; + double _firstT = 0, _lastT = 0; + // closed bins by index (holes included): [n, rmssd?, lo?, hi?] + final List> closed = []; + + NightShapeState({this.binMin = 30, this.minBeatsPerBin = kMinBeatsPerHrvBin}); + + double get _binMs => binMin * 60000.0; + double _hi(int b) => _t0! + (b + 1) * _binMs; + + static List _binValue( + List nn, List t, int minBeats) { + final n = nn.length; + if (n < minBeats) return [n, null, null, null]; + final m = hrvTime(nn, nnTimesMs: t); + final rmssd = m.value?.rmssd; + final se = rmssd == null ? null : rmssd / math.sqrt(2 * n); + return [ + n, + rmssd, + se == null ? null : math.max(0.0, rmssd! - 1.96 * se), + se == null ? null : rmssd! + 1.96 * se, + ]; + } + + void _step(double v, double tm, List> sink) { + if (_t0 == null) { + _t0 = tm; + _firstT = tm; + } + _lastT = tm; + _count++; + while (tm >= _hi(_bin)) { + sink.add(_binValue(_nn, _t, minBeatsPerBin)); + _nn = []; + _t = []; + _bin++; + } + _nn.add(v); + _t.add(tm); + } + + void fold(List nn, List t) { + for (var i = 0; i < nn.length; i++) { + _step(nn[i], t[i], closed); + } + } + + Metric evaluate(List tailNn, List tailT) { + const inputs = ['rr_cleaned', 'beat_times']; + final c = NightShapeState(binMin: binMin, minBeatsPerBin: minBeatsPerBin) + .._t0 = _t0 + .._bin = _bin + .._nn = List.of(_nn) + .._t = List.of(_t) + .._count = _count + .._firstT = _firstT + .._lastT = _lastT + ..closed.addAll(closed); + for (var i = 0; i < tailNn.length; i++) { + c._step(tailNn[i], tailT[i], c.closed); + } + final total = c._count; + if (total < minBeatsPerBin) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few beats for a nightly shape ' + '($total, need ≥$minBeatsPerBin)', + ); + } + final binMs = _binMs; + final span = c._lastT - c._firstT; + final nBins = math.max(1, (span / binMs).ceil()); + if (nBins < 3) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: + 'night spans ${(span / 3600000).toStringAsFixed(1)} h — under three ' + '${binMin.round()}-min bins there is no shape to describe', + ); + } + final bins = []; + for (var b = 0; b < nBins; b++) { + final List v = b < c.closed.length + ? c.closed[b] + : (b == c._bin + ? _binValue(c._nn, c._t, minBeatsPerBin) + : [0, null, null, null]); + bins.add(NightHrvBin( + startSec: (b * binMs / 1000).round(), + nBeats: v[0]!.toInt(), + rmssdMs: v[1]?.toDouble(), + loMs: v[2]?.toDouble(), + hiMs: v[3]?.toDouble(), + )); + } + final third = bins.length ~/ 3; + double? meanOf(Iterable xs) { + final v = [ + for (final b in xs) + if (b.present) b.rmssdMs! + ]; + return v.length < 2 ? null : mean(v); + } + + final first = meanOf(bins.take(third)); + final last = meanOf(bins.skip(bins.length - third)); + final ratio = + (first == null || last == null || first == 0) ? null : last / first; + return Metric( + value: NightHrvShape( + bins: bins, + firstThirdMs: first, + lastThirdMs: last, + lastOverFirst: ratio, + ), + confidence: (0.8 * bins.where((b) => b.present).length / bins.length) + .clamp(0.0, 0.8), + tier: Tier.high, + inputs_used: inputs, + note: 'per-bin RMSSD (${binMin.round()}-min bins, PRV not ECG-HRV) — a ' + 'DESCRIPTION of the night, never a cause. A low first third is equally ' + 'consistent with alcohol, a late meal, late training, a warm room, ' + 'illness onset, or nothing. Render each bin as a band, not a point.', + ); + } +} + +// =========================================================================== +// 4. edge `dayHrvCurve`: trailing 5-min RMSSD on gated RR, emitted every 60 s +// =========================================================================== +// Right-context 0. Exact append-only. State: ring of gated beats of the last +// 5 min, lastEmit, emitted list. (Works on raw substrate RR, not on NN.) +class DayHrvCurveState { + List _ts = [], _rr = []; + int _head = 0; + double _lastEmit = -1e18; + final List> out = []; + + DayHrvCurveState(); + + void fold(List rrMs, List rrTsMs) { + for (var q = 0; q < rrMs.length; q++) { + final v = rrMs[q]; + if (!(v >= 300 && v <= 2000)) continue; + _ts.add(rrTsMs[q]); + _rr.add(v); + final i = _ts.length - 1; + while (_ts[i] - _ts[_head] > 300000.0) { + _head++; + } + if (i - _head >= 10 && _ts[i] - _lastEmit > 60000) { + double? value; + var ssd = 0.0; + var nd = 0; + for (var k = _head + 1; k <= i; k++) { + final d = _rr[k] - _rr[k - 1]; + if (d.abs() > 0.20 * _rr[k - 1] || d.abs() > 200) continue; + ssd += d * d; + nd++; + } + if (nd >= 8) { + final rmssd = math.sqrt(ssd / nd); + value = rmssd <= 220 ? double.parse(rmssd.toStringAsFixed(1)) : null; + } + _lastEmit = _ts[i]; + if (value != null) out.add({'t': (_ts[i] / 1000).round(), 'v': value}); + } + } + if (_head > 512) { + _ts = _ts.sublist(_head); + _rr = _rr.sublist(_head); + _head = 0; + } + } + + int get ringSize => _ts.length - _head; + + Map toJson() => { + 'ts': _ts.sublist(_head), + 'rr': _rr.sublist(_head), + 'lastEmit': _lastEmit, + 'out': out, + }; + + factory DayHrvCurveState.fromJson(Map j) { + final s = DayHrvCurveState(); + s._ts = [for (final x in j['ts'] as List) (x as num).toDouble()]; + s._rr = [for (final x in j['rr'] as List) (x as num).toDouble()]; + s._lastEmit = (j['lastEmit'] as num).toDouble(); + for (final m in j['out'] as List) { + final mm = m as Map; + s.out.add({'t': mm['t'] as num, 'v': mm['v'] as num}); + } + return s; + } +} + +// =========================================================================== +// 5. edge `_daytimeHrv`: 5-min bins of squared successive differences over +// quiet seconds, outside the (known) sleep window +// =========================================================================== +// Each pair (prev, v) lands in bin tSec~/300 and only needs (a) whether the +// second of each beat is quiet and (b) the previous beat in the quiet chain. +// State: per-bin (sum of d^2, count) in beat order, the chain's `prev`, quiet +// seconds near the head, pending beats whose second the accelerometer has not +// reported yet (watermark). Sleep window fixed at construction; a changed +// window means refold (only bins straddling onset/offset differ — see report). +class DaytimeHrvState { + final int onsetSec, offsetSec; + final double cut; + final Map _sum = {}; + final Map _cnt = {}; + double? _prev; + final Set _quiet = {}; + final List _pendRr = [], _pendTs = []; + int _maxBeatSec = 0; + + DaytimeHrvState(this.onsetSec, this.offsetSec, {this.cut = 0.02}); + + static bool _isQuiet(double ax, double ay, double az, double cut) { + final magSq = ax * ax + ay * ay + az * az; + if (!(magSq > 0 && magSq <= 16.0)) return false; + final mag = math.sqrt(ax * ax + ay * ay + az * az); + return (mag - 1.0).abs() <= cut; + } + + /// [watermarkSec]: every accel row with tsSec < watermarkSec has been given. + void fold(List rrMs, List rrTsMs, List accTs, + List ax, List ay, List az, int watermarkSec) { + for (var i = 0; i < accTs.length; i++) { + if (_isQuiet(ax[i], ay[i], az[i], cut)) _quiet.add(accTs[i]); + } + _pendRr.addAll(rrMs); + _pendTs.addAll(rrTsMs); + var used = 0; + for (; used < _pendRr.length; used++) { + final tSec = _pendTs[used] ~/ 1000; + if (tSec >= watermarkSec) break; // second not fully known yet + _beat(_pendRr[used], tSec); + } + _pendRr.removeRange(0, used); + _pendTs.removeRange(0, used); + // forget quiet seconds far behind the head (RR is time-ordered) + if (_quiet.length > 4000) { + final keep = _maxBeatSec - 120; + _quiet.removeWhere((s) => s < keep); + } + } + + void _beat(double v, int tSec) { + if (tSec > _maxBeatSec) _maxBeatSec = tSec; + if (offsetSec > onsetSec && tSec >= onsetSec && tSec < offsetSec) { + _prev = null; + return; + } + if (!_quiet.contains(tSec)) { + _prev = null; + return; + } + if (v < 300 || v > 2000) { + _prev = null; + return; + } + if (_prev != null) { + final d = v - _prev!; + if (d.abs() <= 200) { + final b = tSec ~/ 300; + _sum[b] = (_sum[b] ?? 0.0) + d * d; + _cnt[b] = (_cnt[b] ?? 0) + 1; + } + } + _prev = v; + } + + Map result() { + final timeline = >[]; + final means = []; + final keys = _sum.keys.toList()..sort(); + for (final b in keys) { + final n = _cnt[b]!; + if (n < 5) continue; + final rmssd = math.sqrt(_sum[b]! / n); + timeline.add({'t': b * 300, 'rmssd': (rmssd * 10).round() / 10.0, 'n': n}); + means.add(rmssd); + } + final mean = means.isEmpty ? null : means.reduce((a, c) => a + c) / means.length; + return { + 'timeline': timeline, + 'mean_rmssd': mean == null ? null : (mean * 10).round() / 10.0, + 'n_buckets': timeline.length, + }; + } + + int get pending => _pendRr.length; + + Map toJson() => { + 'sum': {for (final e in _sum.entries) '${e.key}': e.value}, + 'cnt': {for (final e in _cnt.entries) '${e.key}': e.value}, + 'prev': _prev, + 'quiet': _quiet.toList(), + 'pendRr': _pendRr, + 'pendTs': _pendTs, + 'maxBeatSec': _maxBeatSec, + }; + + factory DaytimeHrvState.fromJson( + Map j, int onsetSec, int offsetSec, + {double cut = 0.02}) { + final s = DaytimeHrvState(onsetSec, offsetSec, cut: cut); + (j['sum'] as Map).forEach((k, v) => s._sum[int.parse(k as String)] = (v as num).toDouble()); + (j['cnt'] as Map).forEach((k, v) => s._cnt[int.parse(k as String)] = v as int); + s._prev = (j['prev'] as num?)?.toDouble(); + s._quiet.addAll((j['quiet'] as List).cast()); + s._pendRr.addAll([for (final x in j['pendRr'] as List) (x as num).toDouble()]); + s._pendTs.addAll([for (final x in j['pendTs'] as List) (x as num).toDouble()]); + s._maxBeatSec = j['maxBeatSec'] as int; + return s; + } +} + +// =========================================================================== +// 6. nocturnalRmssd (median of 5-min window RMSSDs) and +// sleepSessionWindowedRmssd (mean of cleaned 5-min window RMSSDs) +// =========================================================================== +// Both are "closed windows" estimators: a window's RMSSD is a pure function of +// the beats inside it, and the headline is an order-statistic / mean over the +// <= ~100 window values. State = per-window record. The stage mask (nocturnal) +// and the session bounds (session) are applied at READ time over those records, +// so a re-staged night does not invalidate them. +// +// Upstream's jitter gate is no longer a pooled ACF1 alone: a night below the +// ACF1 floor can still pass on a respiratory line in the pooled spectrum (then +// every window must clear the gate itself), or, as a last resort, on the +// windows whose breathing line holds still in Hz. Those need each window's +// difference RUNS, not additive sums, so a record keeps its runs (a window +// holds a few hundred differences) and the gate is the batch's own code over +// them, bit-identical. + +/// One closed window. [kind]: 0 usable, 1 over-counted (more beat-time than +/// the window has), 2 thin (fewer differences than the floor). Only a usable +/// window carries [rmssd], [runs] and [meanRr]. +class _WinRec { + final int idx; + final int kind; + final double rmssd; + final List> runs; + final double meanRr; + _WinRec(this.idx, this.kind, this.rmssd, this.runs, this.meanRr); + _WinRec.dropped(this.idx, this.kind) + : rmssd = double.nan, + runs = const [], + meanRr = double.nan; + Object toJson() => [idx, kind, rmssd.isFinite ? rmssd : null, runs, + meanRr.isFinite ? meanRr : null]; +} + +/// Σd² and the count of [runs], summed in the batch's order. +(double, int) _ssdOf(List> runs) { + var ssd = 0.0; + var nd = 0; + for (final r in runs) { + for (final d in r) { + ssd += d * d; + nd++; + } + } + return (ssd, nd); +} + +class NocturnalRmssdState { + final double windowMs; + final int minBeatsPerWindow; + double? _t0; + int _idx = 0; + List _nn = [], _t = []; + final List<_WinRec> recs = []; + + NocturnalRmssdState({this.windowMs = 300000.0, this.minBeatsPerWindow = 5}); + + _WinRec? _close(int idx, List nn, List t) { + if (nn.length < minBeatsPerWindow + 1) return null; + final winRuns = >[]; + var run = []; + for (var k = 1; k < nn.length; k++) { + if (t[k] - t[k - 1] > nn[k] + 0.5) { + if (run.isNotEmpty) { + winRuns.add(run); + run = []; + } + continue; + } + run.add(nn[k] - nn[k - 1]); + } + if (run.isNotEmpty) winRuns.add(run); + final (ssd, nd) = _ssdOf(winRuns); + if (nd < minBeatsPerWindow) return null; + return _WinRec(idx, 0, math.sqrt(ssd / nd), winRuns, mean(nn)!); + } + + void _step(double v, double tm, List<_WinRec> sink) { + _t0 ??= tm; + final idx = ((tm - _t0!) / windowMs).floor(); + if (idx != _idx && _nn.isNotEmpty) { + final r = _close(_idx, _nn, _t); + if (r != null) sink.add(r); + _nn = []; + _t = []; + } + _idx = idx; + _nn.add(v); + _t.add(tm); + } + + int _count = 0; + void fold(List nn, List t) { + for (var i = 0; i < nn.length; i++) { + _step(nn[i], t[i], recs); + } + _count += nn.length; + } + + Metric evaluate(List tailNn, List tailT, + {List? stageMaskPerSec, RrCoverage? coverage}) { + const inputs = ['rr_cleaned', 'beat_times']; + if (coverage != null && coverage.overCounted) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: rrOvercountNote(coverage), + ); + } + final c = NocturnalRmssdState( + windowMs: windowMs, minBeatsPerWindow: minBeatsPerWindow) + .._t0 = _t0 + .._idx = _idx + .._nn = List.of(_nn) + .._t = List.of(_t) + ..recs.addAll(recs); + for (var i = 0; i < tailNn.length; i++) { + c._step(tailNn[i], tailT[i], c.recs); + } + final total = _count + tailNn.length; + if (total < minBeatsPerWindow + 1) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few NN intervals for windowed nocturnal RMSSD', + ); + } + final all = [...c.recs]; + final open = c._close(c._idx, c._nn, c._t); + if (open != null) all.add(open); + final rmssds = []; + final runs = >[]; + final perWindow = >>[]; + final meanRr = []; + for (final r in all) { + if (stageMaskPerSec != null) { + final midSec = ((c._t0! + (r.idx + 0.5) * windowMs) / 1000.0).floor(); + final keep = midSec >= 0 && + midSec < stageMaskPerSec.length && + stageMaskPerSec[midSec]; + if (!keep) continue; + } + rmssds.add(r.rmssd); + runs.addAll(r.runs); + perWindow.add(r.runs); + meanRr.add(r.meanRr); + } + final acf1 = nnDiffAcf1(runs); + final refused = _jitterRefused(acf1, runs); + if (rmssds.isEmpty && !refused) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no usable 5-min windows for nocturnal RMSSD', + ); + } + final kept = _keptWindows(acf1, refused, rmssds, perWindow, meanRr); + if (kept == null) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: refused + ? _jitterNote(acf1!) + : '${_jitterNote(acf1!)}; no 5-min window clears it on its own', + ); + } + final robust = median(kept.rmssds)!; + final conf = kept.byBreathing + ? 0.3 + : ((kept.rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)) + .clamp(0.3, 0.95); + return Metric( + value: robust, + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: 'robust nocturnal RMSSD = MEDIAN of ${kept.rmssds.length} consecutive ' + '5-min-window RMSSDs (REM/arousal-robust). PRV not ECG-HRV; ' + 'RMSSD is quantization-sensitive at 1 Hz.${kept.note}', + ); + } +} + +// ---- verbatim copies of hrv_time.dart's private RMSSD gate (private there) -- +/// True when every successive difference is a whole multiple of one step q and +/// the mean squared difference is under 2q². Beat times rounded to a grid q +/// make RR a two-level sequence on a near-constant heart: its differences are +/// 0 or ±q, a deterministic sawtooth at frac(RR/q) cycles/beat, so it reads as +/// one clean line and the white-floor test cannot see it. Rounding alone puts +/// mean(d²) at 2·min(f, 1−f)·q² ≤ q², so under 2q² the grid, not the heart, +/// sets RMSSD. +// ponytail: exact lattice only; a grid re-rounded to whole ms (7.8 ms → 7/8) +// breaks the common step and is not caught. +bool _onCoarseLattice(List> diffRuns, double msd) { + var q = double.infinity; + for (final r in diffRuns) { + for (final d in r) { + if (d.abs() > 1e-6 && d.abs() < q) q = d.abs(); + } + } + if (q.isInfinite || msd >= 2 * q * q) return false; + for (final r in diffRuns) { + for (final d in r) { + final k = d.abs() / q; + if ((k - k.roundToDouble()).abs() > 1e-3) return false; + } + } + return true; +} + +/// The one RMSSD jitter verdict every path shares: ACF1 below the floor AND +/// the spectrum does not show a respiratory line on a low white floor. +bool _jitterRefused(double? acf1, List> runs, + {int minSegments = 20}) { + if (acf1 == null || acf1 >= kNnDiffAcf1Floor) return false; + final share = nnDiffNoiseShare(runs, minSegments: minSegments); + return share == null || share >= kNnDiffNoiseShareCeiling; +} + +/// One 5-min window judged on its own: a measured ACF1 at or above the floor, +/// or its own spectral line (4 segments is what a window at HR ~40 holds). +bool _windowClears(List> runs) { + final a = nnDiffAcf1(runs); + return a != null && !_jitterRefused(a, runs, minSegments: 4); +} + +/// The window RMSSDs a headline may use. A night that cleared the floor +/// outright keeps them all. One that only passed through the spectral +/// exemption proved a line somewhere in the pooled power, not in every window: +/// quiet jitter-only windows can win the median or dilute the mean while a few +/// loud breathing windows carry the pooled share. There every window has to +/// clear the gate itself. +List _clearedWindows(double? nightAcf1, List rmssds, + List>> winRuns) { + if (nightAcf1 == null || nightAcf1 >= kNnDiffAcf1Floor) return rmssds; + return [ + for (var i = 0; i < rmssds.length; i++) + if (_windowClears(winRuns[i])) rmssds[i] + ]; +} + +final _hann128 = [ + for (var i = 0; i < 128; i++) 0.5 - 0.5 * math.cos(2 * math.pi * i / 127) +]; +final _hann128Sq = _hann128.fold(0.0, (a, w) => a + w * w); + +/// Welch power (128-beat Hann, 50 % overlap, averaged over segments) of one +/// window's RR, rebuilt from its difference runs, at cycles-per-beat +/// frequencies [f]. Null when no run holds a full segment. +List? _windowPsd(List> diffRuns, List f) { + const n = 128; + final p = List.filled(f.length, 0.0); + var segs = 0; + for (final r in diffRuns) { + final x = List.filled(r.length + 1, 0.0); + for (var i = 0; i < r.length; i++) { + x[i + 1] = x[i] + r[i]; + } + for (var s = 0; s + n <= x.length; s += n ~/ 2) { + var m = 0.0; + for (var i = 0; i < n; i++) { + m += x[s + i]; + } + m /= n; + for (var j = 0; j < f.length; j++) { + final c = math.cos(2 * math.pi * f[j]), sn = math.sin(2 * math.pi * f[j]); + var cr = 1.0, ci = 0.0, re = 0.0, im = 0.0; + for (var i = 0; i < n; i++) { + final v = (x[s + i] - m) * _hann128[i]; + re += v * cr; + im += v * ci; + final t = cr * c - ci * sn; + ci = cr * sn + ci * c; + cr = t; + } + p[j] += re * re + im * im; + } + segs++; + } + } + return segs == 0 ? null : [for (final v in p) v / segs]; +} + +/// Last resort for a night the jitter gate refused: the windows whose +/// breathing line holds still in Hz while the heart rate moves. +/// +/// RSA follows breathing (Hirsch & Bishop 1981), a rate in breaths per +/// minute, so its line sits at a fixed frequency in Hz; a heart rate that +/// drifts across the night slides it in cycles per beat. Beat-timing jitter, +/// alternation and grid artifacts are tied to the beat, not the clock. So each +/// 5-min window's spectrum is pooled twice, once on a cycles-per-beat axis and +/// once rescaled by its own mean RR onto a Hz axis, and the night passes only +/// when the Hz pooling shows a line in 8–30 br/min that is sharper than the +/// beat pooling. If the heart rate barely moves the two poolings coincide and +/// the night stays refused: stability in Hz says nothing there. +/// +/// Ceiling: needs >= 12 windows and a heart rate that wanders; breathing at +/// half the heart rate is still an alternation and still refused. +/// Returns the indices of the windows whose own peak sits on the line. +List? _steadyBreathingWindows( + List>> winRuns, List meanRrMs) { + var ssd = 0.0, nd = 0; + final all = [for (final w in winRuns) ...w]; + for (final r in all) { + for (final d in r) { + ssd += d * d; + nd++; + } + } + if (nd == 0 || _onCoarseLattice(all, ssd / nd)) return null; + final ref = median([for (final m in meanRrMs) if (m > 0) m]); + if (ref == null) return null; + // 0.15–0.5 cycles/beat at the night's median beat. + final fc = [for (var j = 38; j <= 128; j++) j / 256]; + final byBeat = List.filled(fc.length, 0.0); + final byHz = List.filled(fc.length, 0.0); + final cover = List.filled(fc.length, 0); + final peakHz = {}; // window -> its own peak, cycles per ms + final psd = >{}; // window -> its cycles-per-beat PSD + for (var w = 0; w < winRuns.length; w++) { + if (meanRrMs[w] <= 0) continue; + final pb = _windowPsd(winRuns[w], fc); + if (pb == null) continue; + final norm = median(pb)!; + if (norm <= 0) continue; + final k = meanRrMs[w] / ref; + final fh = [for (final f in fc) f * k]; + final ph = _windowPsd(winRuns[w], fh)!; + var best = -1; + for (var j = 0; j < fc.length; j++) { + byBeat[j] += pb[j] / norm; + if (fh[j] > 0.5) continue; + byHz[j] += ph[j] / norm; + cover[j]++; + if (best < 0 || ph[j] > ph[best]) best = j; + } + if (best >= 0) peakHz[w] = fc[best] / ref; + psd[w] = pb; + } + final nw = peakHz.length; + if (nw < 12) return null; + final bins = [for (var j = 0; j < fc.length; j++) if (cover[j] == nw) j]; + if (bins.length < 20) return null; + final hz = [for (final j in bins) byHz[j]]; + final bt = [for (final j in bins) byBeat[j]]; + var pk = 0; + for (var i = 1; i < hz.length; i++) { + if (hz[i] > hz[pk]) pk = i; + } + if (pk == 0 || pk == hz.length - 1) return null; + final f0 = fc[bins[pk]] / ref; + final brpm = f0 * 60000; + if (brpm < 8 || brpm > 30) return null; + // Same bins, same normalisation: the line must stand taller aligned in Hz + // than the beat pooling stands anywhere within ±12 bins of it. + var near = 0.0; + for (var i = math.max(0, pk - 12); i <= math.min(bt.length - 1, pk + 12); i++) { + near = math.max(near, bt[i]); + } + // The pooled noise ripple shrinks as 1/√windows, so the bar does too. + final promHz = hz[pk] / median(hz)!; + if (promHz < math.max(2.5, 1.5 + 7 / math.sqrt(nw))) return null; + if (promHz < 1.1 * near / median(bt)!) return null; + // Alternation lives at Nyquist on the beat axis, outside these bins. + if (byBeat.last / median(bt)! >= 0.5 * promHz) return null; + final keep = [ + for (final e in peakHz.entries) + if (math.log(e.value / f0).abs() <= 0.15) e.key + ]; + if (keep.length < 6) return null; + // The line proves breathing is there, not that it carries RMSSD: beat-time + // jitter loud enough to fail the gate would still be most of the number. + // Same ceiling as [nnDiffNoiseShare], over the kept windows. Jitter σ² has + // an RR spectrum σ²·(2 − 2cos ω), flat once divided by that shape, and its + // differences carry 6σ²; physiology only adds power on top. Breathing + // wanders across the night and its line spreads with it, so each window + // drops the bins of every rate the kept windows peaked at (plus a Hann + // main lobe), mapped onto its own beats. σ² is the floor of what is left, + // pooled over windows and smoothed over four resolution cells. + var lo = double.infinity, hi = 0.0; + for (final w in keep) { + lo = math.min(lo, peakHz[w]!); + hi = math.max(hi, peakHz[w]!); + } + var sq = 0.0, nAll = 0; + final acc = List.filled(fc.length, 0.0); + final wt = List.filled(fc.length, 0.0); + for (final w in keep) { + var n = 0; + for (final r in winRuns[w]) { + for (final d in r) { + sq += d * d; + n++; + } + } + nAll += n; + final a = lo * meanRrMs[w] - 3 / 128, b = hi * meanRrMs[w] + 3 / 128; + for (var j = 0; j < fc.length; j++) { + if (fc[j] > a && fc[j] < b) continue; + acc[j] += n * psd[w]![j] / (2 - 2 * math.cos(2 * math.pi * fc[j])); + wt[j] += n; + } + } + var floor = double.infinity; + for (var j = 8; j < fc.length - 8; j++) { + var s = 0.0, m = 0; + for (var i = j - 8; i <= j + 8; i++) { + if (wt[i] < nAll / 2) break; + s += acc[i] / wt[i]; + m++; + } + if (m == 17) floor = math.min(floor, s / 17); + } + if (floor == double.infinity) return null; + // The lowest stretch of a noisy curve reads below its mean; 1.1 puts the + // floor back at these pooled sizes, so jitter alone is not undercounted. + final noise = 1.1 * 6 * floor / _hann128Sq * nAll; + return noise < kNnDiffNoiseShareCeiling * sq ? keep : null; +} + +/// The window RMSSDs a windowed headline publishes, or null for none: the +/// usual gate first, and only when it leaves nothing, the windows on a +/// breathing line that holds still in Hz ([_steadyBreathingWindows]), which +/// publish at the confidence floor. +({List rmssds, bool byBreathing, String note})? _keptWindows( + double? acf1, + bool refused, + List rmssds, + List>> winRuns, + List meanRrMs) { + final kept = refused ? const [] : _clearedWindows(acf1, rmssds, winRuns); + if (kept.isNotEmpty) return (rmssds: kept, byBreathing: false, note: ''); + if (acf1 == null || acf1 >= kNnDiffAcf1Floor) return null; + final idx = _steadyBreathingWindows(winRuns, meanRrMs); + if (idx == null) return null; + return ( + rmssds: [for (final i in idx) rmssds[i]], + byBreathing: true, + note: ' Jitter gate failed; kept the ${idx.length} windows on a breathing ' + 'line steady in Hz while heart rate drifted, at floor confidence.', + ); +} + +/// Confidence multiplier for a measured [acf1]: 1.0 on a smooth tachogram, +/// falling linearly to 0 at [kNnDiffAcf1Floor] so confidence bottoms out +/// exactly where RMSSD is refused. 1.0 when ACF1 could not be measured. +double _acf1Quality(double? acf1) => + acf1 == null ? 1.0 : (1 - acf1 / kNnDiffAcf1Floor).clamp(0.0, 1.0); + +String _jitterNote(double acf1) => + 'rmssd_refused:acf1=${acf1.toStringAsFixed(3)} — the NN successive ' + 'differences are essentially differenced white noise (−0.5 = pure, floor ' + '$kNnDiffAcf1Floor), so RMSSD/pNN50 would measure beat-timing jitter, not ' + 'vagal tone'; + +/// Intervals [rrCoverage] counts as beat-time, ms. Wider than the cleaners' +/// 2000 ms on purpose: a 2000–2400 ms beat is a slow heart (25–30 bpm), still +/// real elapsed time; only a glitch (e.g. a 65 535 ms sentinel) must not be +/// summed, or it would fake an over-count on a short span. +const double _rrCoverageMinMs = 300; +const double _rrCoverageMaxMs = 2400; + +bool _rrCoverageCounts(double rrMs) => + rrMs >= _rrCoverageMinMs && rrMs <= _rrCoverageMaxMs; + +// ---- verbatim copy of hrv_time.dart `_cleanWindowRuns` (private there) ---- +List> _cleanWindowRuns(List rr, List ts) { + const radius = 2; + const threshold = 0.20; + // Range filter first, keeping each survivor's position in [rr] — BOTH filters + // break a run, so neither one's compaction can manufacture a difference. + final nn = []; + final at = []; + final nnTs = []; + for (var i = 0; i < rr.length; i++) { + if (rr[i] >= 300 && rr[i] <= 2000) { + nn.add(rr[i]); + at.add(i); + nnTs.add(ts[i]); + } + } + final runs = >[]; + var run = []; + var lastKept = -2; + var lastTs = 0.0; + for (var i = 0; i < nn.length; i++) { + var keep = true; + if (nn.length > radius) { + final lo = math.max(0, i - radius); + final hi = math.min(nn.length - 1, i + radius); + final neighbors = []; + for (var j = lo; j <= hi; j++) { + if (j != i) neighbors.add(nn[j]); + } + final med = neighbors.length < 2 ? null : median(neighbors); + if (med != null && med > 0) keep = (nn[i] - med).abs() / med <= threshold; + } + if (!keep) { + if (run.isNotEmpty) { + runs.add(run); + run = []; + } + continue; + } + if (run.isNotEmpty && + (at[i] != lastKept + 1 || nnTs[i] - lastTs > nn[i] + 1000.0)) { + runs.add(run); + run = []; + } + run.add(nn[i]); + lastKept = at[i]; + lastTs = nnTs[i]; + } + if (run.isNotEmpty) runs.add(run); + return runs; +} + +/// Raw-RR stream (NOT the corrected NN): a bucket is final the moment a beat of +/// a later bucket arrives. Session bounds fixed at construction. +class SessionRmssdState { + final int startSec, endSec, windowSec, minDiffsPerWindow; + int _idx = -1; + List _rr = [], _ts = []; + final List<_WinRec> recs = []; + double? _previousTime; + bool _regressing = false; + int _seen = 0; // beats folded, in the session or not + // Session-wide coverage (rrCoverage over the in-session beats), from + // running figures: the earliest beat (first of equals) and its interval, + // the latest beat time, and the plausible RR summed in input order. + int _inN = 0; + double _loTs = 0, _loRr = 0, _hiTs = 0, _sumRr = 0; + int _implausible = 0; + + SessionRmssdState(this.startSec, this.endSec, + {this.windowSec = 300, this.minDiffsPerWindow = kMinDiffsPerRmssdWindow}); + + _WinRec _close(int idx, List winRr, List winTs) { + // Per-window over-count, as the batch judges it. + final winStart = startSec + idx * windowSec; + final winSec = math.min(winStart + windowSec, endSec) - winStart; + var bankedMs = 0.0; + var lo = 0; + for (var i = 0; i < winRr.length; i++) { + if (winTs[i] < winTs[lo]) lo = i; + if (_rrCoverageCounts(winRr[i])) bankedMs += winRr[i]; + } + final overhangMs = _rrCoverageCounts(winRr[lo]) ? winRr[lo] : 0.0; + if (bankedMs > kRrCoverageCeiling * (winSec * 1000 + overhangMs)) { + return _WinRec.dropped(idx, 1); + } + final rrRuns = [ + for (final r in _cleanWindowRuns(winRr, winTs)) + if (r.length >= 2) r + ]; + final diffRuns = [ + for (final r in rrRuns) [for (var i = 1; i < r.length; i++) r[i] - r[i - 1]] + ]; + final (ssd, nd) = _ssdOf(diffRuns); + if (nd < minDiffsPerWindow) return _WinRec.dropped(idx, 2); + return _WinRec(idx, 0, math.sqrt(ssd / nd), diffRuns, + mean([for (final r in rrRuns) ...r])!); + } + + void _step(double rr, double ts, List<_WinRec> sink) { + if (_previousTime != null && ts < _previousTime!) _regressing = true; + _previousTime = ts; + _seen++; + final tsSec = (ts / 1000.0).round(); + if (tsSec < startSec || tsSec >= endSec) return; + if (_inN == 0 || ts < _loTs) { + _loTs = ts; + _loRr = rr; + } + if (_inN == 0 || ts > _hiTs) _hiTs = ts; + if (_rrCoverageCounts(rr)) { + _sumRr += rr; + } else { + _implausible++; + } + _inN++; + final idx = (tsSec - startSec) ~/ windowSec; + if (idx != _idx && _rr.isNotEmpty) { + sink.add(_close(_idx, _rr, _ts)); + _rr = []; + _ts = []; + } + _idx = idx; + _rr.add(rr); + _ts.add(ts); + } + + void fold(List rr, List ts) { + for (var i = 0; i < rr.length; i++) { + _step(rr[i], ts[i], recs); + } + } + + /// `rrCoverage` of the in-session beats, or null where the batch has none. + /// The duplicate count is not kept (it needs every beat); the refusal reads + /// only the coverage. + RrCoverage? _coverage() { + if (_inN < 2) return null; + final spanSec = + (_hiTs - _loTs + (_rrCoverageCounts(_loRr) ? _loRr : 0)) / 1000.0; + if (!spanSec.isFinite || spanSec < kRrCoverageMinSpanSec) return null; + final sumSec = _sumRr / 1000.0; + return RrCoverage( + coverage: sumSec / spanSec, + sumRrSec: sumSec, + spanSec: spanSec, + beats: _inN, + implausibleBeats: _implausible, + duplicateBeats: 0, + ); + } + + /// The open bucket is closed on a copy; the state is not changed. + Metric evaluate() { + const inputs = ['rr_sleep_window']; + if (_regressing) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'regressing beat time'); + } + if (startSec <= 0 || endSec <= startSec || _seen == 0) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'invalid or empty RR session window', + ); + } + if (_inN == 0) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no RR beats inside the session window', + ); + } + final cov = _coverage(); + if (cov != null && cov.overCounted) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: rrOvercountNote(cov), + ); + } + final all = [...recs]; + if (_rr.isNotEmpty) all.add(_close(_idx, _rr, _ts)); + final rmssds = []; + final runs = >[]; + final perWindow = >>[]; + final meanRr = []; + var overCountedWindows = 0, thinWindows = 0; + for (final r in all) { + if (r.kind == 1) { + overCountedWindows++; + continue; + } + if (r.kind == 2) { + thinWindows++; + continue; + } + rmssds.add(r.rmssd); + runs.addAll(r.runs); + perWindow.add(r.runs); + meanRr.add(r.meanRr); + } + final droppedWhy = [ + if (thinWindows > 0) + '$thinWindows window(s) had fewer than $minDiffsPerWindow clean ' + 'successive differences', + if (overCountedWindows > 0) + '$overCountedWindows window(s) held more beat-time than elapsed time', + ]; + final dropped = droppedWhy.isEmpty ? '' : ' (${droppedWhy.join('; ')})'; + final acf1 = nnDiffAcf1(runs); + final refused = _jitterRefused(acf1, runs); + if (rmssds.isEmpty && !refused) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no valid 5-min windows for sleep-session RMSSD$dropped', + ); + } + final kept = _keptWindows(acf1, refused, rmssds, perWindow, meanRr); + if (kept == null) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: refused + ? '${_jitterNote(acf1!)}$dropped' + : '${_jitterNote(acf1!)}; no 5-min window clears it on its own' + '$dropped', + ); + } + final conf = kept.byBreathing + ? 0.3 + : ((kept.rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)) + .clamp(0.3, 0.95); + return Metric( + value: mean(kept.rmssds)!, + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: 'sleep-session HRV: mean RMSSD over cleaned 5-min windows' + '$dropped.${kept.note}', + ); + } +} + +// =========================================================================== +// 7. irregularBeatScreen over the (day-long) corrected NN +// =========================================================================== +// Aggregates (SD1/SD2 from stddev of diffs and of levels, pNN70) are sums => +// Welford, ~1e-13. The "sustained across 5-min windows" test is a COUNT of +// flagged/valid closed windows plus one open window evaluated on a copy => exact. +class IrregularScreenState { + // elementwise stream state over the corrected NN list + bool _prevKept = false; + double _prevV = 0; + // Time of the previous kept beat: a pair across a time hole is not + // successive (the batch's seam test). NaN = unknown, never within a seam. + double _prevT = double.nan; + int _nKept = 0; + // diff stats (adjacent kept pairs) + int _dN = 0; + double _dMean = 0, _dM2 = 0; + int _over = 0; + // level stats (kept beats) + int _lN = 0; + double _lMean = 0, _lM2 = 0; + // windows + double? _winStart; + List _bk = []; + List _bkAdj = []; + int _valid = 0, _flagged = 0; + + final double sd1sd2Flag, pnnThresholdMs, pnnFlagPct, windowMs, sustainedFraction; + final int minWindowBeats; + + IrregularScreenState({ + this.sd1sd2Flag = 0.70, + this.pnnThresholdMs = 70, + this.pnnFlagPct = 30, + double windowMinutes = 5, + this.minWindowBeats = 40, + this.sustainedFraction = 0.5, + }) : windowMs = windowMinutes * 60000; + + IrregularScreenState _copy() { + final c = IrregularScreenState( + sd1sd2Flag: sd1sd2Flag, + pnnThresholdMs: pnnThresholdMs, + pnnFlagPct: pnnFlagPct, + windowMinutes: windowMs / 60000, + minWindowBeats: minWindowBeats, + sustainedFraction: sustainedFraction) + .._prevKept = _prevKept + .._prevV = _prevV + .._prevT = _prevT + .._nKept = _nKept + .._dN = _dN + .._dMean = _dMean + .._dM2 = _dM2 + .._over = _over + .._lN = _lN + .._lMean = _lMean + .._lM2 = _lM2 + .._winStart = _winStart + .._bk = List.of(_bk) + .._bkAdj = List.of(_bkAdj) + .._valid = _valid + .._flagged = _flagged; + return c; + } + + void _flush() { + final diffs = [ + for (var i = 1; i < _bk.length; i++) + if (_bkAdj[i]) _bk[i] - _bk[i - 1] + ]; + // Valid on successive pairs, not beats (the batch's window rule). + if (diffs.length >= minWindowBeats - 1) { + _valid++; + final sdsd = stddev(diffs); + final sdnn = stddev(_bk); + if (sdsd != null && sdnn != null) { + final sd1 = sdsd / math.sqrt2; + final v = 2 * sdnn * sdnn - sd1 * sd1; + final sd2 = v > 0 ? math.sqrt(v) : 0.0; + if (sd2 > 0) { + final ratio = sd1 / sd2; + final over = diffs.where((d) => d.abs() > pnnThresholdMs).length; + final pnn = 100.0 * over / diffs.length; + if (ratio >= sd1sd2Flag && pnn >= pnnFlagPct) _flagged++; + } + } + } + _bk = []; + _bkAdj = []; + } + + void _add(double v, double tm) { + final kept = v >= 300 && v <= 2000; + if (kept) { + // previous INPUT element was kept and no time hole lies between them + final adjacent = _prevKept && tm - _prevT <= v + 0.5; + if (adjacent) { + final d = v - _prevV; + _dN++; + final dl = d - _dMean; + _dMean += dl / _dN; + _dM2 += dl * (d - _dMean); + if (d.abs() > pnnThresholdMs) _over++; + } + _lN++; + final dl = v - _lMean; + _lMean += dl / _lN; + _lM2 += dl * (v - _lMean); + _nKept++; + _winStart ??= tm; + if (tm - _winStart! >= windowMs) { + _flush(); + _winStart = tm; + } + _bk.add(v); + _bkAdj.add(adjacent); + } + _prevKept = kept; + if (kept) { + _prevV = v; + _prevT = tm; + } + } + + void fold(List nn, List t) { + for (var i = 0; i < nn.length; i++) { + _add(nn[i], t[i]); + } + } + + Metric evaluate(List tailNn, List tailT, + {double artifactFraction = 0.0, + int minBeats = irregularScreenMinBeats, + double maxArtifact = 0.30}) { + const inputs = ['rr_cleaned']; + final c = _copy()..fold(tailNn, tailT); + // The batch gates on the successive pairs it averages, not on beats. + if (c._dN < minBeats - 1) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'too few clean beats for an irregular-rhythm screen', + ); + } + if (artifactFraction > maxArtifact) { + return Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'artifact fraction ${(artifactFraction * 100).round()}% > ' + '${(maxArtifact * 100).round()}% — screen suppressed on noisy RR', + ); + } + final sdsd = c._dN < 2 ? null : math.sqrt(c._dM2 / (c._dN - 1)); + final sdnn = c._lN < 2 ? null : math.sqrt(c._lM2 / (c._lN - 1)); + if (sdsd == null || sdnn == null) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'no successive clean beats to build a Poincare plot from', + ); + } + final sd1 = sdsd / math.sqrt2; + final v = 2 * sdnn * sdnn - sd1 * sd1; + final sd2 = v > 0 ? math.sqrt(v) : 0.0; + if (sd2 <= 0) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'no long-term variability (SD2 = 0) — the SD1/SD2 ratio is ' + 'undefined, not "perfectly regular"', + ); + } + final ratio = sd1 / sd2; + final pnnPct = c._dN == 0 ? 0.0 : 100.0 * c._over / c._dN; + final aggregateHigh = ratio >= sd1sd2Flag && pnnPct >= pnnFlagPct; + var flag = false; + if (aggregateHigh) { + c._flush(); // the open window closes at end of data + flag = c._valid != 0 && c._flagged / c._valid >= sustainedFraction; + } + final conf = (c._nKept / 5000.0 * (1 - artifactFraction)).clamp(0.2, 0.9); + return Metric( + value: IrregularRhythm( + sd1: sd1, + sd2: sd2, + sd1sd2: ratio, + pnnPct: pnnPct, + nBeats: c._nKept, + flag: flag, + ), + confidence: conf, + tier: Tier.estimate, + inputs_used: inputs, + note: 'irregular-rhythm SCREEN (not a diagnosis): Poincaré SD1/SD2 + pNN' + '${pnnThresholdMs.round()}. PRV not ECG — wrist pulse misses P-waves. ' + 'Discuss with a clinician only if you have symptoms.', + ); + } + + Map toJson() => { + 'sd1sd2Flag': sd1sd2Flag, + 'pnnThresholdMs': pnnThresholdMs, + 'pnnFlagPct': pnnFlagPct, + 'windowMinutes': windowMs / 60000, + 'minWindowBeats': minWindowBeats, + 'sustainedFraction': sustainedFraction, + 'prevKept': _prevKept, + 'prevV': _prevV, + 'prevT': _prevT.isFinite ? _prevT : null, + 'nKept': _nKept, + 'dN': _dN, + 'dMean': _dMean, + 'dM2': _dM2, + 'over': _over, + 'lN': _lN, + 'lMean': _lMean, + 'lM2': _lM2, + 'winStart': _winStart, + 'bk': List.of(_bk), + 'bkAdj': [for (final a in _bkAdj) a ? 1 : 0], + 'valid': _valid, + 'flagged': _flagged, + }; + + factory IrregularScreenState.fromJson(Map j) { + final s = IrregularScreenState( + sd1sd2Flag: (j['sd1sd2Flag'] as num).toDouble(), + pnnThresholdMs: (j['pnnThresholdMs'] as num).toDouble(), + pnnFlagPct: (j['pnnFlagPct'] as num).toDouble(), + windowMinutes: (j['windowMinutes'] as num).toDouble(), + minWindowBeats: j['minWindowBeats'] as int, + sustainedFraction: (j['sustainedFraction'] as num).toDouble(), + ); + s._prevKept = j['prevKept'] as bool; + s._prevV = (j['prevV'] as num).toDouble(); + s._prevT = (j['prevT'] as num?)?.toDouble() ?? double.nan; + s._nKept = j['nKept'] as int; + s._dN = j['dN'] as int; + s._dMean = (j['dMean'] as num).toDouble(); + s._dM2 = (j['dM2'] as num).toDouble(); + s._over = j['over'] as int; + s._lN = j['lN'] as int; + s._lMean = (j['lMean'] as num).toDouble(); + s._lM2 = (j['lM2'] as num).toDouble(); + s._winStart = (j['winStart'] as num?)?.toDouble(); + s._bk = [for (final x in j['bk'] as List) (x as num).toDouble()]; + s._bkAdj = [for (final x in j['bkAdj'] as List) x == 1]; + s._valid = j['valid'] as int; + s._flagged = j['flagged'] as int; + return s; + } +} diff --git a/tool/incremental/oracle_util.dart b/tool/incremental/oracle_util.dart index 6f12de6..bb315e3 100644 --- a/tool/incremental/oracle_util.dart +++ b/tool/incremental/oracle_util.dart @@ -24,6 +24,9 @@ List randomCuts(math.Random r, int n, /// Cuts that fall every [sec] seconds of the beat clock: what a 15-minute /// derive pass sees. [tsMs] sorted-ish epoch ms. List timeCuts(List tsMs, double sec) { + if (!(sec > 0) || !sec.isFinite) { + throw ArgumentError.value(sec, 'sec', 'must be a positive finite number'); + } final cuts = []; if (tsMs.isEmpty) return cuts; var next = tsMs.first + sec * 1000; diff --git a/tool/incremental/resp_incr.dart b/tool/incremental/resp_incr.dart new file mode 100644 index 0000000..2cb8c89 --- /dev/null +++ b/tool/incremental/resp_incr.dart @@ -0,0 +1,911 @@ +// RESEARCH PROTOTYPES — incremental breathing-rate and spectral estimators. +// +// All three Welch-style estimators (rsaRespRate, hrvFreq, and the per-30-min +// bins behind BRV) have the same structure: the answer is an aggregate over +// SUB-WINDOWS that are each a pure function of the beats inside them. A +// sub-window is final as soon as a beat past its end is settled, so the state +// is "list of finished sub-window results + the beats since the next +// unfinished sub-window". Aggregates (median, consensus, sum/k) are re-run on +// that list with the batch's own arithmetic => bit-identical. +import 'dart:math' as math; + +import 'package:openstrap_analytics/src/aggregate/json_support.dart'; + +import 'package:openstrap_analytics/onehz.dart' + hide RrCorrector, RrSettled, RrSnapshot, IrregularScreenState; + +int _lowerBound(List a, double v) { + var lo = 0, hi = a.length; + while (lo < hi) { + final m = (lo + hi) >> 1; + if (a[m] < v) { + lo = m + 1; + } else { + hi = m; + } + } + return lo; +} + +double _powerAt(LombScargle ls, double fHz) { + double best = 0; + double bestDist = double.infinity; + for (final pt in ls.spectrum) { + final d = (pt.freqHz - fHz).abs(); + if (d < bestDist) { + bestDist = d; + best = pt.psd; + } + } + return best; +} + +// =========================================================================== +// rsaRespRate +// =========================================================================== +class _SegCounts { + final List peaks = [], peakHz = [], peakPwr = []; + int atCeiling = 0, atFloor = 0, belowBand = 0, thin = 0, gappy = 0; + // The at-ceiling sub-windows' own ceilings, br/min (min and max). + double ceilingLo = double.infinity, ceilingHi = 0.0; + _SegCounts copy() { + final c = _SegCounts(); + c.peaks.addAll(peaks); + c.peakHz.addAll(peakHz); + c.peakPwr.addAll(peakPwr); + c.atCeiling = atCeiling; + c.atFloor = atFloor; + c.belowBand = belowBand; + c.thin = thin; + c.gappy = gappy; + c.ceilingLo = ceilingLo; + c.ceilingHi = ceilingHi; + return c; + } +} + +/// [a] and [b] sorted by [double.compareTo] (as `median` sorts), merged. +List _mergeSorted(List a, List b) { + if (b.isEmpty) return a; + if (a.isEmpty) return b; + final out = List.filled(a.length + b.length, 0); + var i = 0, j = 0, k = 0; + while (i < a.length && j < b.length) { + out[k++] = a[i].compareTo(b[j]) <= 0 ? a[i++] : b[j++]; + } + while (i < a.length) { + out[k++] = a[i++]; + } + while (j < b.length) { + out[k++] = b[j++]; + } + return out; +} + +/// Usable sub-windows at which the batch's confidence reaches full weight +/// (resp_rate.dart `_rsaFullWeightSubwindows`). +const double _rsaFullWeightSubwindows = 23; + +class RsaWelchState { + static const double segSec = rsaSegmentSec; // 300 + // buffer of settled beats (ms and s), from the next unfinished sub-window + List _tMs = [], _ts = [], _nn = []; + // Every NN folded, sorted: the whole-input ceiling is the MEDIAN beat + // interval, which needs all of them (upstream's gap-free ceiling). + List _sorted = []; + // A non-finite beat time was folded: the batch abstains on it. + bool _nonFinite = false; + double? _first; + int _count = 0; + double _lastT = 0; + bool _folding = false; + double _next = 0; + _SegCounts _sc = _SegCounts(); + + RsaWelchState(); + + int get bufferedBeats => _nn.length; + int get foldedSegments => + _sc.peaks.length + _sc.atCeiling + _sc.atFloor + _sc.belowBand + _sc.thin + _sc.gappy; + + void fold(List nn, List tMs) { + _sorted = _mergeSorted(_sorted, [...nn]..sort()); + for (final t in tMs) { + if (!(t / 1000.0).isFinite) _nonFinite = true; + } + if (_nonFinite) { + // Nothing settles after a non-finite time (the walk below would never + // end); the batch abstains, and so does [evaluate]. + _nn.addAll(nn); + _tMs.addAll(tMs); + _ts.addAll([for (final t in tMs) t / 1000.0]); + _count += nn.length; + return; + } + for (var i = 0; i < nn.length; i++) { + _nn.add(nn[i]); + _tMs.add(tMs[i]); + final s = tMs[i] / 1000.0; + _ts.add(s); + _first ??= s; + _lastT = s; + _count++; + } + if (!_folding && _count > 0 && _lastT - _first! >= 2 * segSec) { + _folding = true; + _next = _first!; + } + if (_folding) { + while (_next + segSec <= _lastT) { + _evalSeg(_next, _ts, _nn, _sc); + _next += segSec / 2; + } + final cut = _lowerBound(_ts, _next); + if (cut > 0) { + _tMs = _tMs.sublist(cut); + _ts = _ts.sublist(cut); + _nn = _nn.sublist(cut); + } + } + } + + static void _evalSeg( + double s, List tSec, List nnMs, _SegCounts c) { + final lo = _lowerBound(tSec, s); + final hi = _lowerBound(tSec, s + segSec); + final k = hi - lo; + final segT = tSec.sublist(lo, hi); + final segNn = nnMs.sublist(lo, hi); + // Time-complete = the kept beats COVER >= 80 % of the sub-window (sum of + // NN, not first-to-last span), judged before the beat count. + final coveredSec = segNn.fold(0, (a, b) => a + b) / 1000.0; + if (coveredSec < segSec * 0.8) { + c.gappy++; + return; + } + if (k < 30) { + c.thin++; + return; + } + final segSpan = segT.last - segT.first; + // The sub-window's ceiling from its median beat interval, gap-free. + final segHi = rsaCeilingHz(median(segNn)! / 1000.0); + if (segHi < rsaHiHz) { + c.belowBand++; + return; + } + final grid = math.max(64, ((segHi - rsaLoHz) * 4 * segSpan).ceil()); + final ls = lombScargle(segT, segNn, freqGrid(rsaLoHz, segHi, grid)); + if (ls == null) { + c.thin++; + return; + } + final pk = ls.peakFreq(rsaLoHz, segHi); + if (pk == null) { + c.thin++; + return; + } + if (pk >= segHi - (segHi - rsaLoHz) / (grid - 1)) { + c.atCeiling++; + c.ceilingLo = math.min(c.ceilingLo, segHi * 60); + c.ceilingHi = math.max(c.ceilingHi, segHi * 60); + return; + } + // Upstream's floor guard withholds the skirt of a below-band peak. + if (pk <= rsaLoHz + math.min(2 / segSpan, 2 / rsaSegmentSec)) { + c.atFloor++; + return; + } + c.peaks.add(pk * 60.0); + c.peakHz.add(pk); + c.peakPwr.add(_powerAt(ls, pk)); + } + + /// `rsaRespRate(settled ++ tail, artifactFraction: ...)`. + Metric evaluate(List tailNn, List tailTMs, + {required double artifactFraction, + double tolBrpm = 2.0, + double minConsensus = 0.5, + double maxArtifact = 0.30}) { + const inputs = ['rr_cleaned', 'beat_times']; + final n = _count + tailNn.length; + if (n < 20) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few beats for an RSA spectral estimate (need ≥20)', + ); + } + final allNn = [..._nn, ...tailNn]; + final allTMs = [..._tMs, ...tailTMs]; + final allTs = [..._ts, for (final t in tailTMs) t / 1000.0]; + final first = _first ?? allTs.first; + final last = allTs.last; + final spanSec = last - first; + if (!_folding) { + // short record (< 2 sub-windows): the oracle's segSec = span/2 rule + // makes every window length depend on the total. Buffer holds all + // beats here (no folding yet, and a non-finite time never starts it), + // so just call the oracle. + return rsaRespRate(allNn, allTMs, artifactFraction: artifactFraction, + tolBrpm: tolBrpm, minConsensus: minConsensus, maxArtifact: maxArtifact); + } + if (artifactFraction > maxArtifact) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'artifact fraction ${round6(artifactFraction)} > gate ' + '— RSA peak unreliable', + ); + } + if (_nonFinite || allTs.any((t) => !t.isFinite)) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'non-finite beat time', + ); + } + if (spanSec <= 0) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'degenerate beat times', + ); + } + // The beat interval is the median NN over settled ++ tail (gap-free). + final beatSec = + percentile(_mergeSorted(_sorted, [...tailNn]..sort()), 50)! / 1000.0; + final hiHz = rsaCeilingHz(beatSec); + if (hiHz < rsaHiHz) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'sleeping heart rate ${round6(60 / beatSec)} bpm (median beat ' + 'interval ${round6(beatSec)} s) can resolve breathing from beat ' + 'timing only up to ${round6(hiHz * 60)} br/min, below the top of the ' + 'normal 9–24 br/min band, so any peak could be an alias; rate ' + 'withheld', + ); + } + // provisional sub-windows on a copy: those that end after the settled edge + final sc = _sc.copy(); + var s = _next; + while (s + segSec <= last + 1e-9) { + _evalSeg(s, allTs, allNn, sc); + s += segSec / 2; + } + return _finish(sc, segSec, hiHz, artifactFraction, tolBrpm, minConsensus); + } + + static Metric _finish(_SegCounts c, double segSec, double hiHz, + double artifactFraction, double tolBrpm, double minConsensus) { + const inputs = ['rr_cleaned', 'beat_times']; + final peaks = c.peaks, peakHz = c.peakHz, peakPwr = c.peakPwr; + final dropped = c.atCeiling + c.atFloor + c.belowBand + c.thin + c.gappy; + final total = peaks.length + dropped; + if (peaks.length < 3) { + // The largest counter is the reason; ties go to the earlier entry. + final reasons = <(int, String)>[ + ( + c.belowBand, + '${c.belowBand} of $total sub-windows had a beat rate too low to ' + 'cover the HF band (any peak could be an alias)' + ), + ( + c.atCeiling, + c.atCeiling == 0 + ? '' + : '${c.atCeiling} of $total sub-windows peaked at/above their ' + 'resolvable ceiling (${round6(c.ceilingLo)}' + '${c.ceilingHi > c.ceilingLo ? '–${round6(c.ceilingHi)}' : ''} ' + 'br/min)' + ), + ( + c.atFloor, + '${c.atFloor} of $total sub-windows peaked at the floor of the RSA band ' + '(${round6(rsaLoHz * 60)} br/min) — the true rate may be below it' + ), + ( + c.gappy, + '${c.gappy} of $total ${round6(segSec)}s sub-windows had less than ' + '80 % of their time covered by clean beats (sensor gaps or ' + 'rejected beats)' + ), + ( + c.thin, + '${c.thin} of $total sub-windows had fewer than 30 beats or no ' + 'spectral peak' + ), + ]; + var top = reasons.first; + for (final r in reasons.skip(1)) { + if (r.$1 > top.$1) top = r; + } + final why = + dropped == 0 ? 'only ${peaks.length} usable sub-windows' : top.$2; + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no stable HF respiratory peak resolved — $why', + ); + } + final medBrpm0 = median(peaks)!; + var within = 0; + for (final p in peaks) { + if ((p - medBrpm0).abs() <= tolBrpm) within++; + } + final consensus = within / (peaks.length + c.atFloor); + if (consensus < minConsensus) { + return Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'HF peak unstable across the window\'s own sub-windows — only ' + '$within of ${peaks.length + c.atFloor} ${round6(segSec)}s sub-windows fall ' + 'within ${round6(tolBrpm)} br/min of the median; withheld', + ); + } + var best = 0; + for (var i = 1; i < peaks.length; i++) { + if ((peaks[i] - medBrpm0).abs() < (peaks[best] - medBrpm0).abs()) best = i; + } + final brpm = peaks[best]; + final conf = ((1 - artifactFraction) * + consensus * + (peaks.length / _rsaFullWeightSubwindows).clamp(0.0, 1.0)) + .clamp(0.2, 0.9); + return Metric( + value: RespEstimate(brpm, peakHz[best], peakPwr[best], 'rsa', + usableSubwindows: peaks.length, subwindows: total), + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: 'RSA HF-peak respiratory rate (Lomb-Scargle on native beat times, ' + 'median of ${peaks.length} ${round6(segSec)}s sub-windows, $within of ' + 'them within ${round6(tolBrpm)} br/min of it — brpm, peak_hz and power ' + 'all come from the medoid sub-window); PRV-derived; this window could ' + 'resolve up to ${round6(hiHz * 60)} br/min', + ); + } + + Map toJson() => { + 'tMs': [for (final x in _tMs) encodeDouble(x)], + 'nn': [for (final x in _nn) encodeDouble(x)], + 'sorted': [for (final x in _sorted) encodeDouble(x)], + 'nonFinite': _nonFinite, + 'first': _first, + 'count': _count, + 'lastT': _lastT, + 'folding': _folding, + 'next': _next, + 'peaks': List.of(_sc.peaks), + 'peakHz': List.of(_sc.peakHz), + 'peakPwr': List.of(_sc.peakPwr), + 'atCeiling': _sc.atCeiling, + 'atFloor': _sc.atFloor, + 'belowBand': _sc.belowBand, + 'thin': _sc.thin, + 'gappy': _sc.gappy, + 'ceilingLo': _sc.ceilingLo.isFinite ? _sc.ceilingLo : null, + 'ceilingHi': _sc.ceilingHi, + }; + + factory RsaWelchState.fromJson(Map j) { + List dl(Object? o) => [for (final x in o as List) decodeDouble(x)]; + final s = RsaWelchState(); + s._tMs = dl(j['tMs']); + s._ts = [for (final t in s._tMs) t / 1000.0]; + s._nn = dl(j['nn']); + s._sorted = dl(j['sorted']); + s._nonFinite = j['nonFinite'] as bool; + s._first = (j['first'] as num?)?.toDouble(); + s._count = j['count'] as int; + s._lastT = (j['lastT'] as num).toDouble(); + s._folding = j['folding'] as bool; + s._next = (j['next'] as num).toDouble(); + s._sc.peaks.addAll(dl(j['peaks'])); + s._sc.peakHz.addAll(dl(j['peakHz'])); + s._sc.peakPwr.addAll(dl(j['peakPwr'])); + s._sc.atCeiling = j['atCeiling'] as int; + s._sc.atFloor = j['atFloor'] as int; + s._sc.belowBand = j['belowBand'] as int; + s._sc.thin = j['thin'] as int; + s._sc.gappy = j['gappy'] as int; + s._sc.ceilingLo = (j['ceilingLo'] as num?)?.toDouble() ?? double.infinity; + s._sc.ceilingHi = (j['ceilingHi'] as num).toDouble(); + return s; + } +} + +// =========================================================================== +// _respPerWindow (feeds BRV): 30-min bins of NN from the first NN beat +// =========================================================================== +class RespWindowsState { + final double windowMs; + final int minBeats; + double? _t0; + int _bin = 0; + RsaWelchState _open = RsaWelchState(); + int _openCount = 0; + final List closed = []; + + RespWindowsState({this.windowMs = 1800000.0, this.minBeats = 60}); + + void _closeOpen(List sink, RsaWelchState st, int count) { + if (count < minBeats) return; + final r = st.evaluate(const [], const [], artifactFraction: 0.0); + final b = r.present ? r.value!.brpm : null; + if (b != null) sink.add(b); + } + + void fold(List nn, List tMs) { + var from = 0; + for (var i = 0; i < nn.length; i++) { + _t0 ??= tMs[i]; + final idx = ((tMs[i] - _t0!) / windowMs).floor(); + if (idx != _bin) { + _open.fold(nn.sublist(from, i), tMs.sublist(from, i)); + _openCount += i - from; + from = i; + _closeOpen(closed, _open, _openCount); + _open = RsaWelchState(); + _openCount = 0; + _bin = idx; + } + } + _open.fold(nn.sublist(from), tMs.sublist(from)); + _openCount += nn.length - from; + } + + /// `_respPerWindow(settled ++ tail)`. + List evaluate(List tailNn, List tailTMs) { + final out = [...closed]; + if (tailNn.isEmpty) { + _closeOpen(out, _open, _openCount); + return out; + } + // tail beats may open new bins: split by bin index + var curIdx = _bin; + var curSettledOpen = _open; + var curCount = _openCount; + var from = 0; + var useCopy = true; // the first bin continues the settled open bin + for (var i = 0; i < tailNn.length; i++) { + final idx = ((tailTMs[i] - (_t0 ?? tailTMs[0])) / windowMs).floor(); + if (idx != curIdx) { + _flushTail(out, curSettledOpen, curCount, useCopy, tailNn.sublist(from, i), + tailTMs.sublist(from, i)); + useCopy = false; + curSettledOpen = RsaWelchState(); + curCount = 0; + curIdx = idx; + from = i; + } + } + _flushTail(out, curSettledOpen, curCount, useCopy, tailNn.sublist(from), + tailTMs.sublist(from)); + return out; + } + + void _flushTail(List out, RsaWelchState st, int count, bool settled, + List nn, List t) { + final total = count + nn.length; + if (total < minBeats) return; + final r = st.evaluate(nn, t, artifactFraction: 0.0); + final b = r.present ? r.value!.brpm : null; + if (b != null) out.add(b); + } +} + +// =========================================================================== +// hrvFreq (LF/HF): Welch-averaged Lomb-Scargle band powers, 4 bands +// =========================================================================== +class _Band { + final double lo, hi, segSec, step; + final List grid; + double sum = 0; + int k = 0; + double next = 0; + bool started = false; + _Band(this.lo, this.hi, double oversample) + : segSec = 10.0 / lo, + step = (10.0 / lo) / 2, + grid = _gridFor(lo, hi, 10.0 / lo, oversample); + static List _gridFor(double lo, double hi, double seg, double os) { + final df = 1.0 / seg / os; + final nGrid = ((hi - lo) / df).ceil() + 1; + return freqGrid(lo, hi, nGrid); + } +} + +class HrvFreqState { + final double oversample; + + /// ULF needs a 33 333 s segment: it can only ever resolve on a record longer + /// than 9.26 h, and while unresolved it forces the WHOLE record to be kept. + /// With [includeUlf] false the state stays small (VLF buffer ~1.5 segments + /// = 83 min) but is only valid while the record spans < 33 333 s; beyond + /// that [evaluate] throws and the caller must use the batch function. + final bool includeUlf; + final List<_Band> _bands; + List _ts = [], _y = []; + double? _first; + int _count = 0, _dropped = 0; + double _lastT = 0; + + HrvFreqState({this.oversample = 4.0, this.includeUlf = true}) + : _bands = [ + _Band(0.0003, 0.003, oversample), + _Band(0.003, 0.04, oversample), + _Band(0.04, 0.15, oversample), + _Band(0.15, 0.40, oversample), + ]; + + int get bufferedBeats => _ts.length; + + /// One sub-window of one band; returns the band power or null (skipped). + static double? _seg(_Band b, double start, List tSec, List y) { + final end = start + b.segSec; + final lo = _lowerBound(tSec, start); + final hi = _lowerBound(tSec, end); + final ts = tSec.sublist(lo, hi); + final ys = y.sublist(lo, hi); + if (ts.length < 16 || ts.last - ts.first < b.segSec * 0.8) return null; + var maxGap = 0.0; + for (var i = 1; i < ts.length; i++) { + final g = ts[i] - ts[i - 1]; + if (g > maxGap) maxGap = g; + } + if (maxGap > b.segSec * 0.2) return null; + final ls = lombScargle(ts, ys, b.grid); + if (ls == null) return null; + final p = ls.bandPower(b.lo, b.hi); + if (!p.isFinite) return null; + return p; + } + + void fold(List nn, List tMs) { + if (nn.length != tMs.length) throw ArgumentError('beat columns differ'); + if (tMs.any((t) => !(t / 1000.0).isFinite)) { + throw ArgumentError('beat times must be finite'); + } + _count += nn.length; + for (var i = 0; i < nn.length; i++) { + final s = tMs[i] / 1000.0; + if (_first != null && s < _lastT) { + _dropped++; + continue; + } + _y.add(nn[i]); + _ts.add(s); + _first ??= s; + _lastT = s; + } + if (_count == 0) return; + for (final b in _bands) { + if (!includeUlf && identical(b, _bands[0])) continue; + if (!b.started) { + b.started = true; + b.next = _first!; + } + while (b.next + b.segSec <= _lastT) { + final p = _seg(b, b.next, _ts, _y); + if (p != null) { + b.sum += p; + b.k++; + } + b.next += b.step; + } + } + var minNext = double.infinity; + for (final b in _bands) { + if (!includeUlf && identical(b, _bands[0])) continue; + if (b.next < minNext) minNext = b.next; + } + final cut = _lowerBound(_ts, minNext); + if (cut > 0) { + _ts = _ts.sublist(cut); + _y = _y.sublist(cut); + } + } + + double? _band(_Band b, List tSec, List y, int count, + double first, double last) { + if (count < 16) return null; + final span = last - first; + if (span < b.segSec) return null; + var sum = b.sum; + var k = b.k; + var start = b.started ? b.next : first; + while (start + b.segSec <= last) { + final p = _seg(b, start, tSec, y); + if (p != null) { + sum += p; + k++; + } + start += b.step; + } + return k == 0 ? null : sum / k; + } + + Metric evaluate(List tailNn, List tailTMs, + {required double artifactFraction, double hfArtifactGate = 0.15}) { + const inputs = ['rr_cleaned', 'beat_times']; + final n = _count + tailNn.length; + if (n < 16) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'too few beats for a spectral estimate', + ); + } + if (tailNn.length != tailTMs.length) throw ArgumentError('beat columns differ'); + if (tailTMs.any((t) => !(t / 1000.0).isFinite)) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'non-finite beat time'); + } + final ts = [..._ts], y = [..._y]; + var latest = _first == null ? double.negativeInfinity : _lastT; + var dropped = _dropped; + for (var i = 0; i < tailNn.length; i++) { + final t = tailTMs[i] / 1000.0; + if (t < latest) { + dropped++; + } else { + ts.add(t); + y.add(tailNn[i]); + latest = t; + } + } + if (dropped > 0 && + (dropped > n * kMaxRegressingBeatFraction || n - dropped < 16)) { + return const Metric.absent(tier: Tier.high, inputs_used: inputs, + note: 'regressing beat time'); + } + final first = _first ?? ts.first; + final last = ts.last; + final spanSec = last - first; + if (spanSec <= 0) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'degenerate beat times', + ); + } + if (!includeUlf && spanSec >= _bands[0].segSec) { + throw StateError('ULF is resolvable (span >= ${_bands[0].segSec} s): ' + 'this state excludes it; use hrvFreq() or includeUlf: true'); + } + final ulf = includeUlf ? _band(_bands[0], ts, y, n, first, last) : null; + final vlf = _band(_bands[1], ts, y, n, first, last); + final lf = _band(_bands[2], ts, y, n, first, last); + final hfRaw = _band(_bands[3], ts, y, n, first, last); + if (lf == null && hfRaw == null) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'record too short to resolve any HRV band', + ); + } + final hfGated = artifactFraction > hfArtifactGate; + final hf = hfGated ? null : hfRaw; + double? lfhf, nuLf, nuHf; + if (lf != null && hf != null && (lf + hf) > 0) { + lfhf = hf == 0 ? null : lf / hf; + nuLf = 100.0 * lf / (lf + hf); + nuHf = 100.0 * hf / (lf + hf); + } + final bands = {'ulf': ulf, 'vlf': vlf, 'lf': lf, 'hf': hfRaw}; + final resolved = [ + for (final e in bands.entries) + if (e.value != null) e.key + ]; + final total = (hfGated || lf == null || hfRaw == null) + ? null + : [for (final b in resolved) bands[b]!].reduce((a, b) => a + b); + final conf = ((1 - artifactFraction) * (hfGated ? 0.6 : 0.9)).clamp(0.2, 0.9); + return Metric( + value: HrvFreq( + ulf: ulf, + vlf: vlf, + lf: lf, + hf: hf, + total: total, + totalBands: total == null ? null : resolved, + lfhf: lfhf, + nuLf: nuLf, + nuHf: nuHf, + hfGated: hfGated, + ), + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: hfGated + ? 'HF suppressed: artifact fraction ${round6(artifactFraction)} ' + '> gate — LF/VLF reported, HF/LF-HF/nu/total withheld' + : dropped > 0 + ? 'PRV spectrum; $dropped beats behind a re-anchored clock ' + 'left out; HF band quantization-limited at 1 Hz' + : 'PRV spectrum; HF band quantization-limited at 1 Hz', + ); + } + + Map toJson() => { + 'ts': List.of(_ts), + 'y': List.of(_y), + 'first': _first, + 'count': _count, + 'dropped': _dropped, + 'lastT': _lastT, + 'bands': [ + for (final b in _bands) + {'sum': b.sum, 'k': b.k, 'next': b.next, 'started': b.started} + ], + }; + + factory HrvFreqState.fromJson(Map j, + {double oversample = 4.0, bool includeUlf = true}) { + final s = HrvFreqState(oversample: oversample, includeUlf: includeUlf); + s._ts = [for (final x in j['ts'] as List) (x as num).toDouble()]; + s._y = [for (final x in j['y'] as List) (x as num).toDouble()]; + s._first = (j['first'] as num?)?.toDouble(); + s._count = j['count'] as int; + s._dropped = j['dropped'] as int; + s._lastT = (j['lastT'] as num).toDouble(); + final bs = j['bands'] as List; + for (var i = 0; i < s._bands.length; i++) { + final m = bs[i] as Map; + s._bands[i] + ..sum = (m['sum'] as num).toDouble() + ..k = m['k'] as int + ..next = (m['next'] as num).toDouble() + ..started = m['started'] as bool; + } + return s; + } +} + +// =========================================================================== +// edge `dayRespCurve`: 3-min RSA windows every 5 min, stillness-gated +// =========================================================================== +// Right-context 0 in RR, but the stillness gate reads accelerometer seconds up +// to ceil(ts[i]/1000), so an attempt WAITS for the accel watermark. Exact and +// append-only once an attempt has run. +class DayRespCurveState { + final double cut; + List _ts = [], _rr = []; + int _head = 0; + int _gated = 0; + double _lastEmit = -1e18; + final List> _out = []; + final List _rowSec = []; + final List _rowQuiet = []; + final List _pendRr = [], _pendTs = []; + int attempts = 0; + + DayRespCurveState({this.cut = 0.02}); + + List> get curve => _gated < 60 ? const [] : _out; + + static bool _present(double ax, double ay, double az) { + final magSq = ax * ax + ay * ay + az * az; + return magSq > 0 && magSq <= 16.0; + } + + void fold(List rrMs, List rrTsMs, List accTs, + List ax, List ay, List az, int watermarkSec) { + for (var i = 0; i < accTs.length; i++) { + var q = false; + if (_present(ax[i], ay[i], az[i])) { + final mag = + math.sqrt(ax[i] * ax[i] + ay[i] * ay[i] + az[i] * az[i]); + q = (mag - 1.0).abs() <= cut; + } + _rowSec.add(accTs[i]); + _rowQuiet.add(q); + } + _pendRr.addAll(rrMs); + _pendTs.addAll(rrTsMs); + var used = 0; + for (; used < _pendRr.length; used++) { + final v = _pendRr[used]; + if (!(v >= 300 && v <= 2000)) continue; + final t = _pendTs[used]; + // would this beat trigger an attempt? decide on the ring + this beat + final i = _ts.length; // index it will take + var head = _head; + // while (ts[i]-ts[lo] > win) lo++ — evaluated with the beat appended + final tsTmp = t; + while (head < i && tsTmp - _ts[head] > 180000.0) { + head++; + } + final attempt = (i - head >= 30) && (tsTmp - _lastEmit > 300000); + if (attempt) { + final hiSec = (tsTmp / 1000).ceil(); + if (watermarkSec < hiSec) break; // accel for this window not here yet + } + _ts.add(t); + _rr.add(v); + _gated++; + _head = head; + if (attempt) _attempt(i); + } + _pendRr.removeRange(0, used); + _pendTs.removeRange(0, used); + // trim the rings + if (_head > 256) { + _ts = _ts.sublist(_head); + _rr = _rr.sublist(_head); + _head = 0; + } + if (_ts.isNotEmpty) { + final keepSec = (_ts[_head] / 1000).floor() - 5; + var k = 0; + while (k < _rowSec.length && _rowSec[k] < keepSec) { + k++; + } + if (k > 0) { + _rowSec.removeRange(0, k); + _rowQuiet.removeRange(0, k); + } + } + } + + void _attempt(int i) { + final loSec = (_ts[_head] / 1000).floor(); + final hiSec = (_ts[i] / 1000).ceil(); + var still = 0; + for (var r = 0; r < _rowSec.length; r++) { + if (_rowSec[r] >= loSec && _rowSec[r] < hiSec && _rowQuiet[r]) still++; + } + final spanSec = hiSec - loSec; + double? brpm; + if (!(spanSec <= 0 || still < 0.9 * spanSec)) { + attempts++; + final nn = _rr.sublist(_head, i + 1); + final t0 = _ts[_head]; + final nnt = [for (var k = _head; k <= i; k++) _ts[k] - t0]; + final est = rsaRespRate(nn, nnt, artifactFraction: 0.15); + brpm = est.present ? est.value!.brpm : null; + } + _lastEmit = _ts[i]; + if (brpm != null) { + _out.add({ + 't': (_ts[i] / 1000).round(), + 'v': double.parse(brpm.toStringAsFixed(1)), + }); + } + } + + int get pending => _pendRr.length; + int get ringBeats => _ts.length - _head; + + Map toJson() => { + 'ts': _ts.sublist(_head), + 'rr': _rr.sublist(_head), + 'gated': _gated, + 'lastEmit': _lastEmit, + 'out': _out, + 'rowSec': _rowSec, + 'rowQuiet': [for (final q in _rowQuiet) q ? 1 : 0], + 'pendRr': _pendRr, + 'pendTs': _pendTs, + }; + + factory DayRespCurveState.fromJson(Map j, {double cut = 0.02}) { + List dl(Object? o) => [for (final x in o as List) (x as num).toDouble()]; + final s = DayRespCurveState(cut: cut); + s._ts = dl(j['ts']); + s._rr = dl(j['rr']); + s._gated = j['gated'] as int; + s._lastEmit = (j['lastEmit'] as num).toDouble(); + for (final m in j['out'] as List) { + final mm = m as Map; + s._out.add({'t': mm['t'] as num, 'v': mm['v'] as num}); + } + s._rowSec.addAll((j['rowSec'] as List).cast()); + s._rowQuiet.addAll([for (final q in j['rowQuiet'] as List) q == 1]); + s._pendRr.addAll(dl(j['pendRr'])); + s._pendTs.addAll(dl(j['pendTs'])); + return s; + } +} diff --git a/tool/incremental/rr_stream.dart b/tool/incremental/rr_stream.dart index a28c58b..f08aa39 100644 --- a/tool/incremental/rr_stream.dart +++ b/tool/incremental/rr_stream.dart @@ -132,13 +132,21 @@ class RrCorrector { final out = RrSettled(); if (rrMs.isEmpty) return out; final wall = tsMs != null && tsMs.length == rrMs.length; - _wall ??= wall; - if (_wall != wall) { + if ((_wall ?? wall) != wall) { throw StateError('timestamps must be supplied for every fold or none'); } + // The whole chunk is checked before any state changes, so a rejected chunk + // can be corrected and retried without the beats before the bad one being + // kept twice. + for (var k = 0; k < rrMs.length; k++) { + if (!rrMs[k].isFinite) throw ArgumentError('non-finite rr at $k'); + if (wall && !tsMs[k].isFinite) { + throw ArgumentError('non-finite timestamp at $k'); + } + } + _wall ??= wall; for (var k = 0; k < rrMs.length; k++) { final r = rrMs[k]; - if (!r.isFinite) throw ArgumentError('non-finite rr at $k'); final i = n; double cur; final ts = wall ? tsMs[k] : 0.0;