diff --git a/ALGORITHMS.md b/ALGORITHMS.md index 14db222..f43d820 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -9,6 +9,9 @@ relative-only ADC channels (skin temp, SpO2, ambient light). This file is the index — what's implemented, where it lives, and who published it. If a row here doesn't have a real citation next to it, that's a bug in this file, go fix it. +See `docs/ALGORITHM_CATALOG_1HZ.md` for the underlying literature review and what's +deliberately not shipped. + Almost everything returns a `Metric` envelope: ```dart @@ -46,7 +49,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative |---|---|---| | `correctRr` | `foundations/rr_correction.dart` | Lipponen & Tarvainen 2019 RR artifact correction (dRR/mRR/sRR beat classification, Kubios-style) | | `Baselines` (Winsorized-EWMA) | `foundations/ewma_baselines.dart` | Winsorized exponentially-weighted moving baseline — the rolling personal reference most other metrics compare against | -| PPG signal-quality index | `foundations/ppg_sqi.dart` | Skewness-based SQI | | inverse-variance fusion | `foundations/fusion.dart` | Standard inverse-variance weighting for combining multiple noisy estimates of the same quantity | ### `clinical/` — Tier-1 cardiac/autonomic metrics @@ -61,7 +63,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `illnessCusum` | `clinical/illness_cusum.dart` | Online CUSUM state machine (green/yellow/red) over RHR — "NightSignal" | Alavi et al. 2022; Mishra et al. 2020 | | `readinessLnRmssd` | `clinical/readiness_lnrmssd.dart` | ln(RMSSD) z-scored against a rolling prior-nights baseline | Plews et al. 2013 | | `cosinor` | `clinical/cosinor.dart` | Cosinor rhythmometry (MESOR/amplitude/acrophase) | Halberg & Nelson 1979 | -| `banisterTrimp` / `StrainScorer.edwardsTRIMP` | `clinical/load_trimp.dart` | Training impulse from HR-reserve | Banister 1991; Edwards 1993 | +| `banisterTrimp` | `clinical/load_trimp.dart` | Training impulse from HR-reserve | Banister 1991 | | `strainScoreMetric` | `clinical/load_trimp.dart` | log-squash of TRIMP onto a 0-21 scale | — | | `trimpStrain` | `clinical/load_trimp.dart` | TRIMP → 0-100 strain, honesty-wrapped (absent without real HRmax/RHR anchors) | — | | `ctlAtlTsb` | `clinical/load_trimp.dart` | Fitness-Fatigue-Form: EWMA CTL (42d) / ATL (7d) / TSB = CTL-ATL | Banister impulse-response model | @@ -99,12 +101,11 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `relativeIntensityBands` | `motion/enmo.dart` | ENMO/MAD activity intensity bands | — | | `staticTilt` | `motion/orientation.dart` | orientation/posture from gravity vector | — | | `branchedEnergyFusion` | `motion/energy_fusion.dart` | HR-anchored-when-possible energy expenditure fusion | Brage et al. 2004 | -| `dailyStepEstimate` | `motion/steps.dart` | 1 Hz fallback step estimate — ENMO+HR gated, bout-length gated (contiguous-run requirement), only for minutes the live 100 Hz pedometer didn't cover | AN-2554-adjacent (see `livePedometer` for the real 100 Hz method) | +| `dailyActiveMinutes` | `motion/steps.dart` | minutes of sustained wrist movement from the 1 Hz substrate, no step count — true per-step counting is impossible below gait Nyquist | internal ESTIMATE (personal dynamic-floor movement threshold, no published method; unrelated to AN-2554 — see `livePedometer` for the real AN-2554-derived 100 Hz method) | ### `workout/` | Function | File | Method | Citation | |---|---|---|---| -| `detectWorkouts` | `workout/workout_detect.dart` | explicit workout detection + zones | — | | `autoDetectWorkouts` | `workout/auto_detect.dart` | automatic workout detection | — | | `hrRecovery` | `workout/hr_recovery.dart` | HRR — HR drop N seconds post-peak | Cole/Lauer 1999-style HRR | | `Calories.dailyEnergy` / `estimateBoutCalories` | `workout/calories.dart` | Keytel HR→kcal regression + Harris-Benedict/Mifflin BMR | Keytel et al. 2005 | @@ -122,14 +123,13 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative ### `human/` | Function | File | Method | Citation | |---|---|---|---| -| `sleepRegularityIndex` | `human/sleep_regularity.dart` | SRI (see also `phillipsSri` above) | Phillips et al. 2017 | | `sleepDebt` | `human/sleep_regularity.dart` | accumulated sleep debt vs. need | — | | `socialJetlag` / `chronotype` | `human/circadian_lifestyle.dart` | MSFsc-based social jetlag + chronotype | Wittmann & Roenneberg 2006; MCTQ | | `alcoholNightFlag` | `human/event_detection.dart` | dose-graded autonomic-stress signature — **reports state, never asserts a cause** | Pietilä et al. 2018 | | `roughNight` | `human/event_detection.dart` | neutral fallback descriptor when the signature is ambiguous | — | | `percentileOfYou` / `personalRecord` | `human/percentile_of_you.dart` | percentile-vs-your-own-history, miss-tolerant personal-record streaks | — | | `glassBoxReadiness` | `human/readiness_glassbox.dart` | **deprecated** — kept only for its percentile-of-you breakdown + narrative and edge back-compat; `readinessComposite` is canonical | — | -| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) | +| `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | — | | `journalCorrelations` | `human/coaching.dart` | per-tag mean-difference correlation vs. logged outcomes, on-device, personal | — | --- diff --git a/README.md b/README.md index afd6d50..c3260c2 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,32 @@ metric that needs 7 nights of baseline and only has 3 says so directly (`note: "need_baseline:have=3,need=7"`) instead of quietly computing something off 3 nights and hoping you don't notice. +## Quick start + +```dart +import 'package:openstrap_analytics/onehz.dart'; + +// nnMs: cleaned beat-to-beat RR intervals in ms (see foundations/rr_correction.dart +// for turning raw RR into this). nnTimesMs: cumulative beat timestamp in ms +// (time since the first beat, NOT the per-beat RR duration), same length. +final nnMs = [800, 810, 795, 805]; +final nnTimesMs = [0, 810, 1605, 2410]; // nnTimesMs[i] = nnTimesMs[i-1] + nnMs[i] +final Metric hrv = hrvTime(nnMs, nnTimesMs: nnTimesMs, artifactFraction: 0.04); +if (hrv.value != null) { + print('RMSSD ${hrv.value!.rmssd} ms (confidence ${hrv.confidence}, tier ${hrv.tier})'); +} +``` + +Every metric function in the package follows this shape: plain `List` (or a +small typed input class for the composite ones) in, `Metric` out. + ## What's actually in here Eight families, each its own subdirectory with its own sub-barrel, built on two shared foundation layers: - **`foundations/`** — Lipponen-Tarvainen RR artifact correction, Winsorized-EWMA rolling - baselines, inverse-variance fusion, a PPG signal-quality index. + baselines, inverse-variance fusion. - **`clinical/`** (Tier-1) — HRV time/frequency domain (RMSSD/SDNN/pNNx, Lomb-Scargle LF/HF), PRSA (deceleration/acceleration capacity), nocturnal RHR/dip, an illness-risk CUSUM state machine, Plews ln-RMSSD readiness, Baevsky stress index, Banister/Edwards @@ -83,11 +102,12 @@ foundation layers: (IS/IV/RA/L5/M10). - **`respiration/`** — RSA-derived respiratory rate fused with motion-modulated RIIV, CVHR-based apnea screening, a relative (never absolute) oxygen-desaturation ratio. -- **`motion/`** — ENMO/MAD activity metrics, a hybrid live/1 Hz step estimator (AN-2554 - 100 Hz pedometer preferred, a gated-and-bout-length-checked 1 Hz fallback for whatever - the live stream missed), energy-expenditure fusion. -- **`workout/`** — workout detection (both explicit and automatic), heart-rate-reserve - zones, Keytel/Harris-Benedict calorie estimation. +- **`motion/`** — ENMO/MAD activity metrics, a 100 Hz AN-2554-derived live pedometer + plus a separate 1 Hz movement-minutes estimate (no step count — true per-step + counting is impossible below gait Nyquist) for whatever the live stream missed, + energy-expenditure fusion. +- **`workout/`** — automatic workout detection (bout suggestion, never explicit/ + retroactive), heart-rate-reserve zones, Keytel/Harris-Benedict calorie estimation. - **`wellness/`** — the canonical composite readiness score, multivariate (Mahalanobis) anomaly detection, CUSUM changepoint detection, temperature-based illness flagging. - **`human/`** — sleep regularity index, social jetlag/chronotype, single-night event @@ -110,7 +130,24 @@ not a feature, no matter how tempting the plausible-looking headline is. dart test # run from the repo root — some fixtures resolve paths relative to it ``` -290 tests, nothing mocked — pure functions, fixture in, assertion out. +All pass (a handful skipped), nothing mocked — pure functions, fixture in, +assertion out. + +## Validation + +`tool/` has four harnesses that score shipped detectors against labelled corpora, not +synthetic fixtures — run one before touching the logic it covers: + +- `dart run tool/oxwalk_validate.dart ` — the pedometer against + OxWalk (Oxford, CC BY): 39 subjects, camera-annotated heel strikes. +- `dart run tool/stager_harness.dart ` — the sleep-staging decision layer + against a PSG-labelled corpus (e.g. DREAMT), reporting Cohen's kappa. +- `dart run tool/nap_harness.dart ` — the nap detector against hand-labelled + days. +- `dart run tool/whoop_proportions.dart ` — sweeps sleep-stage cutoffs + against normative stage proportions on real device captures. + +Each file's header comment has the full usage, flags, and fixture schema. ## If you want to add a metric diff --git a/docs/ALGORITHM_CATALOG_1HZ.md b/docs/ALGORITHM_CATALOG_1HZ.md index 8ffda11..a22f937 100644 --- a/docs/ALGORITHM_CATALOG_1HZ.md +++ b/docs/ALGORITHM_CATALOG_1HZ.md @@ -40,7 +40,7 @@ Synthesized from 6 independent literature reviews (HRV, cardiac, sleep/circadian - **24-h SDNN + SDANN/SDNN-index** — jitter-immune (operate on 5-min means). `24/7 · HIGH` - **Nocturnal HR dip %** / night-day ratio (dipper/non-dipper/riser) — CV-risk + acute-strain signal. `24/7 · HIGH` - **NightSignal FSM + CUSUM/EWMA on RHR** — Alavi 2022 / Mishra 2020 (28-day baseline, designed ARL, yellow→red persistence). Best wearable-validated deterministic illness alarm. `24/7 · HIGH` -- **TRIMP (Edwards + Banister) + CTL/ATL/TSB** — Edwards 1993 / Banister; Morton 1990. Needs measured HRmax + RHR. Guard non-wear gaps. `24/7 · HIGH` +- **TRIMP (Banister) + CTL/ATL/TSB** — Banister; Morton 1990. Needs measured HRmax + RHR. Guard non-wear gaps. `24/7 · HIGH` ### Sleep & circadian (accel + HR + RR + temp + light) - **van Hees / GGIR angle sleep-window** — 2015/2018. Count-FREE, gravity-orientation @1 Hz is ample. **THE sleep/wake spine** (sidesteps the Cole-Kripke count-calibration trap). `24/7 · HIGH` diff --git a/lib/onehz.dart b/lib/onehz.dart index b518e09..129e8cf 100644 --- a/lib/onehz.dart +++ b/lib/onehz.dart @@ -1,9 +1,9 @@ /// openstrap_analytics — 1 Hz-native physiological analytics family. /// -/// Independent of the minute-resolution library in -/// `lib/openstrap_analytics.dart`. Operates on the always-on 1 Hz substrate -/// (beat-to-beat RR, 1 Hz HR, 1 Hz tri-axial accel, relative-ADC channels) per -/// docs/ALGORITHM_CATALOG_1HZ.md. +/// The old minute-resolution library that used to live alongside this one has +/// been removed; this is the only analytics library in the package now. +/// Operates on the always-on 1 Hz substrate (beat-to-beat RR, 1 Hz HR, 1 Hz +/// tri-axial accel, relative-ADC channels) per docs/ALGORITHM_CATALOG_1HZ.md. /// /// Honesty ceilings are enforced in code: PRV not ECG-HRV; relative signals /// carry no absolute %/°C; absent input => null + confidence 0 (never a diff --git a/lib/src/onehz/clinical/cardiac_coherence.dart b/lib/src/onehz/clinical/cardiac_coherence.dart index 241fa9d..95b32ee 100644 --- a/lib/src/onehz/clinical/cardiac_coherence.dart +++ b/lib/src/onehz/clinical/cardiac_coherence.dart @@ -88,7 +88,8 @@ Metric cardiacCoherence( // honesty guard rather than pretending a short session sees down to 0.0033 Hz. final totalLoHz = (1.0 / spanSec).clamp(0.0033, searchLoHz); - final ls = lombScargle(tSec, nnMs, freqGrid(totalLoHz, totalHiHz, gridPoints)); + final ls = + lombScargle(tSec, nnMs, freqGrid(totalLoHz, totalHiHz, gridPoints)); if (ls == null) { return const Metric.absent( tier: Tier.estimate, @@ -128,11 +129,8 @@ Metric cardiacCoherence( // this span actually covers (floored/ceilinged), lightly penalized when the // found peak isn't near the guided pace (could still be real RSA, just less // clearly the paced-breathing entrainment this feature is meant to reward). - final conf = clamp( - (spanSec / 180.0).clamp(0.3, 1.0) * (onPace ? 1.0 : 0.85), - 0.2, - 0.9, - ); + final conf = ((spanSec / 180.0).clamp(0.3, 1.0) * (onPace ? 1.0 : 0.85)) + .clamp(0.2, 0.9); return Metric( value: CardiacCoherence(ratio: ratio, score: score, peakHz: peakHz), diff --git a/lib/src/onehz/clinical/cosinor.dart b/lib/src/onehz/clinical/cosinor.dart index e3a25ab..aeda025 100644 --- a/lib/src/onehz/clinical/cosinor.dart +++ b/lib/src/onehz/clinical/cosinor.dart @@ -124,14 +124,14 @@ Metric cosinor( ssTot += (y[i] - yMean) * (y[i] - yMean); ssRes += (y[i] - fit) * (y[i] - fit); } - final r2 = ssTot == 0 ? 0.0 : clamp(1 - ssRes / ssTot, 0, 1); + final double r2 = ssTot == 0 ? 0.0 : (1 - ssRes / ssTot).clamp(0, 1); // Adjusted for the 3 fitted parameters (M, β, γ). Confidence MUST come from // the adjusted value: the raw R² of a 3-parameter fit is upward-biased // (E[R²] = 2/(n−1) under the null), so a handful of noise points used to // score confidence 0.95 at tier HIGH. - final r2Adj = clamp(1 - (1 - r2) * (n - 1) / (n - 3), 0, 1); + final double r2Adj = (1 - (1 - r2) * (n - 1) / (n - 3)).clamp(0, 1); - final conf = clamp(r2Adj, 0.1, 0.95); + final conf = r2Adj.clamp(0.1, 0.95); return Metric( value: CosinorFit( mesor: mesor, diff --git a/lib/src/onehz/clinical/hrv_freq.dart b/lib/src/onehz/clinical/hrv_freq.dart index 164360d..70dcb6e 100644 --- a/lib/src/onehz/clinical/hrv_freq.dart +++ b/lib/src/onehz/clinical/hrv_freq.dart @@ -150,7 +150,7 @@ Metric hrvFreq( : [for (final b in resolved) bands[b]!].reduce((a, b) => a + b); // Confidence: penalize artifacts heavily; low-band-only reads still HIGH-ish. - final conf = clamp((1 - artifactFraction) * (hfGated ? 0.6 : 0.9), 0.2, 0.9); + final conf = ((1 - artifactFraction) * (hfGated ? 0.6 : 0.9)).clamp(0.2, 0.9); return Metric( value: HrvFreq( ulf: ulf, diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 3293021..39e6263 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -76,7 +76,7 @@ double? nnDiffAcf1(List> diffRuns) { /// 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 : clamp(1 - acf1 / kNnDiffAcf1Floor, 0.0, 1.0); + 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 ' @@ -200,14 +200,11 @@ Metric hrvTime( // were ~pure noise. The beat-count term is capped BEFORE the quality terms // multiply it; multiplying first let an all-night beat count (n/250 ≈ 100) // swallow any penalty and re-clamp to 0.95 regardless. - final conf = clamp( - clamp(nnMs.length / 250.0, 0.0, 1.0) // ~250 beats ≈ 5 min - * - _acf1Quality(acf1) * - (1 - artifactFraction), - 0.3, - 0.95, - ); + final conf = ((nnMs.length / 250.0).clamp(0.0, 1.0) // ~250 beats ≈ 5 min + * + _acf1Quality(acf1) * + (1 - artifactFraction)) + .clamp(0.3, 0.95); return Metric( value: HrvTime( rmssd: rmssd, @@ -341,11 +338,11 @@ Metric nocturnalRmssd( final robust = median(rmssds)!; // Confidence scales with how many windows we could median over, and with the // measured jitter level (see [kNnDiffAcf1Floor]). - final conf = clamp( - clamp(rmssds.length / 12.0, 0.0, 1.0) * _acf1Quality(acf1), // 12 ≈ 1 h - 0.3, - 0.95, - ); + final conf = + ((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)).clamp( + // 12 ≈ 1 h + 0.3, + 0.95); return Metric( value: robust, confidence: conf, @@ -452,11 +449,8 @@ Metric sleepSessionWindowedRmssd( } final meanRmssd = mean(rmssds)!; - final conf = clamp( - clamp(rmssds.length / 12.0, 0.0, 1.0) * _acf1Quality(acf1), - 0.3, - 0.95, - ); + final conf = ((rmssds.length / 12.0).clamp(0.0, 1.0) * _acf1Quality(acf1)) + .clamp(0.3, 0.95); return Metric( value: meanRmssd, confidence: conf, diff --git a/lib/src/onehz/clinical/irregular_rhythm.dart b/lib/src/onehz/clinical/irregular_rhythm.dart index 5aff5eb..5d91e75 100644 --- a/lib/src/onehz/clinical/irregular_rhythm.dart +++ b/lib/src/onehz/clinical/irregular_rhythm.dart @@ -84,7 +84,24 @@ Metric irregularBeatScreen( // inflated sdsd/sd1, pushing both flag conditions toward a false "sustained // irregularity" screen positive. final keep = [for (final v in rrMs) v >= 300 && v <= 2000]; - final nn = [for (var i = 0; i < rrMs.length; i++) if (keep[i]) rrMs[i]]; + final nn = [ + for (var i = 0; i < rrMs.length; i++) + if (keep[i]) rrMs[i] + ]; + // Per-element flag, aligned to [nn]: was this beat immediately preceded (no + // dropped beat in between) by the previous kept beat in the ORIGINAL rrMs? + // Carried into the window pass below so it can skip diffs across a removed + // artifact beat the same way the aggregate diffs already do (see [keep] + // note above) instead of just diffing consecutive elements of the + // compacted array. + final nnAdjacent = []; + var prevKeptOrigIdx = -1; + for (var i = 0; i < rrMs.length; i++) { + if (keep[i]) { + nnAdjacent.add(prevKeptOrigIdx == i - 1); + prevKeptOrigIdx = i; + } + } if (nn.length < minBeats) { return const Metric.absent( tier: Tier.estimate, @@ -156,13 +173,17 @@ Metric irregularBeatScreen( // window's own ratio/pNN into a spurious per-window flag. final hasTimes = nnTimesMs != null && nnTimesMs.length == rrMs.length; final nnTimes = hasTimes - ? [for (var i = 0; i < rrMs.length; i++) if (keep[i]) nnTimesMs[i]] + ? [ + for (var i = 0; i < rrMs.length; i++) + if (keep[i]) nnTimesMs[i] + ] : const []; final flag = aggregateHigh && (!hasTimes || _sustainedAcrossWindows( nn, nnTimes, + nnAdjacent, sd1sd2Flag: sd1sd2Flag, pnnThresholdMs: pnnThresholdMs, pnnFlagPct: pnnFlagPct, @@ -174,7 +195,7 @@ Metric irregularBeatScreen( // with the artifact fraction we were handed — it used to ignore it entirely, // so a barely-passing 29 %-artifact night published at the same confidence as // a clean one. - final conf = clamp(nn.length / 5000.0 * (1 - artifactFraction), 0.2, 0.9); + final conf = (nn.length / 5000.0 * (1 - artifactFraction)).clamp(0.2, 0.9); return Metric( value: IrregularRhythm( sd1: sd1, @@ -199,7 +220,10 @@ Metric irregularBeatScreen( /// length as [rrMs] and index-aligned (elapsed ms per beat). bool _sustainedAcrossWindows( List rrMs, - List timesMs, { + List timesMs, + // Aligned to [rrMs]: whether each beat was truly adjacent (no dropped + // artifact beat in between) to the previous one in the ORIGINAL series. + List adjacent, { required double sd1sd2Flag, required double pnnThresholdMs, required double pnnFlagPct, @@ -207,7 +231,11 @@ bool _sustainedAcrossWindows( required int minWindowBeats, required double sustainedFraction, }) { - if (timesMs.length != rrMs.length || rrMs.length < 2) return false; + if (timesMs.length != rrMs.length || + adjacent.length != rrMs.length || + rrMs.length < 2) { + return false; + } // Fail CLOSED (never sustained) on a bad config — a misconfigured caller // must never manufacture a medical false positive. if (!windowMinutes.isFinite || @@ -223,11 +251,16 @@ bool _sustainedAcrossWindows( var validWindows = 0; var flaggedWindows = 0; var bucket = []; + var bucketAdjacent = []; void flush() { if (bucket.length >= minWindowBeats) { validWindows++; + // Mirror the aggregate's `keep[i] && keep[i-1]` guard: never diff + // across a beat that was dropped as an artifact in the original series, + // even though it's now a consecutive pair in this compacted bucket. final diffs = [ - for (var i = 1; i < bucket.length; i++) bucket[i] - bucket[i - 1] + for (var i = 1; i < bucket.length; i++) + if (bucketAdjacent[i]) bucket[i] - bucket[i - 1] ]; final sdsd = stddev(diffs); final sdnn = stddev(bucket); @@ -244,6 +277,7 @@ bool _sustainedAcrossWindows( } } bucket = []; + bucketAdjacent = []; } for (var i = 0; i < rrMs.length; i++) { @@ -252,6 +286,7 @@ bool _sustainedAcrossWindows( windowStart = timesMs[i]; } bucket.add(rrMs[i]); + bucketAdjacent.add(adjacent[i]); } flush(); if (validWindows == 0) return false; diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 38a5f47..8db5f8f 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -252,8 +252,9 @@ double strainScore( trimp - baselineTrimp(wakeMinutes, quietHrr: quietHrr, female: female); if (net <= 0) return 0.0; final u = math.min(1.0, net / maximalNetTrimp); - final s = - 21.0 * math.log(1 + u * (strainCurvature - 1)) / math.log(strainCurvature); + final s = 21.0 * + math.log(1 + u * (strainCurvature - 1)) / + math.log(strainCurvature); return math.min(21.0, math.max(0.0, s)); } @@ -420,18 +421,6 @@ class StrainScorer { // its own 190 for the "did you work out?" prompt, which is not a published // number. - /// Linear-interpolated percentile of an ALREADY-SORTED sequence (numpy-style). - static double _percentileSorted(List sortedValues, double pct) { - final n = sortedValues.length; - if (n == 0) return 0; - if (n == 1) return sortedValues[0]; - final position = (pct / 100.0) * (n - 1); - final lower = position.toInt(); - final upper = math.min(lower + 1, n - 1); - final frac = position - lower; - return sortedValues[lower] + frac * (sortedValues[upper] - sortedValues[lower]); - } - /// Estimate a personalized HRmax from a trailing HR series. /// Returns (hrmax bpm, source ∈ {"observed","tanaka","unknown"}). static (double, String) estimateHRmax(List hrHistory, double? age) { @@ -440,7 +429,7 @@ class StrainScorer { if (n >= hrmaxMinSamples) { final sorted = [...hrHistory]..sort(); - final observed = _percentileSorted(sorted, hrmaxPercentile); + final observed = percentileSorted(sorted, hrmaxPercentile)!; if (tanaka == null) return (observed, 'observed'); return observed >= tanaka ? (observed, 'observed') : (tanaka, 'tanaka'); } @@ -502,7 +491,8 @@ class StrainScorer { /// Banister exponential TRIMP: Σ duration(min) × x × y(x), y per [banisterY]. static double banisterTRIMP(List bpm, double restingHR, - double hrReserve, List durationsMin, {bool female = false}) { + double hrReserve, List durationsMin, + {bool female = false}) { var acc = 0.0; for (var i = 0; i < bpm.length; i++) { final dur = i < durationsMin.length @@ -520,7 +510,8 @@ class StrainScorer { /// TRIMP ≤ 0 → 0; above the D−1 ceiling the score is CLAMPED at [maxStrain] /// (it used to run off the top of its own documented range: TRIMP 14400 → /// 107.8, while the sibling [strainScore] clamped correctly). - static double trimpToStrain(double trimp, {double denominator = strainDenominator}) { + static double trimpToStrain(double trimp, + {double denominator = strainDenominator}) { if (trimp <= 0) return 0; final value = maxStrain * math.log(trimp + 1.0) / math.log(denominator); final clamped = math.min(maxStrain, math.max(0.0, value)); @@ -621,7 +612,8 @@ Metric trimpStrain( return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, - note: 'strain needs ≥600 HR samples (or ≥20 spanning ≥600 s) and HRmax>RHR', + note: + 'strain needs ≥600 HR samples (or ≥20 spanning ≥600 s) and HRmax>RHR', ); } return Metric( @@ -687,13 +679,14 @@ Metric ctlAtlTsb(List dailyTrimp, ctl = ctl + lc * (dailyTrimp[i] - ctl); atl = atl + la * (dailyTrimp[i] - atl); } - final conf = clamp(dailyTrimp.length / 42.0, 0.3, 0.85); + final conf = (dailyTrimp.length / 42.0).clamp(0.3, 0.85); return Metric( value: LoadState(ctl, atl, ctl - atl), confidence: conf, tier: Tier.estimate, inputs_used: inputs, - note: 'Banister CTL(42d)/ATL(7d)/TSB, primed from the first $prime observed ' + note: + 'Banister CTL(42d)/ATL(7d)/TSB, primed from the first $prime observed ' 'days; descriptive load, not injury risk', ); } diff --git a/lib/src/onehz/clinical/nocturnal.dart b/lib/src/onehz/clinical/nocturnal.dart index 7bb85be..d34cd23 100644 --- a/lib/src/onehz/clinical/nocturnal.dart +++ b/lib/src/onehz/clinical/nocturnal.dart @@ -128,7 +128,7 @@ Metric nocturnalRhr(List hr, // Confidence is COVERAGE IN SECONDS, not a sample count — 480 samples of a // 60 s band is the same 8 h of night as 28,800 samples at 1 Hz. final conf = - clamp(valid.length * cadence / 7200.0, 0.4, 0.95); // ~2 h => high + (valid.length * cadence / 7200.0).clamp(0.4, 0.95); // ~2 h => high return Metric( value: NocturnalRhr(best, p1, valid.length), confidence: conf, @@ -189,7 +189,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 = clamp((dv.length + nv.length) / 14400.0, 0.4, 0.9); + final conf = ((dv.length + 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.dart b/lib/src/onehz/clinical/prsa.dart index 5d4d460..e22a3bb 100644 --- a/lib/src/onehz/clinical/prsa.dart +++ b/lib/src/onehz/clinical/prsa.dart @@ -137,7 +137,7 @@ Metric _prsa( final xm2 = profile[l - 2]; // k=-2 final capacity = (x0 + x1 - xm1 - xm2) / 4; - final conf = clamp(anchors.length / 1000.0, 0.3, 0.95); + final conf = (anchors.length / 1000.0).clamp(0.3, 0.95); return Metric( value: PrsaResult( capacity: capacity, diff --git a/lib/src/onehz/clinical/readiness_lnrmssd.dart b/lib/src/onehz/clinical/readiness_lnrmssd.dart index d2696cb..d653556 100644 --- a/lib/src/onehz/clinical/readiness_lnrmssd.dart +++ b/lib/src/onehz/clinical/readiness_lnrmssd.dart @@ -115,7 +115,7 @@ Metric readinessLnRmssd( final saturation = meanNnTodayMs != null && meanNnTodayMs > 1100 && z != null && z > 1.0; - final conf = clamp(priorWindow.length / windowDays.toDouble(), 0.3, 0.9); + final conf = (priorWindow.length / windowDays.toDouble()).clamp(0.3, 0.9); return Metric( value: ReadinessLnRmssd( lnRmssdToday: today, diff --git a/lib/src/onehz/clinical/stress_si.dart b/lib/src/onehz/clinical/stress_si.dart index 3073ee2..6d80394 100644 --- a/lib/src/onehz/clinical/stress_si.dart +++ b/lib/src/onehz/clinical/stress_si.dart @@ -104,7 +104,7 @@ Metric baevskyStressIndex(List nnMs, amoPct: (median(amos) ?? 0), mxdmnS: (median(ranges) ?? 0), ), - confidence: clamp(0.4 + sis.length / 60.0, 0.4, 0.7), + confidence: (0.4 + sis.length / 60.0).clamp(0.4, 0.7), tier: Tier.estimate, inputs_used: inputs, note: 'Baevsky Stress Index — median of ~5-min-window SI ' diff --git a/lib/src/onehz/human/associations.dart b/lib/src/onehz/human/associations.dart index 9889a06..a0509a9 100644 --- a/lib/src/onehz/human/associations.dart +++ b/lib/src/onehz/human/associations.dart @@ -643,7 +643,10 @@ Metric scanAssociations({ v.key, 'need_pairs:have=$nPresent,need=$minPairedDays')); continue; } - final vals = [for (final e in g) if (e != null) e]; + final vals = [ + for (final e in g) + if (e != null) e + ]; if (vals.toSet().length < 2) { refusals.add(AssociationRefusal(v.key, 'constant')); continue; @@ -705,14 +708,15 @@ Metric scanAssociations({ refusals.add(AssociationRefusal(subject, 'constant')); continue; } - final rhoRaw = - _pearsonMasked(rankRaw[inp.key]!, _lagShift(rankRaw[out.key]!, lag)) ?? - rho; + final rhoRaw = _pearsonMasked( + rankRaw[inp.key]!, _lagShift(rankRaw[out.key]!, lag)) ?? + rho; // The feelable number, computed on the RAW (unadjusted) series: a person // experiences their actual nights, not weekday residuals. The adjustment // is what earns the claim; the contrast is what states it. - final contrast = _contrast(grid[inp.key]!, _lagShift(grid[out.key]!, lag)); + final contrast = + _contrast(grid[inp.key]!, _lagShift(grid[out.key]!, lag)); if (contrast == null) { refusals.add(AssociationRefusal(subject, 'no_contrast')); continue; @@ -724,7 +728,8 @@ Metric scanAssociations({ if (rhoRaw.abs() - rho.abs() > 0.15) caveats.add('weekday_confounded'); if (coverage[inp.key]! < 0.8) caveats.add('sparse_input'); if (selfReported.contains(inp.key)) caveats.add('self_reported_input'); - if (contrast.split == ContrastSplit.binary) caveats.add('binary_contrast'); + if (contrast.split == ContrastSplit.binary) + caveats.add('binary_contrast'); // The SHIPPED twelve weeks, not the effective `minPairedDays`. Every row // here already cleared `n >= minPairedDays` and n can never exceed span, // so `span < minPairedDays` is unreachable by construction — writing it @@ -809,7 +814,10 @@ Metric scanAssociations({ // ---- effect-size floor + FDR gate --------------------------------------- final outcomeIqr = {}; for (final v in usable) { - final vals = [for (final e in grid[v.key]!) if (e != null) e]; + final vals = [ + for (final e in grid[v.key]!) + if (e != null) e + ]; final q1 = percentile(vals, 25), q3 = percentile(vals, 75); outcomeIqr[v.key] = (q1 == null || q3 == null) ? 0.0 : (q3 - q1).abs(); } @@ -832,7 +840,8 @@ Metric scanAssociations({ String? clash; for (final kept in findings) { if (kept.outcomeKey != cand.outcomeKey) continue; - final r = _pearsonMasked(rankAdj[kept.inputKey]!, rankAdj[cand.inputKey]!); + final r = + _pearsonMasked(rankAdj[kept.inputKey]!, rankAdj[cand.inputKey]!); if (r != null && r.abs() >= redundancyRho) { clash = kept.inputKey; break; @@ -870,7 +879,7 @@ Metric scanAssociations({ // Deliberately capped low and never a function of how strong the findings // look. This is one person's uncontrolled observational history; more of it // buys a little confidence, and nothing buys a lot. - confidence: clamp(0.20 + 0.20 * (span / 365.0), 0.20, 0.45), + confidence: (0.20 + 0.20 * (span / 365.0)).clamp(0.20, 0.45), tier: Tier.estimate, inputs_used: inputs, note: 'association only, in your own history — never cause. lag is derived ' @@ -941,8 +950,8 @@ const Map _catalog = { // --- the night that ended this morning --- 'tst_min': _Spec('Sleep duration', 'min', VarTiming.night, derivedFrom: {'rem_min', 'deep_min', 'light_min'}), - 'efficiency': _Spec('Sleep efficiency', '%', VarTiming.night, - derivedFrom: {'tst_min'}), + 'efficiency': + _Spec('Sleep efficiency', '%', VarTiming.night, derivedFrom: {'tst_min'}), 'rem_min': _Spec('REM sleep', 'min', VarTiming.night), 'deep_min': _Spec('Deep sleep', 'min', VarTiming.night), 'light_min': _Spec('Light sleep', 'min', VarTiming.night), @@ -952,8 +961,7 @@ const Map _catalog = { derivedFrom: {'tst_min'}), 'midsleep_sec': _Spec('Sleep midpoint', 's', VarTiming.night), 'rmssd': _Spec('HRV', 'ms', VarTiming.night), - 'ln_rmssd': - _Spec('HRV (ln)', '', VarTiming.night, derivedFrom: {'rmssd'}), + 'ln_rmssd': _Spec('HRV (ln)', '', VarTiming.night, derivedFrom: {'rmssd'}), 'sdnn': _Spec('HRV (SDNN)', 'ms', VarTiming.night), 'rhr': _Spec('Resting heart rate', 'bpm', VarTiming.night), 'resp_rate': _Spec('Respiratory rate', 'br/min', VarTiming.night), @@ -969,8 +977,8 @@ const Map _catalog = { 'resp_rate', 'skin_temp_z', }), - 'sleep_quality': _Spec('Sleep quality (logged)', '', VarTiming.night, - role: VarRole.input), + 'sleep_quality': + _Spec('Sleep quality (logged)', '', VarTiming.night, role: VarRole.input), // --- the waking day --- 'strain': _Spec('Strain', '', VarTiming.day, @@ -1048,7 +1056,10 @@ List _weekdayAdjust(List v, List weekday, final x = v[i]; if (x != null) byDay[weekday[i]].add(x); } - final all = [for (final x in v) if (x != null) x]; + final all = [ + for (final x in v) + if (x != null) x + ]; final overall = median(all); if (overall == null) return List.filled(v.length, null); final centre = [ @@ -1068,7 +1079,10 @@ List _weekdayAdjust(List v, List weekday, /// is what keeps the permutation test valid; re-ranking inside each surrogate's /// own mask would compare two differently-defined statistics. List _ranksWithNulls(List v) { - final idx = [for (var i = 0; i < v.length; i++) if (v[i] != null) i]; + final idx = [ + for (var i = 0; i < v.length; i++) + if (v[i] != null) i + ]; final ranks = averageRanks([for (final i in idx) v[i]!]); final out = List.filled(v.length, null); for (var k = 0; k < idx.length; k++) { diff --git a/lib/src/onehz/human/circadian_lifestyle.dart b/lib/src/onehz/human/circadian_lifestyle.dart index a10d340..c2e2e56 100644 --- a/lib/src/onehz/human/circadian_lifestyle.dart +++ b/lib/src/onehz/human/circadian_lifestyle.dart @@ -143,8 +143,8 @@ Metric socialJetlag( ); } final sjl = _circDiffH(msf, msw); // signed shortest arc, (−12, +12] - final conf = clamp( - (freeMidSleepH.length + workMidSleepH.length) / 14.0, 0.3, 0.9); + final conf = + ((freeMidSleepH.length + workMidSleepH.length) / 14.0).clamp(0.3, 0.9); return Metric( value: SocialJetlag( sjl, sjl.abs(), msf, msw, freeMidSleepH.length, workMidSleepH.length), @@ -260,7 +260,7 @@ Metric chronotype( return Metric( value: Chronotype(msfSc, label, stability), - confidence: clamp(totalDaysObserved / 28.0, 0.3, 0.9), + confidence: (totalDaysObserved / 28.0).clamp(0.3, 0.9), tier: Tier.high, inputs_used: inputs, note: 'MSFsc chronotype label (direction only, no absolute minutes)', diff --git a/lib/src/onehz/human/event_detection.dart b/lib/src/onehz/human/event_detection.dart index 634a2b5..32bac50 100644 --- a/lib/src/onehz/human/event_detection.dart +++ b/lib/src/onehz/human/event_detection.dart @@ -63,6 +63,7 @@ class NightSignature { class EventState { /// 'normal' | 'mildly_off' | 'autonomically_stressed' — the STATE we will say. final String state; + /// Dose-graded HYPOTHESIS band IF the user later tags alcohol: 'none' | /// 'light' | 'moderate' | 'heavy'. This is NOT an assertion that they drank. final String alcoholHypothesisBand; @@ -212,7 +213,8 @@ Metric alcoholNightFlag( // AMBIGUITY: a confident STATE with NO disambiguating second axis (temp/resp // info absent) means alcohol vs illness vs hot-room can't be separated — we // surface the state but mark the hypothesis ambiguous. - final hasDisambiguator = tonight.skinTempZ != null || tonight.respRate != null; + final hasDisambiguator = + tonight.skinTempZ != null || tonight.respRate != null; final ambiguous = state == 'autonomically_stressed' && !hasDisambiguator; final note = state == 'normal' @@ -232,7 +234,7 @@ Metric alcoholNightFlag( signsPresent: signs, ambiguous: ambiguous, ), - confidence: clamp(signs / 4.0, 0.2, 0.85), + confidence: (signs / 4.0).clamp(0.2, 0.85), tier: Tier.high, inputs_used: inputs, note: note, @@ -267,7 +269,7 @@ Metric roughNight(EventState ev) { : 'a typical night for you'); return Metric( value: RoughNight(rough, ev.signsPresent, descriptor), - confidence: clamp(ev.signsPresent / 4.0, 0.2, 0.8), + confidence: (ev.signsPresent / 4.0).clamp(0.2, 0.8), tier: Tier.relative, inputs_used: inputs, note: 'neutral state descriptor — never attributes a cause', diff --git a/lib/src/onehz/human/percentile_of_you.dart b/lib/src/onehz/human/percentile_of_you.dart index bfad5ba..86b54f7 100644 --- a/lib/src/onehz/human/percentile_of_you.dart +++ b/lib/src/onehz/human/percentile_of_you.dart @@ -71,7 +71,7 @@ Metric percentileOfYou( final pct = 100.0 * (below + 0.5 * equal) / history.length; final label = _bandLabel(pct, better); // Confidence grows with history depth (more of your own data => sturdier CDF). - final conf = clamp(history.length / 60.0, 0.3, 0.95); + final conf = (history.length / 60.0).clamp(0.3, 0.95); return Metric( value: PercentileOfYou(value, pct, history.length, label), confidence: conf, @@ -170,7 +170,7 @@ Metric personalRecord( margin: beats, mdc: gate, ), - confidence: clamp(history.length / 60.0, 0.3, 0.9), + confidence: (history.length / 60.0).clamp(0.3, 0.9), tier: Tier.relative, inputs_used: inputs, note: 'record only if it beats your prior extreme by > MDC', diff --git a/lib/src/onehz/human/readiness_glassbox.dart b/lib/src/onehz/human/readiness_glassbox.dart index d317480..aa65aa2 100644 --- a/lib/src/onehz/human/readiness_glassbox.dart +++ b/lib/src/onehz/human/readiness_glassbox.dart @@ -82,7 +82,8 @@ class ReadinessBreakdownItem { percentileOfYou == null ? null : round6(percentileOfYou!), 'weight': round6(weight), 'weighted_contribution': round6(weightedContribution), - 'past_mdc': beyondUsualSpread, // wire name kept; SWC gate since 2026-08-17 + 'past_mdc': + beyondUsualSpread, // wire name kept; SWC gate since 2026-08-17 'used': used, if (note != null) 'note': note, }; @@ -94,8 +95,8 @@ class GlassBoxReadiness { final List drivers; // ranked by |w·z|, only NAMED past the SWC final String narrative; // deterministic, definitional "why" final int inputsUsed; - const GlassBoxReadiness(this.score, this.breakdown, this.drivers, this.narrative, - this.inputsUsed); + const GlassBoxReadiness(this.score, this.breakdown, this.drivers, + this.narrative, this.inputsUsed); Map toJson() => { 'score': round6(score), 'breakdown': [for (final b in breakdown) b.toJson()], @@ -218,7 +219,7 @@ Metric glassBoxReadiness( ); } - final score = clamp(wpsum / wsum, 0, 100); + final double score = (wpsum / wsum).clamp(0, 100); // Drivers ranked by |contribution|; only NAME a driver past the SWC. final ranked = [...raw]..sort((a, b) => b.c.abs().compareTo(a.c.abs())); @@ -235,7 +236,7 @@ Metric glassBoxReadiness( final narrative = _buildNarrative(score, drivers); // Confidence reflects how many of the priority inputs were usable. - final conf = clamp(nUsable / inputs.length.toDouble(), 0.3, 0.9); + final conf = (nUsable / inputs.length.toDouble()).clamp(0.3, 0.9); return Metric( value: GlassBoxReadiness(score, items, drivers, narrative, nUsable), confidence: conf, diff --git a/lib/src/onehz/human/sleep_regularity.dart b/lib/src/onehz/human/sleep_regularity.dart index 1cda870..f0f5d21 100644 --- a/lib/src/onehz/human/sleep_regularity.dart +++ b/lib/src/onehz/human/sleep_regularity.dart @@ -89,7 +89,7 @@ Metric sleepDebt( final debt = osd - habitual; return Metric( value: SleepDebt(osd, habitual, debt, true), - confidence: clamp(freeNightSleepH.length / 5.0, 0.3, 0.85), + confidence: (freeNightSleepH.length / 5.0).clamp(0.3, 0.85), tier: Tier.high, inputs_used: inputs, note: 'p75 of your unconstrained nights vs your habitual night — a ' diff --git a/lib/src/onehz/human/weekday_effect.dart b/lib/src/onehz/human/weekday_effect.dart index 2f7fdd1..e1d0c83 100644 --- a/lib/src/onehz/human/weekday_effect.dart +++ b/lib/src/onehz/human/weekday_effect.dart @@ -249,7 +249,7 @@ Metric weekdayEffect( // stapled to it. meaningful: omnibusP <= alpha && peakP <= alpha, ), - confidence: clamp(0.25 + 0.25 * (span / 365.0), 0.25, 0.5), + confidence: (0.25 + 0.25 * (span / 365.0)).clamp(0.25, 0.5), tier: Tier.estimate, inputs_used: inputs, note: 'descriptive weekday split, Kruskal-Wallis omnibus then a ' diff --git a/lib/src/onehz/motion/energy_fusion.dart b/lib/src/onehz/motion/energy_fusion.dart index 0091e37..1f64943 100644 --- a/lib/src/onehz/motion/energy_fusion.dart +++ b/lib/src/onehz/motion/energy_fusion.dart @@ -48,7 +48,6 @@ // --------------------------------------------------------------------------- import '../types.dart'; -import '../util.dart'; /// Per-sample fused energy estimate. class EnergyPoint { @@ -90,10 +89,10 @@ double _normHr(double hr, double? restingHr, double? maxHr) { if (hr <= 0) return 0.0; if (restingHr != null && maxHr != null && maxHr > restingHr) { final hrr = (hr - restingHr) / (maxHr - restingHr); - return clamp(hrr, 0.0, 1.0); + return hrr.clamp(0.0, 1.0); } final v = (hr - 50.0) / (180.0 - 50.0); - return clamp(v, 0.0, 1.0); + return v.clamp(0.0, 1.0); } /// Branched HR-accel energy fusion over time-aligned 1 Hz HR + ENMO samples. @@ -156,13 +155,13 @@ Metric branchedEnergyFusion( } var idx = wA * a + (1 - wA) * (hValid ? h : 0.0); if (branch == 'transient') idx *= 0.7; // discount artifact-prone motion - idx = clamp(idx, 0.0, 1.0); + idx = idx.clamp(0.0, 1.0); load += idx; pts.add(EnergyPoint(tsMs[i], idx, a, hValid ? h : 0.0, wA, branch)); } // confidence: data coverage × (calibration bonus) final cov = usable / n; - final conf = clamp(cov * (calibrated ? 0.7 : 0.5), 0.0, 0.7); + final conf = (cov * (calibrated ? 0.7 : 0.5)).clamp(0.0, 0.7); return Metric( value: EnergyFusion(pts, load, calibrated), confidence: conf, diff --git a/lib/src/onehz/motion/enmo.dart b/lib/src/onehz/motion/enmo.dart index 4398581..37e70d1 100644 --- a/lib/src/onehz/motion/enmo.dart +++ b/lib/src/onehz/motion/enmo.dart @@ -290,7 +290,7 @@ EnmoResult enmoSeries( // count the unworn ends too. final spanMinutes = minutes.isEmpty ? 0 : keys.last - keys.first + 1; final denom = expectedMinutes ?? spanMinutes; - final coverage = denom <= 0 ? 0.0 : clamp(covered / denom, 0.0, 1.0); + final coverage = denom <= 0 ? 0.0 : (covered / denom).clamp(0.0, 1.0); return EnmoResult(ref, minutes, coverage); } @@ -405,7 +405,7 @@ Metric relativeIntensityBands( percentile(moving, 75)!, percentile(moving, 90)!, // confidence scales with how much moving data anchors the percentiles. - clamp(moving.length / 60.0, 0.3, 0.8), + (moving.length / 60.0).clamp(0.3, 0.8), 'RELATIVE within-user intensity (50/75/90th moving pct OF THIS INPUT — ' 'cut-points not frozen); NOT METs', ); diff --git a/lib/src/onehz/motion/orientation.dart b/lib/src/onehz/motion/orientation.dart index 75109a7..186b318 100644 --- a/lib/src/onehz/motion/orientation.dart +++ b/lib/src/onehz/motion/orientation.dart @@ -41,7 +41,8 @@ const double _rad2deg = 180.0 / math.pi; class Tilt { final double pitchDeg; // forward(+)/back(−) final double rollDeg; // right(+)/left(−) - final String position; // supine|prone|lateral_left|lateral_right|upright|unknown + final String + position; // supine|prone|lateral_left|lateral_right|upright|unknown final int nSamples; final double stillness; // 0..1 (1 = perfectly still) const Tilt( @@ -114,7 +115,8 @@ Metric staticTilt( final a = valid[i - 1], b = valid[i]; final ma = mags[i - 1], mb = mags[i]; if (ma <= 0 || mb <= 0) continue; - final cos = clamp((a.x * b.x + a.y * b.y + a.z * b.z) / (ma * mb), -1.0, 1.0); + final cos = + ((a.x * b.x + a.y * b.y + a.z * b.z) / (ma * mb)).clamp(-1.0, 1.0); rotSum += math.acos(cos) * _rad2deg; rotN++; } @@ -137,11 +139,9 @@ Metric staticTilt( jitter = mags.length > 1 ? jitter / (mags.length - 1) : 0.0; // Stillness is the WORSE of the two — a posture is only as trustworthy as // the loosest thing that could have disturbed it. - final stillness = clamp( - math.min(1.0 - rotDeg / maxRotationDeg, 1.0 - jitter / maxJitterG), - 0.0, - 1.0, - ); + final stillness = + (math.min(1.0 - rotDeg / maxRotationDeg, 1.0 - jitter / maxJitterG)) + .clamp(0.0, 1.0); if (rotDeg > maxRotationDeg) { return Metric.absent( tier: Tier.high, @@ -165,7 +165,8 @@ Metric staticTilt( final roll = math.atan2(my, mz) * _rad2deg; final pos = classifyPosition(pitch, roll); // confidence blends stillness with epoch length. - final conf = clamp(stillness * (valid.length / 30.0).clamp(0.3, 1.0), 0.0, 0.9); + final conf = + (stillness * (valid.length / 30.0).clamp(0.3, 1.0)).clamp(0.0, 0.9); return Metric( value: Tilt(pitch, roll, pos, valid.length, stillness), confidence: conf, diff --git a/lib/src/onehz/motion/steps.dart b/lib/src/onehz/motion/steps.dart index dab8c28..cf8c7a2 100644 --- a/lib/src/onehz/motion/steps.dart +++ b/lib/src/onehz/motion/steps.dart @@ -90,8 +90,7 @@ class PedometerResult { this.confidence, ); - static const PedometerResult none = - PedometerResult(0, 0, 0, 0, 0); + static const PedometerResult none = PedometerResult(0, 0, 0, 0, 0); Map toJson() => { 'steps': steps, @@ -235,7 +234,8 @@ int pedometer(List sig, {double sampleRateHz = StepParams.fs}) { var stateMax = true; // 'max' → looking for a max; else looking for a min var curMax = 0.0; var curMaxIdx = -1; - var lastStepIdx = -1; // peak index of the last credited pair (interval anchor) + var lastStepIdx = + -1; // peak index of the last credited pair (interval anchor) for (var k = 0; k < candI.length; k++) { final ci = candI[k], cMax = candMax[k], cv = candV[k]; if (stateMax) { @@ -260,7 +260,8 @@ int pedometer(List sig, {double sampleRateHz = StepParams.fs}) { continue; } final mx = curMax, mn = cv; - if (mx > dynVal + StepParams.sens / 2 && mn < dynVal - StepParams.sens / 2) { + if (mx > dynVal + StepParams.sens / 2 && + mn < dynVal - StepParams.sens / 2) { // Step-interval bound (AN-2554). The interval between two steps is the // gap between their peaks; outside 0.2–2.0 s no human produced both, so // this pair cannot CONTINUE the run — it can only start a new one. @@ -352,7 +353,7 @@ PedometerResult livePedometer( if (v < mn) mn = v; if (v > mx) mx = v; } - final conf = clamp(cadence >= 60 && cadence <= 200 ? 0.85 : 0.5, 0.0, 1.0); + final conf = (cadence >= 60 && cadence <= 200 ? 0.85 : 0.5).clamp(0.0, 1.0); return PedometerResult(steps, durationS, cadence, mx - mn, conf); } @@ -425,7 +426,6 @@ StepCalibration? calibrateCadence( ); } - // ───────────────────────── TIER B: 1 Hz daily estimate ────────────────────── // // WHY THIS LOOKS THE WAY IT DOES — the two anchors that DON'T work: @@ -515,7 +515,7 @@ double? personalDynFloor( if (v.isFinite && v >= 0) v ]; if (xs.length < minMinutes) return null; - final q = percentile(xs, clamp(quantile, 0.0, 1.0) * 100.0); + final q = percentile(xs, quantile.clamp(0.0, 1.0) * 100.0); if (q == null || !q.isFinite || q <= 0) return null; return q; } @@ -619,11 +619,13 @@ double? dailyDynSummary( }) { final xs = [ for (final m in motion) - if (m.nSamples >= minSamplesPerMinute && m.dynAmp.isFinite && m.dynAmp >= 0) + if (m.nSamples >= minSamplesPerMinute && + m.dynAmp.isFinite && + m.dynAmp >= 0) m.dynAmp ]; if (xs.length < minCoveredMinutes) return null; - final q = percentile(xs, clamp(quantile, 0.0, 1.0) * 100.0); + final q = percentile(xs, quantile.clamp(0.0, 1.0) * 100.0); if (q == null || !q.isFinite || q <= 0) return null; return q; } @@ -785,9 +787,10 @@ Metric dailyActiveMinutes( // coverage 1.0 here while enmoSeries(expectedMinutes: 1440) read 0.167 on the // identical substrate. Pass [expectedMinutes] to count the ends too. final spanMinutes = - ((motion.last.tsMinStartMs - motion.first.tsMinStartMs) / 60000).round() + 1; + ((motion.last.tsMinStartMs - motion.first.tsMinStartMs) / 60000).round() + + 1; final denom = expectedMinutes ?? spanMinutes; - final coverage = denom <= 0 ? 0.0 : clamp(covered / denom, 0.0, 1.0); + final coverage = denom <= 0 ? 0.0 : (covered / denom).clamp(0.0, 1.0); if (covered < dailyStepMinCoveredMinutes) { return Metric.absent( tier: Tier.estimate, @@ -866,7 +869,7 @@ Metric dailyActiveMinutes( // Upper bound is 0.30, which is what the expression can actually reach — // the inner clamp caps at 1.0, so `0.30 * 1.0` is the ceiling. Writing a // larger outer bound would imply a confidence this metric never claims. - final conf = clamp(0.30 * clamp(coverage / 0.6, 0.3, 1.0), 0.1, 0.30); + final conf = (0.30 * (coverage / 0.6).clamp(0.3, 1.0)).clamp(0.1, 0.30); return Metric( value: DailyMovementEstimate( @@ -878,7 +881,8 @@ Metric dailyActiveMinutes( confidence: conf, tier: Tier.estimate, inputs_used: inputs, - note: 'ESTIMATE: minutes of sustained wrist movement, measured against your ' + note: + 'ESTIMATE: minutes of sustained wrist movement, measured against your ' 'personal movement floor. This is activity volume, NOT walking — at the ' 'wrist, arm work (cooking, dishes, driving) registers as strongly as ' 'walking does. It is deliberately not converted to a step count', diff --git a/lib/src/onehz/respiration/brv_trend.dart b/lib/src/onehz/respiration/brv_trend.dart index 021915d..197034d 100644 --- a/lib/src/onehz/respiration/brv_trend.dart +++ b/lib/src/onehz/respiration/brv_trend.dart @@ -53,7 +53,7 @@ Metric breathingRateVariability(List brpm) { final cv = m == 0 ? 0.0 : sd / m; final slope = theilSen(brpm); // robust trend across the window // Confidence grows with window count, capped (MED tier). - final conf = clamp(0.3 + 0.05 * brpm.length, 0.3, 0.8); + final conf = (0.3 + 0.05 * brpm.length).clamp(0.3, 0.8); return Metric( value: BrvResult( meanBrpm: m, diff --git a/lib/src/onehz/respiration/cvhr_apnea.dart b/lib/src/onehz/respiration/cvhr_apnea.dart index c50c645..5eef9b2 100644 --- a/lib/src/onehz/respiration/cvhr_apnea.dart +++ b/lib/src/onehz/respiration/cvhr_apnea.dart @@ -183,7 +183,7 @@ Metric cvhrApneaScreen( } final perHour = cycleCount / analyzedHours; - final conf = clamp((1 - artifactFraction) * 0.85, 0.2, 0.85); + final conf = ((1 - artifactFraction) * 0.85).clamp(0.2, 0.85); return Metric( value: CvhrResult( cycleCount: cycleCount, @@ -389,8 +389,8 @@ Metric cvhrPersonalDistribution(List nights) { tier: Tier.relative, // The floor is 5 nights, so a 5-night answer must not read like a 30-night // one. Confidence rises with the nights actually behind it. - confidence: clamp( - 0.3 + 0.4 * (kept.length / cvhrDistributionWindowNights), 0.3, 0.7), + confidence: (0.3 + 0.4 * (kept.length / cvhrDistributionWindowNights)) + .clamp(0.3, 0.7), inputs_used: inputs, note: 'CVHR is a CARDIAC SURROGATE, not a breathing measurement: this is ' 'how often the pattern showed up across ${kept.length} of your own ' diff --git a/lib/src/onehz/respiration/relative_odi.dart b/lib/src/onehz/respiration/relative_odi.dart index 23041c6..37662c2 100644 --- a/lib/src/onehz/respiration/relative_odi.dart +++ b/lib/src/onehz/respiration/relative_odi.dart @@ -126,7 +126,10 @@ Metric relativeOdi( // normalized perfusion ratio's inverse mapping. A 1 Hz-honest self- // referential surrogate: oxy ∝ -R. We track dips as RISES in R relative to a // rolling baseline (equiv. to drops in oxygenation), thresholded at dipPct. - final validR = [for (final v in relR) if (!v.isNaN) v]; + final validR = [ + for (final v in relR) + if (!v.isNaN) v + ]; // HONEST-BY-TYPE: if EVERY ratio sample is NaN (no channel passed the DC/IR // guards) there is no self-referential trend to report — a fabricated 0.0 // would read as a real (and impossibly stable) relative-R. Return absent @@ -202,7 +205,7 @@ Metric relativeOdi( } } final nanCount = relR.where((v) => v.isNaN).length; - final conf = clamp(0.5 * validFraction, 0.1, 0.5); + final conf = (0.5 * validFraction).clamp(0.1, 0.5); return Metric( value: RelativeOdiResult( meanRelR: meanRelR, diff --git a/lib/src/onehz/respiration/resp_rate.dart b/lib/src/onehz/respiration/resp_rate.dart index 9b683ed..3179e5f 100644 --- a/lib/src/onehz/respiration/resp_rate.dart +++ b/lib/src/onehz/respiration/resp_rate.dart @@ -304,7 +304,7 @@ Metric rsaRespRate( final bestPower = peakPwr[best]; // Confidence: how much of the window agrees with itself, discounted by // artifacts. Cap below 1 (PRV ceiling). - final conf = clamp((1 - artifactFraction) * consensus, 0.2, 0.9); + final conf = ((1 - artifactFraction) * consensus).clamp(0.2, 0.9); return Metric( value: RespEstimate(brpm, bestPeakHz, bestPower, 'rsa'), confidence: conf, @@ -399,7 +399,7 @@ Metric riivRespRate( final brpm = pk * 60.0; final pwr = _powerAt(ls, pk); // RIIV from 1 Hz green is MED/relative at best — never high confidence. - final conf = clamp(0.6 * validFraction, 0.15, 0.6); + final conf = (0.6 * validFraction).clamp(0.15, 0.6); return Metric( value: RespEstimate(brpm, pk, pwr, 'riiv'), confidence: conf, @@ -416,7 +416,8 @@ class FusedResp { final double? rsaBrpm; final double? riivBrpm; final bool agreed; // passed the Karlen SD-gate - final String decision; // 'fused' | 'rsa_only' | 'riiv_only' | 'disagree' | 'none' + final String + decision; // 'fused' | 'rsa_only' | 'riiv_only' | 'disagree' | 'none' const FusedResp({ required this.brpm, required this.rsaBrpm, @@ -515,11 +516,8 @@ Metric fuseRespRate( ]); final brpm = fused.value ?? rsaV; // Agreement boosts confidence above either alone (independent corroboration). - final conf = clamp( - math.max(rsa.confidence, riiv.confidence) + 0.1, - 0.2, - 0.95, - ); + final conf = + (math.max(rsa.confidence, riiv.confidence) + 0.1).clamp(0.2, 0.95); return Metric( value: FusedResp( brpm: brpm, @@ -539,7 +537,7 @@ Metric fuseRespRate( /// Map a 0..1 confidence to a positive variance for inverse-variance fusion. /// Higher confidence => lower variance. Floored so confidence 0 stays finite. double _confToVar(double conf) { - final c = clamp(conf, 0.05, 1.0); + final c = conf.clamp(0.05, 1.0); return 1.0 / (c * c); } diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 272d062..070e973 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -548,7 +548,7 @@ class AdvancedSleepStager { static double? _hrBaseline(List hr) { if (hr.isEmpty) return null; - return _median([for (final h in hr) h.bpm]); + return median([for (final h in hr) h.bpm]); } static bool _hrSleepBandAcross(int a, int b, List hr, double? baseline) { @@ -1056,7 +1056,7 @@ class AdvancedSleepStager { final lo = math.max(0, i - halfW); final hi = math.min(n, i + halfW + 1); final winDog = dogHR.isEmpty ? [0.0] : dogHR.sublist(lo, hi); - final hrVar = winDog.length >= 2 ? _populationStd(winDog) : double.nan; + final hrVar = winDog.length >= 2 ? stddevPop(winDog)! : double.nan; final winRR = []; final winResp = []; @@ -1068,7 +1068,7 @@ class AdvancedSleepStager { final rmssd = filteredRR.length >= 5 ? (_rmssdRaw(filteredRR) ?? double.nan) : double.nan; final sdnn = - filteredRR.length >= 5 ? (_sdnnRaw(filteredRR) ?? double.nan) : double.nan; + filteredRR.length >= 5 ? (stddev(filteredRR) ?? double.nan) : double.nan; // BUG FIX (2026-07): `winResp` is fed from `resp:`/`RespTs`, a raw 1 Hz // respiration-ADC channel — but the WHOOP 4 R24 record has no such // channel (an early candidate field was dropped as constant/mirror @@ -1116,7 +1116,7 @@ class AdvancedSleepStager { final mean = respRaw.reduce((a, b) => a + b) / respRaw.length; final x = [for (final v in respRaw) v - mean]; if (x.every((v) => v.abs() < 1e-12)) return [double.nan, double.nan]; - final std = _populationStd(x); + final std = stddevPop(x)!; if (std <= 0) return [double.nan, double.nan]; final minDistance = math.max(2, (2.0 / dtS).round()); final peaks = _findPeaks(x, minDistance, 0.0); @@ -1127,8 +1127,8 @@ class AdvancedSleepStager { if (iv >= 1.5 && iv <= 12.0) intervals.add(iv); } if (intervals.length < 2) return [double.nan, double.nan]; - final rate = 60 / _median(intervals)!; - final rrv = _populationStd(intervals); + final rate = 60 / median(intervals)!; + final rrv = stddevPop(intervals)!; return [rate, rrv]; } @@ -1151,7 +1151,7 @@ class AdvancedSleepStager { final mean = rrMs.reduce((a, b) => a + b) / rrMs.length; final x = [for (final v in rrMs) v - mean]; if (x.every((v) => v.abs() < 1e-9)) return [double.nan, double.nan]; - final std = _populationStd(x); + final std = stddevPop(x)!; if (std <= 0) return [double.nan, double.nan]; // Peaks must be >=2 beats apart — a beat-to-beat RR series has ~1 sample // per beat, so a distance-1 peak would just be beat-to-beat noise, not a @@ -1164,8 +1164,8 @@ class AdvancedSleepStager { if (iv >= 1.5 && iv <= 12.0) intervalsS.add(iv); } if (intervalsS.length < 2) return [double.nan, double.nan]; - final rate = 60 / _median(intervalsS)!; - final rrv = _populationStd(intervalsS); + final rate = 60 / median(intervalsS)!; + final rrv = stddevPop(intervalsS)!; return [rate, rrv]; } @@ -1835,12 +1835,8 @@ class AdvancedSleepStager { double Function(double?) zfun(List vals) { final present = [for (final v in vals) if (v != null) v]; if (present.isEmpty) return (_) => 0; - final m = present.reduce((a, b) => a + b) / present.length; - var sd0 = 0.0; - for (final v in present) { - sd0 += (v - m) * (v - m); - } - sd0 = math.sqrt(sd0 / present.length); + final m = mean(present)!; + final sd0 = stddevPop(present)!; final sd = sd0 == 0 ? 1.0 : sd0; return (v) => v == null ? 0 : (v - m) / sd; } @@ -2015,34 +2011,6 @@ class AdvancedSleepStager { return math.sqrt(sumSq / (nn.length - 1)); } - static double? _sdnnRaw(List nn) { - if (nn.length < 2) return null; - final mean = nn.reduce((a, b) => a + b) / nn.length; - var ss = 0.0; - for (final v in nn) { - ss += (v - mean) * (v - mean); - } - return math.sqrt(ss / (nn.length - 1)); - } - - static double? _median(List values) { - if (values.isEmpty) return null; - final s = [...values]..sort(); - final n = s.length; - if (n.isOdd) return s[n ~/ 2]; - return (s[n ~/ 2 - 1] + s[n ~/ 2]) / 2.0; - } - - static double _populationStd(List xs) { - if (xs.isEmpty) return 0; - final m = xs.reduce((a, b) => a + b) / xs.length; - var ss = 0.0; - for (final v in xs) { - ss += (v - m) * (v - m); - } - return math.sqrt(ss / xs.length); - } - /// numpy-style linear-interp percentile of an ALREADY-SORTED list. static double _percentileSorted(List sortedValues, double pct) { final n = sortedValues.length; diff --git a/lib/src/onehz/sleep/cardio_stager.dart b/lib/src/onehz/sleep/cardio_stager.dart index 1c57d24..bf4ec34 100644 --- a/lib/src/onehz/sleep/cardio_stager.dart +++ b/lib/src/onehz/sleep/cardio_stager.dart @@ -243,7 +243,7 @@ class SleepUserProfile { /// Personal-vs-local blend weight: 0 at cold start → 0.5 hard cap at ≥14 /// nights (so per-night-local always holds ≥50% of every threshold). - double get personalWeight => clamp(nights / 28.0, 0.0, 0.5); + double get personalWeight => (nights / 28.0).clamp(0.0, 0.5); static double? _d(dynamic v) => (v is num) ? v.toDouble() : null; @@ -409,8 +409,8 @@ CardioStagerResult cardioStager( // stager could not read, reported as zero wake. No new parameter is needed // for the fix — `accel[i].tsMs` is already the clock `_cleanBeatsInWindow` // binary-searches, so the cadence is measurable from the input we have. - final cadenceSec = - sampleCadenceSeconds([for (var i = 0; i < n; i++) accel[i].tsMs / 1000.0]); + final cadenceSec = sampleCadenceSeconds( + [for (var i = 0; i < n; i++) accel[i].tsMs / 1000.0]); // Coarser than one sample per epoch ⇒ there is no 30-s epoch to score, and // the Webster/Cole-Kripke continuity rules below are specified at this epoch // length. Stretching `epochSec` to fit the device would make a number appear @@ -426,7 +426,8 @@ CardioStagerResult cardioStager( // thresholds from below, and the drift accumulates over the whole night. // Abstain rather than publish a grid whose real spacing does not match its // own label. - if ((perEpoch * cadenceSec - epochSec).abs() > 1e-6) return _abstain(epochSec); + if ((perEpoch * cadenceSec - epochSec).abs() > 1e-6) + return _abstain(epochSec); final nEpoch = n ~/ perEpoch; if (nEpoch < 3) return _abstain(epochSec); @@ -966,15 +967,13 @@ CardioStagerResult classifyCardioEpochs( // HR-relative gates (wake, REM floor, deep trough) had anything to fire on. final hrCovConf = nEpoch == 0 ? 0.0 - : clamp( - [ + : ([ for (final h in hr) if (!h.isNaN) h ].length / - nEpoch.toDouble(), - 0.0, - 1.0); - final conf = clamp((0.35 + 0.25 * rrCov) * hrCovConf, 0.15, 0.6); + nEpoch.toDouble()) + .clamp(0.0, 1.0); + final conf = ((0.35 + 0.25 * rrCov) * hrCovConf).clamp(0.15, 0.6); return CardioStagerResult( StagerResult( @@ -1032,12 +1031,7 @@ double _windowSdnn(List rrMs, List rrTsMs, List accel, int s, int t, int epochSec) { final beats = _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t).beats; if (beats.length < 5) return double.nan; - final m = mean(beats)!; - var ss = 0.0; - for (final v in beats) { - ss += (v - m) * (v - m); - } - return math.sqrt(ss / (beats.length - 1)); + return stddev(beats) ?? double.nan; } /// Clean RR beats (ms) inside a ±[halfWinMs] window centred on epoch [s,t), @@ -1225,8 +1219,8 @@ void _websterRescore(List sm, int epochSec) { List rrTsMs, List accel, int s, int t, int epochSec) { // ±90 s per the REM feature spec — a DIFFERENT window from the RMSSD/SDNN // one; see [_cleanBeatsInWindow]. - final win = _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t, - halfWinMs: 90 * 1000); + final win = + _cleanBeatsInWindow(rrMs, rrTsMs, accel, s, t, halfWinMs: 90 * 1000); final beats = win.beats; // clean RR (ms) final beatTsSec = win.tsSec; // matching beat times (s), rebased to window if (beats.length < 16) diff --git a/lib/src/onehz/sleep/circadian_np.dart b/lib/src/onehz/sleep/circadian_np.dart index 6ee4f02..c80c478 100644 --- a/lib/src/onehz/sleep/circadian_np.dart +++ b/lib/src/onehz/sleep/circadian_np.dart @@ -123,14 +123,14 @@ Metric circadianNonparametric( : 0.0; final days = n / epochsPerDay; - final conf = clamp(days / 7.0, 0.3, 0.95); + final conf = (days / 7.0).clamp(0.3, 0.95); return Metric( value: CircadianNp( - interdailyStability: clamp(is_, 0, 1), + interdailyStability: is_.clamp(0, 1), intradailyVariability: math.max(0, iv), m10: m10.value, l5: l5.value, - relativeAmplitude: clamp(ra, 0, 1), + relativeAmplitude: ra.clamp(0, 1), m10StartEpoch: m10.start, l5StartEpoch: l5.start, ), @@ -162,8 +162,7 @@ _Window _bestWindow(List profile, int w, {required bool maximize}) { sum += profile[(s + k) % p]; } final m = sum / w; - if (best == null || - (maximize ? m > best : m < best)) { + if (best == null || (maximize ? m > best : m < best)) { best = m; bestStart = s; } diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 8aa07f3..268468e 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -455,7 +455,7 @@ Metric> detectNaps( // Confidence, NOT efficiency. A 20% dip below the awake baseline earns // full marks on that axis; the rest rewards evidence, not sleep quality. // Capped at 0.85 — this is a wrist estimate and never becomes a fact. - final dipScore = clamp((baseline - medHr) / (baseline * 0.20), 0, 1); + final dipScore = ((baseline - medHr) / (baseline * 0.20)).clamp(0, 1); final stillScore = tib <= 0 ? 0.0 : tst / tib; // Wear corroboration for THIS bout, not for the day. A day-global // `wristOff.isNotEmpty` flag rewarded every nap on a day the band happened @@ -463,16 +463,13 @@ Metric> detectNaps( // day it never came off — backwards on both counts. This scores how much of // THIS bout is contradicted by an off-body span: none → full marks. final worstOff = offFrac > exFrac ? offFrac : exFrac; - final corroborated = clamp(1 - worstOff / maxNapOffWristFraction, 0, 1); - final conf = clamp( - 0.20 + - 0.30 * dipScore + - 0.25 * coverage + - 0.15 * stillScore + - 0.10 * corroborated, - 0.2, - 0.85, - ); + final corroborated = (1 - worstOff / maxNapOffWristFraction).clamp(0, 1); + final conf = (0.20 + + 0.30 * dipScore + + 0.25 * coverage + + 0.15 * stillScore + + 0.10 * corroborated) + .clamp(0.2, 0.85); naps.add(NapWindow( startSec: aStart - baseSec, diff --git a/lib/src/onehz/sleep/night_hrv_shape.dart b/lib/src/onehz/sleep/night_hrv_shape.dart index 27e6f22..8d9b960 100644 --- a/lib/src/onehz/sleep/night_hrv_shape.dart +++ b/lib/src/onehz/sleep/night_hrv_shape.dart @@ -202,11 +202,8 @@ Metric nightHrvShape( ), // Bounded by how many bins actually carried beats — a night that abstained // on half its bins is not a shape you can read. - confidence: clamp( - 0.8 * bins.where((b) => b.present).length / bins.length, - 0.0, - 0.8, - ), + 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 ' diff --git a/lib/src/onehz/sleep/segment.dart b/lib/src/onehz/sleep/segment.dart index 0ecb324..543f107 100644 --- a/lib/src/onehz/sleep/segment.dart +++ b/lib/src/onehz/sleep/segment.dart @@ -142,7 +142,7 @@ class StageInterval { required int tstSec, required double confidence, }) { - final t = clamp(confidence / kMaxSleepConfidence, 0.0, 1.0); + final t = (confidence / kMaxSleepConfidence).clamp(0.0, 1.0); double rel(double best, double worst) => worst + (best - worst) * t; final deepHalf = math.max( (rel(_deepRelHalfBest, _deepRelHalfWorst) * deepSec).round(), @@ -669,8 +669,8 @@ SleepSegmentation segmentSleep( for (final b in rr) { if (b.ts >= chosen.start && b.ts < chosen.end) rrSeconds.add(b.ts); } - final hrCov = clamp(hrCovered / inBed, 0.0, 1.0); - final rrCov = clamp(rrSeconds.length / inBed, 0.0, 1.0); + final hrCov = (hrCovered / inBed).clamp(0.0, 1.0); + final rrCov = (rrSeconds.length / inBed).clamp(0.0, 1.0); final stagingConf = (0.35 + 0.25 * rrCov) * hrCov; // Confidence in the window WE PUBLISHED, on van Hees' own construction // (length up to a typical 7 h night, clamped) — but measured on `chosen`, the @@ -679,9 +679,9 @@ SleepSegmentation segmentSleep( // file never reads. Half of every night's published confidence came from the // detector that lost, and that confidence sets the SLP-13 stage-interval // widths. - final windowConf = clamp(inBed / (7 * 3600), 0.3, 0.95); + final windowConf = (inBed / (7 * 3600)).clamp(0.3, 0.95); final conf = - clamp((windowConf + stagingConf) / 2.0, 0.0, kMaxSleepConfidence); + ((windowConf + stagingConf) / 2.0).clamp(0.0, kMaxSleepConfidence); return SleepSegmentation( window: SleepWindow( diff --git a/lib/src/onehz/sleep/sri.dart b/lib/src/onehz/sleep/sri.dart index 552b337..88c8083 100644 --- a/lib/src/onehz/sleep/sri.dart +++ b/lib/src/onehz/sleep/sri.dart @@ -138,7 +138,7 @@ Metric phillipsSri( final sri = 200.0 * agreement / cases - 100.0; // Confidence: scales with the number of day-comparisons (more days, more // stable). Saturates around a typical 7-day record. - final conf = clamp((days - 1) / 7.0, 0.3, 0.95); + final conf = ((days - 1) / 7.0).clamp(0.3, 0.95); return Metric( value: SriResult(sri, days, cases, pairs), confidence: conf, diff --git a/lib/src/onehz/sleep/van_hees.dart b/lib/src/onehz/sleep/van_hees.dart index f21d81a..be16176 100644 --- a/lib/src/onehz/sleep/van_hees.dart +++ b/lib/src/onehz/sleep/van_hees.dart @@ -400,8 +400,7 @@ Metric vanHeesSleepWindow( final hasTs = accel.every((a) => a.tsMs != 0) || accel.first.tsMs != 0; final onsetMs = hasTs ? accel[bestStart].tsMs : null; - final offsetMs = - hasTs ? accel[math.min(bestEnd, n - 1)].tsMs : null; + final offsetMs = hasTs ? accel[math.min(bestEnd, n - 1)].tsMs : null; var unresolved = 0; for (final u in immobileUnknown) { @@ -414,7 +413,7 @@ Metric vanHeesSleepWindow( final undecidableSec = (unresolved * cadence).round(); // Confidence grows with the detected SPT length up to a typical night. - final conf = clamp(sptSec / (7 * 3600), 0.3, 0.95); + final conf = (sptSec / (7 * 3600)).clamp(0.3, 0.95); return Metric( value: SleepWindow( onsetIdx: bestStart, diff --git a/lib/src/onehz/util.dart b/lib/src/onehz/util.dart index 24e93d5..ac69c03 100644 --- a/lib/src/onehz/util.dart +++ b/lib/src/onehz/util.dart @@ -185,8 +185,6 @@ double? z(double x, List sample) { return (x - m) / sd; } -double clamp(double x, double lo, double hi) => math.max(lo, math.min(hi, x)); - /// Ordinary-least-squares slope of y vs x (x defaults to 0..n-1). Null if <2. double? olsSlope(List y, [List? x]) { final n = y.length; @@ -471,7 +469,7 @@ double normalTwoSidedP(double zScore) { t * (-0.82215223 + t * 0.17087277))))))))); - return clamp(ans, 0.0, 1.0); + return ans.clamp(0.0, 1.0); } /// Benjamini-Hochberg (1995) step-up FDR adjustment over a family of p-values. diff --git a/lib/src/onehz/wellness/anomaly.dart b/lib/src/onehz/wellness/anomaly.dart index 41ff445..3b5e892 100644 --- a/lib/src/onehz/wellness/anomaly.dart +++ b/lib/src/onehz/wellness/anomaly.dart @@ -46,7 +46,8 @@ const int multivariateAnomalyMinBaseline = 10; class AnomalyDay { final String date; - final double? mahalanobis; // robust Mahalanobis distance (null if no baseline) + final double? + mahalanobis; // robust Mahalanobis distance (null if no baseline) final bool flagged; // distance crossed gate AND persistence satisfied final bool candidate; // distance crossed gate THIS night (pre-persistence) final List drivers; // per-feature signed contribution @@ -59,8 +60,9 @@ class AnomalyDay { /// standardize against and we abstain rather than invent one. /// Null when the night WAS evaluated. final String? need; - const AnomalyDay(this.date, this.mahalanobis, this.flagged, this.candidate, - this.drivers, {this.need}); + const AnomalyDay( + this.date, this.mahalanobis, this.flagged, this.candidate, this.drivers, + {this.need}); Map toJson() => { 'date': date, if (mahalanobis != null) 'mahalanobis': round6(mahalanobis!), @@ -133,7 +135,8 @@ List multivariateAnomaly( String? need; var bestHave = -1; for (var f = 0; f < 4; f++) { - if (cur[f] != null && cols[f].length > bestHave) bestHave = cols[f].length; + if (cur[f] != null && cols[f].length > bestHave) + bestHave = cols[f].length; } if (bestHave >= 0) { need = needBaselineNote(have: bestHave, need: minBaseline); @@ -161,7 +164,8 @@ List multivariateAnomaly( for (final f in idx) { final m = mad(cols[f]) ?? 0; final sc = m > 0 ? m : (stddev(cols[f]) ?? 0); - if (!sc.isFinite || sc <= 0) continue; // no dispersion → not standardizable + if (!sc.isFinite || sc <= 0) + continue; // no dispersion → not standardizable keep.add(f); center.add(median(cols[f])!); scale.add(sc); @@ -173,7 +177,10 @@ List multivariateAnomaly( continue; } // Standardized current vector. - final zc = [for (var a = 0; a < keep.length; a++) (cur[keep[a]]! - center[a]) / scale[a]]; + final zc = [ + for (var a = 0; a < keep.length; a++) + (cur[keep[a]]! - center[a]) / scale[a] + ]; // Robust correlation matrix from aligned rows (standardized), regularized. final cov = _robustCorr(rows, keep, center, scale, ridge); @@ -199,7 +206,8 @@ List multivariateAnomaly( drivers.add(Driver(_featLabels[keep[a]], roundTo(zc[a], 6), detail: 'standardized deviation')); } - drivers.sort((x, y) => y.contribution.abs().compareTo(x.contribution.abs())); + drivers + .sort((x, y) => y.contribution.abs().compareTo(x.contribution.abs())); // "N nights running" means CONSECUTIVE NIGHTS, not consecutive rows. if (day[i] - lastScoredDay > 1) run = 0; @@ -328,7 +336,7 @@ List> _robustCorr( final xa = [for (final s in std) s[a]]; final xb = [for (final s in std) s[b]]; final r = _corr(xa, xb); - final rc = clamp(r ?? 0.0, -0.95, 0.95); + final rc = (r ?? 0.0).clamp(-0.95, 0.95); m[a][b] = rc; m[b][a] = rc; } diff --git a/lib/src/onehz/wellness/cycle_lengths.dart b/lib/src/onehz/wellness/cycle_lengths.dart index 7fda577..3bcc2bd 100644 --- a/lib/src/onehz/wellness/cycle_lengths.dart +++ b/lib/src/onehz/wellness/cycle_lengths.dart @@ -29,7 +29,6 @@ // that this is a prompt to ask a clinician, not an answer. import '../types.dart'; -import '../util.dart'; /// Consecutive-cycle difference used by the published criteria, in days. const int cycleLengthDifferenceCriterionDays = 7; @@ -137,7 +136,7 @@ Metric cycleLengthSeries( maxConsecutiveDifferenceDays: diffs.reduce((a, b) => a > b ? a : b), longestIntervalDays: lengths.reduce((a, b) => a > b ? a : b), ), - confidence: clamp(lengths.length / 24.0, 0.3, 0.8), + confidence: (lengths.length / 24.0).clamp(0.3, 0.8), tier: Tier.high, inputs_used: inputs, note: caveat, diff --git a/lib/src/onehz/wellness/readiness_composite.dart b/lib/src/onehz/wellness/readiness_composite.dart index 2e77765..ae58ca5 100644 --- a/lib/src/onehz/wellness/readiness_composite.dart +++ b/lib/src/onehz/wellness/readiness_composite.dart @@ -280,13 +280,14 @@ Metric readinessComposite( Driver(d.label, roundTo(d.contribution / weightSum, 6), detail: d.detail) ]; // Rank by |contribution| (the deterministic-narrative driver ordering). - normDrivers.sort((a, b) => b.contribution.abs().compareTo(a.contribution.abs())); + normDrivers + .sort((a, b) => b.contribution.abs().compareTo(a.contribution.abs())); // Map composite z -> 0..100 via logistic; ~50 at z=0, scale so ±2 z ~ 12/88. final score = 100 / (1 + math.exp(-composite)); // Confidence scales with how many inputs were available (more = better). - final conf = clamp(0.3 + 0.15 * used.length, 0.3, 0.9); + final conf = (0.3 + 0.15 * used.length).clamp(0.3, 0.9); return Metric( value: Readiness(score, composite), @@ -294,7 +295,8 @@ Metric readinessComposite( tier: Tier.estimate, inputs_used: used, drivers: normDrivers, - note: 'GLASS-BOX readiness: disclosed weights HRV>RHR>RR>temp, renormalized ' + note: + 'GLASS-BOX readiness: disclosed weights HRV>RHR>RR>temp, renormalized ' 'over present inputs. Drivers are definitional within the formula ' '(correction, not inferred cause).$suffix', ); diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index e7f15a8..30fdbe0 100644 --- a/lib/src/onehz/wellness/temp_circadian.dart +++ b/lib/src/onehz/wellness/temp_circadian.dart @@ -200,7 +200,7 @@ Metric nightlySkinTemp( } return Metric( value: SettledSkinTemp(mean(kept)!, frac, cal.unit), - confidence: clamp(frac, 0.0, 1.0), + confidence: frac.clamp(0.0, 1.0), tier: Tier.relative, inputs_used: inputs, note: 'RELATIVE nightly skin-temp mean over the SETTLED portion only ' @@ -289,7 +289,7 @@ Metric tempCircadian( } // Confidence: tie to cosinor R² when present, else a modest np-only value. - final conf = cos.value != null ? clamp(cos.value!.r2, 0.1, 0.9) : 0.3; + final conf = cos.value != null ? (cos.value!.r2).clamp(0.1, 0.9) : 0.3; return Metric( value: TempCircadian(cos.value, np, cal.unit), confidence: conf, @@ -387,7 +387,7 @@ CircadianNonparam? _nonparam( } profVar /= profile.length; } - final is_ = varTot > 0 ? clamp(profVar / (varTot / p), 0, 1) : 0.0; + final double is_ = varTot > 0 ? (profVar / (varTot / p)).clamp(0, 1) : 0.0; // M10 / L5 / RA are DELIBERATELY NOT COMPUTED. See the file header: the temp // series that survives retention is median-centred, so it is signed, and diff --git a/lib/src/onehz/workout/hr_recovery.dart b/lib/src/onehz/workout/hr_recovery.dart index 7fbf3d9..19edebc 100644 --- a/lib/src/onehz/workout/hr_recovery.dart +++ b/lib/src/onehz/workout/hr_recovery.dart @@ -298,7 +298,7 @@ Metric hrRecovery( } final pct = peak > 0 ? 100.0 * drop / peak : 0.0; // Confidence from DATA QUALITY, not from the answer. This used to be - // `clamp(drop / 30, 0.3, 0.9)` — confidence derived from the metric's own + // `(drop / 30).clamp(0.3, 0.9)` — confidence derived from the metric's own // magnitude, so a motion-artifact spike inflated `peak`, inflated `drop`, and // RAISED the confidence: the least trustworthy readings scored highest. var seen = 0, valid = 0; @@ -315,11 +315,9 @@ Metric hrRecovery( // we have no way to know the recovery sample is really 60 s later. A // timestamped tail too sparse to fill the peak window doesn't earn it either // — the timestamps proved the window was short rather than fixing it. - final conf = clamp( - 0.3 + 0.4 * validFrac + (times != null && peakWindowFull ? 0.2 : 0.0), - 0.2, - 0.9, - ); + final conf = + (0.3 + 0.4 * validFrac + (times != null && peakWindowFull ? 0.2 : 0.0)) + .clamp(0.2, 0.9); // TAU (CV-08). Same tail, same slice, no second pass over the substrate. // Timestamps are REQUIRED: a time constant fitted to array positions of diff --git a/lib/src/onehz/workout/hr_zones.dart b/lib/src/onehz/workout/hr_zones.dart index 61fb9b2..16c66e3 100644 --- a/lib/src/onehz/workout/hr_zones.dart +++ b/lib/src/onehz/workout/hr_zones.dart @@ -146,9 +146,7 @@ class HeartRateZones { ]; if (valid.length < minDays) return null; valid.sort(); - final rhr = valid.length.isOdd - ? valid[valid.length ~/ 2] - : (valid[valid.length ~/ 2 - 1] + valid[valid.length ~/ 2]) / 2.0; + final rhr = percentileSorted(valid, 50)!; if (!(maxHr > rhr)) return null; final reserve = maxHr - rhr; final built = []; diff --git a/lib/src/onehz/workout/observed_max_hr.dart b/lib/src/onehz/workout/observed_max_hr.dart index 79a08e8..16de579 100644 --- a/lib/src/onehz/workout/observed_max_hr.dart +++ b/lib/src/onehz/workout/observed_max_hr.dart @@ -143,34 +143,73 @@ Metric sessionHrCeiling( final holdMs = holdSeconds * 1000.0; final gapMs = maxGapSeconds * 1000.0; + // A real held effort's corroborating motion is not necessarily even across + // the hold (e.g. a couple of seconds of arm swing at each end of a quiet + // middle), so corroboration is judged on a short trailing sub-window, not + // the whole hold's average — a real burst anywhere in the hold would get + // diluted back below the gate by an otherwise-quiet average, which is + // exactly the edges-quiet-middle case this exists for. As the window + // extends past the minimal holdSeconds, that trailing sub-window sweeps + // across the rest of the candidate span, so a burst anywhere in it still + // gets found — capped, so one quiet start can't turn this into an O(n²) + // scan of a whole day. + final maxSpanMs = holdMs * 4; + const corrobMs = 3000.0; // "a couple of seconds of arm swing" HrCeiling? best; - // ponytail: O(n · holdSeconds) — one session at 1 Hz, so ~15 passes over a - // few thousand samples. A monotonic-deque sliding minimum if it ever runs - // over a whole day. + // ponytail: O(n · holdSeconds) — one session at 1 Hz, so a bounded number of + // passes over a few thousand samples. A monotonic-deque sliding minimum if + // it ever runs over a whole day. for (var i = 0; i < rows.length; i++) { var lo = rows[i].hr; var motionSum = 0.0; var count = 0; + var trailStart = i; + var trailSum = 0.0; + var trailCount = 0; + // Running max of the trailing sub-window average seen so far as j sweeps + // forward. trailStart only ever advances, so once j moves past a burst + // the trailing sum itself forgets it — bestTrail is what keeps a burst + // near the start of a long hold (or anywhere before a quiet tail) from + // being lost once the window slides past it. + var bestTrail = 0.0; for (var j = i; j < rows.length; j++) { if (j > i && rows[j].ts - rows[j - 1].ts > gapMs) break; // stream broke lo = math.min(lo, rows[j].hr); motionSum += rows[j].motion; count++; + trailSum += rows[j].motion; + trailCount++; + while (rows[j].ts - rows[trailStart].ts > corrobMs) { + trailSum -= rows[trailStart].motion; + trailCount--; + trailStart++; + } + // Only trust the trailing average once it actually spans a full + // corrobMs — before that (the first couple of samples at a fresh + // start index) it's one or two samples, and a single noisy sample + // could otherwise "corroborate" as if it were a real sustained burst. + final trailAvg = trailSum / trailCount; + if (rows[j].ts - rows[i].ts >= corrobMs && trailAvg > bestTrail) { + bestTrail = trailAvg; + } final span = rows[j].ts - rows[i].ts; if (span < holdMs) continue; - // The window qualifies on duration. `lo` is the bpm sustained across all - // of it; extending further can only lower it, so this is the best this - // start can do and we stop. - final motion = motionSum / count; - if (motion >= gate && (best == null || lo > best.bpm)) { - best = HrCeiling( - bpm: lo, - tsMs: rows[i].ts, - heldSeconds: (span / 1000).round(), - motionG: motion, - ); + if (span > maxSpanMs) break; // gave this start its fair shot + // The window qualifies on duration. `lo` is the bpm sustained across + // all of it. Only stop once a short burst of real motion actually + // corroborated it ANYWHERE in the span so far (bestTrail), not just in + // whatever the trailing window currently covers. + if (bestTrail >= gate) { + if (best == null || lo > best.bpm) { + best = HrCeiling( + bpm: lo, + tsMs: rows[i].ts, + heldSeconds: (span / 1000).round(), + motionG: motionSum / count, + ); + } + break; } - break; } } @@ -183,7 +222,7 @@ Metric sessionHrCeiling( } return Metric( value: best, - confidence: clamp(best.heldSeconds / (holdSeconds * 2.0), 0.3, 0.8), + confidence: (best.heldSeconds / (holdSeconds * 2.0)).clamp(0.3, 0.8), tier: Tier.high, inputs_used: inputs, note: 'OBSERVED ceiling, not physiological HRmax — an underestimate if you ' diff --git a/test/onehz/observed_max_hr_test.dart b/test/onehz/observed_max_hr_test.dart index dc576fc..8005a8c 100644 --- a/test/onehz/observed_max_hr_test.dart +++ b/test/onehz/observed_max_hr_test.dart @@ -66,6 +66,74 @@ void main() { } }); + test('motion at both edges of a quiet middle still corroborates', () { + // 30s hold at 170 bpm: a couple seconds of arm swing at each end, + // quiet in between. Averaging motion over the whole window dilutes + // the edges away; corroboration has to find the burst. + final hr = []; + final accel = []; + for (var i = 0; i < 30; i++) { + final t = i * 1000.0; + final edge = i < 3 || i >= 27; + hr.add(HrSample(t, 170)); + accel.add(AccelSample(t, 0, 0, 1.0 + (edge ? 0.25 : 0.005))); + } + final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4'); + expect(m.present, isTrue); + expect(m.value!.bpm, 170); + }); + + test('a burst at the very start of the hold still corroborates', () { + // 40s at 170 bpm: arm-swing burst only in seconds 0-2, quiet after. + // The trailing 3s window sweeps past the burst almost immediately, so + // corroboration has to remember it, not just see it at the current j. + final hr = []; + final accel = []; + for (var i = 0; i < 40; i++) { + final t = i * 1000.0; + final burst = i < 3; + hr.add(HrSample(t, 170)); + accel.add(AccelSample(t, 0, 0, 1.0 + (burst ? 0.25 : 0.005))); + } + final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4'); + expect(m.present, isTrue); + expect(m.value!.bpm, 170); + }); + + test('a mid-window burst followed by a long quiet tail still corroborates', + () { + // 40s at 170 bpm: burst at seconds 10-12, quiet for the remaining 27s. + final hr = []; + final accel = []; + for (var i = 0; i < 40; i++) { + final t = i * 1000.0; + final burst = i >= 10 && i < 13; + hr.add(HrSample(t, 170)); + accel.add(AccelSample(t, 0, 0, 1.0 + (burst ? 0.25 : 0.005))); + } + final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4'); + expect(m.present, isTrue); + expect(m.value!.bpm, 170); + }); + + test('a single one-sample motion spike cannot corroborate a hold', () { + // 20s at 170 bpm: ONE sample of real burst-level motion (same 0.25 dev + // the other burst tests use) at t=0, dead still for the rest. Checked + // raw at trailCount==1 that one sample alone clears the gate; diluted + // across the couple-of-seconds window the gate is meant to require, it + // does not — one noisy sample is not a corroborating burst. + final hr = []; + final accel = []; + for (var i = 0; i < 20; i++) { + final t = i * 1000.0; + hr.add(HrSample(t, 170)); + accel.add(AccelSample(t, 0, 0, 1.0 + (i == 0 ? 0.25 : 0.005))); + } + final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4'); + expect(m.present, isFalse, + reason: 'one noisy sample is not a corroborating burst'); + }); + test('a gap in the stream breaks the hold', () { final a = _run(0, 10, 180, 0.20); final b = _run(60000, 10, 180, 0.20); // 50 s later diff --git a/test/onehz/util_test.dart b/test/onehz/util_test.dart index ff9e49f..d031213 100644 --- a/test/onehz/util_test.dart +++ b/test/onehz/util_test.dart @@ -34,12 +34,6 @@ void main() { final zr = robustZ(7, [1, 2, 3, 4, 5]); expect(zr, closeTo((7 - 3) / 1.4826, 1e-6)); }); - - test('clamp', () { - expect(clamp(5, 0, 3), 3); - expect(clamp(-1, 0, 3), 0); - expect(clamp(2, 0, 3), 2); - }); }); group('regression', () { @@ -260,9 +254,8 @@ void main() { test('HeartRateZones.timeInZone: 300 s scores, 301 s and 600 s are null', () { final zoneSet = HeartRateZones.zonesFromMaxHr(200); - List stream(double cadence, int n) => [ - for (var i = 0; i < n; i++) HrSample(i * cadence * 1000.0, 150) - ]; + List stream(double cadence, int n) => + [for (var i = 0; i < n; i++) HrSample(i * cadence * 1000.0, 150)]; // 300 s: 20 readings, each credited its own 300 s → 6000 s in z3. expect(HeartRateZones.timeInZone(stream(300, 20), zoneSet)!.total, closeTo(6000, 1e-9)); @@ -285,10 +278,10 @@ void main() { // The abstention has to reach the published number, not stop at the // helper: `banisterTRIMP` credits `fallbackSampleMin` per sample when // handed an empty list, which is the fabricated 1 s all over again. - expect(StrainScorer.strain(bpm, ts(301), maxHR: 190, restingHR: 50), - isNull); - expect(StrainScorer.strain(bpm, ts(600), maxHR: 190, restingHR: 50), - isNull); + expect( + StrainScorer.strain(bpm, ts(301), maxHR: 190, restingHR: 50), isNull); + expect( + StrainScorer.strain(bpm, ts(600), maxHR: 190, restingHR: 50), isNull); expect(StrainScorer.strain(bpm, ts(300), maxHR: 190, restingHR: 50), isNotNull); }); @@ -306,12 +299,10 @@ void main() { // a real 300 s day would have come out as one unbroken 8 h sleep session. // See `AdvancedSleepStager.maxStillCadenceSec` and // `test/onehz/sleep_cadence_test.dart`. - List grav(int cadence) => [ - for (var t = 0; t < 8 * 3600; t += cadence) GravTs(t, 0, 0, 1.0) - ]; - List hr(int cadence) => [ - for (var t = 0; t < 8 * 3600; t += cadence) HrTs(t, 52) - ]; + List grav(int cadence) => + [for (var t = 0; t < 8 * 3600; t += cadence) GravTs(t, 0, 0, 1.0)]; + List hr(int cadence) => + [for (var t = 0; t < 8 * 3600; t += cadence) HrTs(t, 52)]; expect(AdvancedSleepStager.detectSleep(grav(30), hr(30)), isNotEmpty); expect(AdvancedSleepStager.detectSleep(grav(300), hr(300)), isEmpty); expect(AdvancedSleepStager.detectSleep(grav(301), hr(301)), isEmpty);