diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 2572f7540..153c00f67 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -27,8 +27,10 @@ import 'dart:io' show Platform; import 'dart:isolate'; import 'dart:math' as math; +import 'quiet_level_seed.dart' show seedQuietHrrHistoryOnce; import 'strain_backfill.dart' show backfillStrainScale; +import 'package:crypto/crypto.dart' show sha1; import 'package:flutter/foundation.dart'; import 'findings.dart'; import 'nap_edits.dart'; @@ -1780,7 +1782,8 @@ import 'vendor_sleep.dart'; // 106 → 107: analytics main @ c0effea, #86 rmssd gate refuses noise windows, so the stored rmssd/hrv can go null or move. // 107 → 108: a ring's own hypnogram stages the main sleep (`vendor_staged`, above auto, below the user's override) when it passes the plausibility gate. Edge-only. // 108 → 109: analytics main @ 27b0ba4, #87: at low resting hr a breathing line that stays steady in Hz across the night lets rmssd publish (floor confidence) where the jitter gate refused it. -const int kAlgoVersion = 109; +// 109 → 110: analytics main @ b7d5819, #78 #88-#91. Sleep detection no longer bridges unobserved recording gaps; bridges capped at 90 min. rmssd is one nightly estimator, the mean of the sleep session's 5-min windows, absent when the RR stream banks more beat-time than elapsed or no window has 20 clean differences; the RSA respiratory rate survives sensor gaps; strain (and the new trimp_net) is priced against the user's own quiet-waking level (quiet_hrr, median of 28 prior days, abstains under 3). Days derived before 110 keep their stored values. +const int kAlgoVersion = 110; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -1961,7 +1964,7 @@ const int kAlgoVersion = 109; // SleepSegmentation.bandOffsetTrimSec), on OpenStrap/analytics main, for v100 // above. // REPIN @ c0effea: analytics main, #79 + #86 (rmssd gate), for v105. -const String kAnalyticsPin = '27b0ba486234900084ed791cfb95e71738645d98'; +const String kAnalyticsPin = 'b7d5819a504cbf7842d0bdf09806d4189a8ac0e2'; // Repinned to analytics main's tip, which carries BOTH PR #72 (hrv_freq // Welch gap guard) and PR #73 (overreachingConjunction rhr quantum guard) — // the two independent kAlgoVersion bumps above (93 and 94). Verified both @@ -2278,6 +2281,13 @@ Future>> debugSweepBaselineWindows( return [for (final day in orderedDays) history.valuesBefore(key, day)]; } +/// Test seam: the baseline signature [DerivationEngine.rescanRecent] gates on, +/// rebuilt from the current `metric_series`. +@visibleForTesting +Future debugBaselineSignature() async => + (await _BaselineHistoryCache.load()).toArtifactJson()['signature'] + as String; + @visibleForTesting ({List days, String reason}) selectLightDeriveDays({ required Set rawDays, @@ -2375,6 +2385,10 @@ class _BaselineHistoryCache { // derive needs the PRIOR days' values of, which is exactly what this // snapshot is (see [maxBefore]). 'hr_ceiling_bpm', + // Each day's own quiet-waking level (median wake %HRR). Strain is priced + // against the median of the PRIOR days' values — a trait, never the day + // under test. Unitless, so no family-seam mask applies. + 'quiet_hrr', ]; /// DATED baseline samples, ascending by date, one entry per day (metric_series @@ -2524,6 +2538,41 @@ class _BaselineHistoryCache { return [for (var i = from; i < samples.length; i++) samples[i].value]; } + /// Strain's quiet-waking level is a baseline too, and [rescanRecent] has to + /// re-price the recent days when ANY of their windows moves — a count and a + /// median of the whole series do not see that: each day is priced on the + /// median of the days strictly BEFORE it, so a value can change under one + /// day's window while the overall count and median stay put (e.g. yesterday + /// .15 → .25 in [.10,.10,.20,.20,.20,.15,.30] moves today's baseline .175 → + /// .20 and leaves both unchanged). So this fingerprints the DATED entries + /// themselves — every one that can sit in the window of a day the rescan + /// covers: the [_rescanWindowDays] + [_baselineWindowDays] stored days + /// before the newest. + /// + /// THE NEWEST IS LEFT OUT while no stored day comes after it. It is then + /// normally today's, rewritten on every light derive as the wake series + /// grows, and no day yet sits on a window holding it. Hashing it re-ran the + /// whole rescan window on every heavy pass for nothing. Once ANY baseline + /// series has a later day (one too short to write its own level, say, which + /// may already be finalized), that day was priced on it, so it is hashed. + String _quietFingerprint() { + final series = _series['quiet_hrr'] ?? const <_DatedValue>[]; + const span = _rescanWindowDays + _baselineWindowDays; + final newest = series.isEmpty ? null : series.last.date; + final laterDay = newest != null && + _series.entries.any((e) => + e.key != 'quiet_hrr' && + e.value.isNotEmpty && + e.value.last.date.compareTo(newest) > 0); + final end = series.isEmpty ? 0 : series.length - (laterDay ? 0 : 1); + final from = end <= span ? 0 : end - span; + final dated = [ + for (var i = from; i < end; i++) + '${series[i].date}=${series[i].value.toStringAsFixed(6)}', + ].join(','); + return sha1.convert(utf8.encode(dated)).toString().substring(0, 12); + } + Map toArtifactJson() { double? avg(List xs) { if (xs.isEmpty) return null; @@ -2538,7 +2587,8 @@ class _BaselineHistoryCache { final readiness = values('readiness'); final signature = 'v$kAlgoVersion|n${rhr.length}|rhr${fmt(_median(rhr))}|rmssd${fmt(_median(rmssd))}' - '|temp${fmt(_median(temp))}|resp${fmt(_median(resp))}'; + '|temp${fmt(_median(temp))}|resp${fmt(_median(resp))}' + '|q${_quietFingerprint()}'; return { 'algo_version': kAlgoVersion, 'signature': signature, @@ -2759,6 +2809,37 @@ class DerivationEngine { } catch (_) {} try { + // FIRST, ahead of every early return below: it reads only stored bundles, + // so an install with bundles but no decoded rows (a restore) or with + // every day finalized still gets its quiet levels — the live gauge and + // session scoring need them as much as a derive does. And before the + // history snapshot, so this very sweep prices strain on them. One-shot + // (`compute_freshness`); a failure leaves the key unset and the next pass + // retries — never fatal to the derive. + _diag['stage'] = 'quiet_hrr_seed'; + var seeded = 0; + try { + seeded = await seedQuietHrrHistoryOnce( + ageYears: profile.ageYears, + manualRestingHr: profile.restingHrManual?.toDouble(), + ); + if (seeded > 0) _log('[derive] quiet_hrr seed: $seeded day(s)'); + } catch (e) { + _log('[derive] quiet_hrr seed failed (will retry): $e'); + } + // New levels move the baseline signature `rescanRecent` gates on. + // Refreshed here, ahead of every early return: on a pass with nothing + // to derive nothing else would, and the days the levels now price would + // never be re-scored. Its own guard — the seed is already marked done, + // so a failure here is not a seed failure; the next refresh catches up. + if (seeded > 0) { + try { + await _refreshBaselines(); + } catch (e) { + _log('[derive] baseline refresh after the quiet_hrr seed failed: $e'); + } + } + _diag['stage'] = 'scope'; final scope = await _deriveScope(heavy: heavy, force: force); _diag ..['scope_days'] = scope.targetDays.length @@ -2784,8 +2865,17 @@ class DerivationEngine { ]; if (todoDays.isEmpty) { _log('derive: all days finalized — nothing to do'); + // A day still owed a strain score is unfinalized, so it is normally in + // the todo set; re-score it here too rather than leave it a pass. + final rescored = + await _quietLevelRepass(profile, dataNowSec, scope.rawDays); + if (rescored > 0) { + await _refreshBaselines(); + await _runCrossDay(profile); + await _runNotifications(); + } await _pruneOldDecoded(scope.rawDays, dataNowSec); - return 0; + return rescored; } _diag['todo_days'] = todoDays.length; _diag['stage'] = 'history'; @@ -2874,6 +2964,10 @@ class DerivationEngine { } await runWithConcurrency(orderedDays, _deriveConcurrency, processDay); + // BEFORE the prune below: a day still owed a strain score is re-scored + // while its raw exists (and the prune holds it if this fails). + _diag['stage'] = 'quiet_repass'; + done += await _quietLevelRepass(profile, dataNowSec, scope.rawDays); // 4. Cross-day rollup + notifications (best-effort). if (done > 0) { @@ -3061,6 +3155,7 @@ class DerivationEngine { } await runWithConcurrency(orderedDays, _deriveConcurrency, processDay); + done += await _quietLevelRepass(profile, dataNowSec); // A SELECTED re-analyze that happens to cover the whole raw history, with // every day resolved, is a full restage by any other name — it re-derived // every day under the current timezone, so it clears the travel hold too. @@ -4082,6 +4177,201 @@ class DerivationEngine { ); } + /// `compute_freshness` key prefix (``) marking a day + /// whose strain abstained for want of a quiet-waking level that prior days + /// could still supply. DURABLE on purpose: the day is held unfinalized and + /// its raw is held from the prune (see [_pruneOldDecoded]) until a pass + /// actually scores it — so a failed second pass, or the app being killed + /// between passes, leaves it for the next run instead of losing it. + static const String kQuietRepassKeyPrefix = 'quiet_repass:'; + + /// Test seam: awaited at the start of [_quietLevelRepass] (with null) and + /// before each day it re-derives. A throw simulates the pass failing — or, + /// at the start, the process dying before it ran. Null in production. + @visibleForTesting + static Future Function(String? day)? debugQuietRepassHook; + + /// Test seam: awaited just before a day's offloaded second half. A throw + /// lands exactly where a second-half timeout does. Null in production. + @visibleForTesting + static Future Function(String day)? debugSecondHalfHook; + + /// Of the days still owed a strain score ([kQuietRepassKeyPrefix]), the ones + /// that have a personal quiet level now — the days [_quietLevelRepass] + /// re-derives. + /// + /// A sweep prices every day on ONE frozen history snapshot. On a fresh + /// install whose first sync brings a week, that snapshot holds no `quiet_hrr` + /// at all, so every day abstains on strain — while each writes its own level + /// for the days after it. A day whose level merely MOVED is not here: that + /// is ordinary baseline drift, refreshed by [rescanRecent] like every other. + @visibleForTesting + static List quietLevelRepassDays( + Iterable owedDays, { + required List Function(String day) after, + }) => [ + for (final day in owedDays) + if (ana.personalQuietWakingHrr(after(day)).present) day, + ]; + + /// Whether [bundle] (day [date], about to be persisted) MAY be owed a strain + /// re-score — deliberately an OVER-approximation, with no counting. + /// + /// Owed when strain abstained ON THE QUIET LEVEL (the `need_baseline` rung, + /// last in the gate order, so no other input was missing) and ANY earlier + /// day could have changed that: one whose raw is not finalized yet (a sweep + /// derives newest-first and concurrently, so it may simply not be done) or + /// one with a stored level (which may have landed after this day's + /// snapshot). Deciding it exactly — "will the prior days reach three?" — + /// meant counting across reads that concurrent day commits interleave with, + /// and a miscount there finalized and pruned a day still owed a score. + /// + /// Race-free as ordered: raw days are never pruned mid-sweep and levels are + /// only ever added, and a day's level lands in the SAME transaction as its + /// row (`putDayResult`). So an earlier day either still reads as unfinalized + /// raw below, or its level is already visible to the read after it. + /// + /// Over-marking costs a held raw day (under the [_maxRawHoldDays] floor) and + /// a cheap check per run — never a number. + Future _quietRepassOwed(String date, Map bundle) async { + if (!quietBlocked(bundle)) return false; + final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); + final rawDays = (await LocalDb.decodedRecTsMaxByDay()).keys; + if (rawDays.any((d) => d.compareTo(date) < 0 && !finalized.contains(d))) { + return true; + } + await debugQuietBetweenReads?.call(); + return (await LocalDb.trailingSeriesValues('quiet_hrr', 1, before: date)) + .isNotEmpty; + } + + /// PROOF that no pass can ever give [date] a quiet level from the data in + /// hand — the one thing besides a scored derive that may clear a marker. + /// Without it a day that can never reach [ana.quietHrrMinDays] (a cold + /// start's days 2–3, or a user whose days are all too short to yield a + /// level) stays owed until its raw ages out, and every later day is marked + /// in turn because the one before it is still unfinalized. + /// + /// Counts, so it orders its reads where a concurrent commit cannot slip + /// through: the DERIVED set first, then the levels. An earlier day that + /// commits after the first read is counted as still pending; one that + /// committed before it has its level (written in the same transaction as + /// its row) visible to the second. Partial rows are not "derived" — they + /// never wrote their series — so they count as pending too. + Future _quietLevelUnreachable(String date) async { + final derived = await LocalDb.dayResultIds(kAlgoVersion); + await debugQuietBetweenReads?.call(); + final prior = await LocalDb.trailingSeriesValues( + 'quiet_hrr', ana.quietHrrWindowDays, before: date); + final valid = prior + .where((q) => q.isFinite && q > 0 && q <= ana.maxQuietHrr) + .length; + if (valid >= ana.quietHrrMinDays) return false; + final pending = [ + for (final d in (await LocalDb.decodedRecTsMaxByDay()).keys) + if (d.compareTo(date) < 0 && !derived.contains(d)) d, + ].length; + return valid + pending < ana.quietHrrMinDays; + } + + /// Test seam onto [_quietLevelUnreachable]. + @visibleForTesting + Future debugQuietLevelUnreachable(String date) => + _quietLevelUnreachable(date); + + /// Strain abstained for want of a quiet-waking level and nothing else. + @visibleForTesting + static bool quietBlocked(Map bundle) { + if ((bundle['scalars'] as Map?)?['strain'] != null) return false; + final note = (bundle['absent_notes'] as Map?)?['strain']; + return note is String && note.startsWith('need_baseline:'); + } + + /// Test seam: awaited between the two reads of [_quietRepassOwed] and of + /// [_quietLevelUnreachable], so a test can commit a concurrent earlier day + /// exactly there. + @visibleForTesting + static Future Function()? debugQuietBetweenReads; + + /// Test seam onto [_quietRepassOwed]. + @visibleForTesting + Future debugQuietRepassOwed( + String date, Map bundle) => + _quietRepassOwed(date, bundle); + + /// Days still owed a strain re-score, from their durable markers. + static Future> _quietRepassOwedDays() async => { + for (final k in await LocalDb.computeFreshnessKeys(kQuietRepassKeyPrefix)) + k.substring(kQuietRepassKeyPrefix.length), + }; + + /// The second pass behind [quietLevelRepassDays]: reload the history once + /// and re-derive the owed days that have a level now, raw still on disk. One + /// pass converges — a day's own `quiet_hrr` reads no history, so the + /// reloaded snapshot holds every level the first pass produced. A day it + /// scores clears its own marker ([_derivePreparedDay]); one it fails keeps + /// it, stays unfinalized with its raw held, and is retried next run. + /// [rawDays] (every day with substrate left) retires markers for days whose + /// raw is gone, and so does a night the prune already cut into (the + /// [_maxRawHoldDays] floor can): re-deriving it would replace a full-night + /// result with a truncated one, exactly as [rescanDayIds] refuses to. Either + /// way the day's raw has aged out and its abstention is final. + /// + /// Held, like [rescanRecent], while a timezone change is suspected: these + /// days are bucketed in the CURRENT zone, so re-deriving them would relabel + /// and overwrite the nights that hold protects. They stay owed (unfinalized, + /// raw held) until "Re-analyze data" clears it. + /// + /// [rawDays] defaults to every day with substrate, read only when a marker + /// exists. Returns how many days it re-derived, so the caller's cross-day + /// refresh runs for them. + Future _quietLevelRepass( + Profile profile, + int dataNowSec, [ + Iterable? rawDays, + ]) async { + var done = 0; + try { + final owed = await _quietRepassOwedDays(); + if (owed.isEmpty) return 0; + if (await _timezoneTravelSuspected()) { + _log('quiet repass: possible timezone change — held'); + return 0; + } + final prunedBeforeSec = await LocalDb.getCursorInt(_prunedBeforeCursor); + final withRaw = { + for (final d in rawDays ?? (await LocalDb.decodedRecTsMaxByDay()).keys) + if (!windowTruncatedByPrune(d, prunedBeforeSec)) d, + }; + for (final d in owed.difference(withRaw)) { + await LocalDb.deleteComputeFreshness('$kQuietRepassKeyPrefix$d'); + } + await debugQuietRepassHook?.call(null); + final after = await _BaselineHistoryCache.load(); + final days = quietLevelRepassDays( + owed.intersection(withRaw), + after: (d) => after.valuesBefore('quiet_hrr', d), + )..sort((a, b) => b.compareTo(a)); // newest first, like the sweep + if (days.isEmpty) return 0; + _log('derive: re-scoring ${days.length} day(s) owed a quiet level'); + await runWithConcurrency(days, _deriveConcurrency, (dayId) async { + try { + await debugQuietRepassHook?.call(dayId); + final prepared = await _prepareTargetDay(dayId); + if (prepared != null && !prepared.daySub.isEmpty) { + await _derivePreparedDay(prepared, profile, dataNowSec, after); + done++; + } + } catch (e) { + _log('quiet repass day $dayId FAILED (kept owed, raw held): $e'); + } + }); + } catch (e) { + _log('quiet repass failed (days kept owed, raw held): $e'); + } + return done; + } + // ── baseline-dirty recent rescan ───────────────────────────────────────────── /// Re-derive the recent (≤ raw-retention) window — INCLUDING finalized days — @@ -4863,6 +5153,7 @@ class DerivationEngine { stepSpans: stepSpans, dynFloorG: dynFloorG, dynHistoryDays: dynHistory.length, + quietHrrHistory: history.valuesBefore('quiet_hrr', day.date), savedSessions: savedSessions, wristOffSpans: wristOffSpans, chargingSpans: chargingSpans, @@ -4885,6 +5176,7 @@ class DerivationEngine { tonightSleepOnsetSec: day.tonightSleepOnsetSec, tonightSleepOffsetSec: day.tonightSleepOffsetSec, ); + await debugSecondHalfHook?.call(day.date); final blocks = await _runDayBlocksCancellable(blocksInput, _perDayTimeout); @@ -5042,6 +5334,34 @@ class DerivationEngine { } } + // OWED A STRAIN SCORE ([kQuietRepassKeyPrefix]). Marked conservatively + // ([_quietRepassOwed]), BEFORE the row, so no interruption can leave an + // unmarked, prunable day still waiting on a score. CLEARED ONLY ON PROOF + // (after the row, below): a derive that completed BOTH halves — no + // timeout, no carried-forward previous row — and is either no longer + // blocked on the quiet level or provably never can be + // ([_quietLevelUnreachable]). A second half that failed leaves `trimp_net`/ + // `quiet_hrr` unwritten even when the pipeline's strain came back, so it + // proves nothing. While marked the day is never locked, by age or by a + // carried-forward row. An import force-finalizes regardless: it has no + // raw to re-score from, and is never marked. + final quietKey = '$kQuietRepassKeyPrefix${day.date}'; + final quietSettled = !forceFinalize && + secondHalfOk && + (!quietBlocked(bundle) || await _quietLevelUnreachable(day.date)); + var quietMarked = false; + if (!forceFinalize) { + if (!quietSettled && + (await LocalDb.computeFreshness(quietKey) != null || + await _quietRepassOwed(day.date, bundle))) { + quietMarked = true; + } + if (quietMarked) { + await LocalDb.putComputeFreshness(quietKey, '{}'); + effectiveFinalized = false; + } + } + final scalars = (bundle['scalars'] as Map?)?.cast() ?? const {}; double? sc(String k) => (scalars[k] as num?)?.toDouble(); @@ -5103,6 +5423,14 @@ class DerivationEngine { // previous version's daytime-RHR strain left behind, not keep it. 'strain': sc('strain'), 'trimp': sc('trimp'), + // This day's own quiet-waking level: the series LATER days' strain is + // priced against (median of the trailing 28, strictly before). Never + // written for an imported day — imports carry no 1 Hz wake series. + 'quiet_hrr': sc('quiet_hrr'), + // Banister TRIMP earned ABOVE the quiet level, exercise unoffset — the + // per-day load a CTL/ATL training-load read takes, where gross `trimp` + // would count being awake as training. + 'trimp_net': sc('trimp_net'), // `strain_effort`, `spo2` and `odi_per_hour` used to be listed here. // Nothing in the tree ever produced them (12 rows, 0 values per key on // a real install), so they were three permanently-null series with a @@ -5171,13 +5499,15 @@ class DerivationEngine { 'hr_ceiling_bpm': sc('hr_ceiling_bpm'), }, ); + // AFTER the row: retired only once the row that proves it is durable. + if (quietSettled) await LocalDb.deleteComputeFreshness(quietKey); // NOTE: the sweep's `history` snapshot is deliberately NOT updated here. // See _BaselineHistoryCache — mutating the shared snapshot mid-sweep is the // duplicate-day pollution bug, and each day already derives its own // date-bounded window from the frozen snapshot. _log( 'derived ${day.date} v$kAlgoVersion ' - '(sleep=${day.sleepOffsetSec > day.sleepOnsetSec}, final=$finalized)', + '(sleep=${day.sleepOffsetSec > day.sleepOnsetSec}, final=$effectiveFinalized)', ); await _maybeFreezeHeadlineReadiness(day, dataNowSec, sc('readiness')); // Learn the step-counter calibration from finalized days only (a partial @@ -5574,6 +5904,10 @@ class DerivationEngine { // not a window (see [maxBefore]), and strictly before today so a day is // never banded on a ceiling its own session set. m['observed_hr_ceiling_bpm'] = history.maxBefore('hr_ceiling_bpm', date); + // The quiet-waking levels strain is priced against — the SAME window the + // second half's recompute gets (`_DayBlocksInput.quietHrrHistory`), so the + // two cannot score the day on different levels. + m['quiet_hrr_history'] = history.valuesBefore('quiet_hrr', date); return m; } @@ -6442,6 +6776,10 @@ class DerivationEngine { /// Onset of tonight's sleep when it began before midnight, 0 = none. See /// `PreparedDerivationDay.tonightSleepOnsetSec`. int tonightSleepOnsetSec = 0, + /// Prior days' `quiet_hrr`, strictly before this one (see + /// [_BaselineHistoryCache.valuesBefore]). Fewer than three valid values + /// and strain abstains. + List quietHrrHistory = const [], }) { final wake = _buildWakeDayFeatures( daySub, @@ -6455,6 +6793,7 @@ class DerivationEngine { restingHr: restingHr, dynFloorG: dynFloorG, stepSpans: stepSpans, + quietHrrHistory: quietHrrHistory, ); // ACTIVE ENERGY WORKOUT-GAP CREDIT. `wake['calories']` above is built // ENTIRELY from `daySub.hr` — the day's own continuous 1 Hz trace — and @@ -6545,6 +6884,13 @@ class DerivationEngine { // a daytime "resting" HR, and `if (strain != null)` kept exactly those. final strain = (wake['strain'] as num?)?.toDouble(); scMap?['strain'] = strain; + // Same rule for the two figures that ride with it: a re-derive that now + // abstains must erase what the last one wrote. `quiet_hrr` is this day's + // own quiet level — it feeds LATER days' strain baselines, never this + // day's — and `trimp_net` is the load earned above it, the per-day input + // a training-load (CTL/ATL) read wants instead of gross TRIMP. + scMap?['quiet_hrr'] = (wake['quiet_hrr'] as num?)?.toDouble(); + scMap?['trimp_net'] = (wake['trimp_net'] as num?)?.toDouble(); final calories = (wake['calories'] as num?)?.toDouble(); if (calories != null) scMap?['calories'] = calories; final steps = (wake['steps'] as num?)?.toDouble(); @@ -7062,6 +7408,7 @@ class DerivationEngine { double? dynFloorG, List> stepSpans = const [], int tonightSleepOnsetSec = 0, + List quietHrrHistory = const [], }) { final activeMin = _activeMinutes(daySub, sleepOnsetSec, sleepOffsetSec); final wear = _wearBlock( @@ -7102,7 +7449,22 @@ class DerivationEngine { // say which strap measured this day. final hrMax = estimatedMaxHr(profile.ageYears, daySub.deviceFamily); final rhrForTrimp = restingHr ?? profile.restingHrManual?.toDouble(); + // THIS user's quiet-waking level, from PRIOR days only — the same list the + // pure pipeline got, through the same analytics call, so both halves price + // the day identically. + final quiet = ana.personalQuietWakingHrr(quietHrrHistory); + // TODAY's own quiet level — persisted for LATER days' baselines only. It + // never scores today (that would subtract today's own living from itself). + // Below `quietHrrTraitMinMinutes` wake minutes the day measured that + // window, not ordinary living, and contributes nothing. + final quietToday = (rhrForTrimp == null || hrMax == null) + ? null + : ana.dailyQuietWakingHrr(perMin, + restingHr: rhrForTrimp, + maxHr: hrMax, + minMinutes: ana.quietHrrTraitMinMinutes); double? strain; + ana.NetTrimp? net; // Why each absent activity figure is absent, in the order the gates below // apply. Absence is never a bare nothing here: the day carries its own // reason PER FIGURE so every caller can say what is missing instead of @@ -7129,6 +7491,8 @@ class DerivationEngine { ? needInputNote('resting_hr') : sex == null ? needInputNote('sex') + : !quiet.present + ? (quiet.note ?? kUnknownAbsenceNote) : null; // `wakeDayEnergy`'s own gates, named. It returns a bare null, so the reason // has to be reconstructed from the same inputs it reads — in its order. @@ -7165,33 +7529,32 @@ class DerivationEngine { // daytime HR when no sleep was detected: ~20 bpm high on the days it // fired, which shrinks the HR reserve and manufactures strain out of // sitting still. No resting HR of either kind now means NO STRAIN. - if (dayHrValid.isNotEmpty && rhrForTrimp != null && sex != null) { - final trimp = ana.banisterTrimp( + if (dayHrValid.isNotEmpty && + rhrForTrimp != null && + sex != null && + quiet.present) { + // Same sex normalisation the calorie path uses. This read `sex == 'f'` + // alone, so a profile stored as 'female' (which the profile screen can + // write) got female calorie coefficients and MALE TRIMP off the same + // field. Banister publishes only two constants, so `nonbinary` has + // nowhere else to go here. + final sexEnum = + _workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male; + // Banister TRIMP ABOVE this user's quiet level over `perMin`, the wake + // window. Exercise minutes (≥ 40 % HRR) are never offset by quiet ones, + // so a quiet afternoon cannot cancel a morning run. + net = ana.netTrimpAboveQuiet( perMin, restingHr: rhrForTrimp, maxHr: hrMax, - // Same sex normalisation the calorie path uses. This read `sex == 'f'` - // alone, so a profile stored as 'female' (which the profile screen can - // write) got female calorie coefficients and MALE TRIMP off the same - // field. Banister publishes only two constants, so `nonbinary` has - // nowhere else to go here. - sex: _workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male, + quietHrr: quiet.value!.hrr, + sex: sexEnum, ); - if (trimp.present && trimp.value != null) { - // `perMin` IS the wake window the TRIMP was accumulated over, so it - // sets the quiet-waking baseline that gets subtracted. Passing the - // observed length (not an assumed 24 h) is what stops a partial-wear - // day from being charged a full day's overhead. - final score = ana.strainScoreMetric( - trimp.value, - wakeMinutes: perMin.length.toDouble(), - // Reference level, not this user's — see onehz_pipeline's - // `strainMetric` for why, and edge#226 for the fix. - quietHrr: ana.quietWakingHrr, - female: _workoutSex(sex) == 'female', - ); - if (score.present) strain = score.value; - } + // The same map `strainScoreFromSeries` applies to the same NetTrimp — + // one pass, and the persisted `trimp_net` and `strain` cannot come + // from two different accumulations. (The calibrating confidence lives + // on the pipeline's envelope; this half writes a bare scalar.) + if (net != null) strain = ana.strainFromNetTrimp(net.net); // Every named input was there and the scorer still abstained. We do not // know why; saying so is the honest floor, and it is what the rule // "never a guessed cause" leaves when there is no cause to name. @@ -7276,6 +7639,8 @@ class DerivationEngine { 'active_min': activeMin, 'movement_min': movementMin, 'strain': strain, + 'quiet_hrr': quietToday, + 'trimp_net': net?.net, // Machine-readable reason `strain` is null (see `strainAbsent`). Null // when a strain WAS produced. 'strain_absent': strain == null ? strainAbsent : null, @@ -8588,6 +8953,7 @@ class DerivationEngine { stepSpans: inp.stepSpans, sessions: inp.savedSessions, tonightSleepOnsetSec: inp.tonightSleepOnsetSec, + quietHrrHistory: inp.quietHrrHistory, ); bundlePatch['daytime_hrv'] = _daytimeHrv(daySub, onset, offset, @@ -9327,6 +9693,10 @@ class _DayBlocksInput { /// How many trailing days backed [dynFloorG] — only for the cold-start note. final int dynHistoryDays; + /// Prior days' `quiet_hrr` (strictly before [date]) — the same window the + /// pure pipeline was handed, so both halves price strain on one level. + final List quietHrrHistory; + final List> savedSessions; /// The user's nap edits for this day, replayed over the detector's output. @@ -9379,6 +9749,7 @@ class _DayBlocksInput { this.stepSpans = const [], required this.dynFloorG, required this.dynHistoryDays, + this.quietHrrHistory = const [], required this.savedSessions, this.napEdits = const [], required this.wristOffSpans, diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index 4053aa997..5dabed301 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -251,33 +251,39 @@ List zoneMinutesFor( /// ceiling is a property of the strap that measured the window as well as the /// athlete's age (`estimatedMaxHr`), and this scorer is device-agnostic. Null /// — no age, or an uncalibrated/unstamped strap — abstains. +/// +/// [quietHrr] is THIS user's quiet-waking level for the day the window sits in +/// (`personalQuietLevelBefore`) — the level that day's own strain was priced +/// on, so a bout and its day are on one baseline. Null abstains: the 0.20 +/// population reference billed being awake as load for some users and erased +/// real workouts for others. [quietSettled] false only lowers confidence +/// inside analytics; the value is the same. double? strainFromPerMinuteHr( List perMinuteHr, { required Profile profile, required double? restingHr, required double? hrMax, + required double? quietHrr, + bool quietSettled = true, }) { final sex = profile.sex?.toLowerCase(); - if (perMinuteHr.isEmpty || hrMax == null || restingHr == null || sex == null) { + if (perMinuteHr.isEmpty || + hrMax == null || + restingHr == null || + sex == null || + quietHrr == null) { return null; } - final trimp = ana.banisterTrimp( + // Banister TRIMP above the quiet level over the window's own minutes, with + // exercise minutes never offset by quiet ones — the same analytics call the + // day headline makes, so "workout strain" and "daily strain" are one method. + final score = ana.strainScoreFromSeries( perMinuteHr, restingHr: restingHr, maxHr: hrMax, + quietHrr: quietHrr, sex: workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male, - ); - if (!trimp.present || trimp.value == null) return null; - // The window's own length is the baseline window: strain is the load earned - // ABOVE quiet waking, and the same sex constant has to price the baseline as - // priced the TRIMP or the subtraction is off by the male/female coefficient. - final score = ana.strainScoreMetric( - trimp.value, - wakeMinutes: perMinuteHr.length.toDouble(), - // Reference level, not this user's — see onehz_pipeline's - // `strainMetric` for why, and edge#226 for the fix. - quietHrr: ana.quietWakingHrr, - female: workoutSex(sex) == 'female', + quietSettled: quietSettled, ); return score.present ? score.value : null; } @@ -289,8 +295,8 @@ double? strainFromPerMinuteHr( /// [restingHr] is the nightly/user RHR; [profile] supplies age/weight/sex. /// /// Uses the SAME published methods as the day-level derivation so a manual -/// session and the day it sits in are on one scale: `ana.banisterTrimp` -> -/// `ana.strainScoreMetric` for strain, `ana.Calories.estimateBoutCalories` +/// session and the day it sits in are on one scale: `ana.strainScoreFromSeries` +/// against [quietHrr] (the session day's level) for strain, `ana.Calories.estimateBoutCalories` /// (Keytel 2005) for kcal. A missing anchor makes the dependent metric null. /// /// [hrMax] is THE ceiling for this window — `estimatedMaxHr(age, family)`, @@ -308,6 +314,8 @@ ManualSessionStats computeManualSessionStats({ required double? hrMax, double? restingHr, ana.HeartRateZoneSet? zoneSet, + double? quietHrr, + bool quietSettled = true, }) { if (hrTs.isEmpty || hrTs.length != hrBpm.length) { return const ManualSessionStats(); @@ -344,7 +352,11 @@ ManualSessionStats computeManualSessionStats({ final sex = profile.sex?.toLowerCase(); final strain = strainFromPerMinuteHr(perMin, - profile: profile, restingHr: restingHr, hrMax: hrMax); + profile: profile, + restingHr: restingHr, + hrMax: hrMax, + quietHrr: quietHrr, + quietSettled: quietSettled); double? calories; if (profile.hasCalorieAnchors && diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 628de95c7..4fdceceb6 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -280,11 +280,12 @@ class DayBundleInput { final List rhrHistory; final List respHistory; - /// Trailing robust nocturnal RMSSD means (ms) — the SAME `rmssd` series the - /// engine writes to metric_series (NREM-restricted, median-of-5-min). Used as - /// the history for the EWMA hrv baseline so its center and today's value are the - /// SAME metric (was previously reconstructed from ln(whole-window RMSSD), a - /// definition mismatch that made the z spuriously large). + /// Trailing nightly RMSSD (ms) — the SAME `rmssd` series the engine writes to + /// metric_series (the sleep-session mean of 5-min-window RMSSDs; absent when + /// that estimator abstains). Used as the history for the EWMA hrv baseline so + /// its center and today's value are the SAME metric (was previously + /// reconstructed from ln(whole-window RMSSD), a definition mismatch that made + /// the z spuriously large). final List rmssdHistory; /// Trailing RAW nightly skin-temp ADC means (NOT z-scores). The personal @@ -293,6 +294,12 @@ class DayBundleInput { /// matches today's raw mean (the old z-vs-z series was a unit mismatch bug). final List skinTempAdcHistory; + /// Trailing per-day quiet-waking levels (`quiet_hrr`, %HRR fraction), + /// strictly before this day, oldest→newest. Strain is priced against their + /// median ([personalQuietWakingHrr]) — never against this day's own level, + /// which would subtract the day's own living from itself. + final List quietHrrHistory; + /// TS-03 — the highest heart rate the band has OBSERVED (held >=15 s with /// corroborating motion, `observed_max_hr.dart`) on any day STRICTLY BEFORE /// this one, or null when there is none. Not a physiological HRmax: if the @@ -350,6 +357,7 @@ class DayBundleInput { this.respHistory = const [], this.rmssdHistory = const [], this.skinTempAdcHistory = const [], + this.quietHrrHistory = const [], this.observedHrCeilingBpm, this.dayConfidence = 0, this.dayFlags = const [], @@ -381,6 +389,7 @@ class DayBundleInput { 'resp_history': respHistory, 'rmssd_history': rmssdHistory, 'skin_temp_adc_history': skinTempAdcHistory, + 'quiet_hrr_history': quietHrrHistory, 'observed_hr_ceiling_bpm': observedHrCeilingBpm, 'day_confidence': dayConfidence, 'day_flags': dayFlags, @@ -431,6 +440,7 @@ class DayBundleInput { respHistory: dbls('resp_history'), rmssdHistory: dbls('rmssd_history'), skinTempAdcHistory: dbls('skin_temp_adc_history'), + quietHrrHistory: dbls('quiet_hrr_history'), observedHrCeilingBpm: (m['observed_hr_ceiling_bpm'] as num?)?.toDouble(), dayConfidence: (m['day_confidence'] as num?)?.toDouble() ?? 0, dayFlags: strs('day_flags'), @@ -485,6 +495,10 @@ Map deriveDayBundle(Map inputJson) { final nn = corrected.nn; final nnTimes = corrected.nnTimesMs; final artifactFraction = (1.0 - corrected.cleanFraction).clamp(0.0, 1.0); + // Σ RR ÷ wall span of the raw sleep RR: above 1.10 the stream holds more + // beat-time than elapsed (duplicated or interleaved beats), and every RMSSD + // below refuses it. Null under 10 min of span. + final rrCov = rrCoverage(d.sleepRrMs, d.sleepRrTsMs); final hasSleep = (d.sleepJson['tst_sec']) != null; @@ -515,20 +529,21 @@ Map deriveDayBundle(Map inputJson) { // Whole-window time-domain HRV is kept for SDNN / detail rows only. The // nightly headline HRV is the mean of 5-min cleaned-window RMSSDs across the // detected sleep session, not one RMSSD over the whole night's NN stream. - final hrvT = hrvTime(nn, nnTimesMs: nnTimes); + final hrvT = hrvTime(nn, nnTimesMs: nnTimes, coverage: rrCov); // Keep the robust estimator as a secondary detail only; the canonical nightly // RMSSD follows the sleep-session windowed formulation. final nremMask = _nremMaskAlignedToNn(d, nnTimes, d.sleepRrTsMs); - final robustRmssd = nocturnalRmssd(nn, nnTimes, stageMaskPerSec: nremMask); - final sleepSessionRmssdMetric = sleepSessionWindowedRmssd( + final robustRmssd = nocturnalRmssd(nn, nnTimes, + stageMaskPerSec: nremMask, coverage: rrCov); + final sleepSessionRmssdMetric = sleepSessionRmssdDetail( d.sleepRrMs, d.sleepRrTsMs, startSec: d.sleepOnsetSec, endSec: d.sleepOffsetSec, ); - final sleepSessionRmssd = sleepSessionRmssdMetric.present - ? sleepSessionRmssdMetric.value - : null; + final sessionDetail = sleepSessionRmssdMetric.value; + final sleepSessionRmssd = + sleepSessionRmssdMetric.present ? sessionDetail!.rmssd : null; final hrvF = nn.length >= 20 ? hrvFreq(nn, nnTimes, artifactFraction: artifactFraction) : Metric.absent( @@ -823,6 +838,11 @@ Map deriveDayBundle(Map inputJson) { // TRIMP, HR zones, and calories so all three see the same wake series). final wakeHr = _perMinuteWakeSeries(d); final perMin = [for (final p in wakeHr) p.hr]; + // THIS user's quiet-waking level: the median of the trailing days' own + // levels, strictly before today. Absent below three days — strain then + // abstains with the baseline grammar rather than pricing being awake at a + // population constant. + final quiet = personalQuietWakingHrr(d.quietHrrHistory); // ── WHY the activity family is absent, named AT THE GATE THAT CAUSED IT ──── // // These four figures — strain/TRIMP, the zone minutes, the ceiling they were @@ -857,6 +877,8 @@ Map deriveDayBundle(Map inputJson) { ? needInputNote('resting_hr') : sex == null ? needInputNote('sex') + : !quiet.present + ? (quiet.note ?? kUnknownAbsenceNote) : null; final caloriesAbsentNote = perMin.isEmpty ? needInputNote('wake_hr') @@ -950,26 +972,25 @@ Map deriveDayBundle(Map inputJson) { } } - // HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE the quiet-waking - // baseline; raw TRIMP kept as a detail. `perMin` is the wake window the TRIMP - // was accumulated over, so it sets the baseline that gets subtracted. - final rawTrimp = trimp.present ? trimp.value : null; - final strainMetric = strainScoreMetric( - rawTrimp, - wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(), - // THE REFERENCE LEVEL, NOT THIS USER'S (edge#226 is still open). analytics - // stopped defaulting the quiet-waking level so every caller has to state - // which one it means; `quietWakingHrr` is the constant the anchor table was - // generated at, so passing it reproduces the strain this app ships today - // and nobody's number moves on this commit. The real level is - // `dailyQuietWakingHrr` fed through a rolling personal median — a trait, - // not a day, and the workout scorers need the same one the day uses or a - // bout subtracts its own effort away. That plumbing is edge#226. - // ponytail: population constant, swap for the rolling personal median when - // edge#226 lands — see the same comment at the other four call sites. - quietHrr: quietWakingHrr, - female: workoutSex(sex) == 'female', - ); + // HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE this user's + // quiet-waking level, exercise minutes (≥ 40 % HRR) never offset by quiet + // ones; raw TRIMP kept as a detail. Same analytics arithmetic as the curve + // below and the engine's recompute, so the three cannot disagree. + final sexEnum = workoutSex(sex) == 'female' ? Sex.female : Sex.male; + final strainMetric = strainAbsentNote != null || !quiet.present + ? Metric.absent( + tier: Tier.estimate, + inputs_used: const ['hr_1hz', 'profile', 'quiet_waking_hrr_history'], + note: strainAbsentNote ?? kUnknownAbsenceNote, + ) + : strainScoreFromSeries( + perMin, + restingHr: rhrForTrimp, + maxHr: hrMax, + quietHrr: quiet.value!.hrr, + sex: sexEnum, + quietSettled: quiet.value!.settled, + ); // ── curve series for the UI ──────────────────────────────────────────────── final hrCurve = _downsampleHr(d.dayTsSec, d.dayHr); @@ -982,7 +1003,10 @@ Map deriveDayBundle(Map inputJson) { final hrvOriginMs = (d.sleepRrTsMs.isEmpty || d.sleepRrMs.isEmpty) ? null : d.sleepRrTsMs.first - d.sleepRrMs.first; - final hrvTimeline = _hrvTimeline(nn, nnTimes, hrvOriginMs); + // A rolling RMSSD of the same beats: none on an over-counted stream. + final hrvTimeline = rrCov?.overCounted == true + ? const >[] + : _hrvTimeline(nn, nnTimes, hrvOriginMs); // CV-06 — the SHAPE of the night: per-bin RMSSD over the same cleaned NN the // headline uses, so the curve and the number can never disagree. Bins that // fall under the beat floor stay in the series as HOLES on purpose — dropping @@ -996,12 +1020,15 @@ Map deriveDayBundle(Map inputJson) { // `startSec` is seconds from the FIRST BEAT, not an epoch — `origin_ms` is // the wall-clock instant that clock starts at, the same `hrvOriginMs` the // timeline above is placed on. - final nightShape = nightHrvShape(nn, nnTimes); + // Every bin is an RMSSD of the same beats: absent with them on an + // over-counted stream. + final nightShape = nightHrvShape(nn, nnTimes, coverage: rrCov); final strainCurve = _strainCurve( wakeHr, restingHr: rhrForTrimp, maxHr: hrMax, sex: sex, + quietHrr: quiet.value?.hrr, ); final zoneTimeline = zoneSet == null ? const >[] @@ -1064,6 +1091,20 @@ Map deriveDayBundle(Map inputJson) { 'tier': Tier.high, 'inputs_used': const ['rr_sleep_window'], 'note': sleepSessionRmssdMetric.note, + // Diagnostics of the same estimate; null when it is absent (the + // whole-night `hrv_time` envelope still carries diff_acf1). + 'windows': sessionDetail?.windows, + 'diff_acf1': sessionDetail?.diffAcf1 == null + ? null + : _round(sessionDetail!.diffAcf1!, 4), + 'rr_coverage': sessionDetail?.rrCoverage == null + ? null + : _round(sessionDetail!.rrCoverage!, 4), + 'overcounted_windows': sessionDetail?.overCountedWindows, + // Windows under the floor of clean successive differences, and that + // floor: how much of the night the mean does NOT rest on. + 'thin_windows': sessionDetail?.thinWindows, + 'min_diffs_per_window': sessionDetail?.minDiffsPerWindow, }, 'rmssd_nocturnal': robustRmssd.toJson(), 'hrv_freq': hrvF.toJson((v) => v.toJson()), @@ -1181,16 +1222,14 @@ Map deriveDayBundle(Map inputJson) { }; // Indexed scalars (also surfaced to metric_series by the engine). - // HEADLINE RMSSD = mean of 5-min cleaned-window RMSSDs across the detected - // sleep session. Fall back to the robust estimator, then the whole-window - // RMSSD only when the canonical sleep-session value is absent. - final rmssdScalar = - sleepSessionRmssd ?? - (robustRmssd.present - ? robustRmssd.value - : ((hrvT.present && hrvT.value!.rmssd != null) - ? hrvT.value!.rmssd - : null)); + // ONE ESTIMATOR. `rmssd` is the sleep-session mean of 5-min-window RMSSDs — + // the same number `ln_rmssd` (→ readiness) is the log of — or it is absent. + // The NREM median (`clinical.rmssd_nocturnal`) and the whole-night RMSSD + // (`scalars.rmssd_whole`, `clinical.hrv_time`) stay published under their + // own keys; they are different statistics over differently-cleaned beats and + // never stand in for this one (AGENTS §3.3). The fallback used to keep the + // chart unbroken by mixing three estimators into one series. + final rmssdScalar = sleepSessionRmssd; // Whole-window RMSSD kept available as a secondary detail (NOT the headline). final rmssdWholeScalar = (hrvT.present && hrvT.value!.rmssd != null) ? hrvT.value!.rmssd @@ -1503,6 +1542,8 @@ Map deriveDayBundle(Map inputJson) { 'nn_clean': nn.length, 'clean_fraction': _round(corrected.cleanFraction, 4), 'sleep_seconds': inBedSec ?? 0, + 'rr_coverage': rrCov == null ? null : _round(rrCov.coverage, 4), + 'rr_duplicate_beats': rrCov?.duplicateBeats, }, 'readiness_absent_diag': ?readinessAbsentDiag, 'scalars': { @@ -1516,7 +1557,8 @@ Map deriveDayBundle(Map inputJson) { // every stored bundle. 'rhr': rhrToday, 'rhr_nocturnal': rhrToday, - // Headline RMSSD (robust nocturnal, NREM). Whole-window kept separately. + // Headline RMSSD (sleep-session mean of 5-min windows; no fallback). + // Whole-night RMSSD kept separately. 'rmssd': rmssdScalar, 'rmssd_whole': rmssdWholeScalar, 'readiness': readinessScalar, @@ -1802,50 +1844,25 @@ List> _strainCurve( required double? restingHr, required double? maxHr, required String? sex, + required double? quietHrr, }) { - if (wakeHr.isEmpty || - restingHr == null || - maxHr == null || - maxHr <= restingHr || - sex == null) { - return const []; - } - // Banister's sex constants, via the ONE shared weighting factor. This used to - // inline `exp(b·hrr)` and drop the 0.64/0.86 scale coefficient entirely, so - // the curve accumulated a TRIMP 1.5625× the day's own — the curve and the - // headline were never on the same scale. It matters more now: the headline - // subtracts a baseline priced with `banisterY`, so a curve accumulating - // without it would be netted against an allowance from a different formula. - final female = workoutSex(sex) == 'female'; - final reserve = maxHr - restingHr; - var trimp = 0.0; - var wakeMin = 0.0; - final out = >[]; - for (final p in wakeHr) { - var hrr = (p.hr - restingHr) / reserve; - if (hrr < 0) hrr = 0; - if (hrr > 1) hrr = 1; - trimp += hrr * StrainScorer.banisterY(hrr, female: female); - // The baseline grows with the wake window ALREADY elapsed, so the curve - // stays flat through quiet waking and climbs only on real effort — rather - // than charging a whole day's allowance against the first minute. - wakeMin += 1; - out.add({ - 't': p.tsSec, - 'v': _round( - strainScore( - trimp, - wakeMinutes: wakeMin, - // Reference level, not this user's — see onehz_pipeline's - // `strainMetric` for why, and edge#226 for the fix. - quietHrr: quietWakingHrr, - female: female, - ), - 2, - ), - }); - } - return out; + if (wakeHr.isEmpty || sex == null) return const []; + // The headline's own arithmetic, minute by minute (`strainCurveFromSeries`), + // so the last point IS the headline and exercise already banked never falls + // back out through a quiet afternoon. Null — no level, or bad anchors — + // leaves the curve empty, which the strain screen explains from the note. + final curve = strainCurveFromSeries( + [for (final p in wakeHr) p.hr], + restingHr: restingHr, + maxHr: maxHr, + quietHrr: quietHrr, + sex: workoutSex(sex) == 'female' ? Sex.female : Sex.male, + ); + if (curve == null) return const []; + return [ + for (var i = 0; i < wakeHr.length; i++) + {'t': wakeHr[i].tsSec, 'v': _round(curve[i], 2)}, + ]; } double? _mean(List xs) { diff --git a/lib/compute/quiet_level.dart b/lib/compute/quiet_level.dart new file mode 100644 index 000000000..fb6b69e4b --- /dev/null +++ b/lib/compute/quiet_level.dart @@ -0,0 +1,29 @@ +// THE personal quiet-waking level for scoring anything OUTSIDE a derive sweep +// — a saved/edited session, the on-read re-score, the live gauge, the strain +// rescale. A day's own derive reads the same window from its sweep snapshot +// (`_BaselineHistoryCache.valuesBefore('quiet_hrr', date)`); this is that read +// against the database, so a session and the day it sits in are priced on one +// level. + +import 'package:openstrap_analytics/onehz.dart' as ana; + +import '../data/db.dart'; + +/// The median of the trailing 28 measured `quiet_hrr` days strictly before +/// [dayLabel] (a LOCAL day label). Absent — never a stand-in — below three +/// days, or when the read fails. +Future> personalQuietLevelBefore( + String dayLabel, +) async { + try { + return ana.personalQuietWakingHrr( + await LocalDb.trailingSeriesValues( + 'quiet_hrr', + ana.quietHrrWindowDays, + before: dayLabel, + ), + ); + } catch (_) { + return ana.personalQuietWakingHrr(const []); // abstains, never a stand-in + } +} diff --git a/lib/compute/quiet_level_seed.dart b/lib/compute/quiet_level_seed.dart new file mode 100644 index 000000000..1584131c7 --- /dev/null +++ b/lib/compute/quiet_level_seed.dart @@ -0,0 +1,127 @@ +// ONE-SHOT SEED — `quiet_hrr` history for installs that predate it. +// +// Strain is priced against the median of the trailing days' own quiet-waking +// level (`quiet_hrr`), and abstains below three of them. A derive writes that +// series going forward, but raw 1 Hz substrate is pruned `rawRetentionDays` +// behind the data edge, so a re-derive reaches only the last few days: without +// this every existing user would start at zero prior levels and see strain go +// blank for days. +// +// It does not need raw. Every stored bundle still carries the day's per-minute +// wake HR in two halves: `series.strain_curve` has one point per WAKE minute +// (its `t` values are exactly the wake-minute keys), and `series.hr_curve` is +// the per-minute mean HR for every minute, rounded to whole bpm. Their join is +// the wake series the pipeline scored, to within ±0.5 bpm (≈0.004 HRR), so the +// seeded median is the user's own measurement, not an estimate of it. Days +// whose curve was dropped by the v63 strain rescale yield nothing and are +// skipped; the series then fills naturally. + +import 'dart:isolate'; + +import 'package:openstrap_analytics/onehz.dart' as ana; + +import '../data/db.dart'; +import '../data/series_codec.dart'; +import 'hr_max.dart'; + +/// `compute_freshness` key marking the seed as applied. +const String kQuietHrrSeedKey = 'quiet_hrr_seed_v1'; + +/// Pure: this stored day's quiet-waking HRR, or null. [payload] is a decoded +/// bundle (`SeriesCodec.decodePayloadJson`, so curves are `[{t, v}]` lists). +/// [manualRestingHr] stands in only for a missing NOCTURNAL resting HR — the +/// same pair, in the same order, the day's own TRIMP is anchored on. +/// +/// The ceiling is [estimatedMaxHr] at the CURRENT profile [ageYears], exactly +/// what the live derive prices `quiet_hrr` on — not the bundle's stored +/// `max_hr_used`, which a later birthday or profile edit leaves on another +/// scale. Null without an age, as the live derive is. +double? quietHrrFromStoredBundle( + Map payload, { + required num? ageYears, + double? manualRestingHr, +}) { + final scalars = payload['scalars'] is Map + ? payload['scalars'] as Map + : const {}; + final series = payload['series'] is Map + ? payload['series'] as Map + : const {}; + final rhr = (scalars['rhr_nocturnal'] as num?)?.toDouble() ?? manualRestingHr; + final hrMax = estimatedMaxHr(ageYears, payload['device_family'] as String?); + final wake = { + for (final p in (series['strain_curve'] as List? ?? const [])) + if (p is Map && p['t'] is num) (p['t'] as num).toInt(), + }; + final hr = [ + for (final p in (series['hr_curve'] as List? ?? const [])) + if (p is Map && + p['t'] is num && + p['v'] is num && + wake.contains((p['t'] as num).toInt())) + (p['v'] as num).toDouble(), + ]; + return ana.dailyQuietWakingHrr( + hr, + restingHr: rhr, + maxHr: hrMax, + minMinutes: ana.quietHrrTraitMinMinutes, + ); +} + +/// Seed `quiet_hrr` for the trailing window of stored days, once. +/// +/// Skips imported days (another vendor's numbers, no 1 Hz wake series), +/// skipped and partial rows (a partial day must not seed the trait, the same +/// reason `putDayResult` skips its series write), and days that already have a +/// value — a derive's own measurement is never overwritten. Decoding and the +/// median run off the UI isolate. Returns the number of days written. +/// +/// Without [ageYears] there is no ceiling to price on: nothing is seeded and +/// the seed stays armed for a pass that has one. +Future seedQuietHrrHistoryOnce({ + required num? ageYears, + double? manualRestingHr, +}) async { + if (await LocalDb.computeFreshness(kQuietHrrSeedKey) != null) return 0; + if (ageYears == null) return 0; + final imported = await LocalDb.importedDates(); + final have = { + for (final r in await LocalDb.metricSeries('quiet_hrr')) + r['date'] as String, + }; + // The window is over MEASURED days, chosen before it is applied: a long + // import (or a run of skipped/partial rows) newer than them must not fill + // the window and leave the levels that are recoverable unseeded. Every + // served day, payload-free (LIMIT -1 is SQLite's "no limit"); bundles are + // then read one day at a time. + final measured = [ + for (final r in await LocalDb.recentDayResultsMeta(-1)) + if (r['day_id'] is String && + !imported.contains(r['day_id']) && + (r['skipped'] as num?)?.toInt() != 1 && + (r['partial'] as num?)?.toInt() != 1) + r['day_id'] as String, + ].take(ana.quietHrrWindowDays + 3); + final todo = <(String, String)>[ + for (final day in measured) + if (!have.contains(day)) + if ((await LocalDb.dayResult(day))?['payload_json'] case final String j) + (day, j), + ]; + final levels = todo.isEmpty + ? const <(String, double)>[] + : await Isolate.run(() => [ + for (final (date, json) in todo) + if (SeriesCodec.decodePayloadJson(json) case final p?) + if (quietHrrFromStoredBundle(p, + ageYears: ageYears, manualRestingHr: manualRestingHr) + case final q?) + (date, q), + ]); + for (final (date, q) in levels) { + await LocalDb.putMetricSeriesValue(date, 'quiet_hrr', q); + } + await LocalDb.putComputeFreshness(kQuietHrrSeedKey, '{"done":true}'); + return levels.length; +} diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart index 8afc2a28f..c3b3e5b08 100644 --- a/lib/compute/strain_backfill.dart +++ b/lib/compute/strain_backfill.dart @@ -13,10 +13,11 @@ // logs "no substrate (raw pruned) — kept" and keeps the old row — so a // kAlgoVersion bump alone can only ever fix the last few days. // -// It does not need raw. Strain is a pure function of (TRIMP, wake minutes, -// sex); `metric_series` stores `trimp` and the stored bundle carries -// `series.strain_curve`, which is ONE POINT PER WAKE MINUTE — literally the -// series the pipeline handed the scorer. +// It does not need raw. The lump-form strain is a pure function of (TRIMP, +// wake minutes, sex, the user's quiet-waking level); `metric_series` stores +// `trimp` and `quiet_hrr`, and the stored bundle carries `series.strain_curve`, +// which is ONE POINT PER WAKE MINUTE — literally the series the pipeline +// handed the scorer. // // THE WAKE WINDOW IS READ, NOT RECONSTRUCTED. This used to derive it as // `worn_min − tst_min`, which is a different quantity in both directions: the @@ -35,6 +36,7 @@ import 'package:openstrap_analytics/onehz.dart' as ana; import '../data/day_label.dart'; import '../data/db.dart'; import 'derivation_engine.dart' show kAlgoVersion, rawRetentionDays; +import 'quiet_level.dart'; /// `compute_freshness` key marking the rescale as already applied. Bumped with /// the algo version so a future rescale is a new one-shot rather than a no-op. @@ -62,22 +64,32 @@ class StrainBackfillResult { /// Rebuild one day's headline strain from its stored TRIMP and the wake window /// the pipeline actually priced it over ([wakeMinutes] = `strain_curve.length`). /// -/// Returns null when the day cannot be rescaled — no TRIMP to rescale from, or -/// no wake window to price the baseline over. A day that cannot be rescaled is -/// LEFT ALONE: an un-rescalable day must not silently become 0, which is a -/// number, not an absence. +/// Returns null when the day cannot be rescaled — no TRIMP to rescale from, no +/// wake window to price the baseline over, or no [quietHrr] (this user's +/// quiet-waking level) to price it at. A day that cannot be rescaled is LEFT +/// ALONE: an un-rescalable day must not silently become 0, which is a number, +/// not an absence. +/// +/// THE LUMP FORM, deliberately. Everywhere else strain is scored from the +/// per-minute series with exercise minutes protected +/// (`ana.strainScoreFromSeries`); these days have only their TRIMP and the +/// wake-minute count left, so the stored-TRIMP form is the only one possible. double? rescaledStrain({ required double? trimp, required double? wakeMinutes, required bool female, + required double? quietHrr, }) { - if (trimp == null || wakeMinutes == null || wakeMinutes <= 0) return null; + if (trimp == null || + wakeMinutes == null || + wakeMinutes <= 0 || + quietHrr == null) { + return null; + } return ana.strainScore( trimp, wakeMinutes: wakeMinutes, - // Reference level, not this user's — see onehz_pipeline's - // `strainMetric` for why, and edge#226 for the fix. - quietHrr: ana.quietWakingHrr, + quietHrr: quietHrr, female: female, ); } @@ -102,6 +114,16 @@ Future backfillStrainScale({ return none; } + // This user's quiet level, resolved once. Without one there is nothing + // honest to rescale against, so return WITHOUT marking done: the one-shot + // runs again once three days of `quiet_hrr` exist. ONE level for every + // rescaled day, deliberately: these days predate any per-day level, so a + // trailing median per day would mostly be a median of nothing. A live + // derive prices each day on its own prior days; the difference is small. + final quiet = await personalQuietLevelBefore(LocalDb.localDayLabelNow()); + final quietHrr = quiet.value?.hrr; + if (quietHrr == null) return none; + final trimpBy = await _byDate('trimp'); // The DATA EDGE is the newest day on disk, matching how the pruner measures @@ -177,6 +199,7 @@ Future backfillStrainScale({ series is Map ? series['strain_curve'] : null, )?.toDouble(), female: female, + quietHrr: quietHrr, ); if (next == null) { skipped++; diff --git a/lib/data/db.dart b/lib/data/db.dart index c6ce117c3..71f7b17cd 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10484,6 +10484,25 @@ class LocalDb { return rows.isEmpty ? null : rows.first; } + /// Every `compute_freshness` key starting with [prefix] — for per-day + /// markers (``), which are one row each so concurrent + /// day derives never read-modify-write a shared list. + static Future> computeFreshnessKeys(String prefix) async { + final db = await instance; + final rows = await db.query( + 'compute_freshness', + columns: ['key'], + where: 'substr(key, 1, ?) = ?', + whereArgs: [prefix.length, prefix], + ); + return {for (final r in rows) r['key'] as String}; + } + + static Future deleteComputeFreshness(String key) async { + final db = await instance; + await db.delete('compute_freshness', where: 'key = ?', whereArgs: [key]); + } + /// Bookkeeping key for the one-time walk in [reencodeLegacyDayResults]. static const String kReencodeCursorKey = 'series_reencode'; diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index b9ddd57ef..2ee2eaef8 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -38,6 +38,14 @@ class RepositoryException implements Exception { String toString() => 'Repository error $status: $body'; } +/// Whether a stored `rmssd` came from the sleep-session estimator: its +/// envelope ([sessionEnvelope] = `clinical.rmssd_sleep_session`) holds a value. +/// A bundle derived before `rmssd` became that single estimator can hold an +/// earlier fallback (NREM median or whole-night) beside an absent envelope. +/// The one rule Today's confidence and Investigate's label both read. +bool rmssdFromSessionEstimator(Object? sessionEnvelope) => + sessionEnvelope is Map && sessionEnvelope['value'] is num; + /// The local data contract for every insights screen + the AI Coach. /// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). abstract class LocalRepository { diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index d2bb036b2..ca7dcff26 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -24,6 +24,7 @@ import '../compute/hr_max.dart'; import '../compute/manual_session.dart'; import '../compute/onehz_pipeline.dart' show kUnknownAbsenceNote, needInputNote; import '../compute/profile.dart'; +import '../compute/quiet_level.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; import 'package:openstrap_analytics/onehz.dart' as ana; @@ -374,9 +375,6 @@ class LocalRepositoryImpl extends LocalRepository { : (_sub(sleepBundle, 'clinical') ?? const {}); final cd = await _crossDay(); - final hrvTime = clinical['hrv_time'] is Map - ? (clinical['hrv_time'] as Map).cast() - : null; final rhrEnv = clinical['resting_hr'] is Map ? (clinical['resting_hr'] as Map).cast() : null; @@ -513,7 +511,7 @@ class LocalRepositoryImpl extends LocalRepository { 'baseline': (await _seriesMean('rmssd', before: (sleepBundle?['date'] as String?) ?? todayDay)) ?.round(), - 'confidence': (hrvTime?['confidence'] as num?) ?? 0.5, + 'confidence': hrvConfidenceForToday(clinical), }; return { @@ -871,6 +869,10 @@ class LocalRepositoryImpl extends LocalRepository { 'sdnn': _scalar(b, 'sdnn'), 'ln_rmssd': _scalar(b, 'ln_rmssd'), 'baseline': await _seriesMean('rmssd', before: served), + // Which estimator `rmssd` came from: the sleep-session mean when this + // envelope holds a value; on a bundle derived before `rmssd` became that + // single estimator, an earlier fallback stands beside an absent one. + 'rmssd_sleep_session': _sub(b, 'clinical.rmssd_sleep_session'), 'hrv_time': _sub(b, 'clinical.hrv_time'), 'hrv_freq': _sub(b, 'clinical.hrv_freq'), 'prsa_dc': _sub(b, 'clinical.prsa_dc'), @@ -2910,6 +2912,8 @@ class LocalRepositoryImpl extends LocalRepository { // row, every import, every raw replay) — is a refusal, not gen4 by default. final deviceFamily = (existing?['device_family'] as String?) ?? await _windowDeviceFamily(startTs, endTs); + final quiet = await personalQuietLevelBefore( + dayLabelOf(DateTime.fromMillisecondsSinceEpoch(startTs * 1000))); final stats = computeManualSessionStats( hrTs: hrTs, @@ -2922,6 +2926,10 @@ class LocalRepositoryImpl extends LocalRepository { // calorie anchor; the two are named separately because they can now be // different ceilings. zoneSet: _zoneSetFor(deviceFamily, await _zoneAnchors()), + // The quiet level of the session's OWN local day (its start), so the + // bout is priced on the baseline that day's strain was. + quietHrr: quiet.value?.hrr, + quietSettled: quiet.value?.settled ?? true, ); final row = buildManualSessionRow( @@ -3030,6 +3038,9 @@ class LocalRepositoryImpl extends LocalRepository { _profileMaxHr(row['device_family'] as String?)?.toDouble(); final restingHrForSession = await _recentRestingHr() ?? profile.restingHrManual?.toDouble(); + // Same rule as the save path: the session's own local day's level. + final quiet = await personalQuietLevelBefore( + dayLabelOf(DateTime.fromMillisecondsSinceEpoch(startTs * 1000))); final stats = computeManualSessionStats( hrTs: [for (final e in hrRows) (e['rec_ts'] as num).toInt()], hrBpm: hrBpm, @@ -3038,6 +3049,8 @@ class LocalRepositoryImpl extends LocalRepository { restingHr: restingHrForSession, zoneSet: _zoneSetFor( row['device_family'] as String?, await _zoneAnchors()), + quietHrr: quiet.value?.hrr, + quietSettled: quiet.value?.settled ?? true, ); // The peak is smoothed inside `computeManualSessionStats` now — one // definition for the manual save, this re-score and the workout list @@ -4476,6 +4489,30 @@ Map? coachToday(Map? crossDay) { }; } +/// The confidence of the /today HRV block: the sleep-session headline's own, +/// because that is the estimate whose value the block shows — not the +/// whole-night `hrv_time` envelope's, a different estimator. Pure + public so +/// the Today seam is unit-testable. +/// +/// LEGACY BUNDLES. A day_result derived before `rmssd` became that single +/// estimator can hold a FALLBACK `rmssd` (the NREM median or the whole-night +/// value) beside an absent session envelope (`value: '—'`, confidence 0). The +/// session's confidence does not describe that number, and 0 would make every +/// consumer that honours confidence (Health's HRV row: `Metric.isEmpty`) blank +/// a value the widget, the trends and the baselines still show. Such a bundle +/// keeps the confidence it was always served with, `hrv_time`'s. A bundle +/// derived since never reaches that branch: its `rmssd` is null whenever the +/// session is absent, so no HRV block is built at all. +num hrvConfidenceForToday(Map clinical) { + final session = clinical['rmssd_sleep_session']; + final whole = clinical['hrv_time']; + final wholeConf = whole is Map ? whole['confidence'] as num? : null; + if (rmssdFromSessionEstimator(session)) { + return ((session as Map)['confidence'] as num?) ?? wholeConf ?? 0.5; + } + return wholeConf ?? 0.5; +} + /// The /today `stress` block from a day bundle — the pipeline's Baevsky block, /// verbatim, with NO fallback substitute when SI couldn't compute a score. /// (Previously mirrored getDayStress's `100 - readiness` fallback; removed for diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6fb079602..c18396b57 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -958,6 +958,8 @@ "investigateStrapCounterFactor": "integrierter Zähler · Smartphone-Faktor", "investigateTimeDomain": "Zeitbereich", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nächtlich (Mittel der 5-Min.-Fenster)", + "investigateRmssdStored": "RMSSD, nächtlich (frühere Schätzung)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN-Index", @@ -1360,8 +1362,8 @@ "healthCatMovementLoad": "Bewegung & Belastung", "healthCatBodyWear": "Körper & Tragezeit", "healthBlurbRestingHr": "Der niedrigste anhaltende Puls der Nacht", - "healthBlurbHrv": "RMSSD im saubersten Schlafabschnitt", - "healthBlurbHrvCv": "Wie stark das von Nacht zu Nacht schwankt", + "healthBlurbHrv": "Mittlerer RMSSD über die 5-Minuten-Fenster der Nacht", + "healthBlurbHrvCv": "SDNN als Anteil des mittleren Schlagintervalls, innerhalb einer Nacht", "healthBlurbLfHf": "Wie sich die Herzschlag-Variabilität auf die Frequenzbereiche verteilt", "healthBlurbDip": "Wie stark deine Herzfrequenz im Schlaf sinkt", "healthBlurbHrr": "Wie schnell sie in der Minute nach einer Belastung sinkt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 35718a7ba..589311461 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4693,6 +4693,14 @@ "@investigateRmssd": { "description": "HRV metric label: RMSSD (kept as scientific abbreviation)" }, + "investigateRmssdNightly": "RMSSD, nightly (mean of 5-min windows)", + "@investigateRmssdNightly": { + "description": "HRV metric label: the nightly headline RMSSD, the mean of the RMSSDs of the sleep session's 5-minute windows (RMSSD kept as scientific abbreviation)" + }, + "investigateRmssdStored": "RMSSD, nightly (earlier estimate)", + "@investigateRmssdStored": { + "description": "HRV metric label: a nightly RMSSD stored by an earlier version of the app, computed by a method other than the 5-minute-window mean (RMSSD kept as scientific abbreviation)" + }, "investigateSdnn": "SDNN", "@investigateSdnn": { "description": "HRV metric label: SDNN (kept as scientific abbreviation)" @@ -6779,13 +6787,13 @@ "@healthBlurbRestingHr": { "description": "Explore catalogue one-line description for resting heart rate" }, - "healthBlurbHrv": "RMSSD over the cleanest window of sleep", + "healthBlurbHrv": "Mean RMSSD across the night's 5-minute windows", "@healthBlurbHrv": { - "description": "Explore catalogue one-line description for HRV" + "description": "Explore catalogue one-line description for HRV: the nightly mean of the RMSSDs of the sleep session's 5-minute windows" }, - "healthBlurbHrvCv": "How much that swings from night to night", + "healthBlurbHrvCv": "SDNN as a share of the average beat interval, within one night", "@healthBlurbHrvCv": { - "description": "Explore catalogue one-line description for HRV night-to-night variability" + "description": "Explore catalogue one-line description for HRV stability: within-night SDNN as a percentage of the mean beat interval (not a night-to-night variability)" }, "healthBlurbLfHf": "Where beat-timing power sits across frequencies", "@healthBlurbLfHf": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index de07bc802..259be1a0d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -920,6 +920,8 @@ "investigateStrapChipReported": "chip de la banda reportó", "investigateTimeDomain": "Dominio del tiempo", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nocturno (media de ventanas de 5 min)", + "investigateRmssdStored": "RMSSD, nocturno (estimación anterior)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "Índice SDNN", @@ -1316,8 +1318,8 @@ "healthCatMovementLoad": "Movimiento y carga", "healthCatBodyWear": "Cuerpo y uso", "healthBlurbRestingHr": "El ritmo sostenido más bajo de la noche", - "healthBlurbHrv": "RMSSD durante la ventana de sueño más limpia", - "healthBlurbHrvCv": "Cuánto varía eso de una noche a otra", + "healthBlurbHrv": "RMSSD medio de las ventanas de 5 minutos de la noche", + "healthBlurbHrvCv": "SDNN como fracción del intervalo medio entre latidos, dentro de una noche", "healthBlurbLfHf": "Dónde se concentra la potencia del ritmo cardíaco entre frecuencias", "healthBlurbDip": "Cuánto baja tu frecuencia cardíaca mientras duermes", "healthBlurbHrr": "Cuán rápido baja en el minuto después de un esfuerzo", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c3c2fb5f2..5645625c5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -920,6 +920,8 @@ "investigateStrapChipReported": "puce du bracelet a indiqué", "investigateTimeDomain": "Domaine temporel", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, nocturne (moyenne des fenêtres de 5 min)", + "investigateRmssdStored": "RMSSD, nocturne (estimation antérieure)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "Indice SDNN", @@ -1316,8 +1318,8 @@ "healthCatMovementLoad": "Mouvement et charge", "healthCatBodyWear": "Corps et port", "healthBlurbRestingHr": "Le rythme soutenu le plus bas de la nuit", - "healthBlurbHrv": "RMSSD sur la fenêtre de sommeil la plus propre", - "healthBlurbHrvCv": "À quel point cela varie d'une nuit à l'autre", + "healthBlurbHrv": "RMSSD moyen sur les fenêtres de 5 minutes de la nuit", + "healthBlurbHrvCv": "SDNN en proportion de l'intervalle moyen entre battements, sur une nuit", "healthBlurbLfHf": "Où se situe la puissance du rythme cardiaque selon les fréquences", "healthBlurbDip": "De combien votre fréquence cardiaque baisse pendant votre sommeil", "healthBlurbHrr": "À quelle vitesse elle baisse dans la minute suivant un effort", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 919f7d4fb..d23a2d54e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -920,6 +920,8 @@ "investigateStrapChipReported": "बैंड चिप द्वारा दर्ज", "investigateTimeDomain": "टाइम डोमेन", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD, रात का (5-मिनट विंडो का औसत)", + "investigateRmssdStored": "RMSSD, रात का (पहले का अनुमान)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN इंडेक्स", @@ -1316,8 +1318,8 @@ "healthCatMovementLoad": "गतिविधि और भार", "healthCatBodyWear": "शरीर और पहनावा", "healthBlurbRestingHr": "रात की सबसे कम स्थिर दर", - "healthBlurbHrv": "नींद के सबसे स्वच्छ समय-खंड पर RMSSD", - "healthBlurbHrvCv": "यह रात-दर-रात कितना बदलता है", + "healthBlurbHrv": "रात की 5-मिनट की खिड़कियों पर औसत RMSSD", + "healthBlurbHrvCv": "एक रात के भीतर, औसत धड़कन-अंतराल के अनुपात में SDNN", "healthBlurbLfHf": "धड़कन-समय की ऊर्जा विभिन्न आवृत्तियों में कहाँ है", "healthBlurbDip": "सोते समय आपकी हृदय गति कितनी गिरती है", "healthBlurbHrr": "किसी कसरत के बाद एक मिनट में यह कितनी तेज़ी से गिरती है", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4e1e1739f..5f6c1f88a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -920,6 +920,8 @@ "investigateStrapChipReported": "腕带芯片上报值", "investigateTimeDomain": "时域指标", "investigateRmssd": "RMSSD", + "investigateRmssdNightly": "RMSSD,夜间(5 分钟窗口的均值)", + "investigateRmssdStored": "RMSSD,夜间(早期估算)", "investigateSdnn": "SDNN", "investigateSdann": "SDANN", "investigateSdnnIndex": "SDNN 指数", @@ -1316,8 +1318,8 @@ "healthCatMovementLoad": "运动与负荷", "healthCatBodyWear": "身体与佩戴", "healthBlurbRestingHr": "整夜持续保持的最低心率", - "healthBlurbHrv": "取睡眠中信号最干净时段的 RMSSD", - "healthBlurbHrvCv": "该数值在各夜之间的波动幅度", + "healthBlurbHrv": "整晚各 5 分钟窗口 RMSSD 的平均值", + "healthBlurbHrvCv": "单晚内 SDNN 占平均心跳间期的比例", "healthBlurbLfHf": "心跳间期功率在各频段的分布", "healthBlurbDip": "睡眠期间心率下降的幅度", "healthBlurbHrr": "运动结束后一分钟内心率下降的速度", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 8b43255ea..e239fc27d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -49,6 +49,7 @@ import '../compute/manual_session.dart' show strainFromPerMinuteHr, supersededSuggestionIds; import '../compute/hr_max.dart'; import '../compute/profile.dart'; +import '../compute/quiet_level.dart'; import '../data/day_label.dart'; import '../data/journal_fields.dart' show JournalMetricValue, kJournalFieldsByKey; @@ -1737,6 +1738,11 @@ class AppState extends ChangeNotifier { Future debugReconcileOrphanedLiveWorkout() => _reconcileOrphanedLiveWorkout(); + /// Run the cross-day anchor read (nightly RHR, quiet-waking level) that + /// `_initSteps` awaits before the reconcile above. Tests only. + @visibleForTesting + Future debugRefreshNightlyRhr() => _refreshNightlyRhr(); + /// Feed one live accel frame through the live-pedometer path exactly as /// [_onLiveFrame] does, with the ingest wall-clock supplied by the caller. /// Tests only — lets a test replay a session's frames deterministically. @@ -6428,9 +6434,41 @@ class AppState extends ChangeNotifier { double? _observedCeilingBpm; List _rhr28 = const []; + /// FILL A GAP on [w] with the personal quiet-waking level for ITS OWN local + /// start day — the level the saved session is re-scored on, so the gauge + /// and the stored number agree, even for a session resumed after midnight. + /// Read fresh every time, never cached: a level read earlier can predate the + /// derive that wrote yesterday's `quiet_hrr`, or belong to another day. Same + /// rule as the resting HR: never move a level a running session was already + /// scored on. Absent below three measured days; the gauge then shows "—". + Future _fillLiveQuietLevel(LiveWorkoutState w) async { + if (w.quietHrr != null) return; + final lvl = + (await personalQuietLevelBefore(dayLabelOf(w.startTime))).value; + if (lvl == null || w.quietHrr != null || !identical(activeWorkout, w)) { + return; + } + w.quietHrr = lvl.hrr; + w.quietSettled = lvl.settled; + // Now, not at the next sample: a resumed session's restored series was + // scored before the level landed. + w._scoreStrain(); + notifyListeners(); + } + Future _refreshNightlyRhr() async { try { _observedCeilingBpm = (await LocalDb.observedHrCeiling())?.bpm; + // BEFORE the resting-HR early return below: a user with no nightly + // series still has a quiet level once three days are measured. + final live = activeWorkout; + if (live != null) { + // Its own guard: a failed fill leaves the gauge at "—" but must not + // skip the resting-HR refresh below. + try { + await _fillLiveQuietLevel(live); + } catch (_) {} + } _rhr28 = await LocalDb.trailingSeriesValues('rhr', 28); final vals = await LocalDb.trailingSeriesValues('rhr', 7); if (vals.isEmpty) return; @@ -6544,6 +6582,8 @@ class AppState extends ChangeNotifier { manualZoneLowerBpm: manualZoneBoundsFromProfile(user), ), restingHr: _liveRestingHr, + // No quiet level yet: the refresh above reads it fresh for this + // session's day and fills the gap within the first samples. ); // The workout is now an owner (HR; plus IMU for a foreground gait type — // the live step count rides the 100 Hz stream). AFTER the assignment: the @@ -6771,6 +6811,9 @@ class AppState extends ChangeNotifier { manualZoneLowerBpm: manualZoneBoundsFromProfile(user), ), restingHr: _liveRestingHr, + // The quiet level is filled by the refresh below, from the + // session's OWN start day — after a relaunch past midnight that + // is not today. ); // Restore the per-second tallies a PREVIOUS process snapshotted // before it was killed — without this, strain/calories/zone @@ -7641,6 +7684,16 @@ class LiveWorkoutState { /// fraction of a second after it started. double? restingHr; + /// This user's quiet-waking level the strain score is priced above + /// (`personalQuietLevelBefore`). Mutable for the same reason as + /// [restingHr]: [AppState._refreshNightlyRhr] fills it when the read lands, + /// and only fills a gap. Null keeps strain absent. + double? quietHrr; + + /// Whether [quietHrr] rests on a settled week of days; only the confidence + /// moves with it, never the number. + bool quietSettled; + /// Per-minute mean HR, the unit Banister TRIMP weights. Live HR arrives at /// 1 Hz, so it is folded into the current minute here rather than kept as /// thousands of raw samples. @@ -7715,6 +7768,8 @@ class LiveWorkoutState { this.hrMax, this.zoneSet, this.restingHr, + this.quietHrr, + this.quietSettled = true, }) : _hrPeak = RollingMaxHr(age: age), idleWatch = WorkoutIdleWatch(startedAt: startTime); @@ -7774,17 +7829,25 @@ class LiveWorkoutState { // ONE strain method across the app (see strainFromPerMinuteHr). Null when // an anchor is missing — the gauge shows "—" rather than a number built on // an invented HRmax or resting HR. + _scoreStrain(); + + // Calories re-score off the same series, through the same estimator the + // substrate re-score uses, so both live figures on the gauge mean the same + // thing the finished session will. + _scoreCalories(); + } + + /// Re-score [strain] from the per-minute series — see the note in + /// [accrueHr]. Null as well while the quiet level has not landed. + void _scoreStrain() { strain = strainFromPerMinuteHr( perMinuteHr(), profile: profile, restingHr: restingHr, hrMax: hrMax, + quietHrr: quietHrr, + quietSettled: quietSettled, ); - - // Calories re-score off the same series, through the same estimator the - // substrate re-score uses, so both live figures on the gauge mean the same - // thing the finished session will. - _scoreCalories(); } /// Restore per-second tallies persisted mid-session by a PREVIOUS process, @@ -7826,12 +7889,7 @@ class LiveWorkoutState { ..clear() ..addAll(secondsByBpm); maxHrSeen = maxHrSeenIn; - strain = strainFromPerMinuteHr( - perMinuteHr(), - profile: profile, - restingHr: restingHr, - hrMax: hrMax, - ); + _scoreStrain(); _scoreCalories(); } } diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index e1ceb483f..a722d0e5b 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -357,8 +357,9 @@ class _Cat { const _catalogue = <_Cat>[ _Cat('Heart & rhythm', [ _CatRow('resting_hr', 'rhr', 'The lowest sustained rate of the night'), - _CatRow('hrv', 'rmssd', 'RMSSD over the cleanest window of sleep'), - _CatRow('hrv_cv', 'hrv_cv', 'How much that swings from night to night'), + _CatRow('hrv', 'rmssd', 'Mean RMSSD across the night\'s 5-minute windows'), + _CatRow('hrv_cv', 'hrv_cv', + 'SDNN as a share of the average beat interval, within one night'), _CatRow('lf_hf', 'lf_hf', 'Where beat-timing power sits across frequencies'), _CatRow('dip', 'dip_pct', 'How far your heart rate falls while you sleep'), _CatRow('hrr', 'hrr_bpm', 'How fast it falls in the minute after a bout'), diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index b05199282..b59c70ee0 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -396,7 +396,23 @@ class _InvestigateState extends State { return [ MonoTable(l?.investigateTimeDomain ?? 'Time domain', [ - (l?.investigateRmssd ?? 'RMSSD', ms(time['rmssd_ms'] ?? d.hrv['rmssd'])), + // Whole-night RMSSD, from the same `hrv_time` envelope as the rest of + // this table. Absent (refused) drops the row; it never borrows the + // nightly headline, which is a different statistic. + (l?.investigateRmssd ?? 'RMSSD', ms(time['rmssd_ms'])), + // The nightly headline, labelled by the estimator that produced it: + // the session mean when its envelope holds a value. A bundle derived + // before `rmssd` became that single estimator stores an earlier + // fallback (NREM median or whole-night) beside an absent envelope, and + // must not be passed off as the 5-min-window mean. + ( + rmssdFromSessionEstimator(d.hrv['rmssd_sleep_session']) + ? (l?.investigateRmssdNightly ?? + 'RMSSD, nightly (mean of 5-min windows)') + : (l?.investigateRmssdStored ?? + 'RMSSD, nightly (earlier estimate)'), + ms(d.hrv['rmssd']) + ), (l?.investigateSdnn ?? 'SDNN', ms(time['sdnn_ms'] ?? d.hrv['sdnn'])), (l?.investigateSdann ?? 'SDANN', ms(time['sdann_ms'])), (l?.investigateSdnnIndex ?? 'SDNN index', ms(time['sdnn_index_ms'])), diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 842b1ed70..e464aff5e 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -115,11 +115,15 @@ const _specs = { unit: 'ms', color: C.green, icon: LucideIcons.activity, - method: 'RMSSD over the longest artefact-free window during sleep. Beat ' - 'timing is recovered from the band\'s 1 Hz records and corrected by ' - 'the Lipponen–Tarvainen method before any statistic is taken. ' + method: 'The mean of RMSSD across every 5-minute window of the detected ' + 'sleep session that holds at least 20 clean successive differences. ' + 'Beats outside 300–2000 ms, and beats more than 20% from their local ' + 'median, are dropped first. On a night whose beat timing looks noisy, ' + 'only windows that pass the jitter check on their own, or that sit on ' + 'a steady breathing line, count; a window, or a night, whose beats ' + 'add up to more time than passed is left out. ' 'Pulse-derived, so this is PRV: real and trendable, but not ECG HRV.', - citation: 'Task Force 1996 · Lipponen & Tarvainen 2019', + citation: 'Task Force 1996 · 20% local-median ectopic filter, after Malik', requires: {InputSignal.rrIntervals}, ), 'readiness': MetricSpec( @@ -150,9 +154,13 @@ const _specs = { icon: LucideIcons.wind, higherBetter: false, method: 'Breathing rate recovered from respiratory sinus arrhythmia — the ' - 'periodic modulation breathing imposes on beat timing — over a grid of ' - 'candidate rates.', - citation: 'Pimentel 2017', + 'periodic modulation breathing imposes on beat timing. The spectral ' + 'peak is found in overlapping five-minute stretches of clean beats ' + '(Lomb–Scargle on native beat times) and the night\'s rate is their ' + 'median, reported only when most stretches agree. At a sleeping heart ' + 'rate near or below 48 bpm the beats are too slow to resolve normal ' + 'breathing, and the rate is withheld.', + citation: 'Welch 1967 · Lomb–Scargle (Press & Rybicki 1989)', requires: {InputSignal.rrIntervals}, ), 'sleep': MetricSpec( @@ -301,8 +309,12 @@ const _specs = { color: C.green, icon: LucideIcons.activity, higherBetter: false, - method: 'Night-to-night coefficient of variation of RMSSD.', - citation: 'Within-user dispersion', + method: 'Within one night: the standard deviation of the night\'s beat ' + 'intervals after artifact correction (SDNN) as a percentage of their ' + 'mean. The chart shows how that per-night figure moves across nights; ' + 'it is not a night-to-night coefficient of variation.', + citation: 'SDNN (Task Force 1996) over Lipponen & Tarvainen 2019 corrected ' + 'beats, normalised by mean NN', requires: {InputSignal.rrIntervals}, ), 'brv': MetricSpec( diff --git a/pubspec.lock b/pubspec.lock index 082c0c26f..d658b428c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -937,8 +937,8 @@ packages: dependency: "direct main" description: path: "." - ref: "27b0ba486234900084ed791cfb95e71738645d98" - resolved-ref: "27b0ba486234900084ed791cfb95e71738645d98" + ref: "b7d5819a504cbf7842d0bdf09806d4189a8ac0e2" + resolved-ref: "b7d5819a504cbf7842d0bdf09806d4189a8ac0e2" url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index f0fbd45e2..9c3d61c04 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -500,7 +500,10 @@ dependencies: # grep -n 'varTot == 0\|diffN == 0'` # analytics main @ 4fc2b12: adds #80 (band-state night end) plus #81-#85. # analytics main @ c0effea: adds #79 (catalog) and #86 (rmssd gate). - ref: 27b0ba486234900084ed791cfb95e71738645d98 + # analytics main @ b7d5819: adds #88 (rr over-count gate), #89 (20-difference + # window floor), #90 (gap-aware rsa respiration), #91 (personal quiet level), + # #78 (sleep detection no longer bridges unobserved gaps; bridges capped at 90 min). + ref: b7d5819a504cbf7842d0bdf09806d4189a8ac0e2 # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index 9f1ee89de..399802083 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -901,7 +901,14 @@ void main() { 'resting_hr': 55, }; + // A settled week of quiet-waking levels. Live strain is priced on the + // user's own level, which the reconcile's anchor refresh fills in after + // the restore — awaited here, so the restored series is re-scored on it. + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2020-01-0$d', 'quiet_hrr', 0.20); + } await app.debugReconcileOrphanedLiveWorkout(); + await app.debugRefreshNightlyRhr(); final w = app.activeWorkout; expect(w?.workoutId, id); @@ -989,6 +996,73 @@ void main() { expect(await LocalDb.session(id), isNull); }); }); + + // ── the live gauge prices a session on ITS OWN day's quiet level ───────── + group('live quiet level is read for the session\'s own day', () { + // Seven prior days at 0.20: every day after 2020-01-07 has a settled + // level; 2020-01-03 has only two days behind it. + Future seedLevels() async { + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2020-01-0$d', 'quiet_hrr', 0.20); + } + } + + test('a new session is filled with a level read fresh for its day', + () async { + await seedLevels(); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.startWorkout(workoutId: 'fresh-level', type: 'run'); + final w = app.activeWorkout!; + expect(w.quietHrr, isNull, + reason: 'nothing cached — the refresh reads it for this day'); + await app.debugRefreshNightlyRhr(); + expect(w.quietHrr, 0.20); + await app.stopWorkout(); + }); + + test('a level a running session was scored on never moves', () async { + await seedLevels(); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.startWorkout(workoutId: 'pinned-level', type: 'run'); + final w = app.activeWorkout!..quietHrr = 0.33; + await app.debugRefreshNightlyRhr(); + expect(w.quietHrr, closeTo(0.33, 1e-12)); + await app.stopWorkout(); + }); + + test('a resumed session is priced on its START day, not today', () async { + await seedLevels(); + final app = AppState.forTesting(); + addTearDown(app.dispose); + // Started on a day with only two prior levels: its own day abstains, + // even though today has a settled level cached. + final early = LiveWorkoutState( + startTime: DateTime(2020, 1, 3, 23, 50), + targetKcal: 300, + workoutId: 'resumed-early', + type: 'run', + ); + app.activeWorkout = early; + await app.debugRefreshNightlyRhr(); + expect(early.quietHrr, isNull); + + // Started on a day with five prior levels: present, but calibrating — + // not today's settled week. + final later = LiveWorkoutState( + startTime: DateTime(2020, 1, 6, 23, 50), + targetKcal: 300, + workoutId: 'resumed-later', + type: 'run', + ); + app.activeWorkout = later; + await app.debugRefreshNightlyRhr(); + expect(later.quietHrr, 0.20); + expect(later.quietSettled, isFalse); + app.activeWorkout = null; + }); + }); } class _ConnectedEngine extends BleEngine { diff --git a/test/derive_day_window_test.dart b/test/derive_day_window_test.dart index be12e1c2e..a98a35d88 100644 --- a/test/derive_day_window_test.dart +++ b/test/derive_day_window_test.dart @@ -269,6 +269,12 @@ void main() { }); test('a real profile still produces strain and calories', () async { + // Strain is priced on the user's own prior quiet-waking levels and + // abstains below three of them — a settled week, so only the profile + // decides here. + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2026-04-0$d', 'quiet_hrr', 0.20); + } final got = await deriveWith( const Profile( ageYears: 34, diff --git a/test/fixtures/two_device_day_expected.json b/test/fixtures/two_device_day_expected.json index 7f2b969df..0fcd1c50b 100644 --- a/test/fixtures/two_device_day_expected.json +++ b/test/fixtures/two_device_day_expected.json @@ -1,18 +1,18 @@ { "single_device": { "date": "2020-01-02", - "day_confidence": 0.6, + "day_confidence": 0.5857, "flags": [], "clinical": { "hrv_time": { "value": { - "rmssd_ms": 19.995484, - "sdnn_ms": 14.141481, - "sdann_ms": 0.298746, - "sdnn_index_ms": 14.173759, + "rmssd_ms": 19.998437, + "sdnn_ms": 14.142136, + "sdann_ms": 0.140587, + "sdnn_index_ms": 14.171088, "pnn50_pct": 0.0, - "n_beats": 21597, - "diff_acf1": -0.249858 + "n_beats": 14399, + "diff_acf1": -0.249952 }, "confidence": 0.3, "tier": "HIGH", @@ -51,9 +51,9 @@ "sd_brpm": 0.0, "cv": 0.0, "trend_slope": 0.0, - "n_windows": 12 + "n_windows": 8 }, - "confidence": 0.8, + "confidence": 0.7, "tier": "ESTIMATE", "inputs_used": [ "resp_rate_series" @@ -67,7 +67,13 @@ "inputs_used": [ "rr_sleep_window" ], - "note": "sleep-session HRV: mean RMSSD over cleaned 5-min windows." + "note": "sleep-session HRV: mean RMSSD over cleaned 5-min windows.", + "windows": 48, + "diff_acf1": -0.2467, + "rr_coverage": 0.98, + "overcounted_windows": 0, + "thin_windows": 0, + "min_diffs_per_window": 20 }, "rmssd_nocturnal": { "value": 20.0, @@ -77,22 +83,22 @@ "rr_cleaned", "beat_times" ], - "note": "robust nocturnal RMSSD = MEDIAN of 72 consecutive 5-min-window RMSSDs (REM/arousal-robust). PRV not ECG-HRV; RMSSD is quantization-sensitive at 1 Hz." + "note": "robust nocturnal RMSSD = MEDIAN of 47 consecutive 5-min-window RMSSDs (REM/arousal-robust). PRV not ECG-HRV; RMSSD is quantization-sensitive at 1 Hz." }, "hrv_freq": { "value": { - "vlf": 0.008888, - "lf": 0.428788, - "hf": 148.099517, - "total": 148.537193, + "vlf": 0.006896, + "lf": 0.452347, + "hf": 148.112011, + "total": 148.571254, "total_bands": [ "vlf", "lf", "hf" ], - "lf_hf": 0.002895, - "nu_lf": 0.288691, - "nu_hf": 99.711309, + "lf_hf": 0.003054, + "nu_lf": 0.304479, + "nu_hf": 99.695521, "hf_gated": false }, "confidence": 0.9, @@ -105,9 +111,9 @@ }, "resting_hr": { "value": { - "low30_mean_bpm": 62.996665, + "low30_mean_bpm": 62.998333, "p1_bpm": 60.0, - "valid_samples": 21597 + "valid_samples": 14399 }, "confidence": 0.95, "tier": "HIGH", @@ -117,19 +123,24 @@ "note": "lowest-30-min mean + 1st-percentile; HR=0 excluded as off-skin" }, "hr_dip": { - "value": "—", - "confidence": 0.0, + "value": { + "dip_pct": -0.000882, + "day_mean_bpm": 62.999444, + "night_mean_bpm": 63.0, + "band": "riser" + }, + "confidence": 0.9, "tier": "HIGH", "inputs_used": [ "hr_1hz_day", "hr_1hz_night" ], - "note": "HR dip needs ≥300 valid samples on each of day and night (have day=0, night=21597)" + "note": "night-day HR ratio; CV-risk + acute-strain signal" }, "prsa_dc": { "value": { - "capacity_ms": 14.997685, - "anchors": 4319, + "capacity_ms": 15.0, + "anchors": 2879, "kind": "DC" }, "confidence": 0.95, @@ -141,8 +152,8 @@ }, "prsa_ac": { "value": { - "capacity_ms": -3.749855, - "anchors": 17274, + "capacity_ms": -3.75, + "anchors": 11516, "kind": "AC" }, "confidence": 0.95, @@ -173,11 +184,11 @@ "confidence": 0.0, "tier": "ESTIMATE", "inputs_used": [ - "trimp", - "wake_minutes", - "quiet_waking_hrr" + "hr_1hz", + "profile", + "quiet_waking_hrr_history" ], - "note": "need_input:name=wake_hr" + "note": "need_input:name=age" }, "trimp": { "value": "—", @@ -187,7 +198,7 @@ "hr_1hz", "profile" ], - "note": "need_input:name=wake_hr" + "note": "need_input:name=age" } }, "baselines": { @@ -197,7 +208,7 @@ "n_valid": 0, "nights_since_update": 0, "status": "calibrating", - "value": 62.996664813785436, + "value": 62.998333333333335, "z": null, "delta": null, "ratio": null, @@ -212,7 +223,7 @@ "n_valid": 0, "nights_since_update": 0, "status": "calibrating", - "value": 19.900451813684356, + "value": 19.899932209964643, "z": null, "delta": null, "ratio": null, @@ -227,7 +238,7 @@ "n_valid": 0, "nights_since_update": 0, "status": "calibrating", - "value": null, + "value": 12.265550239234448, "z": null, "delta": null, "ratio": null, @@ -242,7 +253,7 @@ "n_valid": 0, "nights_since_update": 0, "status": "calibrating", - "value": 30004.999907394547, + "value": 30005.0, "z": null, "delta": null, "ratio": null, @@ -256,13 +267,13 @@ "window": { "value": { "onset_idx": 0, - "offset_idx": 21597, + "offset_idx": 14399, "onset_ms": 1577923200000.0, - "offset_ms": 1577951999000.0, - "spt_sec": 28799, + "offset_ms": 1577937599000.0, + "spt_sec": 14399, "undecidable_sec": 299 }, - "confidence": 0.6, + "confidence": 0.585694, "tier": "HIGH", "inputs_used": [ "accel_1hz", @@ -271,23 +282,23 @@ }, "accounting": { "value": { - "tst_sec": 21597, + "tst_sec": 14399, "waso_sec": 0, - "in_bed_sec": 28799, - "unobserved_sec": 7202, - "observed_in_bed_sec": 21597, + "in_bed_sec": 14399, + "unobserved_sec": 0, + "observed_in_bed_sec": 14399, "efficiency_pct": 100.0, - "nrem_sec": 21597, - "light_sec": 21597, + "nrem_sec": 14399, + "light_sec": 14399, "deep_sec": 0, "rem_sec": 0, "wake_sec": 0, "deep_low_confidence": true, "awakenings": 0, - "longest_sleep_sec": 14400, + "longest_sleep_sec": 14399, "sol_sec": null }, - "confidence": 0.6, + "confidence": 0.585694, "tier": "ESTIMATE", "inputs_used": [ "sleep_stages" @@ -301,9 +312,9 @@ "nrem_pct": 100.0, "rem_pct": 0.0, "epoch_sec": 1, - "epochs": 28799 + "epochs": 14399 }, - "confidence": 0.6, + "confidence": 0.585694, "tier": "ESTIMATE", "inputs_used": [ "hr_1hz", @@ -325,4623 +336,3516 @@ "cycle_series": [ { "t": 1577923200, - "z": -0.395 + "z": -0.214 }, { "t": 1577923260, - "z": -0.395 + "z": -0.214 }, { "t": 1577923320, - "z": -0.395 + "z": -0.214 }, { "t": 1577923380, - "z": -0.395 + "z": -0.214 }, { "t": 1577923440, - "z": -0.395 + "z": -0.214 }, { "t": 1577923500, - "z": -0.395 + "z": -0.214 }, { "t": 1577923560, - "z": -0.395 + "z": -0.214 }, { "t": 1577923620, - "z": -0.395 + "z": -0.214 }, { "t": 1577923680, - "z": -0.395 + "z": -0.214 }, { "t": 1577923740, - "z": -0.395 + "z": -0.214 }, { "t": 1577923800, - "z": -0.395 + "z": -0.214 }, { "t": 1577923860, - "z": -0.395 + "z": -0.214 }, { "t": 1577923920, - "z": -0.395 + "z": -0.214 }, { "t": 1577923980, - "z": -0.395 + "z": -0.214 }, { "t": 1577924040, - "z": -0.395 + "z": -0.214 }, { "t": 1577924100, - "z": -0.395 + "z": -0.214 }, { "t": 1577924160, - "z": -0.395 + "z": -0.214 }, { "t": 1577924220, - "z": -0.395 + "z": -0.214 }, { "t": 1577924280, - "z": -0.395 + "z": -0.214 }, { "t": 1577924340, - "z": -0.395 + "z": -0.214 }, { "t": 1577924400, - "z": -0.395 + "z": -0.214 }, { "t": 1577924460, - "z": -0.395 + "z": -0.214 }, { "t": 1577924520, - "z": -0.395 + "z": -0.214 }, { "t": 1577924580, - "z": -0.395 + "z": -0.214 }, { "t": 1577924640, - "z": -0.395 + "z": -0.214 }, { "t": 1577924700, - "z": -0.395 + "z": -0.214 }, { "t": 1577924760, - "z": -0.395 + "z": -0.214 }, { "t": 1577924820, - "z": -0.395 + "z": -0.214 }, { "t": 1577924880, - "z": -0.395 + "z": -0.214 }, { "t": 1577924940, - "z": -0.395 + "z": -0.214 }, { "t": 1577925000, - "z": -0.395 + "z": -0.214 }, { "t": 1577925060, - "z": -0.395 + "z": -0.214 }, { "t": 1577925120, - "z": -0.395 + "z": -0.214 }, { "t": 1577925180, - "z": -0.395 + "z": -0.214 }, { "t": 1577925240, - "z": -0.395 + "z": -0.214 }, { "t": 1577925300, - "z": -0.395 + "z": -0.214 }, { "t": 1577925360, - "z": -0.395 + "z": -0.214 }, { "t": 1577925420, - "z": -0.395 + "z": -0.214 }, { "t": 1577925480, - "z": -0.395 + "z": -0.214 }, { "t": 1577925540, - "z": -0.395 + "z": -0.214 }, { "t": 1577925600, - "z": -0.395 + "z": -0.214 }, { "t": 1577925660, - "z": -0.395 + "z": -0.214 }, { "t": 1577925720, - "z": -0.395 + "z": -0.214 }, { "t": 1577925780, - "z": -0.395 + "z": -0.214 }, { "t": 1577925840, - "z": -0.395 + "z": -0.214 }, { "t": 1577925900, - "z": -0.395 + "z": -0.214 }, { "t": 1577925960, - "z": -0.395 + "z": -0.214 }, { "t": 1577926020, - "z": -0.395 + "z": -0.214 }, { "t": 1577926080, - "z": -0.395 + "z": -0.214 }, { "t": 1577926140, - "z": -0.395 + "z": -0.214 }, { "t": 1577926200, - "z": -0.395 + "z": -0.214 }, { "t": 1577926260, - "z": -0.395 + "z": -0.214 }, { "t": 1577926320, - "z": -0.395 + "z": -0.214 }, { "t": 1577926380, - "z": -0.395 + "z": -0.214 }, { "t": 1577926440, - "z": -0.395 + "z": -0.214 }, { "t": 1577926500, - "z": -0.395 + "z": -0.214 }, { "t": 1577926560, - "z": -0.395 + "z": -0.214 }, { "t": 1577926620, - "z": -0.395 + "z": -0.214 }, { "t": 1577926680, - "z": -0.395 + "z": -0.214 }, { "t": 1577926740, - "z": -0.395 + "z": -0.214 }, { "t": 1577926800, - "z": -0.395 + "z": -0.214 }, { "t": 1577926860, - "z": -0.395 + "z": -0.214 }, { "t": 1577926920, - "z": -0.395 + "z": -0.214 }, { "t": 1577926980, - "z": -0.395 + "z": -0.214 }, { "t": 1577927040, - "z": -0.395 + "z": -0.214 }, { "t": 1577927100, - "z": -0.395 + "z": -0.214 }, { "t": 1577927160, - "z": -0.395 + "z": -0.214 }, { "t": 1577927220, - "z": -0.395 + "z": -0.214 }, { "t": 1577927280, - "z": -0.395 + "z": -0.214 }, { "t": 1577927340, - "z": -0.395 + "z": -0.214 }, { "t": 1577927400, - "z": -0.395 + "z": -0.214 }, { "t": 1577927460, - "z": -0.395 + "z": -0.214 }, { "t": 1577927520, - "z": -0.395 + "z": -0.214 }, { "t": 1577927580, - "z": -0.395 + "z": -0.214 }, { "t": 1577927640, - "z": -0.395 + "z": -0.214 }, { "t": 1577927700, - "z": -0.395 + "z": -0.214 }, { "t": 1577927760, - "z": -0.395 + "z": -0.214 }, { "t": 1577927820, - "z": -0.395 + "z": -0.214 }, { "t": 1577927880, - "z": -0.395 + "z": -0.214 }, { "t": 1577927940, - "z": -0.395 + "z": -0.214 }, { "t": 1577928000, - "z": -0.395 + "z": -0.214 }, { "t": 1577928060, - "z": -0.395 + "z": -0.214 }, { "t": 1577928120, - "z": -0.395 + "z": -0.214 }, { "t": 1577928180, - "z": -0.395 + "z": -0.214 }, { "t": 1577928240, - "z": -0.395 + "z": -0.214 }, { "t": 1577928300, - "z": -0.395 + "z": -0.214 }, { "t": 1577928360, - "z": -0.395 + "z": -0.214 }, { "t": 1577928420, - "z": -0.395 + "z": -0.214 }, { "t": 1577928480, - "z": -0.395 + "z": -0.214 }, { "t": 1577928540, - "z": -0.395 + "z": -0.214 }, { "t": 1577928600, - "z": -0.395 + "z": -0.214 }, { "t": 1577928660, - "z": -0.395 + "z": -0.214 }, { "t": 1577928720, - "z": -0.395 + "z": -0.214 }, { "t": 1577928780, - "z": -0.395 + "z": -0.214 }, { "t": 1577928840, - "z": -0.395 + "z": -0.214 }, { "t": 1577928900, - "z": -0.395 + "z": -0.214 }, { "t": 1577928960, - "z": -0.395 + "z": -0.214 }, { "t": 1577929020, - "z": -0.395 + "z": -0.214 }, { "t": 1577929080, - "z": -0.395 + "z": -0.214 }, { "t": 1577929140, - "z": -0.395 + "z": -0.214 }, { "t": 1577929200, - "z": -0.395 + "z": -0.214 }, { "t": 1577929260, - "z": -0.395 + "z": -0.214 }, { "t": 1577929320, - "z": -0.395 + "z": -0.214 }, { "t": 1577929380, - "z": -0.395 + "z": -0.214 }, { "t": 1577929440, - "z": -0.395 + "z": -0.214 }, { "t": 1577929500, - "z": -0.395 + "z": -0.214 }, { "t": 1577929560, - "z": -0.395 + "z": -0.214 }, { "t": 1577929620, - "z": -0.395 + "z": -0.214 }, { "t": 1577929680, - "z": -0.395 + "z": -0.214 }, { "t": 1577929740, - "z": -0.395 + "z": -0.214 }, { "t": 1577929800, - "z": -0.395 + "z": -0.214 }, { "t": 1577929860, - "z": -0.395 + "z": -0.214 }, { "t": 1577929920, - "z": -0.395 + "z": -0.214 }, { "t": 1577929980, - "z": -0.395 + "z": -0.214 }, { "t": 1577930040, - "z": -0.395 + "z": -0.214 }, { "t": 1577930100, - "z": -0.395 + "z": -0.214 }, { "t": 1577930160, - "z": -0.395 + "z": -0.214 }, { "t": 1577930220, - "z": -0.395 + "z": -0.214 }, { "t": 1577930280, - "z": -0.395 + "z": -0.214 }, { "t": 1577930340, - "z": -0.395 + "z": -0.214 }, { "t": 1577930400, - "z": -0.395 + "z": -0.214 }, { "t": 1577930460, - "z": -0.395 + "z": -0.214 }, { "t": 1577930520, - "z": -0.395 + "z": -0.214 }, { "t": 1577930580, - "z": -0.395 + "z": -0.214 }, { "t": 1577930640, - "z": -0.395 + "z": -0.214 }, { "t": 1577930700, - "z": -0.395 + "z": -0.214 }, { "t": 1577930760, - "z": -0.395 + "z": -0.214 }, { "t": 1577930820, - "z": -0.395 + "z": -0.214 }, { "t": 1577930880, - "z": -0.395 + "z": -0.214 }, { "t": 1577930940, - "z": -0.395 + "z": -0.214 }, { "t": 1577931000, - "z": -0.395 + "z": -0.214 }, { "t": 1577931060, - "z": -0.395 + "z": -0.214 }, { "t": 1577931120, - "z": -0.395 + "z": -0.214 }, { "t": 1577931180, - "z": -0.395 + "z": -0.214 }, { "t": 1577931240, - "z": -0.395 + "z": -0.214 }, { "t": 1577931300, - "z": -0.395 + "z": -0.214 }, { "t": 1577931360, - "z": -0.395 + "z": -0.214 }, { "t": 1577931420, - "z": -0.395 + "z": -0.214 }, { "t": 1577931480, - "z": -0.395 + "z": -0.214 }, { "t": 1577931540, - "z": -0.395 + "z": -0.214 }, { "t": 1577931600, - "z": -0.395 + "z": -0.214 }, { "t": 1577931660, - "z": -0.395 + "z": -0.214 }, { "t": 1577931720, - "z": -0.395 + "z": -0.214 }, { "t": 1577931780, - "z": -0.395 + "z": -0.214 }, { "t": 1577931840, - "z": -0.395 + "z": -0.214 }, { "t": 1577931900, - "z": -0.395 + "z": -0.214 }, { "t": 1577931960, - "z": -0.395 + "z": -0.214 }, { "t": 1577932020, - "z": -0.395 + "z": -0.214 }, { "t": 1577932080, - "z": -0.395 + "z": -0.214 }, { "t": 1577932140, - "z": -0.395 + "z": -0.214 }, { "t": 1577932200, - "z": -0.395 + "z": -0.214 }, { "t": 1577932260, - "z": -0.395 + "z": -0.214 }, { "t": 1577932320, - "z": -0.395 + "z": -0.214 }, { "t": 1577932380, - "z": -0.395 + "z": -0.214 }, { "t": 1577932440, - "z": -0.395 + "z": -0.214 }, { "t": 1577932500, - "z": -0.395 + "z": -0.214 }, { "t": 1577932560, - "z": -0.395 + "z": -0.214 }, { "t": 1577932620, - "z": -0.395 + "z": -0.214 }, { "t": 1577932680, - "z": -0.395 + "z": -0.214 }, { "t": 1577932740, - "z": -0.395 + "z": -0.214 }, { "t": 1577932800, - "z": -0.395 + "z": -0.214 }, { "t": 1577932860, - "z": -0.395 + "z": -0.214 }, { "t": 1577932920, - "z": -0.395 + "z": -0.214 }, { "t": 1577932980, - "z": -0.395 + "z": -0.214 }, { "t": 1577933040, - "z": -0.395 + "z": -0.214 }, { "t": 1577933100, - "z": -0.395 + "z": -0.214 }, { "t": 1577933160, - "z": -0.395 + "z": -0.214 }, { "t": 1577933220, - "z": -0.395 + "z": -0.214 }, { "t": 1577933280, - "z": -0.395 + "z": -0.214 }, { "t": 1577933340, - "z": -0.395 + "z": -0.214 }, { "t": 1577933400, - "z": -0.395 + "z": -0.214 }, { "t": 1577933460, - "z": -0.395 + "z": -0.214 }, { "t": 1577933520, - "z": -0.395 + "z": -0.214 }, { "t": 1577933580, - "z": -0.395 + "z": -0.214 }, { "t": 1577933640, - "z": -0.395 + "z": -0.214 }, { "t": 1577933700, - "z": -0.395 + "z": -0.214 }, { "t": 1577933760, - "z": -0.395 + "z": -0.214 }, { "t": 1577933820, - "z": -0.395 + "z": -0.214 }, { "t": 1577933880, - "z": -0.395 + "z": -0.214 }, { "t": 1577933940, - "z": -0.395 + "z": -0.214 }, { "t": 1577934000, - "z": -0.395 + "z": -0.214 }, { "t": 1577934060, - "z": -0.395 + "z": -0.214 }, { "t": 1577934120, - "z": -0.395 + "z": -0.214 }, { "t": 1577934180, - "z": -0.395 + "z": -0.214 }, { "t": 1577934240, - "z": -0.395 + "z": -0.214 }, { "t": 1577934300, - "z": -0.395 + "z": -0.214 }, { "t": 1577934360, - "z": -0.395 + "z": -0.214 }, { "t": 1577934420, - "z": -0.395 + "z": -0.214 }, { "t": 1577934480, - "z": -0.395 + "z": -0.214 }, { "t": 1577934540, - "z": -0.395 + "z": -0.214 }, { "t": 1577934600, - "z": -0.395 + "z": -0.214 }, { "t": 1577934660, - "z": -0.395 + "z": -0.214 }, { "t": 1577934720, - "z": -0.395 + "z": -0.214 }, { "t": 1577934780, - "z": -0.395 + "z": -0.214 }, { "t": 1577934840, - "z": -0.395 + "z": -0.214 }, { "t": 1577934900, - "z": -0.395 + "z": -0.214 }, { "t": 1577934960, - "z": -0.395 + "z": -0.214 }, { "t": 1577935020, - "z": -0.395 + "z": -0.214 }, { "t": 1577935080, - "z": -0.395 + "z": -0.214 }, { "t": 1577935140, - "z": -0.395 + "z": -0.214 }, { "t": 1577935200, - "z": -0.395 + "z": -0.214 }, { "t": 1577935260, - "z": -0.395 + "z": -0.214 }, { "t": 1577935320, - "z": -0.395 + "z": -0.214 }, { "t": 1577935380, - "z": -0.395 + "z": -0.214 }, { "t": 1577935440, - "z": -0.395 + "z": -0.214 }, { "t": 1577935500, - "z": -0.395 + "z": -0.214 }, { "t": 1577935560, - "z": -0.395 + "z": -0.214 }, { "t": 1577935620, - "z": -0.395 + "z": -0.214 }, { "t": 1577935680, - "z": -0.395 + "z": -0.214 }, { "t": 1577935740, - "z": -0.395 + "z": -0.214 }, { "t": 1577935800, - "z": -0.395 + "z": -0.214 }, { "t": 1577935860, - "z": -0.395 + "z": -0.214 }, { "t": 1577935920, - "z": -0.395 + "z": -0.214 }, { "t": 1577935980, - "z": -0.395 + "z": -0.214 }, { "t": 1577936040, - "z": -0.395 + "z": -0.214 }, { "t": 1577936100, - "z": -0.395 + "z": -0.214 }, { "t": 1577936160, - "z": -0.395 + "z": -0.214 }, { "t": 1577936220, - "z": -0.395 + "z": -0.214 }, { "t": 1577936280, - "z": -0.395 + "z": -0.214 }, { "t": 1577936340, - "z": -0.395 + "z": -0.214 }, { "t": 1577936400, - "z": -0.395 + "z": -0.214 }, { "t": 1577936460, - "z": -0.395 + "z": -0.214 }, { "t": 1577936520, - "z": -0.395 + "z": -0.214 }, { "t": 1577936580, - "z": -0.395 + "z": -0.214 }, { "t": 1577936640, - "z": -0.395 + "z": -0.214 }, { "t": 1577936700, - "z": -0.395 + "z": -0.214 }, { "t": 1577936760, - "z": -0.395 + "z": -0.214 }, { "t": 1577936820, - "z": -0.395 + "z": -0.214 }, { "t": 1577936880, - "z": -0.395 + "z": -0.214 }, { "t": 1577936940, - "z": -0.395 + "z": 3.206 }, { "t": 1577937000, - "z": -0.395 + "z": 3.377 }, { "t": 1577937060, - "z": -0.395 + "z": 3.566 }, { "t": 1577937120, - "z": -0.395 + "z": 3.776 }, { "t": 1577937180, - "z": -0.395 + "z": 4.01 }, { "t": 1577937240, - "z": -0.395 + "z": 4.275 }, { "t": 1577937300, - "z": -0.395 + "z": 4.574 }, { "t": 1577937360, - "z": -0.395 + "z": 4.916 }, { "t": 1577937420, - "z": -0.395 + "z": 5.31 }, { "t": 1577937480, - "z": -0.395 + "z": 5.771 }, { "t": 1577937540, - "z": -0.395 + "z": 6.315 + } + ] + }, + "zones": {}, + "max_hr_used": null, + "device_family": "gen4", + "zone_source": null, + "zone_max_hr": null, + "zone_lower_bpm": null, + "hr_stats": { + "max": 65, + "min": 61, + "avg": 63 + }, + "calories": null, + "absent_notes": { + "max_hr_used": "need_input:name=age", + "zones": "need_input:name=age", + "calories": "need_input:name=age", + "calories_total": "need_input:name=age", + "strain": "need_input:name=age", + "trimp": "need_input:name=age" + }, + "respiration": { + "rsa": { + "value": { + "brpm": 12.26555, + "peak_hz": 0.204426, + "power": 42430.847033, + "source": "rsa", + "usable_subwindows": 93, + "subwindows": 93 + }, + "confidence": 0.9, + "tier": "HIGH", + "inputs_used": [ + "rr_cleaned", + "beat_times" + ], + "note": "RSA HF-peak respiratory rate (Lomb-Scargle on native beat times, median of 93 300.0s sub-windows, 93 of them within 2.0 br/min of it — brpm, peak_hz and power all come from the medoid sub-window); PRV-derived; this window could resolve up to 30.0 br/min" + }, + "cvhr_apnea": { + "value": { + "cycle_count": 0, + "cvhr_per_hour": 0.0, + "analyzed_hours": 3.919456, + "mean_depth_ms": null, + "mean_width_sec": null, + "depth_quartiles_ms": null, + "width_quartiles_sec": null }, + "confidence": 0.85, + "tier": "HIGH", + "inputs_used": [ + "rr_cleaned", + "beat_times" + ], + "note": "CVHR/ACAT (Hayano) apnea SCREEN — NOT a diagnosis, NOT an AHI; single-night CVHR has substantial night-to-night variability, interpret as a trend over multiple nights" + }, + "odi": { + "value": "—", + "confidence": 0.0, + "tier": "RELATIVE", + "inputs_used": [ + "spo2_red_raw", + "spo2_ir_raw" + ], + "note": "refused: the red and IR channels are one signal — ir − red is a fixed offset within a session, so any ratio built from them measures baseline drift, not oxygenation" + } + }, + "hrv_night_shape": { + "value": { + "bins": [ + { + "t": 0, + "n_beats": 1837, + "rmssd_ms": 19.995915, + "lo_ms": 19.349326, + "hi_ms": 20.642503 + }, + { + "t": 1800, + "n_beats": 1837, + "rmssd_ms": 19.995915, + "lo_ms": 19.349326, + "hi_ms": 20.642503 + }, + { + "t": 3600, + "n_beats": 1837, + "rmssd_ms": 20.016333, + "lo_ms": 19.369085, + "hi_ms": 20.663582 + }, + { + "t": 5400, + "n_beats": 1836, + "rmssd_ms": 20.0, + "lo_ms": 19.353104, + "hi_ms": 20.646896 + }, + { + "t": 7200, + "n_beats": 1837, + "rmssd_ms": 19.995915, + "lo_ms": 19.349326, + "hi_ms": 20.642503 + }, + { + "t": 9000, + "n_beats": 1837, + "rmssd_ms": 20.016333, + "lo_ms": 19.369085, + "hi_ms": 20.663582 + }, + { + "t": 10800, + "n_beats": 1837, + "rmssd_ms": 19.995915, + "lo_ms": 19.349326, + "hi_ms": 20.642503 + }, + { + "t": 12600, + "n_beats": 1541, + "rmssd_ms": 20.0, + "lo_ms": 19.293894, + "hi_ms": 20.706106 + } + ], + "first_third_ms": 19.995915, + "last_third_ms": 19.997957, + "last_over_first": 1.000102 + }, + "confidence": 0.8, + "tier": "HIGH", + "inputs_used": [ + "rr_cleaned", + "beat_times" + ], + "note": "per-bin RMSSD (30-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.", + "origin_ms": 1577923199000.0 + }, + "wellness": { + "skin_temp": { + "value": "—", + "confidence": 0, + "tier": "RELATIVE", + "inputs_used": [ + "skin_temp_raw", + "skin_temp_c" + ], + "note": "relative deviation (z) vs your baseline; raw sensor units, never an absolute °C" + } + }, + "stress": { + "value": 100.0, + "score": 100.0, + "si": 1026.64, + "level": "high", + "lf_hf": 0.003, + "rmssd": 19.9, + "confidence": 0.7, + "tier": "ESTIMATE", + "inputs_used": [ + "rr_cleaned" + ], + "note": "Baevsky Stress Index → 0–100; resting autonomic tension (PRV)." + }, + "spo2": { + "disabled": true, + "value": null, + "odi_per_hour": null, + "dip_count": null, + "analyzed_hours": null, + "mean_dip_pct": null, + "max_dip_pct": null, + "longest_dip_sec": null, + "burden_pct": null, + "signal_coverage": null, + "trusted_coverage": null, + "reject_counts": null, + "severity_counts": null, + "confidence": 0, + "tier": "RELATIVE", + "inputs_used": [ + "spo2_red_raw", + "spo2_ir_raw" + ], + "note": "refused: the red and IR channels are one signal — ir − red is a fixed offset within a session, so any ratio built from them measures baseline drift, not oxygenation", + "debug": { + "sleep_samples": 14399 + } + }, + "series": { + "hr_curve": [ { - "t": 1577937600, - "z": -0.395 + "t": 1577923200, + "v": 63 }, { - "t": 1577937660, - "z": -0.395 + "t": 1577923260, + "v": 63 }, { - "t": 1577937720, - "z": -0.395 + "t": 1577923320, + "v": 63 }, { - "t": 1577937780, - "z": -0.395 + "t": 1577923380, + "v": 63 }, { - "t": 1577937840, - "z": -0.395 + "t": 1577923440, + "v": 63 }, { - "t": 1577937900, - "z": -0.395 + "t": 1577923500, + "v": 63 }, { - "t": 1577937960, - "z": -0.395 + "t": 1577923560, + "v": 63 }, { - "t": 1577938020, - "z": -0.395 + "t": 1577923620, + "v": 63 }, { - "t": 1577938080, - "z": -0.395 + "t": 1577923680, + "v": 63 }, { - "t": 1577938140, - "z": -0.395 + "t": 1577923740, + "v": 63 }, { - "t": 1577944200, - "z": -0.395 + "t": 1577923800, + "v": 63 }, { - "t": 1577944260, - "z": -0.395 + "t": 1577923860, + "v": 63 }, { - "t": 1577944320, - "z": -0.395 + "t": 1577923920, + "v": 63 }, { - "t": 1577944380, - "z": -0.395 + "t": 1577923980, + "v": 63 }, { - "t": 1577944440, - "z": -0.395 + "t": 1577924040, + "v": 63 }, { - "t": 1577944500, - "z": -0.395 + "t": 1577924100, + "v": 63 }, { - "t": 1577944560, - "z": -0.395 + "t": 1577924160, + "v": 63 }, { - "t": 1577944620, - "z": -0.395 + "t": 1577924220, + "v": 63 }, { - "t": 1577944680, - "z": -0.395 + "t": 1577924280, + "v": 63 }, { - "t": 1577944740, - "z": -0.395 + "t": 1577924340, + "v": 63 }, { - "t": 1577944800, - "z": -0.395 + "t": 1577924400, + "v": 63 }, { - "t": 1577944860, - "z": -0.395 + "t": 1577924460, + "v": 63 }, { - "t": 1577944920, - "z": -0.395 + "t": 1577924520, + "v": 63 }, { - "t": 1577944980, - "z": -0.395 + "t": 1577924580, + "v": 63 }, { - "t": 1577945040, - "z": -0.395 + "t": 1577924640, + "v": 63 }, { - "t": 1577945100, - "z": -0.395 + "t": 1577924700, + "v": 63 }, { - "t": 1577945160, - "z": -0.395 + "t": 1577924760, + "v": 63 }, { - "t": 1577945220, - "z": -0.395 + "t": 1577924820, + "v": 63 }, { - "t": 1577945280, - "z": -0.395 + "t": 1577924880, + "v": 63 }, { - "t": 1577945340, - "z": -0.395 + "t": 1577924940, + "v": 63 }, { - "t": 1577945400, - "z": -0.395 + "t": 1577925000, + "v": 63 }, { - "t": 1577945460, - "z": -0.395 + "t": 1577925060, + "v": 63 }, { - "t": 1577945520, - "z": -0.395 + "t": 1577925120, + "v": 63 }, { - "t": 1577945580, - "z": -0.395 + "t": 1577925180, + "v": 63 }, { - "t": 1577945640, - "z": -0.395 + "t": 1577925240, + "v": 63 }, { - "t": 1577945700, - "z": -0.395 + "t": 1577925300, + "v": 63 }, { - "t": 1577945760, - "z": -0.395 + "t": 1577925360, + "v": 63 }, { - "t": 1577945820, - "z": -0.395 + "t": 1577925420, + "v": 63 }, { - "t": 1577945880, - "z": -0.395 + "t": 1577925480, + "v": 63 }, { - "t": 1577945940, - "z": -0.395 + "t": 1577925540, + "v": 63 }, { - "t": 1577946000, - "z": 2.236 + "t": 1577925600, + "v": 63 }, { - "t": 1577946060, - "z": 2.236 + "t": 1577925660, + "v": 63 }, { - "t": 1577946120, - "z": 2.236 + "t": 1577925720, + "v": 63 }, { - "t": 1577946180, - "z": 2.236 + "t": 1577925780, + "v": 63 }, { - "t": 1577946240, - "z": 2.236 + "t": 1577925840, + "v": 63 }, { - "t": 1577946300, - "z": 2.236 + "t": 1577925900, + "v": 63 }, { - "t": 1577946360, - "z": 2.236 + "t": 1577925960, + "v": 63 }, { - "t": 1577946420, - "z": 2.236 + "t": 1577926020, + "v": 63 }, { - "t": 1577946480, - "z": 2.236 + "t": 1577926080, + "v": 63 }, { - "t": 1577946540, - "z": 2.236 + "t": 1577926140, + "v": 63 }, { - "t": 1577946600, - "z": 2.236 + "t": 1577926200, + "v": 63 }, { - "t": 1577946660, - "z": 2.236 + "t": 1577926260, + "v": 63 }, { - "t": 1577946720, - "z": 2.236 + "t": 1577926320, + "v": 63 }, { - "t": 1577946780, - "z": 2.236 + "t": 1577926380, + "v": 63 }, { - "t": 1577946840, - "z": 2.236 + "t": 1577926440, + "v": 63 }, { - "t": 1577946900, - "z": 2.236 + "t": 1577926500, + "v": 63 }, { - "t": 1577946960, - "z": 2.236 + "t": 1577926560, + "v": 63 }, { - "t": 1577947020, - "z": 2.236 + "t": 1577926620, + "v": 63 }, { - "t": 1577947080, - "z": 2.236 + "t": 1577926680, + "v": 63 }, { - "t": 1577947140, - "z": 2.236 + "t": 1577926740, + "v": 63 }, { - "t": 1577947200, - "z": 2.236 + "t": 1577926800, + "v": 63 }, { - "t": 1577947260, - "z": -0.395 + "t": 1577926860, + "v": 63 }, { - "t": 1577947320, - "z": -0.395 + "t": 1577926920, + "v": 63 }, { - "t": 1577947380, - "z": -0.395 + "t": 1577926980, + "v": 63 }, { - "t": 1577947440, - "z": -0.395 + "t": 1577927040, + "v": 63 }, { - "t": 1577947500, - "z": -0.395 + "t": 1577927100, + "v": 63 }, { - "t": 1577947560, - "z": -0.395 + "t": 1577927160, + "v": 63 }, { - "t": 1577947620, - "z": -0.395 + "t": 1577927220, + "v": 63 }, { - "t": 1577947680, - "z": -0.395 + "t": 1577927280, + "v": 63 }, { - "t": 1577947740, - "z": -0.395 + "t": 1577927340, + "v": 63 }, { - "t": 1577947800, - "z": -0.395 + "t": 1577927400, + "v": 63 }, { - "t": 1577947860, - "z": -0.395 + "t": 1577927460, + "v": 63 }, { - "t": 1577947920, - "z": -0.395 + "t": 1577927520, + "v": 63 }, { - "t": 1577947980, - "z": -0.395 + "t": 1577927580, + "v": 63 }, { - "t": 1577948040, - "z": -0.395 + "t": 1577927640, + "v": 63 }, { - "t": 1577948100, - "z": -0.395 + "t": 1577927700, + "v": 63 }, { - "t": 1577948160, - "z": -0.395 + "t": 1577927760, + "v": 63 }, { - "t": 1577948220, - "z": -0.395 + "t": 1577927820, + "v": 63 }, { - "t": 1577948280, - "z": -0.395 + "t": 1577927880, + "v": 63 }, { - "t": 1577948340, - "z": -0.395 + "t": 1577927940, + "v": 63 }, { - "t": 1577948400, - "z": -0.395 + "t": 1577928000, + "v": 63 }, { - "t": 1577948460, - "z": -0.395 + "t": 1577928060, + "v": 63 }, { - "t": 1577948520, - "z": -0.395 + "t": 1577928120, + "v": 63 }, { - "t": 1577948580, - "z": -0.395 + "t": 1577928180, + "v": 63 }, { - "t": 1577948640, - "z": -0.395 + "t": 1577928240, + "v": 63 }, { - "t": 1577948700, - "z": 2.236 + "t": 1577928300, + "v": 63 }, { - "t": 1577948760, - "z": 2.236 + "t": 1577928360, + "v": 63 }, { - "t": 1577948820, - "z": 2.236 + "t": 1577928420, + "v": 63 }, { - "t": 1577948880, - "z": 2.236 + "t": 1577928480, + "v": 63 }, { - "t": 1577948940, - "z": 2.236 + "t": 1577928540, + "v": 63 }, { - "t": 1577949000, - "z": 2.236 + "t": 1577928600, + "v": 63 }, { - "t": 1577949060, - "z": 2.236 + "t": 1577928660, + "v": 63 }, { - "t": 1577949120, - "z": 2.236 + "t": 1577928720, + "v": 63 }, { - "t": 1577949180, - "z": 2.236 + "t": 1577928780, + "v": 63 }, { - "t": 1577949240, - "z": 2.236 + "t": 1577928840, + "v": 63 }, { - "t": 1577949300, - "z": 2.236 + "t": 1577928900, + "v": 63 }, { - "t": 1577949360, - "z": 2.236 + "t": 1577928960, + "v": 63 }, { - "t": 1577949420, - "z": 2.236 + "t": 1577929020, + "v": 63 }, { - "t": 1577949480, - "z": 2.236 + "t": 1577929080, + "v": 63 }, { - "t": 1577949540, - "z": 2.236 + "t": 1577929140, + "v": 63 }, { - "t": 1577949600, - "z": 2.236 + "t": 1577929200, + "v": 63 }, { - "t": 1577949660, - "z": 2.236 + "t": 1577929260, + "v": 63 }, { - "t": 1577949720, - "z": 2.236 + "t": 1577929320, + "v": 63 }, { - "t": 1577949780, - "z": 2.236 + "t": 1577929380, + "v": 63 }, { - "t": 1577949840, - "z": 2.236 + "t": 1577929440, + "v": 63 }, { - "t": 1577949900, - "z": 2.236 + "t": 1577929500, + "v": 63 }, { - "t": 1577949960, - "z": -0.395 + "t": 1577929560, + "v": 63 }, { - "t": 1577950020, - "z": -0.395 + "t": 1577929620, + "v": 63 }, { - "t": 1577950080, - "z": -0.395 + "t": 1577929680, + "v": 63 }, { - "t": 1577950140, - "z": -0.395 + "t": 1577929740, + "v": 63 }, { - "t": 1577950200, - "z": -0.395 + "t": 1577929800, + "v": 63 }, { - "t": 1577950260, - "z": -0.395 + "t": 1577929860, + "v": 63 }, { - "t": 1577950320, - "z": -0.395 + "t": 1577929920, + "v": 63 }, { - "t": 1577950380, - "z": -0.395 + "t": 1577929980, + "v": 63 }, { - "t": 1577950440, - "z": -0.395 + "t": 1577930040, + "v": 63 }, { - "t": 1577950500, - "z": -0.395 + "t": 1577930100, + "v": 63 }, { - "t": 1577950560, - "z": -0.395 + "t": 1577930160, + "v": 63 }, { - "t": 1577950620, - "z": -0.395 - }, - { - "t": 1577950680, - "z": -0.395 - }, - { - "t": 1577950740, - "z": -0.395 - }, - { - "t": 1577950800, - "z": -0.395 - }, - { - "t": 1577950860, - "z": -0.395 - }, - { - "t": 1577950920, - "z": -0.395 - }, - { - "t": 1577950980, - "z": -0.395 - }, - { - "t": 1577951040, - "z": -0.395 - }, - { - "t": 1577951100, - "z": -0.395 - }, - { - "t": 1577951160, - "z": -0.395 - }, - { - "t": 1577951220, - "z": -0.395 - }, - { - "t": 1577951280, - "z": -0.395 - }, - { - "t": 1577951340, - "z": 2.236 - }, - { - "t": 1577951400, - "z": 2.368 - }, - { - "t": 1577951460, - "z": 2.513 - }, - { - "t": 1577951520, - "z": 2.675 - }, - { - "t": 1577951580, - "z": 2.855 - }, - { - "t": 1577951640, - "z": 3.058 - }, - { - "t": 1577951700, - "z": 3.288 - }, - { - "t": 1577951760, - "z": 3.552 - }, - { - "t": 1577951820, - "z": 3.855 - }, - { - "t": 1577951880, - "z": 4.209 - }, - { - "t": 1577951940, - "z": 4.628 - } - ] - }, - "zones": {}, - "max_hr_used": null, - "device_family": "gen4", - "zone_source": null, - "zone_max_hr": null, - "zone_lower_bpm": null, - "hr_stats": { - "max": 65, - "min": 61, - "avg": 63 - }, - "calories": null, - "absent_notes": { - "max_hr_used": "need_input:name=age", - "zones": "need_input:name=wake_hr", - "calories": "need_input:name=wake_hr", - "calories_total": "need_input:name=wake_hr", - "strain": "need_input:name=wake_hr", - "trimp": "need_input:name=wake_hr" - }, - "respiration": { - "rsa": { - "value": "—", - "confidence": 0.0, - "tier": "HIGH", - "inputs_used": [ - "rr_cleaned", - "beat_times" - ], - "note": "beat rate 45.079242 bpm resolves only to 22.539621 br/min — below the HF band, any peak could be an alias; rate withheld" - }, - "cvhr_apnea": { - "value": { - "cycle_count": 0, - "cvhr_per_hour": 0.0, - "analyzed_hours": 5.878072, - "mean_depth_ms": null, - "mean_width_sec": null, - "depth_quartiles_ms": null, - "width_quartiles_sec": null - }, - "confidence": 0.85, - "tier": "HIGH", - "inputs_used": [ - "rr_cleaned", - "beat_times" - ], - "note": "CVHR/ACAT (Hayano) apnea SCREEN — NOT a diagnosis, NOT an AHI; single-night CVHR has substantial night-to-night variability, interpret as a trend over multiple nights" - }, - "odi": { - "value": "—", - "confidence": 0.0, - "tier": "RELATIVE", - "inputs_used": [ - "spo2_red_raw", - "spo2_ir_raw" - ], - "note": "refused: the red and IR channels are one signal — ir − red is a fixed offset within a session, so any ratio built from them measures baseline drift, not oxygenation" - } - }, - "hrv_night_shape": { - "value": { - "bins": [ - { - "t": 0, - "n_beats": 1837, - "rmssd_ms": 19.995915, - "lo_ms": 19.349326, - "hi_ms": 20.642503 - }, - { - "t": 1800, - "n_beats": 1837, - "rmssd_ms": 19.995915, - "lo_ms": 19.349326, - "hi_ms": 20.642503 - }, - { - "t": 3600, - "n_beats": 1837, - "rmssd_ms": 20.016333, - "lo_ms": 19.369085, - "hi_ms": 20.663582 - }, - { - "t": 5400, - "n_beats": 1836, - "rmssd_ms": 20.0, - "lo_ms": 19.353104, - "hi_ms": 20.646896 - }, - { - "t": 7200, - "n_beats": 1837, - "rmssd_ms": 19.995915, - "lo_ms": 19.349326, - "hi_ms": 20.642503 - }, - { - "t": 9000, - "n_beats": 1837, - "rmssd_ms": 20.016333, - "lo_ms": 19.369085, - "hi_ms": 20.663582 - }, - { - "t": 10800, - "n_beats": 1837, - "rmssd_ms": 19.995915, - "lo_ms": 19.349326, - "hi_ms": 20.642503 - }, - { - "t": 12600, - "n_beats": 1542, - "rmssd_ms": 19.995132, - "lo_ms": 19.289427, - "hi_ms": 20.700837 - }, - { - "t": 14400, - "n_beats": 0, - "rmssd_ms": null, - "lo_ms": null, - "hi_ms": null - }, - { - "t": 16200, - "n_beats": 0, - "rmssd_ms": null, - "lo_ms": null, - "hi_ms": null - }, - { - "t": 18000, - "n_beats": 0, - "rmssd_ms": null, - "lo_ms": null, - "hi_ms": null - }, - { - "t": 19800, - "n_beats": 0, - "rmssd_ms": null, - "lo_ms": null, - "hi_ms": null - }, - { - "t": 21600, - "n_beats": 1800, - "rmssd_ms": 19.983317, - "lo_ms": 19.330529, - "hi_ms": 20.636105 - }, - { - "t": 23400, - "n_beats": 1836, - "rmssd_ms": 20.0, - "lo_ms": 19.353104, - "hi_ms": 20.646896 - }, - { - "t": 25200, - "n_beats": 1781, - "rmssd_ms": 19.983129, - "lo_ms": 19.326874, - "hi_ms": 20.639384 - }, - { - "t": 27000, - "n_beats": 1780, - "rmssd_ms": 20.004215, - "lo_ms": 19.347083, - "hi_ms": 20.661347 - } - ], - "first_third_ms": 20.000815, - "last_third_ms": 19.992665, - "last_over_first": 0.999593 - }, - "confidence": 0.6, - "tier": "HIGH", - "inputs_used": [ - "rr_cleaned", - "beat_times" - ], - "note": "per-bin RMSSD (30-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.", - "origin_ms": 1577923199000.0 - }, - "wellness": { - "skin_temp": { - "value": "—", - "confidence": 0, - "tier": "RELATIVE", - "inputs_used": [ - "skin_temp_raw", - "skin_temp_c" - ], - "note": "relative deviation (z) vs your baseline; raw sensor units, never an absolute °C" - } - }, - "stress": { - "value": 100.0, - "score": 100.0, - "si": 1026.64, - "level": "high", - "lf_hf": 0.003, - "rmssd": 19.9, - "confidence": 0.7, - "tier": "ESTIMATE", - "inputs_used": [ - "rr_cleaned" - ], - "note": "Baevsky Stress Index → 0–100; resting autonomic tension (PRV)." - }, - "spo2": { - "disabled": true, - "value": null, - "odi_per_hour": null, - "dip_count": null, - "analyzed_hours": null, - "mean_dip_pct": null, - "max_dip_pct": null, - "longest_dip_sec": null, - "burden_pct": null, - "signal_coverage": null, - "trusted_coverage": null, - "reject_counts": null, - "severity_counts": null, - "confidence": 0, - "tier": "RELATIVE", - "inputs_used": [ - "spo2_red_raw", - "spo2_ir_raw" - ], - "note": "refused: the red and IR channels are one signal — ir − red is a fixed offset within a session, so any ratio built from them measures baseline drift, not oxygenation", - "debug": { - "sleep_samples": 21597 - } - }, - "series": { - "hr_curve": [ - { - "t": 1577923200, - "v": 63 - }, - { - "t": 1577923260, - "v": 63 - }, - { - "t": 1577923320, - "v": 63 - }, - { - "t": 1577923380, - "v": 63 - }, - { - "t": 1577923440, - "v": 63 - }, - { - "t": 1577923500, - "v": 63 - }, - { - "t": 1577923560, - "v": 63 - }, - { - "t": 1577923620, - "v": 63 - }, - { - "t": 1577923680, - "v": 63 - }, - { - "t": 1577923740, - "v": 63 - }, - { - "t": 1577923800, - "v": 63 - }, - { - "t": 1577923860, - "v": 63 - }, - { - "t": 1577923920, - "v": 63 - }, - { - "t": 1577923980, - "v": 63 - }, - { - "t": 1577924040, - "v": 63 - }, - { - "t": 1577924100, - "v": 63 - }, - { - "t": 1577924160, - "v": 63 - }, - { - "t": 1577924220, - "v": 63 - }, - { - "t": 1577924280, - "v": 63 - }, - { - "t": 1577924340, - "v": 63 - }, - { - "t": 1577924400, - "v": 63 - }, - { - "t": 1577924460, - "v": 63 - }, - { - "t": 1577924520, - "v": 63 - }, - { - "t": 1577924580, - "v": 63 - }, - { - "t": 1577924640, - "v": 63 - }, - { - "t": 1577924700, - "v": 63 - }, - { - "t": 1577924760, - "v": 63 - }, - { - "t": 1577924820, - "v": 63 - }, - { - "t": 1577924880, - "v": 63 - }, - { - "t": 1577924940, - "v": 63 - }, - { - "t": 1577925000, - "v": 63 - }, - { - "t": 1577925060, - "v": 63 - }, - { - "t": 1577925120, - "v": 63 - }, - { - "t": 1577925180, - "v": 63 - }, - { - "t": 1577925240, - "v": 63 - }, - { - "t": 1577925300, - "v": 63 - }, - { - "t": 1577925360, - "v": 63 - }, - { - "t": 1577925420, - "v": 63 - }, - { - "t": 1577925480, - "v": 63 - }, - { - "t": 1577925540, - "v": 63 - }, - { - "t": 1577925600, - "v": 63 - }, - { - "t": 1577925660, - "v": 63 - }, - { - "t": 1577925720, - "v": 63 - }, - { - "t": 1577925780, - "v": 63 - }, - { - "t": 1577925840, - "v": 63 - }, - { - "t": 1577925900, - "v": 63 - }, - { - "t": 1577925960, - "v": 63 - }, - { - "t": 1577926020, - "v": 63 - }, - { - "t": 1577926080, - "v": 63 - }, - { - "t": 1577926140, - "v": 63 - }, - { - "t": 1577926200, - "v": 63 - }, - { - "t": 1577926260, - "v": 63 - }, - { - "t": 1577926320, - "v": 63 - }, - { - "t": 1577926380, - "v": 63 - }, - { - "t": 1577926440, - "v": 63 - }, - { - "t": 1577926500, - "v": 63 - }, - { - "t": 1577926560, - "v": 63 - }, - { - "t": 1577926620, - "v": 63 - }, - { - "t": 1577926680, - "v": 63 - }, - { - "t": 1577926740, - "v": 63 - }, - { - "t": 1577926800, - "v": 63 - }, - { - "t": 1577926860, - "v": 63 - }, - { - "t": 1577926920, - "v": 63 - }, - { - "t": 1577926980, - "v": 63 - }, - { - "t": 1577927040, - "v": 63 - }, - { - "t": 1577927100, - "v": 63 - }, - { - "t": 1577927160, - "v": 63 - }, - { - "t": 1577927220, - "v": 63 - }, - { - "t": 1577927280, - "v": 63 - }, - { - "t": 1577927340, - "v": 63 - }, - { - "t": 1577927400, - "v": 63 - }, - { - "t": 1577927460, - "v": 63 - }, - { - "t": 1577927520, - "v": 63 - }, - { - "t": 1577927580, - "v": 63 - }, - { - "t": 1577927640, - "v": 63 - }, - { - "t": 1577927700, - "v": 63 - }, - { - "t": 1577927760, - "v": 63 - }, - { - "t": 1577927820, - "v": 63 - }, - { - "t": 1577927880, - "v": 63 - }, - { - "t": 1577927940, - "v": 63 - }, - { - "t": 1577928000, - "v": 63 - }, - { - "t": 1577928060, - "v": 63 - }, - { - "t": 1577928120, - "v": 63 - }, - { - "t": 1577928180, - "v": 63 - }, - { - "t": 1577928240, - "v": 63 - }, - { - "t": 1577928300, - "v": 63 - }, - { - "t": 1577928360, - "v": 63 - }, - { - "t": 1577928420, - "v": 63 - }, - { - "t": 1577928480, - "v": 63 - }, - { - "t": 1577928540, - "v": 63 - }, - { - "t": 1577928600, - "v": 63 - }, - { - "t": 1577928660, - "v": 63 - }, - { - "t": 1577928720, - "v": 63 - }, - { - "t": 1577928780, - "v": 63 - }, - { - "t": 1577928840, - "v": 63 - }, - { - "t": 1577928900, - "v": 63 - }, - { - "t": 1577928960, - "v": 63 - }, - { - "t": 1577929020, - "v": 63 - }, - { - "t": 1577929080, - "v": 63 - }, - { - "t": 1577929140, - "v": 63 - }, - { - "t": 1577929200, - "v": 63 - }, - { - "t": 1577929260, - "v": 63 - }, - { - "t": 1577929320, - "v": 63 - }, - { - "t": 1577929380, - "v": 63 - }, - { - "t": 1577929440, - "v": 63 - }, - { - "t": 1577929500, - "v": 63 - }, - { - "t": 1577929560, - "v": 63 - }, - { - "t": 1577929620, - "v": 63 - }, - { - "t": 1577929680, - "v": 63 - }, - { - "t": 1577929740, - "v": 63 - }, - { - "t": 1577929800, - "v": 63 - }, - { - "t": 1577929860, - "v": 63 - }, - { - "t": 1577929920, - "v": 63 - }, - { - "t": 1577929980, - "v": 63 - }, - { - "t": 1577930040, - "v": 63 - }, - { - "t": 1577930100, - "v": 63 - }, - { - "t": 1577930160, - "v": 63 - }, - { - "t": 1577930220, - "v": 63 - }, - { - "t": 1577930280, - "v": 63 - }, - { - "t": 1577930340, - "v": 63 - }, - { - "t": 1577930400, - "v": 63 - }, - { - "t": 1577930460, - "v": 63 - }, - { - "t": 1577930520, - "v": 63 - }, - { - "t": 1577930580, - "v": 63 - }, - { - "t": 1577930640, - "v": 63 - }, - { - "t": 1577930700, - "v": 63 - }, - { - "t": 1577930760, - "v": 63 - }, - { - "t": 1577930820, - "v": 63 - }, - { - "t": 1577930880, - "v": 63 - }, - { - "t": 1577930940, - "v": 63 - }, - { - "t": 1577931000, - "v": 63 - }, - { - "t": 1577931060, - "v": 63 - }, - { - "t": 1577931120, - "v": 63 - }, - { - "t": 1577931180, - "v": 63 - }, - { - "t": 1577931240, - "v": 63 - }, - { - "t": 1577931300, - "v": 63 - }, - { - "t": 1577931360, - "v": 63 - }, - { - "t": 1577931420, - "v": 63 - }, - { - "t": 1577931480, - "v": 63 - }, - { - "t": 1577931540, - "v": 63 - }, - { - "t": 1577931600, - "v": 63 - }, - { - "t": 1577931660, - "v": 63 - }, - { - "t": 1577931720, - "v": 63 - }, - { - "t": 1577931780, - "v": 63 - }, - { - "t": 1577931840, - "v": 63 - }, - { - "t": 1577931900, - "v": 63 - }, - { - "t": 1577931960, - "v": 63 - }, - { - "t": 1577932020, - "v": 63 - }, - { - "t": 1577932080, - "v": 63 - }, - { - "t": 1577932140, - "v": 63 - }, - { - "t": 1577932200, - "v": 63 - }, - { - "t": 1577932260, - "v": 63 - }, - { - "t": 1577932320, - "v": 63 - }, - { - "t": 1577932380, - "v": 63 - }, - { - "t": 1577932440, - "v": 63 - }, - { - "t": 1577932500, - "v": 63 - }, - { - "t": 1577932560, - "v": 63 - }, - { - "t": 1577932620, - "v": 63 - }, - { - "t": 1577932680, - "v": 63 - }, - { - "t": 1577932740, - "v": 63 - }, - { - "t": 1577932800, - "v": 63 - }, - { - "t": 1577932860, - "v": 63 - }, - { - "t": 1577932920, - "v": 63 - }, - { - "t": 1577932980, - "v": 63 - }, - { - "t": 1577933040, - "v": 63 - }, - { - "t": 1577933100, - "v": 63 - }, - { - "t": 1577933160, - "v": 63 - }, - { - "t": 1577933220, - "v": 63 - }, - { - "t": 1577933280, - "v": 63 - }, - { - "t": 1577933340, - "v": 63 - }, - { - "t": 1577933400, - "v": 63 - }, - { - "t": 1577933460, - "v": 63 - }, - { - "t": 1577933520, - "v": 63 - }, - { - "t": 1577933580, - "v": 63 - }, - { - "t": 1577933640, - "v": 63 - }, - { - "t": 1577933700, - "v": 63 - }, - { - "t": 1577933760, - "v": 63 - }, - { - "t": 1577933820, - "v": 63 - }, - { - "t": 1577933880, - "v": 63 - }, - { - "t": 1577933940, - "v": 63 - }, - { - "t": 1577934000, - "v": 63 - }, - { - "t": 1577934060, - "v": 63 - }, - { - "t": 1577934120, - "v": 63 - }, - { - "t": 1577934180, - "v": 63 - }, - { - "t": 1577934240, - "v": 63 - }, - { - "t": 1577934300, - "v": 63 - }, - { - "t": 1577934360, - "v": 63 - }, - { - "t": 1577934420, - "v": 63 - }, - { - "t": 1577934480, - "v": 63 - }, - { - "t": 1577934540, - "v": 63 - }, - { - "t": 1577934600, - "v": 63 - }, - { - "t": 1577934660, - "v": 63 - }, - { - "t": 1577934720, - "v": 63 - }, - { - "t": 1577934780, - "v": 63 - }, - { - "t": 1577934840, - "v": 63 - }, - { - "t": 1577934900, - "v": 63 - }, - { - "t": 1577934960, - "v": 63 - }, - { - "t": 1577935020, - "v": 63 - }, - { - "t": 1577935080, - "v": 63 - }, - { - "t": 1577935140, - "v": 63 - }, - { - "t": 1577935200, - "v": 63 - }, - { - "t": 1577935260, - "v": 63 - }, - { - "t": 1577935320, - "v": 63 - }, - { - "t": 1577935380, - "v": 63 - }, - { - "t": 1577935440, - "v": 63 - }, - { - "t": 1577935500, - "v": 63 - }, - { - "t": 1577935560, - "v": 63 - }, - { - "t": 1577935620, - "v": 63 - }, - { - "t": 1577935680, - "v": 63 - }, - { - "t": 1577935740, - "v": 63 - }, - { - "t": 1577935800, - "v": 63 - }, - { - "t": 1577935860, - "v": 63 - }, - { - "t": 1577935920, - "v": 63 - }, - { - "t": 1577935980, - "v": 63 - }, - { - "t": 1577936040, - "v": 63 - }, - { - "t": 1577936100, - "v": 63 - }, - { - "t": 1577936160, - "v": 63 - }, - { - "t": 1577936220, - "v": 63 - }, - { - "t": 1577936280, - "v": 63 - }, - { - "t": 1577936340, - "v": 63 - }, - { - "t": 1577936400, - "v": 63 - }, - { - "t": 1577936460, - "v": 63 - }, - { - "t": 1577936520, - "v": 63 - }, - { - "t": 1577936580, + "t": 1577930220, "v": 63 }, { - "t": 1577936640, + "t": 1577930280, "v": 63 }, { - "t": 1577936700, + "t": 1577930340, "v": 63 }, { - "t": 1577936760, + "t": 1577930400, "v": 63 }, { - "t": 1577936820, + "t": 1577930460, "v": 63 }, { - "t": 1577936880, + "t": 1577930520, "v": 63 }, { - "t": 1577936940, + "t": 1577930580, "v": 63 }, { - "t": 1577937000, + "t": 1577930640, "v": 63 }, { - "t": 1577937060, + "t": 1577930700, "v": 63 }, { - "t": 1577937120, + "t": 1577930760, "v": 63 }, { - "t": 1577937180, + "t": 1577930820, "v": 63 }, { - "t": 1577937240, + "t": 1577930880, "v": 63 }, { - "t": 1577937300, + "t": 1577930940, "v": 63 }, { - "t": 1577937360, + "t": 1577931000, "v": 63 }, { - "t": 1577937420, + "t": 1577931060, "v": 63 }, { - "t": 1577937480, + "t": 1577931120, "v": 63 }, { - "t": 1577937540, + "t": 1577931180, "v": 63 }, { - "t": 1577944800, + "t": 1577931240, "v": 63 }, { - "t": 1577944860, + "t": 1577931300, "v": 63 }, { - "t": 1577944920, + "t": 1577931360, "v": 63 }, { - "t": 1577944980, + "t": 1577931420, "v": 63 }, { - "t": 1577945040, + "t": 1577931480, "v": 63 }, { - "t": 1577945100, + "t": 1577931540, "v": 63 }, { - "t": 1577945160, + "t": 1577931600, "v": 63 }, { - "t": 1577945220, + "t": 1577931660, "v": 63 }, { - "t": 1577945280, + "t": 1577931720, "v": 63 }, { - "t": 1577945340, + "t": 1577931780, "v": 63 }, { - "t": 1577945400, + "t": 1577931840, "v": 63 }, { - "t": 1577945460, + "t": 1577931900, "v": 63 }, { - "t": 1577945520, + "t": 1577931960, "v": 63 }, { - "t": 1577945580, + "t": 1577932020, "v": 63 }, { - "t": 1577945640, + "t": 1577932080, "v": 63 }, { - "t": 1577945700, + "t": 1577932140, "v": 63 }, { - "t": 1577945760, + "t": 1577932200, "v": 63 }, { - "t": 1577945820, + "t": 1577932260, "v": 63 }, { - "t": 1577945880, + "t": 1577932320, "v": 63 }, { - "t": 1577945940, + "t": 1577932380, "v": 63 }, { - "t": 1577946000, + "t": 1577932440, "v": 63 }, { - "t": 1577946060, + "t": 1577932500, "v": 63 }, { - "t": 1577946120, + "t": 1577932560, "v": 63 }, { - "t": 1577946180, + "t": 1577932620, "v": 63 }, { - "t": 1577946240, + "t": 1577932680, "v": 63 }, { - "t": 1577946300, + "t": 1577932740, "v": 63 }, { - "t": 1577946360, + "t": 1577932800, "v": 63 }, { - "t": 1577946420, + "t": 1577932860, "v": 63 }, { - "t": 1577946480, + "t": 1577932920, "v": 63 }, { - "t": 1577946540, + "t": 1577932980, "v": 63 }, { - "t": 1577946600, + "t": 1577933040, "v": 63 }, { - "t": 1577946660, + "t": 1577933100, "v": 63 }, { - "t": 1577946720, + "t": 1577933160, "v": 63 }, { - "t": 1577946780, + "t": 1577933220, "v": 63 }, { - "t": 1577946840, + "t": 1577933280, "v": 63 }, { - "t": 1577946900, + "t": 1577933340, "v": 63 }, { - "t": 1577946960, + "t": 1577933400, "v": 63 }, { - "t": 1577947020, + "t": 1577933460, "v": 63 }, { - "t": 1577947080, + "t": 1577933520, "v": 63 }, { - "t": 1577947140, + "t": 1577933580, "v": 63 }, { - "t": 1577947200, + "t": 1577933640, "v": 63 }, { - "t": 1577947260, + "t": 1577933700, "v": 63 }, { - "t": 1577947320, + "t": 1577933760, "v": 63 }, { - "t": 1577947380, + "t": 1577933820, "v": 63 }, { - "t": 1577947440, + "t": 1577933880, "v": 63 }, { - "t": 1577947500, + "t": 1577933940, "v": 63 }, { - "t": 1577947560, + "t": 1577934000, "v": 63 }, { - "t": 1577947620, + "t": 1577934060, "v": 63 }, { - "t": 1577947680, + "t": 1577934120, "v": 63 }, { - "t": 1577947740, + "t": 1577934180, "v": 63 }, { - "t": 1577947800, + "t": 1577934240, "v": 63 }, { - "t": 1577947860, + "t": 1577934300, "v": 63 }, { - "t": 1577947920, + "t": 1577934360, "v": 63 }, { - "t": 1577947980, + "t": 1577934420, "v": 63 }, { - "t": 1577948040, + "t": 1577934480, "v": 63 }, { - "t": 1577948100, + "t": 1577934540, "v": 63 }, { - "t": 1577948160, + "t": 1577934600, "v": 63 }, { - "t": 1577948220, + "t": 1577934660, "v": 63 }, { - "t": 1577948280, + "t": 1577934720, "v": 63 }, { - "t": 1577948340, + "t": 1577934780, "v": 63 }, { - "t": 1577948400, + "t": 1577934840, "v": 63 }, { - "t": 1577948460, + "t": 1577934900, "v": 63 }, { - "t": 1577948520, + "t": 1577934960, "v": 63 }, { - "t": 1577948580, + "t": 1577935020, "v": 63 }, { - "t": 1577948640, + "t": 1577935080, "v": 63 }, { - "t": 1577948700, + "t": 1577935140, "v": 63 }, { - "t": 1577948760, + "t": 1577935200, "v": 63 }, { - "t": 1577948820, + "t": 1577935260, "v": 63 }, { - "t": 1577948880, + "t": 1577935320, "v": 63 }, { - "t": 1577948940, + "t": 1577935380, "v": 63 }, { - "t": 1577949000, + "t": 1577935440, "v": 63 }, { - "t": 1577949060, + "t": 1577935500, "v": 63 }, { - "t": 1577949120, + "t": 1577935560, "v": 63 }, { - "t": 1577949180, + "t": 1577935620, "v": 63 }, { - "t": 1577949240, + "t": 1577935680, "v": 63 }, { - "t": 1577949300, + "t": 1577935740, "v": 63 }, { - "t": 1577949360, + "t": 1577935800, "v": 63 }, { - "t": 1577949420, + "t": 1577935860, "v": 63 }, { - "t": 1577949480, + "t": 1577935920, "v": 63 }, { - "t": 1577949540, + "t": 1577935980, "v": 63 }, { - "t": 1577949600, + "t": 1577936040, "v": 63 }, { - "t": 1577949660, + "t": 1577936100, "v": 63 }, { - "t": 1577949720, + "t": 1577936160, "v": 63 }, { - "t": 1577949780, + "t": 1577936220, "v": 63 }, { - "t": 1577949840, + "t": 1577936280, "v": 63 }, { - "t": 1577949900, + "t": 1577936340, "v": 63 }, { - "t": 1577949960, + "t": 1577936400, "v": 63 }, { - "t": 1577950020, + "t": 1577936460, "v": 63 }, { - "t": 1577950080, + "t": 1577936520, "v": 63 }, { - "t": 1577950140, + "t": 1577936580, "v": 63 }, { - "t": 1577950200, + "t": 1577936640, "v": 63 }, { - "t": 1577950260, + "t": 1577936700, "v": 63 }, { - "t": 1577950320, + "t": 1577936760, "v": 63 }, { - "t": 1577950380, + "t": 1577936820, "v": 63 }, { - "t": 1577950440, + "t": 1577936880, "v": 63 }, { - "t": 1577950500, + "t": 1577936940, "v": 63 }, { - "t": 1577950560, + "t": 1577937000, "v": 63 }, { - "t": 1577950620, + "t": 1577937060, "v": 63 }, { - "t": 1577950680, + "t": 1577937120, "v": 63 }, { - "t": 1577950740, + "t": 1577937180, "v": 63 }, { - "t": 1577950800, + "t": 1577937240, "v": 63 }, { - "t": 1577950860, + "t": 1577937300, "v": 63 }, { - "t": 1577950920, + "t": 1577937360, "v": 63 }, { - "t": 1577950980, + "t": 1577937420, "v": 63 }, { - "t": 1577951040, + "t": 1577937480, "v": 63 }, { - "t": 1577951100, + "t": 1577937540, "v": 63 }, { - "t": 1577951160, + "t": 1577944800, "v": 63 }, { - "t": 1577951220, + "t": 1577944860, "v": 63 }, { - "t": 1577951280, + "t": 1577944920, "v": 63 }, { - "t": 1577951340, + "t": 1577944980, "v": 63 }, { - "t": 1577951400, + "t": 1577945040, "v": 63 }, { - "t": 1577951460, + "t": 1577945100, "v": 63 }, { - "t": 1577951520, + "t": 1577945160, "v": 63 }, { - "t": 1577951580, + "t": 1577945220, "v": 63 }, { - "t": 1577951640, + "t": 1577945280, "v": 63 }, { - "t": 1577951700, + "t": 1577945340, "v": 63 }, { - "t": 1577951760, + "t": 1577945400, "v": 63 }, { - "t": 1577951820, + "t": 1577945460, "v": 63 }, { - "t": 1577951880, + "t": 1577945520, "v": 63 }, { - "t": 1577951940, + "t": 1577945580, "v": 63 - } - ], - "strain_curve": [], - "zone_timeline": [], - "hrv_timeline": [ - { - "t": 1577923501, - "v": 20.0 }, { - "t": 1577923562, - "v": 20.0 + "t": 1577945640, + "v": 63 }, { - "t": 1577923623, - "v": 20.0 + "t": 1577945700, + "v": 63 }, { - "t": 1577923684, - "v": 20.0 + "t": 1577945760, + "v": 63 }, { - "t": 1577923745, - "v": 20.0 + "t": 1577945820, + "v": 63 }, { - "t": 1577923806, - "v": 20.0 + "t": 1577945880, + "v": 63 }, { - "t": 1577923867, - "v": 20.0 + "t": 1577945940, + "v": 63 }, { - "t": 1577923928, - "v": 20.0 + "t": 1577946000, + "v": 63 }, { - "t": 1577923989, - "v": 20.1 + "t": 1577946060, + "v": 63 }, { - "t": 1577924050, - "v": 20.0 + "t": 1577946120, + "v": 63 }, { - "t": 1577924111, - "v": 20.1 + "t": 1577946180, + "v": 63 }, { - "t": 1577924172, - "v": 20.0 + "t": 1577946240, + "v": 63 }, { - "t": 1577924233, - "v": 20.0 + "t": 1577946300, + "v": 63 }, { - "t": 1577924294, - "v": 20.0 + "t": 1577946360, + "v": 63 }, { - "t": 1577924355, - "v": 20.0 + "t": 1577946420, + "v": 63 }, { - "t": 1577924416, - "v": 20.0 + "t": 1577946480, + "v": 63 }, { - "t": 1577924477, - "v": 20.0 + "t": 1577946540, + "v": 63 }, { - "t": 1577924538, - "v": 20.1 + "t": 1577946600, + "v": 63 }, { - "t": 1577924599, - "v": 20.0 + "t": 1577946660, + "v": 63 }, { - "t": 1577924660, - "v": 20.1 + "t": 1577946720, + "v": 63 }, { - "t": 1577924721, - "v": 20.0 + "t": 1577946780, + "v": 63 }, { - "t": 1577924782, - "v": 20.0 + "t": 1577946840, + "v": 63 }, { - "t": 1577924843, - "v": 20.0 + "t": 1577946900, + "v": 63 }, { - "t": 1577924904, - "v": 20.0 + "t": 1577946960, + "v": 63 }, { - "t": 1577924965, - "v": 20.0 + "t": 1577947020, + "v": 63 }, { - "t": 1577925026, - "v": 20.0 + "t": 1577947080, + "v": 63 }, { - "t": 1577925087, - "v": 20.1 + "t": 1577947140, + "v": 63 }, { - "t": 1577925148, - "v": 20.0 + "t": 1577947200, + "v": 63 }, { - "t": 1577925209, - "v": 20.1 + "t": 1577947260, + "v": 63 }, { - "t": 1577925270, - "v": 20.0 + "t": 1577947320, + "v": 63 }, { - "t": 1577925331, - "v": 20.0 + "t": 1577947380, + "v": 63 }, { - "t": 1577925392, - "v": 20.0 + "t": 1577947440, + "v": 63 }, { - "t": 1577925453, - "v": 20.0 + "t": 1577947500, + "v": 63 }, { - "t": 1577925514, - "v": 20.0 + "t": 1577947560, + "v": 63 }, { - "t": 1577925575, - "v": 20.0 + "t": 1577947620, + "v": 63 }, { - "t": 1577925636, - "v": 20.0 + "t": 1577947680, + "v": 63 }, { - "t": 1577925697, - "v": 20.0 + "t": 1577947740, + "v": 63 }, { - "t": 1577925758, - "v": 20.1 + "t": 1577947800, + "v": 63 }, { - "t": 1577925819, - "v": 20.0 + "t": 1577947860, + "v": 63 }, { - "t": 1577925880, - "v": 20.1 + "t": 1577947920, + "v": 63 }, { - "t": 1577925941, - "v": 20.0 + "t": 1577947980, + "v": 63 }, { - "t": 1577926002, - "v": 20.0 + "t": 1577948040, + "v": 63 }, { - "t": 1577926063, - "v": 20.0 + "t": 1577948100, + "v": 63 }, { - "t": 1577926124, - "v": 20.0 + "t": 1577948160, + "v": 63 }, { - "t": 1577926185, - "v": 20.0 + "t": 1577948220, + "v": 63 }, { - "t": 1577926246, - "v": 20.0 + "t": 1577948280, + "v": 63 }, { - "t": 1577926307, - "v": 20.1 + "t": 1577948340, + "v": 63 }, { - "t": 1577926368, - "v": 20.0 + "t": 1577948400, + "v": 63 }, { - "t": 1577926429, - "v": 20.1 + "t": 1577948460, + "v": 63 }, { - "t": 1577926490, - "v": 20.0 + "t": 1577948520, + "v": 63 }, { - "t": 1577926551, - "v": 20.0 + "t": 1577948580, + "v": 63 }, { - "t": 1577926612, - "v": 20.0 + "t": 1577948640, + "v": 63 }, { - "t": 1577926673, - "v": 20.0 + "t": 1577948700, + "v": 63 }, { - "t": 1577926734, - "v": 20.0 + "t": 1577948760, + "v": 63 }, { - "t": 1577926795, - "v": 20.0 + "t": 1577948820, + "v": 63 }, { - "t": 1577926856, - "v": 20.0 + "t": 1577948880, + "v": 63 }, { - "t": 1577926917, - "v": 20.0 + "t": 1577948940, + "v": 63 }, { - "t": 1577926978, - "v": 20.1 + "t": 1577949000, + "v": 63 }, { - "t": 1577927039, - "v": 20.0 + "t": 1577949060, + "v": 63 }, { - "t": 1577927100, - "v": 20.1 + "t": 1577949120, + "v": 63 }, { - "t": 1577927161, - "v": 20.0 + "t": 1577949180, + "v": 63 }, { - "t": 1577927222, - "v": 20.0 + "t": 1577949240, + "v": 63 }, { - "t": 1577927283, - "v": 20.0 + "t": 1577949300, + "v": 63 }, { - "t": 1577927344, - "v": 20.0 + "t": 1577949360, + "v": 63 }, { - "t": 1577927405, - "v": 20.0 + "t": 1577949420, + "v": 63 }, { - "t": 1577927466, - "v": 20.0 + "t": 1577949480, + "v": 63 }, { - "t": 1577927527, - "v": 20.1 + "t": 1577949540, + "v": 63 }, { - "t": 1577927588, - "v": 20.0 + "t": 1577949600, + "v": 63 }, { - "t": 1577927649, - "v": 20.1 + "t": 1577949660, + "v": 63 }, { - "t": 1577927710, - "v": 20.0 + "t": 1577949720, + "v": 63 }, { - "t": 1577927771, - "v": 20.0 + "t": 1577949780, + "v": 63 }, { - "t": 1577927832, - "v": 20.0 + "t": 1577949840, + "v": 63 }, { - "t": 1577927893, - "v": 20.0 + "t": 1577949900, + "v": 63 }, { - "t": 1577927954, - "v": 20.0 + "t": 1577949960, + "v": 63 }, { - "t": 1577928015, - "v": 20.0 + "t": 1577950020, + "v": 63 }, { - "t": 1577928076, - "v": 20.1 + "t": 1577950080, + "v": 63 }, { - "t": 1577928137, - "v": 20.0 + "t": 1577950140, + "v": 63 }, { - "t": 1577928198, - "v": 20.1 + "t": 1577950200, + "v": 63 }, { - "t": 1577928259, - "v": 20.0 + "t": 1577950260, + "v": 63 }, { - "t": 1577928320, - "v": 20.0 + "t": 1577950320, + "v": 63 }, { - "t": 1577928381, - "v": 20.0 + "t": 1577950380, + "v": 63 }, { - "t": 1577928442, - "v": 20.0 + "t": 1577950440, + "v": 63 }, { - "t": 1577928503, - "v": 20.0 + "t": 1577950500, + "v": 63 }, { - "t": 1577928564, - "v": 20.0 + "t": 1577950560, + "v": 63 }, { - "t": 1577928625, - "v": 20.0 + "t": 1577950620, + "v": 63 }, { - "t": 1577928686, - "v": 20.0 + "t": 1577950680, + "v": 63 }, { - "t": 1577928747, - "v": 20.1 + "t": 1577950740, + "v": 63 }, { - "t": 1577928808, - "v": 20.0 + "t": 1577950800, + "v": 63 }, { - "t": 1577928869, - "v": 20.1 + "t": 1577950860, + "v": 63 }, { - "t": 1577928930, - "v": 20.0 + "t": 1577950920, + "v": 63 }, { - "t": 1577928991, - "v": 20.0 + "t": 1577950980, + "v": 63 }, { - "t": 1577929052, - "v": 20.0 + "t": 1577951040, + "v": 63 }, { - "t": 1577929113, - "v": 20.0 + "t": 1577951100, + "v": 63 }, { - "t": 1577929174, - "v": 20.0 + "t": 1577951160, + "v": 63 }, { - "t": 1577929235, - "v": 20.0 + "t": 1577951220, + "v": 63 }, { - "t": 1577929296, - "v": 20.1 + "t": 1577951280, + "v": 63 }, { - "t": 1577929357, - "v": 20.0 + "t": 1577951340, + "v": 63 }, { - "t": 1577929418, - "v": 20.1 + "t": 1577951400, + "v": 63 }, { - "t": 1577929479, - "v": 20.0 + "t": 1577951460, + "v": 63 }, { - "t": 1577929540, - "v": 20.0 + "t": 1577951520, + "v": 63 }, { - "t": 1577929601, - "v": 20.0 + "t": 1577951580, + "v": 63 }, { - "t": 1577929662, - "v": 20.0 + "t": 1577951640, + "v": 63 }, { - "t": 1577929723, - "v": 20.0 + "t": 1577951700, + "v": 63 }, { - "t": 1577929784, - "v": 20.0 + "t": 1577951760, + "v": 63 }, { - "t": 1577929845, - "v": 20.0 + "t": 1577951820, + "v": 63 }, { - "t": 1577929906, - "v": 20.0 + "t": 1577951880, + "v": 63 }, { - "t": 1577929967, - "v": 20.1 - }, + "t": 1577951940, + "v": 63 + } + ], + "strain_curve": [], + "zone_timeline": [], + "hrv_timeline": [ { - "t": 1577930028, + "t": 1577923501, "v": 20.0 }, { - "t": 1577930089, - "v": 20.1 + "t": 1577923562, + "v": 20.0 }, { - "t": 1577930150, + "t": 1577923623, "v": 20.0 }, { - "t": 1577930211, + "t": 1577923684, "v": 20.0 }, { - "t": 1577930272, + "t": 1577923745, "v": 20.0 }, { - "t": 1577930333, + "t": 1577923806, "v": 20.0 }, { - "t": 1577930394, + "t": 1577923867, "v": 20.0 }, { - "t": 1577930455, + "t": 1577923928, "v": 20.0 }, { - "t": 1577930516, + "t": 1577923989, "v": 20.1 }, { - "t": 1577930577, + "t": 1577924050, "v": 20.0 }, { - "t": 1577930638, + "t": 1577924111, "v": 20.1 }, { - "t": 1577930699, + "t": 1577924172, "v": 20.0 }, { - "t": 1577930760, + "t": 1577924233, "v": 20.0 }, { - "t": 1577930821, + "t": 1577924294, "v": 20.0 }, { - "t": 1577930882, + "t": 1577924355, "v": 20.0 }, { - "t": 1577930943, + "t": 1577924416, "v": 20.0 }, { - "t": 1577931004, + "t": 1577924477, "v": 20.0 }, { - "t": 1577931065, + "t": 1577924538, "v": 20.1 }, { - "t": 1577931126, + "t": 1577924599, "v": 20.0 }, { - "t": 1577931187, + "t": 1577924660, "v": 20.1 }, { - "t": 1577931248, + "t": 1577924721, "v": 20.0 }, { - "t": 1577931309, + "t": 1577924782, "v": 20.0 }, { - "t": 1577931370, + "t": 1577924843, "v": 20.0 }, { - "t": 1577931431, + "t": 1577924904, "v": 20.0 }, { - "t": 1577931492, + "t": 1577924965, "v": 20.0 }, { - "t": 1577931553, + "t": 1577925026, "v": 20.0 }, { - "t": 1577931614, - "v": 20.0 + "t": 1577925087, + "v": 20.1 }, { - "t": 1577931675, + "t": 1577925148, "v": 20.0 }, { - "t": 1577931736, + "t": 1577925209, "v": 20.1 }, { - "t": 1577931797, + "t": 1577925270, "v": 20.0 }, { - "t": 1577931858, - "v": 20.1 + "t": 1577925331, + "v": 20.0 }, { - "t": 1577931919, + "t": 1577925392, "v": 20.0 }, { - "t": 1577931980, + "t": 1577925453, "v": 20.0 }, { - "t": 1577932041, + "t": 1577925514, "v": 20.0 }, { - "t": 1577932102, + "t": 1577925575, "v": 20.0 }, { - "t": 1577932163, + "t": 1577925636, "v": 20.0 }, { - "t": 1577932224, + "t": 1577925697, "v": 20.0 }, { - "t": 1577932285, + "t": 1577925758, "v": 20.1 }, { - "t": 1577932346, + "t": 1577925819, "v": 20.0 }, { - "t": 1577932407, + "t": 1577925880, "v": 20.1 }, { - "t": 1577932468, + "t": 1577925941, "v": 20.0 }, { - "t": 1577932529, + "t": 1577926002, "v": 20.0 }, { - "t": 1577932590, + "t": 1577926063, "v": 20.0 }, { - "t": 1577932651, + "t": 1577926124, "v": 20.0 }, { - "t": 1577932712, + "t": 1577926185, "v": 20.0 }, { - "t": 1577932773, + "t": 1577926246, "v": 20.0 }, { - "t": 1577932834, - "v": 20.0 + "t": 1577926307, + "v": 20.1 }, { - "t": 1577932895, + "t": 1577926368, "v": 20.0 }, { - "t": 1577932956, + "t": 1577926429, "v": 20.1 }, { - "t": 1577933017, + "t": 1577926490, "v": 20.0 }, { - "t": 1577933078, - "v": 20.1 + "t": 1577926551, + "v": 20.0 }, { - "t": 1577933139, + "t": 1577926612, "v": 20.0 }, { - "t": 1577933200, + "t": 1577926673, "v": 20.0 }, { - "t": 1577933261, + "t": 1577926734, "v": 20.0 }, { - "t": 1577933322, + "t": 1577926795, "v": 20.0 }, { - "t": 1577933383, + "t": 1577926856, "v": 20.0 }, { - "t": 1577933444, + "t": 1577926917, "v": 20.0 }, { - "t": 1577933505, + "t": 1577926978, "v": 20.1 }, { - "t": 1577933566, + "t": 1577927039, "v": 20.0 }, { - "t": 1577933627, + "t": 1577927100, "v": 20.1 }, { - "t": 1577933688, + "t": 1577927161, "v": 20.0 }, { - "t": 1577933749, + "t": 1577927222, "v": 20.0 }, { - "t": 1577933810, + "t": 1577927283, "v": 20.0 }, { - "t": 1577933871, + "t": 1577927344, "v": 20.0 }, { - "t": 1577933932, + "t": 1577927405, "v": 20.0 }, { - "t": 1577933993, + "t": 1577927466, "v": 20.0 }, { - "t": 1577934054, + "t": 1577927527, "v": 20.1 }, { - "t": 1577934115, + "t": 1577927588, "v": 20.0 }, { - "t": 1577934176, + "t": 1577927649, "v": 20.1 }, { - "t": 1577934237, + "t": 1577927710, "v": 20.0 }, { - "t": 1577934298, + "t": 1577927771, "v": 20.0 }, { - "t": 1577934359, + "t": 1577927832, "v": 20.0 }, { - "t": 1577934420, + "t": 1577927893, "v": 20.0 }, { - "t": 1577934481, + "t": 1577927954, "v": 20.0 }, { - "t": 1577934542, + "t": 1577928015, "v": 20.0 }, { - "t": 1577934603, - "v": 20.0 + "t": 1577928076, + "v": 20.1 }, { - "t": 1577934664, + "t": 1577928137, "v": 20.0 }, { - "t": 1577934725, + "t": 1577928198, "v": 20.1 }, { - "t": 1577934786, + "t": 1577928259, "v": 20.0 }, { - "t": 1577934847, - "v": 20.1 + "t": 1577928320, + "v": 20.0 }, { - "t": 1577934908, + "t": 1577928381, "v": 20.0 }, { - "t": 1577934969, + "t": 1577928442, "v": 20.0 }, { - "t": 1577935030, + "t": 1577928503, "v": 20.0 }, { - "t": 1577935091, + "t": 1577928564, "v": 20.0 }, { - "t": 1577935152, + "t": 1577928625, "v": 20.0 }, { - "t": 1577935213, + "t": 1577928686, "v": 20.0 }, { - "t": 1577935274, + "t": 1577928747, "v": 20.1 }, { - "t": 1577935335, + "t": 1577928808, "v": 20.0 }, { - "t": 1577935396, + "t": 1577928869, "v": 20.1 }, { - "t": 1577935457, + "t": 1577928930, "v": 20.0 }, { - "t": 1577935518, + "t": 1577928991, "v": 20.0 }, { - "t": 1577935579, + "t": 1577929052, "v": 20.0 }, { - "t": 1577935640, + "t": 1577929113, "v": 20.0 }, { - "t": 1577935701, + "t": 1577929174, "v": 20.0 }, { - "t": 1577935762, + "t": 1577929235, "v": 20.0 }, { - "t": 1577935823, - "v": 20.0 + "t": 1577929296, + "v": 20.1 }, { - "t": 1577935884, + "t": 1577929357, "v": 20.0 }, { - "t": 1577935945, + "t": 1577929418, "v": 20.1 }, { - "t": 1577936006, + "t": 1577929479, "v": 20.0 }, { - "t": 1577936067, - "v": 20.1 + "t": 1577929540, + "v": 20.0 }, { - "t": 1577936128, + "t": 1577929601, "v": 20.0 }, { - "t": 1577936189, + "t": 1577929662, "v": 20.0 }, { - "t": 1577936250, + "t": 1577929723, "v": 20.0 }, { - "t": 1577936311, + "t": 1577929784, "v": 20.0 }, { - "t": 1577936372, + "t": 1577929845, "v": 20.0 }, { - "t": 1577936433, + "t": 1577929906, "v": 20.0 }, { - "t": 1577936494, + "t": 1577929967, "v": 20.1 }, { - "t": 1577936555, + "t": 1577930028, "v": 20.0 }, { - "t": 1577936616, + "t": 1577930089, "v": 20.1 }, { - "t": 1577936677, + "t": 1577930150, "v": 20.0 }, { - "t": 1577936738, + "t": 1577930211, "v": 20.0 }, { - "t": 1577936799, + "t": 1577930272, "v": 20.0 }, { - "t": 1577936860, + "t": 1577930333, "v": 20.0 }, { - "t": 1577936921, + "t": 1577930394, "v": 20.0 }, { - "t": 1577936982, + "t": 1577930455, "v": 20.0 }, { - "t": 1577937043, + "t": 1577930516, "v": 20.1 }, { - "t": 1577937104, + "t": 1577930577, "v": 20.0 }, { - "t": 1577937165, + "t": 1577930638, "v": 20.1 }, { - "t": 1577937226, - "v": 20.0 - }, - { - "t": 1577937287, - "v": 20.0 - }, - { - "t": 1577944810, + "t": 1577930699, "v": 20.0 }, { - "t": 1577944871, - "v": 19.8 - }, - { - "t": 1577944932, + "t": 1577930760, "v": 20.0 }, { - "t": 1577944993, - "v": 19.9 - }, - { - "t": 1577945054, - "v": 19.9 - }, - { - "t": 1577945115, + "t": 1577930821, "v": 20.0 }, { - "t": 1577945176, + "t": 1577930882, "v": 20.0 }, { - "t": 1577945237, + "t": 1577930943, "v": 20.0 }, { - "t": 1577945298, + "t": 1577931004, "v": 20.0 }, { - "t": 1577945359, + "t": 1577931065, "v": 20.1 }, { - "t": 1577945420, + "t": 1577931126, "v": 20.0 }, { - "t": 1577945481, + "t": 1577931187, "v": 20.1 }, { - "t": 1577945542, + "t": 1577931248, "v": 20.0 }, { - "t": 1577945603, + "t": 1577931309, "v": 20.0 }, { - "t": 1577945664, + "t": 1577931370, "v": 20.0 }, { - "t": 1577945725, + "t": 1577931431, "v": 20.0 }, { - "t": 1577945786, + "t": 1577931492, "v": 20.0 }, { - "t": 1577945847, + "t": 1577931553, "v": 20.0 }, { - "t": 1577945908, + "t": 1577931614, "v": 20.0 }, { - "t": 1577945969, + "t": 1577931675, "v": 20.0 }, { - "t": 1577946030, + "t": 1577931736, "v": 20.1 }, { - "t": 1577946091, + "t": 1577931797, "v": 20.0 }, { - "t": 1577946152, + "t": 1577931858, "v": 20.1 }, { - "t": 1577946213, - "v": 20.0 - }, - { - "t": 1577946274, - "v": 20.0 - }, - { - "t": 1577946335, + "t": 1577931919, "v": 20.0 }, { - "t": 1577946396, + "t": 1577931980, "v": 20.0 }, { - "t": 1577946457, + "t": 1577932041, "v": 20.0 }, { - "t": 1577946518, + "t": 1577932102, "v": 20.0 }, { - "t": 1577946601, + "t": 1577932163, "v": 20.0 }, { - "t": 1577946662, + "t": 1577932224, "v": 20.0 }, { - "t": 1577946723, - "v": 20.0 + "t": 1577932285, + "v": 20.1 }, { - "t": 1577946784, + "t": 1577932346, "v": 20.0 }, { - "t": 1577946845, - "v": 20.0 + "t": 1577932407, + "v": 20.1 }, { - "t": 1577946906, + "t": 1577932468, "v": 20.0 }, { - "t": 1577946967, + "t": 1577932529, "v": 20.0 }, { - "t": 1577947028, + "t": 1577932590, "v": 20.0 }, { - "t": 1577947089, + "t": 1577932651, "v": 20.0 }, { - "t": 1577947150, + "t": 1577932712, "v": 20.0 }, { - "t": 1577947211, + "t": 1577932773, "v": 20.0 }, { - "t": 1577947272, + "t": 1577932834, "v": 20.0 }, { - "t": 1577947333, + "t": 1577932895, "v": 20.0 }, { - "t": 1577947394, + "t": 1577932956, "v": 20.1 }, { - "t": 1577947455, + "t": 1577933017, "v": 20.0 }, { - "t": 1577947516, + "t": 1577933078, "v": 20.1 }, { - "t": 1577947577, + "t": 1577933139, "v": 20.0 }, { - "t": 1577947638, + "t": 1577933200, "v": 20.0 }, { - "t": 1577947699, + "t": 1577933261, "v": 20.0 }, { - "t": 1577947760, + "t": 1577933322, "v": 20.0 }, { - "t": 1577947821, + "t": 1577933383, "v": 20.0 }, { - "t": 1577947882, + "t": 1577933444, "v": 20.0 }, { - "t": 1577947943, + "t": 1577933505, "v": 20.1 }, { - "t": 1577948004, + "t": 1577933566, "v": 20.0 }, { - "t": 1577948065, + "t": 1577933627, "v": 20.1 }, { - "t": 1577948126, - "v": 20.0 - }, - { - "t": 1577948187, - "v": 20.0 - }, - { - "t": 1577948248, + "t": 1577933688, "v": 20.0 }, { - "t": 1577948309, + "t": 1577933749, "v": 20.0 }, { - "t": 1577948370, + "t": 1577933810, "v": 20.0 }, { - "t": 1577948431, + "t": 1577933871, "v": 20.0 }, { - "t": 1577948492, + "t": 1577933932, "v": 20.0 }, { - "t": 1577948553, + "t": 1577933993, "v": 20.0 }, { - "t": 1577948614, + "t": 1577934054, "v": 20.1 }, { - "t": 1577948675, + "t": 1577934115, "v": 20.0 }, { - "t": 1577948736, + "t": 1577934176, "v": 20.1 }, { - "t": 1577948797, + "t": 1577934237, "v": 20.0 }, { - "t": 1577948858, + "t": 1577934298, "v": 20.0 }, { - "t": 1577948919, + "t": 1577934359, "v": 20.0 }, { - "t": 1577948980, + "t": 1577934420, "v": 20.0 }, { - "t": 1577949041, + "t": 1577934481, "v": 20.0 }, { - "t": 1577949102, + "t": 1577934542, "v": 20.0 }, { - "t": 1577949163, - "v": 20.1 - }, - { - "t": 1577949224, + "t": 1577934603, "v": 20.0 }, { - "t": 1577949301, - "v": 19.9 - }, - { - "t": 1577949362, - "v": 19.9 + "t": 1577934664, + "v": 20.0 }, { - "t": 1577949423, + "t": 1577934725, "v": 20.1 }, { - "t": 1577949484, - "v": 19.9 + "t": 1577934786, + "v": 20.0 }, { - "t": 1577949545, + "t": 1577934847, "v": 20.1 }, { - "t": 1577949606, + "t": 1577934908, "v": 20.0 }, { - "t": 1577949667, + "t": 1577934969, "v": 20.0 }, { - "t": 1577949728, + "t": 1577935030, "v": 20.0 }, { - "t": 1577949789, + "t": 1577935091, "v": 20.0 }, { - "t": 1577949850, + "t": 1577935152, "v": 20.0 }, { - "t": 1577949911, + "t": 1577935213, "v": 20.0 }, { - "t": 1577949972, - "v": 20.0 + "t": 1577935274, + "v": 20.1 }, { - "t": 1577950033, + "t": 1577935335, "v": 20.0 }, { - "t": 1577950094, + "t": 1577935396, "v": 20.1 }, { - "t": 1577950155, + "t": 1577935457, "v": 20.0 }, { - "t": 1577950216, - "v": 20.1 + "t": 1577935518, + "v": 20.0 }, { - "t": 1577950277, + "t": 1577935579, "v": 20.0 }, { - "t": 1577950338, + "t": 1577935640, "v": 20.0 }, { - "t": 1577950399, + "t": 1577935701, "v": 20.0 }, { - "t": 1577950460, + "t": 1577935762, "v": 20.0 }, { - "t": 1577950521, + "t": 1577935823, "v": 20.0 }, { - "t": 1577950582, + "t": 1577935884, "v": 20.0 }, { - "t": 1577950643, + "t": 1577935945, "v": 20.1 }, { - "t": 1577950704, + "t": 1577936006, "v": 20.0 }, { - "t": 1577950765, + "t": 1577936067, "v": 20.1 }, { - "t": 1577950826, + "t": 1577936128, "v": 20.0 }, { - "t": 1577950887, + "t": 1577936189, "v": 20.0 }, { - "t": 1577950948, + "t": 1577936250, "v": 20.0 }, { - "t": 1577951009, + "t": 1577936311, "v": 20.0 }, { - "t": 1577951070, + "t": 1577936372, "v": 20.0 }, { - "t": 1577951131, + "t": 1577936433, "v": 20.0 }, { - "t": 1577951192, - "v": 20.0 + "t": 1577936494, + "v": 20.1 }, { - "t": 1577951253, + "t": 1577936555, "v": 20.0 }, { - "t": 1577951314, + "t": 1577936616, "v": 20.1 }, { - "t": 1577951375, + "t": 1577936677, "v": 20.0 }, { - "t": 1577951436, - "v": 20.1 + "t": 1577936738, + "v": 20.0 }, { - "t": 1577951497, + "t": 1577936799, "v": 20.0 }, { - "t": 1577951558, + "t": 1577936860, "v": 20.0 }, { - "t": 1577951619, + "t": 1577936921, "v": 20.0 }, { - "t": 1577951680, + "t": 1577936982, "v": 20.0 }, { - "t": 1577951741, - "v": 20.0 + "t": 1577937043, + "v": 20.1 }, { - "t": 1577951802, + "t": 1577937104, "v": 20.0 }, { - "t": 1577951863, + "t": 1577937165, "v": 20.1 }, { - "t": 1577951924, + "t": 1577937226, + "v": 20.0 + }, + { + "t": 1577937287, "v": 20.0 } ], "hypnogram": [ { "start": 1577923200, - "end": 1577937600, - "stage": "light" - }, - { - "start": 1577937600, - "end": 1577944800, - "stage": "unobserved" - }, - { - "start": 1577944800, - "end": 1577946600, - "stage": "light" - }, - { - "start": 1577946600, - "end": 1577946601, - "stage": "unobserved" - }, - { - "start": 1577946601, - "end": 1577949300, - "stage": "light" - }, - { - "start": 1577949300, - "end": 1577949301, - "stage": "unobserved" - }, - { - "start": 1577949301, - "end": 1577951999, + "end": 1577937599, "stage": "light" } ] @@ -4949,10 +3853,12 @@ "coverage": { "hr_samples": 21598, "hr_valid": 21598, - "rr_beats": 21597, - "nn_clean": 21597, + "rr_beats": 14399, + "nn_clean": 14399, "clean_fraction": 1.0, - "sleep_seconds": 28799 + "sleep_seconds": 14399, + "rr_coverage": 0.98, + "rr_duplicate_beats": 0 }, "readiness_absent_diag": { "hrv": { @@ -4964,7 +3870,7 @@ "baseline_n": 0 }, "resp": { - "value": false, + "value": true, "baseline_n": 0 }, "temp": { @@ -4975,23 +3881,23 @@ "note": "need_baseline:have=0,need=14" }, "scalars": { - "rhr": 62.996664813785436, - "rhr_nocturnal": 62.996664813785436, - "rmssd": 19.900451813684356, - "rmssd_whole": 19.995484137977282, + "rhr": 62.998333333333335, + "rhr_nocturnal": 62.998333333333335, + "rmssd": 19.899932209964643, + "rmssd_whole": 19.99843722189908, "readiness": null, "strain": null, "max_hr_used": null, - "ln_rmssd": 2.990742435677954, - "resp_rate": null, + "ln_rmssd": 2.9907163251902045, + "resp_rate": 12.265550239234448, "skin_temp_z": null, - "skin_temp_adc": 30004.999907394547, - "skin_temp_coverage_frac": 0.7499, + "skin_temp_adc": 30005.0, + "skin_temp_coverage_frac": 1.0, "skin_temp_settled_frac": 1.0, - "sdnn": 14.141480728476779, - "prsa_dc": 14.998, - "prsa_dc_anchors": 4319.0, - "dip_pct": null, + "sdnn": 14.14213555551533, + "prsa_dc": 15.0, + "prsa_dc_anchors": 2879.0, + "dip_pct": -0.0008820870178887956, "trimp": null, "odi_per_hour": null, "stress": 100.0, @@ -5000,11 +3906,11 @@ "calories": null, "sleeping_hr_nadir": 60.0, "sleeping_hr_nadir_ts": 1577923200.0, - "waking_hr": null, + "waking_hr": 62.999444290080575, "rem_min": 0.0, "deep_min": 0.0, - "light_min": 360.0, - "tst_min": 360.0, + "light_min": 240.0, + "tst_min": 240.0, "lf_hf": 0.003, "hrv_cv": 1.4, "irregular_rhythm_flag": 0.0, @@ -5012,11 +3918,11 @@ "brv_slope": 0.0, "efficiency": 100.0, "worn_min": 360.0, - "unobserved_min": 120.0, + "unobserved_min": 0.0, "awakenings": 0.0, "longest_sleep_min": 240.0, "sol_min": null, - "midsleep_sec": 0.0, + "midsleep_sec": -7200.0, "sleep_onset_sec": -14400.0 } } diff --git a/test/investigate_rmssd_l10n_test.dart b/test/investigate_rmssd_l10n_test.dart new file mode 100644 index 000000000..791c8bc1f --- /dev/null +++ b/test/investigate_rmssd_l10n_test.dart @@ -0,0 +1,21 @@ +// The two nightly-RMSSD labels Investigate shows are translated in every +// shipped language, not left in English. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; + +void main() { + test('every language has its own nightly RMSSD labels', () { + final en = lookupAppLocalizations(const Locale('en')); + for (final code in ['de', 'es', 'fr', 'hi', 'zh']) { + final l = lookupAppLocalizations(Locale(code)); + expect(l.investigateRmssdNightly, isNot(en.investigateRmssdNightly), + reason: code); + expect(l.investigateRmssdStored, isNot(en.investigateRmssdStored), + reason: code); + expect(l.investigateRmssdNightly, startsWith('RMSSD'), reason: code); + expect(l.investigateRmssdStored, startsWith('RMSSD'), reason: code); + } + }); +} diff --git a/test/live_strain_convergence_test.dart b/test/live_strain_convergence_test.dart index d5fac06e0..83f9837ad 100644 --- a/test/live_strain_convergence_test.dart +++ b/test/live_strain_convergence_test.dart @@ -43,6 +43,7 @@ void main() { profile: _profile, hrMax: 187.0, restingHr: 55, + quietHrr: 0.20, ); test('starts absent, not zero', () { @@ -96,6 +97,31 @@ void main() { expect(w.strain, isNull); }); + test('abstains until a quiet level lands, then fills the gap', () { + final w = LiveWorkoutState( + startTime: DateTime(2026, 1, 1, 9), + targetKcal: 300, + type: 'run', + age: 30, + profile: _profile, + hrMax: 187.0, + restingHr: 55, + ); + for (var i = 0; i < 120; i++) { + w.elapsed = Duration(seconds: i); + w.accrueHr(150); + } + // No level read yet — never the 0.20 population reference. + expect(w.strain, isNull); + // `_refreshNightlyRhr` fills the gap when the read lands; the next + // sample re-scores through it. + w.quietHrr = 0.20; + w.elapsed = const Duration(seconds: 120); + w.accrueHr(150); + expect(w.strain, isNotNull); + expect(w.strain, greaterThan(0)); + }); + test('off-skin zeros neither pollute a minute nor reset the score', () { final w = make(); for (var i = 0; i < 600; i++) { diff --git a/test/manual_session_test.dart b/test/manual_session_test.dart index cff456933..a1f07fd82 100644 --- a/test/manual_session_test.dart +++ b/test/manual_session_test.dart @@ -253,6 +253,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); expect(s.isUnscored, isTrue); expect(s.hrSampleCount, 0); @@ -271,6 +272,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: null, + quietHrr: 0.20, ); // Banister carries RHR as a term in the formula — absent it, abstain. expect(s.strain, isNull); @@ -295,6 +297,7 @@ void main() { profile: const Profile(weightKg: 75, heightCm: 180, sex: 'm'), hrMax: null, restingHr: 55, + quietHrr: 0.20, ); expect(s.strain, isNull); expect(s.calories, isNull); @@ -312,6 +315,7 @@ void main() { profile: const Profile(ageYears: 30, heightCm: 180, sex: 'm'), hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); expect(s.calories, isNull); expect(s.strain, isNotNull); @@ -325,6 +329,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); expect(s.isUnscored, isFalse); expect(s.avgHr, 150); @@ -343,6 +348,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); // THE WHOLE POINT of the feature: correcting 25 min → 60 min must // actually move the number the athlete came here to fix. @@ -360,6 +366,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); expect(s.avgHr, 150); expect(s.hrSampleCount, 60); @@ -375,6 +382,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); expect(s.calories, wornOnly.calories); expect(s.strain, wornOnly.strain); @@ -391,7 +399,7 @@ void main() { test('a hard hour scores on-scale, nowhere near the old accrual', () { final perMin = List.filled(60, 150); final s = strainFromPerMinuteHr(perMin, - profile: _profile, restingHr: 55, hrMax: _hrMax)!; + profile: _profile, restingHr: 55, hrMax: _hrMax, quietHrr: 0.20)!; // The canonical figure for this effort. expect(s, closeTo(11.62, 0.05)); @@ -409,7 +417,7 @@ void main() { test('never exceeds 21, even for an absurdly long maximal effort', () { // 12 h pinned at 190 bpm — the old accrual would read into the hundreds. final s = strainFromPerMinuteHr(List.filled(720, 190), - profile: _profile, restingHr: 55, hrMax: _hrMax)!; + profile: _profile, restingHr: 55, hrMax: _hrMax, quietHrr: 0.20)!; expect(s, lessThanOrEqualTo(21.0)); }); @@ -418,7 +426,7 @@ void main() { List.filled(minutes, bpm), profile: _profile, restingHr: 55, - hrMax: _hrMax)!; + hrMax: _hrMax, quietHrr: 0.20)!; expect(at(60, 150), greaterThan(at(30, 150))); expect(at(60, 165), greaterThan(at(60, 150))); }); @@ -428,24 +436,55 @@ void main() { // No resting HR. expect( strainFromPerMinuteHr(perMin, - profile: _profile, restingHr: null, hrMax: _hrMax), + profile: _profile, restingHr: null, hrMax: _hrMax, quietHrr: 0.20), isNull); // No ceiling — no age, OR a strap with no calibrated HRmax (TS-03a: an // unknown/unstamped device family REFUSES rather than borrowing gen4's). expect( strainFromPerMinuteHr(perMin, - profile: _profile, restingHr: 55, hrMax: null), + profile: _profile, restingHr: 55, hrMax: null, quietHrr: 0.20), isNull); // No sex → no Banister weighting constant. expect( strainFromPerMinuteHr(perMin, profile: const Profile(ageYears: 30), restingHr: 55, - hrMax: _hrMax), + hrMax: _hrMax, quietHrr: 0.20), isNull); // No HR at all. expect( strainFromPerMinuteHr(const [], - profile: _profile, restingHr: 55, hrMax: _hrMax), + profile: _profile, restingHr: 55, hrMax: _hrMax, quietHrr: 0.20), + isNull); + }); + + test('abstains without this user\'s quiet-waking level', () { + // The 0.20 population reference used to stand in here. A session is + // priced on the quiet level of the day it sits in, or not at all. + final w = _flat(0, 45, 150); + final none = computeManualSessionStats( + hrTs: w.ts, + hrBpm: w.bpm, + profile: _profile, + hrMax: _hrMax, + restingHr: 55, + quietHrr: null, + ); + expect(none.strain, isNull); + // Everything else about the session is still scored. + expect(none.avgHr, 150); + expect(none.calories, isNotNull); + final priced = computeManualSessionStats( + hrTs: w.ts, + hrBpm: w.bpm, + profile: _profile, + hrMax: _hrMax, + restingHr: 55, + quietHrr: 0.20, + ); + expect(priced.strain, isNotNull); + expect( + strainFromPerMinuteHr(List.filled(60, 150), + profile: _profile, restingHr: 55, hrMax: _hrMax, quietHrr: null), isNull); }); @@ -458,9 +497,10 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ).strain; final direct = strainFromPerMinuteHr(hrPerMinute(w.ts, w.bpm), - profile: _profile, restingHr: 55, hrMax: _hrMax); + profile: _profile, restingHr: 55, hrMax: _hrMax, quietHrr: 0.20); expect(viaStats, direct); }); }); @@ -472,6 +512,7 @@ void main() { profile: _profile, hrMax: _hrMax, restingHr: 55, + quietHrr: 0.20, ); test('a new entry is done/manual with a start-keyed id', () { diff --git a/test/manual_workout_repo_test.dart b/test/manual_workout_repo_test.dart index 00f4b84fa..56b74b352 100644 --- a/test/manual_workout_repo_test.dart +++ b/test/manual_workout_repo_test.dart @@ -79,6 +79,12 @@ void main() { LocalDb.dbName = 'openstrap_manual_workout_test.db'; final dir = await databaseFactory.getDatabasesPath(); await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + // A settled week of quiet-waking levels before every window below: a + // session is priced on the quiet level of the day it sits in, and strain + // abstains without one. + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2023-01-0$d', 'quiet_hrr', 0.20); + } }); tearDownAll(() async { diff --git a/test/quiet_hrr_seed_test.dart b/test/quiet_hrr_seed_test.dart new file mode 100644 index 000000000..3427e4e9d --- /dev/null +++ b/test/quiet_hrr_seed_test.dart @@ -0,0 +1,194 @@ +// One-shot `quiet_hrr` seed for installs that predate the series. +// +// A re-derive only reaches the last few days (raw is pruned), so without a +// seed every existing user would start with zero prior quiet levels and see +// strain go blank. Every stored bundle still carries the per-minute wake HR as +// `strain_curve` (one point per wake minute) joined to `hr_curve`. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/quiet_level.dart'; +import 'package:openstrap_edge/compute/quiet_level_seed.dart'; +import 'package:openstrap_edge/compute/strain_backfill.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/series_codec.dart'; + +/// 400 wake minutes at 62 bpm, then 400 sleep minutes at 50 that are in +/// `hr_curve` but not in `strain_curve` — so they must not enter the median. +Map _payload({ + bool withCurve = true, + double? rhrNocturnal = 55, +}) => + { + 'scalars': {'rhr_nocturnal': ?rhrNocturnal}, + 'max_hr_used': 183.5, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 62}, + for (var i = 400; i < 800; i++) {'t': i * 60, 'v': 50}, + ], + if (withCurve) + 'strain_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 0.0}, + ], + }, + }; + +void main() { + group('quietHrrFromStoredBundle — pure', () { + test('the wake minutes\' median HRR', () { + // 62 bpm against RHR 55 / HRmax 183.5 (age 35) = 7/128.5. + expect(quietHrrFromStoredBundle(_payload(), ageYears: 35), closeTo(0.0545, 0.0005)); + }); + + test('the ceiling comes from the CURRENT age, as the live derive\'s does', + () { + // The stored bundle was scored on another ceiling (a later birthday, an + // edited profile). Seeded and live levels must share one scale: age 40 + // is HRmax 180, so 7/125 — not 7/95 off the stored 150. + final stale = {..._payload(), 'max_hr_used': 150.0}; + expect(quietHrrFromStoredBundle(stale, ageYears: 40), + closeTo(0.056, 0.0005)); + expect(quietHrrFromStoredBundle(stale, ageYears: null), isNull, + reason: 'no age, no ceiling — the live derive abstains too'); + }); + + test('no strain curve, no wake series — null', () { + expect(quietHrrFromStoredBundle(_payload(withCurve: false), ageYears: 35), isNull); + }); + + test('no nocturnal RHR — the user-entered one anchors it', () { + final noRhr = _payload(rhrNocturnal: null); + expect(quietHrrFromStoredBundle(noRhr, ageYears: 35), isNull); + expect( + quietHrrFromStoredBundle(noRhr, ageYears: 35, manualRestingHr: 55), + closeTo(0.0545, 0.0005)); + }); + + test('columnar curves decode to the same value', () { + final stored = + SeriesCodec.encodePayloadJson(jsonEncode(_payload())); + expect(stored, isNot(contains('"strain_curve":[{')), + reason: 'the codec actually stored a columnar curve'); + final decoded = SeriesCodec.decodePayloadJson(stored)!; + expect(quietHrrFromStoredBundle(decoded, ageYears: 35), + closeTo(0.0545, 0.0005)); + }); + }); + + group('seedQuietHrrHistoryOnce — an existing install', () { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_quiet_hrr_seed_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + Future day( + String date, { + bool partial = false, + bool skipped = false, + bool imported = false, + }) => + LocalDb.putDayResult( + dayId: date, + algoVersion: 97, + payloadJson: SeriesCodec.encodePayloadJson(jsonEncode({ + 'date': date, + if (imported) 'imported': true, + ..._payload(), + })), + windowJson: '{}', + finalized: true, + partial: partial, + skipped: skipped, + source: imported ? 'whoop_export' : 'band', + series: {'strain': 5.0}, + ); + + Future> quietRows() async => { + for (final r in await LocalDb.metricSeries('quiet_hrr')) + r['date'] as String: (r['value'] as num).toDouble(), + }; + + test('without an age it seeds nothing and stays armed', () async { + await day('2026-08-20'); + expect(await seedQuietHrrHistoryOnce(ageYears: null), 0); + expect(await LocalDb.computeFreshness(kQuietHrrSeedKey), isNull, + reason: 'it runs once an age is set'); + await (await LocalDb.instance).delete('day_result'); + }); + + test('seeds once, even with the strain rescale long since done', () async { + // The population this exists for: an install whose v63 strain rescale + // already ran, so `backfillStrainScale` returns at its freshness check. + await LocalDb.putComputeFreshness(kStrainRescaleKey, '{"done":true}'); + for (final d in ['2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04']) { + await day(d); + } + await day('2026-09-05', partial: true); + await day('2026-09-06', skipped: true); + await day('2026-09-07', imported: true); + // A derive already measured this one — the seed must not overwrite it. + await day('2026-09-08'); + await LocalDb.putMetricSeriesValue('2026-09-08', 'quiet_hrr', 0.3); + + expect((await backfillStrainScale(female: false)).didWork, isFalse); + expect(await seedQuietHrrHistoryOnce(ageYears: 35), 4); + + final rows = await quietRows(); + expect(rows.keys.toSet(), + {'2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', '2026-09-08'}); + for (final d in ['2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04']) { + expect(rows[d], closeTo(0.0545, 0.0005), reason: d); + } + expect(rows['2026-09-08'], 0.3); + expect(await LocalDb.computeFreshness(kQuietHrrSeedKey), isNotNull); + + // …so the next day's strain has a level to be priced on. + final level = await personalQuietLevelBefore('2026-09-09'); + expect(level.present, isTrue); + expect(level.value!.days, 5); + }); + + test('a second run is a no-op', () async { + final before = await quietRows(); + // Even a newly stored day is left for its own derive to measure. + await day('2026-09-09'); + expect(await seedQuietHrrHistoryOnce(ageYears: 35), 0); + expect(await quietRows(), before); + }); + + test('imports filling the recent rows do not crowd out older measured days', + () async { + final db = await LocalDb.instance; + for (final t in ['day_result', 'metric_series', 'compute_freshness']) { + await db.delete(t); + } + const measured = ['2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04']; + for (final d in measured) { + await day(d); + } + // A vendor import newer than all of them, longer than the whole window. + for (var i = 0; i < 40; i++) { + await day( + '2026-07-${(i % 30 + 1).toString().padLeft(2, '0')}' + .replaceFirst('07', i < 30 ? '07' : '08'), + imported: true); + } + expect(await seedQuietHrrHistoryOnce(ageYears: 35), 4); + expect((await quietRows()).keys.toSet(), measured.toSet()); + }); + }); +} diff --git a/test/quiet_level_engine_test.dart b/test/quiet_level_engine_test.dart new file mode 100644 index 000000000..156c35e64 --- /dev/null +++ b/test/quiet_level_engine_test.dart @@ -0,0 +1,593 @@ +// The quiet-waking level through the REAL engine: decoded 1 Hz rows → +// DerivationEngine.run/runDays → day_result + metric_series. +// +// Strain is priced on the median of the PRIOR days' own `quiet_hrr`, read from +// one frozen snapshot per sweep. These pin what that means on disk: a first +// sync of a whole week must not leave the week blank, the persisted rows are +// replaced (not kept) when a re-derive abstains, a day needs 360 wake minutes +// to contribute a level, and the one-shot history seed runs even when there +// is nothing to derive. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/compute/quiet_level_seed.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/series_codec.dart'; + +const _profile = Profile( + ageYears: 35, + sex: 'm', + weightKg: 75, + heightCm: 178, + restingHrManual: 55, +); + +int _localSec(String day, int hour) { + final d = DateTime.parse(day); + return DateTime(d.year, d.month, d.day, hour).millisecondsSinceEpoch ~/ 1000; +} + +/// [wakeMin] minutes of daytime 1 Hz rows from 09:00 local: 45 min at 130 bpm, +/// the rest at ~62, with enough wrist motion to read as awake. +Future _insertDay(String day, {int wakeMin = 400}) async { + final db = await LocalDb.instance; + final start = _localSec(day, 9); + final batch = db.batch(); + for (var i = 0; i < wakeMin * 60; i++) { + final ts = start + i; + batch.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': ts, + 'hr': i < 45 * 60 ? 130 : 62 + (i % 3) - 1, + 'ax': 0.05 * ((i % 60) / 60.0), + 'ay': 0.05 * ((i % 30) / 30.0), + 'az': 0.98, + 'device_family': 'gen4', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); +} + +Future _clear() async { + final db = await LocalDb.instance; + for (final t in const [ + 'decoded_onehz', + 'day_result', + 'metric_series', + 'metric_series_version', + 'compute_freshness', + ]) { + await db.delete(t); + } + // The highest cutoff the raw prune has applied — carried across tests it + // would mark later fixtures' nights as already cut. + await db.delete('sync_cursor', + where: 'name = ?', whereArgs: ['decoded_pruned_before']); +} + +/// The stored `metric_series` row itself — `metricSeries` drops NULL values, +/// and a NULL row is exactly what an abstaining re-derive must leave. +Future>> _row(String day, String key) async { + final db = await LocalDb.instance; + return db.query('metric_series', + where: 'date = ? AND key = ?', whereArgs: [day, key]); +} + +/// Owed days right after a sweep: every day that can still reach three +/// levels ([mustOwe]) and, at most, the cold-start days before them. Those can +/// stay marked one pass longer — proving a day unreachable counts across two +/// reads, and an earlier day committing in between is counted on both sides, +/// the safe direction — and their next complete derive clears them. +Future _expectOwedAfterSweep( + List days, Iterable mustOwe) async { + final owed = await _owed(); + expect(owed, containsAll(mustOwe)); + expect(owed.difference(mustOwe.toSet()), + everyElement(isIn(days.sublist(1, 3)))); +} + +/// Days still owed a strain re-score (their durable markers). +Future> _owed() async => { + for (final k in await LocalDb.computeFreshnessKeys( + DerivationEngine.kQuietRepassKeyPrefix)) + k.substring(DerivationEngine.kQuietRepassKeyPrefix.length), +}; + +/// Decoded 1 Hz rows left on [day] (local calendar day). +Future _decodedRows(String day) async { + final db = await LocalDb.instance; + final d = DateTime.parse(day); + final start = _localSec(day, 0); + // Next local midnight from the calendar, not +86400 (DST). + final end = DateTime(d.year, d.month, d.day + 1).millisecondsSinceEpoch ~/ 1000; + final rows = await db.rawQuery( + 'SELECT COUNT(*) AS n FROM decoded_onehz WHERE rec_ts >= ? AND rec_ts < ?', + [start, end]); + return (rows.single['n'] as num).toInt(); +} + +Future _value(String day, String key) async { + final rows = await _row(day, key); + return rows.isEmpty ? null : (rows.single['value'] as num?)?.toDouble(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_quiet_level_engine_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('the seed runs even with an empty decoded ledger', () async { + await _clear(); + // A restored install: bundles on disk, no 1 Hz rows, so run() returns at + // "no decoded data" — after the seed, not before it. + for (final day in const ['2026-02-01', '2026-02-02', '2026-02-03']) { + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: SeriesCodec.encodePayloadJson(jsonEncode({ + 'date': day, + 'max_hr_used': 183.5, + 'scalars': {'rhr_nocturnal': 55}, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 62}, + ], + 'strain_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 0.0}, + ], + }, + })), + windowJson: '{}', + finalized: true, + source: 'band', + ); + } + expect(await DerivationEngine().run(_profile, heavy: true), 0); + expect(await LocalDb.computeFreshness(kQuietHrrSeedKey), isNotNull); + expect(await _value('2026-02-03', 'quiet_hrr'), closeTo(0.0545, 0.0005)); + }); + + test('a seed that writes levels refreshes the rescan signature', () async { + await _clear(); + // Every day finalized, nothing for a derive to do: the seed is the only + // thing that moves the baseline, so it must move the gate too, or + // rescanRecent never re-scores the days the new levels now price. + for (final day in const ['2026-02-01', '2026-02-02', '2026-02-03']) { + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: SeriesCodec.encodePayloadJson(jsonEncode({ + 'date': day, + 'scalars': {'rhr_nocturnal': 55}, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 62}, + ], + 'strain_curve': [ + for (var i = 0; i < 400; i++) {'t': i * 60, 'v': 0.0}, + ], + }, + })), + windowJson: '{}', + finalized: true, + source: 'band', + ); + } + final before = await debugBaselineSignature(); + await LocalDb.putBaseline( + 'rolling_artifact', jsonEncode({'signature': before})); + await DerivationEngine().run(_profile, heavy: true); + final after = await debugBaselineSignature(); + expect(after, isNot(before), reason: 'the seed moved the quiet levels'); + final stored = jsonDecode( + (await LocalDb.baseline('rolling_artifact'))!['payload_json'] + as String) as Map; + expect(stored['signature'], after); + }); + + // The tests below run real derives over 1 Hz rows — well past the default + // 30 s once the suite is loaded, hence their explicit budgets. + test('a first sync of a whole week does not leave it blank', () async { + await _clear(); + const days = [ + '2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04', // + '2026-03-05', '2026-03-06', '2026-03-07', + ]; + for (final d in days) { + await _insertDay(d); + } + // ONE run, one frozen snapshot with no `quiet_hrr` in it at all. + await DerivationEngine().run(_profile, heavy: true); + + for (final d in days) { + expect(await _value(d, 'quiet_hrr'), isNotNull, reason: d); + } + // Days 1–3 have fewer than three prior levels: an honest cold start. + for (final d in days.take(3)) { + expect(await _value(d, 'strain'), isNull, reason: d); + } + // Day 4 on had three or more by the end of the sweep, and was re-scored + // on them before its raw could be pruned — finalized or not, never blank. + for (final d in days.skip(3)) { + expect(await _value(d, 'strain'), isNotNull, reason: d); + expect(await _value(d, 'trimp_net'), isNotNull, reason: d); + final row = await LocalDb.dayResult(d); + final scalars = + (SeriesCodec.decodePayloadJson(row!['payload_json'])!['scalars'] + as Map); + expect(scalars['strain'], isNotNull, reason: '$d bundle'); + } + // Every day that could be scored was, and cleared its marker. Days 2–3 + // can never reach three levels; the next complete derive proves it and + // releases them. + await _expectOwedAfterSweep(days, const []); + await DerivationEngine().run(_profile, heavy: true); + expect(await _owed(), isEmpty); + }, timeout: const Timeout(Duration(minutes: 5))); + + // A failed second pass, or the app dying before it ran, must not cost the + // days it owes: they stay unfinalized, their raw is held from the prune, and + // the NEXT run — a fresh engine, a light pass — scores them. + for (final (label, failAt) in const [ + ('a failed second pass', 'day'), + ('the process dying between the passes', 'start'), + ]) { + test('$label leaves the owed days for the next run', () async { + await _clear(); + const days = [ + '2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', // + '2026-06-05', '2026-06-06', '2026-06-07', + ]; + for (final d in days) { + await _insertDay(d); + } + DerivationEngine.debugQuietRepassHook = (day) async { + if ((day == null) == (failAt == 'start')) throw StateError('injected'); + }; + addTearDown(() => DerivationEngine.debugQuietRepassHook = null); + await DerivationEngine().run(_profile, heavy: true); + DerivationEngine.debugQuietRepassHook = null; + + final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); + await _expectOwedAfterSweep(days, days.skip(3)); + for (final d in days.skip(3)) { + expect(await _value(d, 'strain'), isNull, reason: d); + expect(finalized, isNot(contains(d)), reason: '$d is not locked'); + } + // The prune ran and held every owed day's raw — day 4's included, + // which is past the 3-day retention on its own. + expect(await _decodedRows(days[3]), 400 * 60, reason: 'held whole'); + + await DerivationEngine().run(_profile); + for (final d in days.skip(3)) { + expect(await _value(d, 'strain'), isNotNull, reason: d); + } + expect(await _owed(), everyElement(isIn(days.sublist(1, 3)))); + await DerivationEngine().run(_profile, heavy: true); + expect(await _owed(), isEmpty); + }, timeout: const Timeout(Duration(minutes: 5))); + } + + test('persisted rows, replaced with NULL when the inputs go', () async { + await _clear(); + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2026-04-0$d', 'quiet_hrr', 0.20); + } + // Seeded already, so the run below does not try to backfill anything. + await LocalDb.putComputeFreshness(kQuietHrrSeedKey, '{"done":true}'); + const day = '2026-04-10'; + await _insertDay(day); + await DerivationEngine().runDays(_profile, {day}); + expect(await _value(day, 'quiet_hrr'), closeTo(0.0545, 0.002)); + expect(await _value(day, 'trimp_net'), greaterThan(0)); + expect(await _value(day, 'strain'), greaterThan(0)); + + // No resting HR of any kind now: no reserve, so no level, no net load, + // no strain — and the rows a previous derive wrote must not survive it. + await DerivationEngine().runDays( + const Profile(ageYears: 35, sex: 'm', weightKg: 75, heightCm: 178), + {day}); + for (final key in const ['quiet_hrr', 'trimp_net', 'strain']) { + final rows = await _row(day, key); + expect(rows, hasLength(1), reason: '$key row is written'); + expect(rows.single['value'], isNull, reason: '$key is erased'); + } + }, timeout: const Timeout(Duration(minutes: 5))); + + test('360 wake minutes contribute a level, 359 do not', () async { + await _clear(); + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2026-05-0$d', 'quiet_hrr', 0.20); + } + await LocalDb.putComputeFreshness(kQuietHrrSeedKey, '{"done":true}'); + await _insertDay('2026-05-10', wakeMin: 359); + await _insertDay('2026-05-11', wakeMin: 360); + await DerivationEngine().runDays(_profile, {'2026-05-10', '2026-05-11'}); + + // Guard: the wake series really is that long (no minute carved off). + for (final (day, n) in const [('2026-05-10', 359), ('2026-05-11', 360)]) { + final row = await LocalDb.dayResult(day); + final series = SeriesCodec.decodePayloadJson(row!['payload_json'])![ + 'series'] as Map; + expect((series['strain_curve'] as List).length, n, reason: day); + } + expect(await _value('2026-05-10', 'quiet_hrr'), isNull); + // …and still scored, on the prior days' level. + expect(await _value('2026-05-10', 'strain'), greaterThan(0)); + expect(await _value('2026-05-11', 'quiet_hrr'), closeTo(0.0545, 0.002)); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('a second-half timeout on the re-score keeps the day owed', () async { + // The re-score's pipeline half DOES produce strain once the level exists; + // its second half (the only producer of `trimp_net`) times out, and the + // first pass's same-version row is carried forward. Strain present is not + // proof the day was scored: the marker stays, the day stays unlocked with + // its raw, and a later COMPLETE derive writes the net load. + // + // The first pass's second half times out too, so the row carried forward + // is that pass's own headline-only row — the recovery the engine can + // perform today. (Carrying a COMPLETE row's curves into a fresh pipeline + // bundle currently throws before anything is written, which keeps the + // marker by accident; it is not what this test is about.) + await _clear(); + const days = [ + '2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', // + '2026-07-05', '2026-07-06', '2026-07-07', + ]; + for (final d in days) { + await _insertDay(d); + } + final target = days[3]; + var calls = 0; + DerivationEngine.debugSecondHalfHook = (day) async { + // Both the first pass and the re-score time out on the second half. + if (day == target && ++calls <= 2) { + throw TimeoutException('injected second-half timeout'); + } + }; + addTearDown(() => DerivationEngine.debugSecondHalfHook = null); + await DerivationEngine().run(_profile, heavy: true); + DerivationEngine.debugSecondHalfHook = null; + expect(calls, 2, reason: 'the re-score reached the second half'); + + expect(await _value(target, 'strain'), isNull, + reason: 'a headline-only row never writes its series'); + final row = await LocalDb.dayResult(target); + expect( + (SeriesCodec.decodePayloadJson(row!['payload_json'])!['scalars'] + as Map)['strain'], + isNotNull, + reason: 'the re-score\'s pipeline half did score it'); + expect(await _owed(), contains(target)); + expect(await LocalDb.finalizedDayIds(kAlgoVersion), isNot(contains(target))); + expect(await _value(target, 'trimp_net'), isNull); + expect(await _decodedRows(target), 400 * 60, reason: 'held whole'); + + // Restart: a fresh engine's light pass completes it. + await DerivationEngine().run(_profile); + expect(await _value(target, 'trimp_net'), isNotNull); + expect(await _value(target, 'strain'), isNotNull); + expect(await _owed(), isNot(contains(target))); + }, timeout: const Timeout(Duration(minutes: 5))); + + // The race the old counting rule lost: it read the stored levels, then the + // derived days, and a third earlier day committing its level AND its row + // between those reads was counted by neither. The rule now asks only + // whether ANY earlier day exists — as unfinalized raw, or as a stored + // level — and a day's level lands in the same transaction as its row, so + // every point of that interleaving answers "owed". + test('owed holds at every point of a concurrent earlier commit', () async { + await _clear(); + final engine = DerivationEngine(); + const days = ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04']; + final blocked = { + 'scalars': {'strain': null}, + 'absent_notes': {'strain': 'need_baseline:have=2,need=3'}, + }; + Future raw(String d) async { + final db = await LocalDb.instance; + final t = _localSec(d, 9); + await db.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': t * 1000, + 'rec_ts': t, + 'counter': t, + 'hr': 62, + 'ax': 0.0, + 'ay': 0.0, + 'az': 1.0, + 'device_family': 'gen4', + }); + } + + Future commit(String d) => LocalDb.putDayResult( + dayId: d, + algoVersion: kAlgoVersion, + payloadJson: '{"date":"$d"}', + windowJson: '{}', + finalized: true, + source: 'band', + series: {'quiet_hrr': 0.2}, + ); + + for (final d in days) { + await raw(d); + } + // Nothing earlier committed yet / two of three / all three committed. + expect(await engine.debugQuietRepassOwed(days[3], blocked), isTrue); + await commit(days[0]); + await commit(days[1]); + expect(await engine.debugQuietRepassOwed(days[3], blocked), isTrue); + await commit(days[2]); + expect(await engine.debugQuietRepassOwed(days[3], blocked), isTrue); + // The interleaving itself: every earlier day finalized, so the raw read + // finds nothing pending — then an earlier day commits its level and its + // row before the level read. It is seen. + await _clear(); + await raw(days[2]); + await LocalDb.putDayResult( + dayId: days[2], + algoVersion: kAlgoVersion, + payloadJson: '{"date":"${days[2]}"}', + windowJson: '{}', + finalized: true, + source: 'band', + ); + DerivationEngine.debugQuietBetweenReads = () => commit(days[2]); + addTearDown(() => DerivationEngine.debugQuietBetweenReads = null); + expect(await engine.debugQuietRepassOwed(days[3], blocked), isTrue); + DerivationEngine.debugQuietBetweenReads = null; + + // Even with every earlier raw day gone, a stored level still counts. + final db = await LocalDb.instance; + await db.delete('decoded_onehz'); + expect(await engine.debugQuietRepassOwed(days[3], blocked), isTrue); + + // Never owed: the first day (nothing before it), or a strain that is + // present or absent for another reason. + await raw(days[0]); + expect(await engine.debugQuietRepassOwed('2026-07-31', blocked), isFalse); + expect( + await engine.debugQuietRepassOwed(days[3], { + 'scalars': {'strain': 8.0}, + }), + isFalse); + expect( + await engine.debugQuietRepassOwed(days[3], { + 'scalars': {'strain': null}, + 'absent_notes': {'strain': 'need_input:resting_hr'}, + }), + isFalse); + }); + + test('an owed day whose night the prune already cut is retired, not re-scored', + () async { + // Only the 14-day hold floor can cut into an owed day's night. Re-deriving + // it then would replace a full-night result with a truncated one — the + // case the rescan refuses — so its abstention becomes final instead. + await _clear(); + const days = [ + '2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', // + '2026-09-05', '2026-09-06', '2026-09-07', + ]; + for (final d in days) { + await _insertDay(d); + } + // Leave days 4–7 owed: no second pass ran. + DerivationEngine.debugQuietRepassHook = (day) async { + if (day == null) throw StateError('injected'); + }; + addTearDown(() => DerivationEngine.debugQuietRepassHook = null); + await DerivationEngine().run(_profile, heavy: true); + DerivationEngine.debugQuietRepassHook = null; + await _expectOwedAfterSweep(days, days.skip(3)); + + // As if the floor had cut at day 5's midnight: days 2–5's nights (from + // the previous noon) are truncated, days 6–7's are whole. + await LocalDb.setCursor( + 'decoded_pruned_before', '${_localSec(days[4], 0)}'); + await DerivationEngine().run(_profile); + expect(await _owed(), isEmpty); + for (final d in days.sublist(1, 5)) { + expect(await _value(d, 'strain'), isNull, reason: '$d not re-scored'); + } + for (final d in days.skip(5)) { + expect(await _value(d, 'strain'), isNotNull, reason: d); + } + }, timeout: const Timeout(Duration(minutes: 5))); + + // Clearing a marker on "can never reach three" COUNTS, so its reads must be + // ordered against a concurrent commit: the derived set first, then the + // levels. Read the other way round, an earlier day committing its level and + // its row in between is seen by neither, and a day still owed a score is + // "proven" unreachable and finalized blank. + test('unreachable is never proven across a concurrent earlier commit', + () async { + await _clear(); + final engine = DerivationEngine(); + const days = ['2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']; + Future raw(String d) async { + final db = await LocalDb.instance; + final t = _localSec(d, 9); + await db.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': t * 1000, + 'rec_ts': t, + 'counter': t, + 'hr': 62, + 'ax': 0.0, + 'ay': 0.0, + 'az': 1.0, + 'device_family': 'gen4', + }); + } + + Future commit(String d) => LocalDb.putDayResult( + dayId: d, + algoVersion: kAlgoVersion, + payloadJson: '{"date":"$d","scalars":{"rhr":55}}', + windowJson: '{}', + finalized: true, + source: 'band', + rhr: 55, + series: {'quiet_hrr': 0.2}, + ); + + for (final d in days) { + await raw(d); + } + await commit(days[0]); + await commit(days[1]); + // Two levels in hand and a third day still deriving: reachable. + expect(await engine.debugQuietLevelUnreachable(days[3]), isFalse); + // …and still reachable when that third day commits between the reads. + DerivationEngine.debugQuietBetweenReads = () => commit(days[2]); + addTearDown(() => DerivationEngine.debugQuietBetweenReads = null); + expect(await engine.debugQuietLevelUnreachable(days[3]), isFalse); + DerivationEngine.debugQuietBetweenReads = null; + + // Proven only when nothing in hand can lift it: day 2 has one level + // before it and no earlier day still pending. + expect(await engine.debugQuietLevelUnreachable(days[1]), isTrue); + // Day 3: two levels before it, nothing earlier still pending. + expect(await engine.debugQuietLevelUnreachable(days[2]), isTrue); + }); + + test('a cold start\'s days 2–3 are released: they can never be scored', + () async { + await _clear(); + const days = ['2026-11-01', '2026-11-02', '2026-11-03']; + for (final d in days) { + await _insertDay(d); + } + await DerivationEngine().run(_profile, heavy: true); + await _expectOwedAfterSweep(days, const []); + await DerivationEngine().run(_profile, heavy: true); + expect(await _owed(), isEmpty); + for (final d in days) { + expect(await _value(d, 'strain'), isNull, reason: d); + expect(await _value(d, 'quiet_hrr'), isNotNull, reason: d); + } + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/test/readiness_baseline_pollution_test.dart b/test/readiness_baseline_pollution_test.dart index e8f332301..719c31f49 100644 --- a/test/readiness_baseline_pollution_test.dart +++ b/test/readiness_baseline_pollution_test.dart @@ -264,4 +264,80 @@ void main() { expect(window.last, 40.0 + 9); expect(window.every((v) => v < 40.0 + 10), isTrue); }); + + test('quiet_hrr rides the sweep: prior measured days only', () async { + // Strain is priced on the median of these. The day itself (its own level + // feeds LATER days, never itself) and an imported day are both out. + await LocalDb.putMetricSeriesValue('2026-12-01', 'quiet_hrr', 0.11); + await LocalDb.putMetricSeriesValue('2026-12-02', 'quiet_hrr', 0.12); + await LocalDb.putDayResult( + dayId: '2026-12-03', + algoVersion: 1, + payloadJson: '{"date":"2026-12-03","imported":true,"source":"whoop_export"}', + windowJson: '{}', + finalized: true, + source: 'whoop_export', + series: {'quiet_hrr': 0.13}, + ); + await LocalDb.putMetricSeriesValue('2026-12-04', 'quiet_hrr', 0.14); + final window = + (await debugSweepBaselineWindows('quiet_hrr', ['2026-12-04'])).single; + expect(window, [0.11, 0.12]); + // The non-sweep read (sessions, live gauge) sees the same window. + expect( + await LocalDb.trailingSeriesValues('quiet_hrr', 28, before: '2026-12-04'), + [0.11, 0.12]); + }); + + test('the rescan signature sees a quiet level move under ONE day\'s window', + () async { + // Chronological [.10, .10, .20, .20, .20, .15, .30]: changing the sixth + // day .15 → .25 keeps the count (7) and the overall median (.20) — all a + // count-and-median signature saw — but the seventh day's strictly-prior + // window goes from median .175 to .20, so that day's strain moves and the + // rescan must not skip it. + final db = await LocalDb.instance; + await db.delete('metric_series', where: 'key = ?', whereArgs: ['quiet_hrr']); + const dates = [ + '2027-01-01', '2027-01-02', '2027-01-03', '2027-01-04', // + '2027-01-05', '2027-01-06', '2027-01-07', + ]; + const values = [0.10, 0.10, 0.20, 0.20, 0.20, 0.15, 0.30]; + for (var i = 0; i < dates.length; i++) { + await LocalDb.putMetricSeriesValue(dates[i], 'quiet_hrr', values[i]); + } + final before = await debugBaselineSignature(); + expect(await debugBaselineSignature(), before, + reason: 'stable when nothing moved — a rescan costs nothing then'); + final whole = await debugBaselineWindow('quiet_hrr'); + final todayBefore = + (await debugSweepBaselineWindows('quiet_hrr', ['2027-01-07'])).single; + + await LocalDb.putMetricSeriesValue('2027-01-06', 'quiet_hrr', 0.25); + final wholeAfter = await debugBaselineWindow('quiet_hrr'); + final todayAfter = + (await debugSweepBaselineWindows('quiet_hrr', ['2027-01-07'])).single; + // What the old signature term read did not move… + expect(wholeAfter.length, whole.length); + expect(ana.median(wholeAfter), ana.median(whole)); + expect(ana.median(whole), closeTo(0.20, 1e-9)); + // …while the window the day is actually priced on did. + expect(ana.median(todayBefore), closeTo(0.175, 1e-9)); + expect(ana.median(todayAfter), closeTo(0.20, 1e-9)); + expect(await debugBaselineSignature(), isNot(before)); + + // The NEWEST level (normally today's, rewritten as its wake series grows) + // sits in no earlier day's window, so moving it must not trigger a rescan. + final stable = await debugBaselineSignature(); + await LocalDb.putMetricSeriesValue('2027-01-07', 'quiet_hrr', 0.12); + expect(await debugBaselineSignature(), stable); + + // …until a LATER day exists that wrote no level of its own (too few wake + // minutes) and may already be finalized: it was priced on the newest + // level, so moving that level must re-price it. + await LocalDb.putMetricSeriesValue('2027-01-08', 'rhr', 52); + final withLater = await debugBaselineSignature(); + await LocalDb.putMetricSeriesValue('2027-01-07', 'quiet_hrr', 0.14); + expect(await debugBaselineSignature(), isNot(withLater)); + }); } diff --git a/test/resp_rate_gappy_night_test.dart b/test/resp_rate_gappy_night_test.dart new file mode 100644 index 000000000..688e29a74 --- /dev/null +++ b/test/resp_rate_gappy_night_test.dart @@ -0,0 +1,79 @@ +// A sensor gap does not cost the night its respiratory rate. +// +// The RSA estimator used to read the beat-rate Nyquist off span/(n−1). Across +// a dropout that is the beat interval DIVIDED BY COVERAGE, so a 55 bpm night +// missing a quarter of its beats "beat" at ~41 bpm and was withheld whole as an +// alias — `scalars.resp_rate` null, the readiness resp driver and the resp +// baseline gone, and a note blaming a heart rate the user never had. The alias +// guard itself is real and stays: at a genuinely low heart rate the beats are +// too slow to resolve normal breathing, and the note now says so truthfully. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 7 * 3600; + +/// 7 h of RSA at [hrBpm], 40 ms at 15 br/min. Beats whose time falls in +/// [holeSec] (seconds from onset) happened but were never recorded. +Map _night({required double hrBpm, (int, int)? holeSec}) { + final base = 60000.0 / hrBpm, f = 15.0 / 60.0; + final rr = [], ts = []; + var tMs = 0.0; + while (tMs < _nightSec * 1000) { + final v = base + 40 * math.sin(2 * math.pi * f * tMs / 1000); + tMs += v; + final sec = tMs / 1000; + if (holeSec != null && sec >= holeSec.$1 && sec < holeSec.$2) continue; + rr.add(v); + ts.add(_t0 * 1000.0 + tMs); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, hrBpm.round()); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: rr, + sleepRrTsMs: ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const { + 'tst_sec': _nightSec, + 'efficiency_pct': 92.0, + }, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: _t0 + _nightSec, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('a night with a 1 h 45 min hole still publishes its breathing rate', () { + final b = _night(hrBpm: 55, holeSec: (2 * 3600, 3 * 3600 + 45 * 60)); + final rsa = _m(_m(b, 'respiration'), 'rsa'); + final scalars = _m(b, 'scalars'); + expect(scalars['resp_rate'], isNotNull, reason: '${rsa['note']}'); + expect(scalars['resp_rate'] as num, closeTo(15, 1.0)); + expect(rsa['note'] as String, isNot(contains('beat rate'))); + }); + + test('a genuinely low heart rate is still withheld, and the note says so', + () { + final b = _night(hrBpm: 45); + final rsa = _m(_m(b, 'respiration'), 'rsa'); + expect(_m(b, 'scalars')['resp_rate'], isNull); + expect(rsa['note'] as String, contains('alias')); + expect(rsa['note'] as String, contains('45')); + }); +} diff --git a/test/rmssd_legacy_bundle_test.dart b/test/rmssd_legacy_bundle_test.dart new file mode 100644 index 000000000..65460e4d8 --- /dev/null +++ b/test/rmssd_legacy_bundle_test.dart @@ -0,0 +1,159 @@ +// A day_result derived BEFORE `rmssd` became one estimator keeps what it was. +// +// Old bundles can hold a fallback `scalars.rmssd` (the NREM median or the +// whole-night value) beside an absent session envelope (`value: '—'`, +// confidence 0). Rows are immutable per version and only the last ~3 days +// re-derive after a bump, so most of a user's HRV history is such bundles for a +// long time. Serving the absent envelope's confidence 0 next to that value made +// Health's HRV row blank (`Metric.isEmpty` honours confidence) while the +// widget, the trends and the baselines still showed and used the number. Every +// consumer must agree: the retained value with the confidence it always had. + +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/models/payloads.dart'; +import 'package:openstrap_edge/ui2/screens/health_screen.dart' show HealthData; +import 'package:openstrap_edge/widget/widget_service.dart'; + +/// A pre-change bundle: rmssd fell back to the NREM median because the +/// session estimator abstained. +String _legacyBundle(String day, double rmssd) => jsonEncode({ + 'date': day, + 'scalars': {'rmssd': rmssd, 'sdnn': 70.0}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 7 * 3600, 'efficiency_pct': 90.0}, + }, + }, + 'clinical': { + 'rmssd_sleep_session': { + 'value': '—', + 'confidence': 0, + 'tier': 'HIGH', + 'inputs_used': ['rr_sleep_window'], + 'note': 'no valid 5-min windows for sleep-session RMSSD', + }, + 'rmssd_nocturnal': {'value': rmssd, 'confidence': 0.7, 'tier': 'HIGH'}, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 25000}, + 'confidence': 0.62, + 'tier': 'HIGH', + }, + }, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Map written; + late LocalRepositoryImpl repo; + // Calendar days, not 24 h steps: across a DST change "now ± n × 24 h" can + // land on the wrong local date. One captured date, offsets by day number. + final now = DateTime.now(); + String dayAt(int offset) => + dayLabelOf(DateTime(now.year, now.month, now.day + offset)); + final today = dayAt(0); + final tomorrow = dayAt(1); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rmssd_legacy_bundle_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + repo = LocalRepositoryImpl(getProfileMap: () => const {}); + // Three older nights and today, all written by the previous algorithm. + final values = [52.0, 50.0, 54.0, 48.3]; + for (var i = 0; i < values.length; i++) { + final day = dayAt(i - (values.length - 1)); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion - 1, + payloadJson: _legacyBundle(day, values[i]), + windowJson: '{}', + rmssd: values[i], + source: 'band', + series: {'rmssd': values[i]}, + ); + } + await LocalDb.refreshComputeFreshness(); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + setUp(() { + written = {}; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(const MethodChannel('home_widget'), + (call) async { + if (call.method == 'saveWidgetData') { + final args = (call.arguments as Map).cast(); + written[args['id'] as String] = args['data']; + } + return true; + }); + messenger.setMockMethodCallHandler( + const MethodChannel('openstrap/ios_config'), (call) async { + return call.method == 'appGroupIdentifier' ? 'group.test' : null; + }); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('home_widget'), null); + messenger.setMockMethodCallHandler( + const MethodChannel('openstrap/ios_config'), null); + }); + + test('every consumer serves the retained value, with its old confidence', + () async { + // Today: the value, and the confidence it was always served with. + final t = TodayData.fromJson(await repo.getToday()); + expect(t.hrv, isNotNull); + expect(t.hrv!.rmssd, 48.3); + expect(t.hrv!.confidence, 0.62, + reason: 'not the absent session envelope\'s 0'); + + // Health: the HRV row honours confidence, so 0 blanked it. + final health = await HealthData.load(repo); + expect(health.hrv.isEmpty, isFalse); + expect(health.hrv.value, 48.3); + + // Widget / Watch: the same number. + await WidgetService.push(t); + expect(written['hrv'], 48); + + // HRV detail and the trend chart read the stored scalar and series. + final dayHrv = await repo.getDayHrv(today); + expect(dayHrv['rmssd'], 48.3); + // The envelope that says WHICH estimator that is travels with it, so + // Investigate does not label it the 5-min-window mean. + expect((dayHrv['rmssd_sleep_session'] as Map)['value'], '—'); + final points = (await repo.getChart('hrv'))['points'] as List; + expect(points.whereType().any((pt) => pt.values.contains(48.3)), + isTrue, + reason: 'the stored series point is still drawn'); + + // The baseline the next derive scores against keeps the old values. + final window = (await debugSweepBaselineWindows('rmssd', [tomorrow])).single; + expect(window, containsAll([52.0, 50.0, 54.0, 48.3])); + }); +} diff --git a/test/rmssd_persistence_test.dart b/test/rmssd_persistence_test.dart new file mode 100644 index 000000000..ca909bf05 --- /dev/null +++ b/test/rmssd_persistence_test.dart @@ -0,0 +1,152 @@ +// An abstaining sleep-session RMSSD persists as NULL — over a stored number, +// and on every re-derive. +// +// `rmssd` used to fall back to the NREM median when the session estimator +// abstained. Without the fallback the scalar is null, and the stored day must +// follow: `day_result.rmssd`, the bundle and `metric_series('rmssd')` all +// overwritten (REPLACE with null, not skipped), the same on the second derive +// as on the first, and the next day's baseline must not fold the stale value. +// +// The night: a forced sleep window over 8 h of 1 Hz HR, with RR in 10-beat +// blocks every 5 minutes. Every 5-min window holds 9 successive differences — +// under the 20 the session estimator needs, over the 5 the NREM median needs — +// so the bundle still publishes `clinical.rmssd_nocturnal`, the number the old +// fallback would have stored. + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/data/db.dart'; + +const _dayId = '2025-09-10'; // fixed, not `now` +const _nextDay = '2025-09-11'; +const _priorDay = '2025-09-09'; + +int _sec(int y, int mo, int d, int h, int mi) => + DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; + +final _onset = _sec(2025, 9, 9, 22, 0); +final _offset = _sec(2025, 9, 10, 6, 0); + +Future _seedNight(Database db) async { + final batch = db.batch(); + var counter = 0; + for (var ts = _onset; ts < _offset; ts++) { + batch.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': counter++, + 'hr': 55, + 'ax': 0.0, + 'ay': 0.0, + 'az': 1.0, + 'device_family': 'gen4', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + for (var block = _onset + 30; block + 15 < _offset; block += 300) { + var tMs = block * 1000.0; + final perRecord = {}; + for (var i = 0; i < 10; i++) { + final rr = 1091.0 + 16 * math.sin(2 * math.pi * i / 12); + tMs += rr; + final recTs = tMs ~/ 1000; + final idx = perRecord[recTs] = (perRecord[recTs] ?? -1) + 1; + batch.insert('decoded_rr', { + 'device_id': '', + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'beat_index': idx, + 'rr_ts_ms': recTs * 1000, + 'beat_ts_ms': tMs.round(), + 'rr_ms': rr.round(), + 'device_family': 'gen4', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + await batch.commit(noResult: true); +} + +Future _seriesValue(Database db, String date, String key) async { + final rows = await db.query('metric_series', + columns: ['value'], where: 'date = ? AND key = ?', whereArgs: [date, key]); + return rows.isEmpty ? 'NO ROW' : rows.single['value']; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rmssd_persistence_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a stored rmssd is overwritten with null, identically on every derive', + () async { + final db = await LocalDb.instance; + await _seedNight(db); + await LocalDb.putSleepOverride( + dayId: _dayId, onsetTs: _onset, offsetTs: _offset, source: 'manual'); + // A real prior night for the baseline, and a same-day number written by + // the old fallback. + await LocalDb.putDayResult( + dayId: _priorDay, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'rmssd': 50.0}}), + windowJson: '{}', + rmssd: 50.0, + source: 'band', + series: {'rmssd': 50.0}, + ); + await LocalDb.putDayResult( + dayId: _dayId, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'rmssd': 37.0}}), + windowJson: '{}', + rmssd: 37.0, + source: 'band', + series: {'rmssd': 37.0}, + ); + expect(await _seriesValue(db, _dayId, 'rmssd'), 37.0); + + for (var pass = 1; pass <= 2; pass++) { + final done = await DerivationEngine() + .runDays(const Profile(), {_dayId}, force: true); + expect(done, 1, reason: 'pass $pass must derive'); + final row = await LocalDb.dayResult(_dayId); + final bundle = jsonDecode(row!['payload_json'] as String) as Map; + final clinical = (bundle['clinical'] as Map).cast(); + final session = (clinical['rmssd_sleep_session'] as Map); + expect(session['value'], '—', reason: 'pass $pass: ${session['note']}'); + expect(session['note'] as String, contains('fewer than 20')); + // The number the old fallback would have stored is still computed, under + // its own key. + expect((clinical['rmssd_nocturnal'] as Map)['value'], isA(), + reason: 'pass $pass: ${(clinical['rmssd_nocturnal'] as Map)['note']}'); + expect((bundle['scalars'] as Map)['rmssd'], isNull, reason: 'pass $pass'); + expect(row['rmssd'], isNull, reason: 'pass $pass: day_result.rmssd'); + expect(await _seriesValue(db, _dayId, 'rmssd'), isNull, + reason: 'pass $pass: metric_series is REPLACEd with null'); + expect(await _seriesValue(db, _dayId, 'ln_rmssd'), isNull, + reason: 'pass $pass'); + } + + // The next day's baseline folds the real prior night, not the stale 37. + final window = + (await debugSweepBaselineWindows('rmssd', [_nextDay])).single; + expect(window, contains(50.0)); + expect(window, isNot(contains(37.0))); + }); +} diff --git a/test/rmssd_single_estimator_test.dart b/test/rmssd_single_estimator_test.dart new file mode 100644 index 000000000..1bd02f251 --- /dev/null +++ b/test/rmssd_single_estimator_test.dart @@ -0,0 +1,110 @@ +// `rmssd` is ONE estimator: the sleep-session mean of 5-min-window RMSSDs, the +// same number `ln_rmssd` (→ readiness) is the log of, or it is absent. +// +// It used to fall back to the NREM median (`nocturnalRmssd`) and then to the +// whole-night RMSSD (`hrvTime`) whenever the session estimator abstained. Those +// are different statistics over differently-cleaned beats, so the charted HRV +// series, its baseline, the widget, the coach and the Health Connect export +// mixed three estimators, and on a fallback night the "HRV" shown was not the +// HRV readiness scored. The other two still publish, under their own keys. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 2 * 3600; + +Map _night({required bool session}) { + final rr = [], ts = []; + var t = _t0 * 1000.0; + for (var i = 0; i < _nightSec; i++) { + final v = 1000.0 + 16.0 * math.sin(2 * math.pi * i / 12); + t += v; + rr.add(v); + ts.add(t); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, 60); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: rr, + sleepRrTsMs: ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const {'tst_sec': _nightSec}, + hypnoStages: const [], + sleepOnsetSec: session ? _t0 : 0, + sleepOffsetSec: session ? (ts.last / 1000).ceil() + 1 : 0, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + rmssdHistory: [for (var i = 0; i < 14; i++) 5.5 + 0.2 * (i % 5)], + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('no session estimate: rmssd is absent, not a stand-in', () { + final b = _night(session: false); + final scalars = _m(b, 'scalars'); + final clinical = _m(b, 'clinical'); + expect(_m(clinical, 'rmssd_sleep_session')['value'], '—'); + // Before: scalars.rmssd == clinical.rmssd_nocturnal.value here, while + // ln_rmssd was null — two keys describing different quantities. + expect(scalars['rmssd'], isNull); + expect(scalars['ln_rmssd'], isNull); + // The other estimators still publish, under their own keys. + expect(_m(clinical, 'rmssd_nocturnal')['value'], isA()); + expect(scalars['rmssd_whole'], isNotNull); + // Nothing downstream folds a stand-in either. + expect(_m(_m(b, 'baselines'), 'hrv')['value'], isNull); + expect(_m(b, 'stress')['rmssd'], isNull); + }); + + test('with a session: rmssd IS the session estimate, and ln_rmssd its log', + () { + final b = _night(session: true); + final scalars = _m(b, 'scalars'); + final session = _m(_m(b, 'clinical'), 'rmssd_sleep_session'); + expect(session['value'], isA()); + expect(scalars['rmssd'] as num, + closeTo(session['value'] as num, 0.05)); // envelope rounds to 1 dp + expect(math.exp(scalars['ln_rmssd'] as num), + closeTo(scalars['rmssd'] as num, 1e-9)); + expect(session['windows'] as num, greaterThanOrEqualTo(20)); + expect(session['thin_windows'], isA()); + expect(session['min_diffs_per_window'], 20); + }); + + test("Today's HRV confidence is the headline's own, not the whole night's", + () { + // The block shows the session estimate, so it carries that estimate's + // confidence. It used to carry `hrv_time`'s, a different estimator. + expect( + hrvConfidenceForToday({ + 'rmssd_sleep_session': {'value': 61.0, 'confidence': 0.82}, + 'hrv_time': {'confidence': 0.3}, + }), + 0.82); + // A bundle derived BEFORE this change can hold a fallback rmssd beside an + // absent session envelope: it keeps the confidence it was served with. + expect( + hrvConfidenceForToday({ + 'rmssd_sleep_session': {'value': '—', 'confidence': 0}, + 'hrv_time': {'confidence': 0.62}, + }), + 0.62); + // Malformed rows only: fall back as before rather than throw. + expect(hrvConfidenceForToday({'hrv_time': {'confidence': 0.3}}), 0.3); + expect(hrvConfidenceForToday(const {}), 0.5); + }); +} diff --git a/test/rr_overcount_pipeline_test.dart b/test/rr_overcount_pipeline_test.dart new file mode 100644 index 000000000..817240cf5 --- /dev/null +++ b/test/rr_overcount_pipeline_test.dart @@ -0,0 +1,101 @@ +// An RR stream that banks more beat-time than elapsed loses every RMSSD. +// +// A double-ingested or two-device-interleaved RR stream reads Σ RR ÷ wall span +// ≈ 2.0 and passes every other gate: duplicated beats add zero differences and +// deflate RMSSD by ~1/√2, interleaved streams inflate it, and neither looks +// like jitter. Analytics refuses RMSSD above 1.10; the pipeline has to hand +// that coverage to the whole-night and NREM estimators too, and store it. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 7 * 3600; + +/// 7 h of RSA at HR 60 / 12 br/min (the jitter screen keeps it), optionally +/// with every (rr, ts) pair stored twice. +Map _night({bool duplicateBeats = false}) { + final rr = [], ts = []; + var tSec = 0.0; + var t = _t0 * 1000.0; + while (tSec < _nightSec - 2) { + final v = 1000 + 30 * math.sin(2 * math.pi * (12 / 60) * tSec); + tSec += v / 1000; + t += v; + rr.add(v); + ts.add(t); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, 60); + return deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: duplicateBeats ? [for (final v in rr) ...[v, v]] : rr, + sleepRrTsMs: duplicateBeats ? [for (final x in ts) ...[x, x]] : ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const { + 'tst_sec': _nightSec, + 'efficiency_pct': 92.0, + }, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: (ts.last / 1000).ceil() + 1, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + ).toJson(), + ); +} + +Map _m(Map b, String k) => + (b[k] as Map).cast(); + +void main() { + test('an honest stream reads ≈ 1.0 and keeps its RMSSD, with diagnostics', + () { + final b = _night(); + final scalars = _m(b, 'scalars'); + final session = _m(_m(b, 'clinical'), 'rmssd_sleep_session'); + expect(scalars['rmssd'], isNotNull, reason: '${session['note']}'); + expect(session['windows'] as num, greaterThan(80)); + expect(session['diff_acf1'], isA()); + expect(session['rr_coverage'] as num, closeTo(1.0, 0.02)); + expect(session['overcounted_windows'], 0); + expect(_m(b, 'series')['hrv_timeline'] as List, isNotEmpty); + expect(_m(b, 'hrv_night_shape')['value'], isNot(anyOf(isNull, '—'))); + final coverage = _m(b, 'coverage'); + expect(coverage['rr_coverage'] as num, closeTo(1.0, 0.02)); + expect(coverage['rr_duplicate_beats'], 0); + }); + + test('every beat duplicated: every RMSSD is refused as an over-count', () { + final b = _night(duplicateBeats: true); + final scalars = _m(b, 'scalars'); + final clinical = _m(b, 'clinical'); + final session = _m(clinical, 'rmssd_sleep_session'); + expect(scalars['rmssd'], isNull); + expect(scalars['ln_rmssd'], isNull); + expect(session['value'], '—'); + expect(session['note'] as String, startsWith('rr_overcount')); + final coverage = _m(b, 'coverage'); + expect(coverage['rr_coverage'] as num, closeTo(2.0, 0.05)); + expect(coverage['rr_duplicate_beats'] as num, greaterThan(0)); + expect(scalars['rmssd_whole'], isNull, + reason: 'whole-night RMSSD refuses the same over-count'); + final nocturnal = _m(clinical, 'rmssd_nocturnal'); + expect(nocturnal['value'], anyOf(isNull, '—')); + expect(nocturnal['note'] as String, startsWith('rr_overcount'), + reason: 'the NREM median refuses it too, by name'); + final shape = _m(b, 'hrv_night_shape'); + expect(shape['value'], anyOf(isNull, '—')); + expect(shape['note'] as String, startsWith('rr_overcount'), + reason: 'so does the nightly HRV shape, every bin of it'); + expect(_m(b, 'series')['hrv_timeline'] as List, isEmpty, + reason: 'and the rolling RMSSD timeline is not drawn'); + }); +} diff --git a/test/strain_quiet_level_test.dart b/test/strain_quiet_level_test.dart new file mode 100644 index 000000000..5a6bfbb3f --- /dev/null +++ b/test/strain_quiet_level_test.dart @@ -0,0 +1,203 @@ +// Strain is priced against THIS user's quiet-waking level, and quiet time never +// debits exercise. +// +// Every strain site used to subtract a population 0.20 HRR as the cost of +// being awake. A user who sits below it had real workouts cancelled (a 45-min +// run at 145 bpm scored 0.00 for someone at 0.10 HRR); a user above it scored +// 7–12/21 for doing nothing. The day now scores against the median of its own +// trailing `quiet_hrr` days (strictly before it), persists its own level for +// later days, and abstains with the baseline grammar until three exist. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; + +// A fixed epoch second on a minute boundary, so N minutes of samples are N +// per-minute buckets. +const int _t0 = 1786699980; + +/// [bouts] as (minutes, bpm), one sample per second, all of it waking. +Substrate _sub(List<(int, int)> bouts) { + final ts = [], hr = []; + final ax = [], ay = [], az = []; + var i = 0; + for (final (mins, bpm) in bouts) { + for (var s = 0; s < mins * 60; s++, i++) { + ts.add(_t0 + i); + hr.add(bpm); + ax.add(0.01 * ((i % 7) - 3)); + ay.add(0.01 * ((i % 5) - 2)); + az.add(1.0); + } + } + final n = ts.length; + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + deviceFamily: 'gen4', + ); +} + +/// The `strain_resting_hr_source_test` day: 300 min at 62 bpm, 60 at 130. +Substrate _daySub() => _sub(const [(300, 62), (60, 130)]); + +/// RHR 50 / HRmax 187 (age 30): 45 min at 145, then 855 quiet minutes at 64. +Substrate _lowQuietSub() => _sub(const [(45, 145), (855, 64)]); + +const _profile = Profile(ageYears: 35, sex: 'm', weightKg: 75, heightCm: 178); +const _profile30 = Profile(ageYears: 30, sex: 'm', weightKg: 75, heightCm: 178); + +({Map scalars, Map bundle}) _run( + Substrate sub, { + required List quietHrrHistory, + Profile profile = _profile, + double restingHr = 55, +}) { + final scalars = {}; + final bundle = {}; + DerivationEngine.applyDayActivity( + bundle: bundle, + scalars: scalars, + daySub: sub, + profile: profile, + sleepOnsetSec: 0, + sleepOffsetSec: 0, + dayStartSec: sub.tsSec.first, + dayCalendarEndSec: sub.tsSec.last + 1, + dataNowSec: sub.tsSec.last + 1, + restingHr: restingHr, + quietHrrHistory: quietHrrHistory, + ); + return (scalars: scalars, bundle: bundle); +} + +/// The same day through the pure pipeline. No sleep, so its resting HR is the +/// profile's — set to the anchor the engine half is handed. +Map _pipeline( + Substrate sub, { + required List quietHrrHistory, + int age = 35, + int restingHr = 55, +}) => + deriveDayBundle(DayBundleInput( + date: '2026-08-15', + dayTsSec: sub.tsSec, + dayHr: sub.hr, + sleepTsSec: const [], + sleepHr: const [], + sleepRrTsMs: const [], + sleepRrMs: const [], + sleepSkinTemp: const [], + sleepJson: const {}, + hypnoStages: const [], + sleepOnsetSec: 0, + sleepOffsetSec: 0, + profile: { + 'age': age, + 'sex': 'm', + 'weight_kg': 75, + 'height_cm': 178, + 'resting_hr': restingHr, + }, + deviceFamily: 'gen4', + quietHrrHistory: quietHrrHistory, + ).toJson()); + +void main() { + group('the day is priced on its own trailing quiet level', () { + test('no prior levels: strain abstains with the baseline grammar', () { + final r = _run(_daySub(), quietHrrHistory: const []); + expect(r.scalars.containsKey('strain'), isTrue, + reason: 'written even when null, so a stale value is erased'); + expect(r.scalars['strain'], isNull); + expect((r.bundle['absent_notes'] as Map)['strain'], + 'need_baseline:have=0,need=3'); + }); + + test('seven prior levels: strain, the day\'s own level and net load', () { + final r = _run(_daySub(), quietHrrHistory: List.filled(7, 0.20)); + // The shipped lump form scored this fixture 2.85: 300 quiet minutes at + // 0.0545 HRR cancelled most of the hour at 130 bpm. + expect(r.scalars['strain'] as num, closeTo(8.55, 0.05)); + // 62 bpm against RHR 55 / HRmax 183.5 = 7/128.5. Persisted for LATER + // days; it never prices this one. + expect(r.scalars['quiet_hrr'] as num, closeTo(0.0545, 0.0005)); + expect(r.scalars['trimp_net'] as num, closeTo(57.46, 0.1)); + }); + + test('three prior levels score the same value, as "calibrating"', () { + final three = _run(_daySub(), quietHrrHistory: List.filled(3, 0.20)); + final seven = _run(_daySub(), quietHrrHistory: List.filled(7, 0.20)); + expect(three.scalars['strain'], seven.scalars['strain']); + + Map env(int n) => ((_pipeline(_daySub(), + quietHrrHistory: List.filled(n, 0.20))['clinical'] + as Map)['strain'] as Map).cast(); + expect(env(3)['confidence'], 0.45); + expect(env(3)['note'] as String, startsWith('calibrating')); + expect(env(7)['confidence'], 0.6); + }); + + test('re-deriving the same day is idempotent', () { + final runs = [ + for (var i = 0; i < 3; i++) + _run(_daySub(), quietHrrHistory: List.filled(7, 0.20)).scalars, + ]; + for (final key in ['strain', 'trimp_net', 'quiet_hrr']) { + expect(runs[1][key], runs[0][key], reason: key); + expect(runs[2][key], runs[0][key], reason: key); + } + }); + + test('a low-quiet user\'s 45-min run is not erased', () { + final own = _run(_lowQuietSub(), + quietHrrHistory: List.filled(7, 0.10), + profile: _profile30, + restingHr: 50); + final ref = _run(_lowQuietSub(), + quietHrrHistory: List.filled(7, 0.20), + profile: _profile30, + restingHr: 50); + expect(own.scalars['strain'] as num, closeTo(9.90, 0.05)); + expect(ref.scalars['strain'] as num, closeTo(9.38, 0.05)); + }); + + test('the curve ends on the engine\'s headline', () { + final engine = _run(_lowQuietSub(), + quietHrrHistory: List.filled(7, 0.10), + profile: _profile30, + restingHr: 50); + final bundle = _pipeline(_lowQuietSub(), + quietHrrHistory: List.filled(7, 0.10), age: 30, restingHr: 50); + final curve = ((bundle['series'] as Map)['strain_curve'] as List) + .cast(); + expect(curve, hasLength(900)); + expect((curve.last['v'] as num).toDouble(), + closeTo(engine.scalars['strain'] as num, 0.01)); + // Banked exercise never falls back out through the quiet afternoon. + final atEndOfRun = (curve[44]['v'] as num).toDouble(); + for (final p in curve.skip(44)) { + expect((p['v'] as num).toDouble(), greaterThanOrEqualTo(atEndOfRun)); + } + }); + + test('the pipeline abstains with the same reason', () { + final bundle = _pipeline(_daySub(), quietHrrHistory: const [0.2, 0.2]); + expect((bundle['absent_notes'] as Map)['strain'], + 'need_baseline:have=2,need=3'); + expect((bundle['series'] as Map)['strain_curve'], isEmpty); + }); + }); +} diff --git a/test/strain_rescale_backfill_test.dart b/test/strain_rescale_backfill_test.dart index cc9681aa5..ef7c8fd55 100644 --- a/test/strain_rescale_backfill_test.dart +++ b/test/strain_rescale_backfill_test.dart @@ -95,6 +95,7 @@ void main() { trimp: 177.80394321843846, wakeMinutes: 611, female: false, + quietHrr: 0.20, ); expect(s, isNotNull); expect(s!, closeTo(9.03, 0.05)); @@ -104,7 +105,7 @@ void main() { // Real bundle 2026-07-10: 23 steps, 135 wake minutes. expect( rescaledStrain(trimp: 23.416868457643158, wakeMinutes: 135, - female: false), + female: false, quietHrr: 0.20), 0.0, ); }); @@ -114,35 +115,45 @@ void main() { // −15..+22 min. It is small but it is not nothing: 22 minutes of extra // quiet-waking allowance is a real slice of a light day's headline. final measured = rescaledStrain( - trimp: 177.80394321843846, wakeMinutes: 611, female: false)!; + trimp: 177.80394321843846, wakeMinutes: 611, female: false, quietHrr: 0.20)!; final guessed = rescaledStrain( - trimp: 177.80394321843846, wakeMinutes: 633, female: false)!; + trimp: 177.80394321843846, wakeMinutes: 633, female: false, quietHrr: 0.20)!; expect(guessed, lessThan(measured)); }); test('abstains rather than guessing when an input is missing', () { // No TRIMP → nothing to rescale from. Must leave the day alone, not zero it. expect( - rescaledStrain(trimp: null, wakeMinutes: 611, female: false), + rescaledStrain(trimp: null, wakeMinutes: 611, female: false, quietHrr: 0.20), isNull, ); // No curve in the bundle → no window to price the baseline over. The old // code reached for `worn_min − tst_min` here; abstaining is the fix. expect( - rescaledStrain(trimp: 177.8, wakeMinutes: null, female: false), + rescaledStrain(trimp: 177.8, wakeMinutes: null, female: false, quietHrr: 0.20), isNull, ); expect( - rescaledStrain(trimp: 177.8, wakeMinutes: 0, female: false), + rescaledStrain(trimp: 177.8, wakeMinutes: 0, female: false, quietHrr: 0.20), + isNull, + ); + }); + + test('abstains without this user\'s quiet-waking level', () { + // The 0.20 population reference used to be passed here. No level, no + // rescale — the day keeps its old value rather than a stand-in one. + expect( + rescaledStrain( + trimp: 177.8, wakeMinutes: 611, female: false, quietHrr: null), isNull, ); }); test('sex changes the baseline, matching how the TRIMP was scored', () { final male = rescaledStrain( - trimp: 300, wakeMinutes: 900, female: false)!; + trimp: 300, wakeMinutes: 900, female: false, quietHrr: 0.20)!; final female = rescaledStrain( - trimp: 300, wakeMinutes: 900, female: true)!; + trimp: 300, wakeMinutes: 900, female: true, quietHrr: 0.20)!; // The female quiet-waking allowance is larger (0.86·e^0.334 vs // 0.64·e^0.384), so the same TRIMP nets less strain. expect(female, lessThan(male)); @@ -150,6 +161,23 @@ void main() { }); group('backfillStrainScale — the stored history', () { + test('waits, UNMARKED, until a quiet-waking level exists', () async { + await seedDay('2026-07-09', + trimp: 177.80394321843846, strain: 12.790964777435558, + wornMin: 827, tstMin: 216, wakeMin: 611); + final r = await backfillStrainScale(female: false); + expect(r.didWork, isFalse); + // Not marked done: the one-shot must run once a level exists. + expect(await LocalDb.computeFreshness(kStrainRescaleKey), isNull); + expect(await seriesValue('strain', '2026-07-09'), + closeTo(12.790964777435558, 1e-9)); + // A settled week at the reference level, so the rescaled values below + // stay comparable with the pure tests. + for (var d = 1; d <= 7; d++) { + await LocalDb.putMetricSeriesValue('2026-05-0$d', 'quiet_hrr', 0.20); + } + }); + test('rescales a raw-pruned historical day in series AND bundle', () async { // worn − tst would be 611 here too, but the curve is what counts and it // is seeded at 611 to keep the pinned 9.03 comparable with the pure test. diff --git a/test/strain_resting_hr_source_test.dart b/test/strain_resting_hr_source_test.dart index 8e2b74986..e6e47845b 100644 --- a/test/strain_resting_hr_source_test.dart +++ b/test/strain_resting_hr_source_test.dart @@ -76,6 +76,9 @@ Map _runDayActivity({ dayCalendarEndSec: sub.tsSec.last + 1, dataNowSec: sub.tsSec.last + 1, restingHr: restingHr, + // A settled week of prior quiet levels, so the resting-HR gate is the only + // thing deciding presence here (strain_quiet_level_test covers the level). + quietHrrHistory: List.filled(7, 0.20), ); return scalars; } diff --git a/test/tonight_sleep_test.dart b/test/tonight_sleep_test.dart index 3c278f540..68900dc69 100644 --- a/test/tonight_sleep_test.dart +++ b/test/tonight_sleep_test.dart @@ -60,6 +60,11 @@ double _strain({int tonight = 0}) { dataNowSec: sub.tsSec.last + 1, restingHr: 50, tonightSleepOnsetSec: tonight, + // Strain is priced on the user's prior quiet-waking levels and abstains + // without them. A level below this day's waking minutes (62 bpm ≈ 0.09 + // HRR), so they net positive and minutes asleep (≈ 0.015 HRR) would net + // that back down if they were still counted as waking. + quietHrrHistory: List.filled(7, 0.05), ); return (scalars['strain'] as num).toDouble(); } diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index c040db969..b9dc1024a 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -870,6 +870,71 @@ void main() { }); }); + // ── the whole-night RMSSD row never borrows the nightly headline ── + // + // The "Time domain" table is the whole-night `hrv_time` envelope. When that + // RMSSD was refused, the row used to fall back to `d.hrv['rmssd']`, the + // sleep-session mean of 5-min windows: a different statistic under the + // whole-night label. The headline now has its own, labelled row. + group('Time-domain RMSSD rows', () { + testWidgets('a refused whole-night RMSSD drops its row; nightly has its own', + (t) async { + t.view.physicalSize = const Size(390 * 3, 3000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Investigate('hrv', + data: InvestigateData(day: _day(0), hrv: { + 'rmssd': 61.0, + 'rmssd_sleep_session': {'value': 61.0, 'confidence': 0.82}, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 20000}, + 'confidence': 0.3, + 'tier': 'HIGH', + }, + })), + )); + await t.pumpAndSettle(); + expect(find.text('SDNN'), findsOneWidget, reason: 'the table rendered'); + expect(find.text('RMSSD'), findsNothing, + reason: 'whole-night RMSSD is absent, so its row is dropped'); + expect(find.text('RMSSD, nightly (mean of 5-min windows)'), + findsOneWidget); + expect(find.text('61.0 ms'), findsOneWidget); + }); + + testWidgets('a legacy fallback RMSSD is not labelled the 5-min-window mean', + (t) async { + // A bundle derived before `rmssd` became one estimator: the stored value + // is the NREM median, beside an absent session envelope. + t.view.physicalSize = const Size(390 * 3, 3000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Investigate('hrv', + data: InvestigateData(day: _day(0), hrv: { + 'rmssd': 48.3, + 'rmssd_sleep_session': { + 'value': '—', + 'confidence': 0, + 'note': 'no valid 5-min windows for sleep-session RMSSD', + }, + 'hrv_time': { + 'value': {'sdnn_ms': 70.0, 'n_beats': 25000}, + 'confidence': 0.62, + 'tier': 'HIGH', + }, + })), + )); + await t.pumpAndSettle(); + expect(find.text('RMSSD, nightly (mean of 5-min windows)'), findsNothing); + expect(find.text('RMSSD, nightly (earlier estimate)'), findsOneWidget); + expect(find.text('48.3 ms'), findsOneWidget); + }); + }); + // ── CV-10: three states, and "not screened" is not "clear" ── group('irregular-rhythm strip', () { testWidgets('counts the days it ran and refuses to reassure', (t) async {