diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 5a8481eb9..e49f5ab87 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -31,7 +31,8 @@ import 'onehz_pipeline.dart' show needInputNote; /// /// Each [daysOldestFirst] element is one day shaped like: /// {'date': 'YYYY-MM-DD', 'rhr': num?, 'rmssd': num?, 'readiness': num?, -/// 'resp_rate': num?, 'skin_temp_z': num?, 'trimp': num?, +/// 'resp_rate': num?, 'skin_temp_z': num?, 'skin_temp_adc': num?, +/// 'skin_temp_family': String?, 'trimp': num?, /// 'onset_sec': int?, 'wake_sec': int?, 'tst_min': int?, /// 'hypnogram': [{start,end,stage}]?} /// @@ -49,11 +50,17 @@ import 'onehz_pipeline.dart' show needInputNote; /// that day (TS-11). A day with more than one is dropped by the analytics, not /// split — it belongs to no single type. Empty ⇒ `session_cost` is absent, /// which is also its state for the first ten sessions of any type. +/// +/// [methodFamilies] maps a device family to its method family (the +/// engine's `kMethodFamily`). The breakdown's HRV, resting HR and breathing +/// rate compare tonight only with prior nights of tonight's method family. +/// Empty ⇒ nights compare by their raw device family. Map buildCrossDayBundle( List> daysOldestFirst, Map profile, { List cycleStartDates = const [], Map> sessionTypesByDate = const {}, + Map methodFamilies = const {}, }) { final days = daysOldestFirst; final n = days.length; @@ -67,6 +74,47 @@ Map buildCrossDayBundle( final tempList = [ for (final d in days) _numOrNull(d['skin_temp_z']), ]; + // The temperature illness flag standardises its input once, itself, so it + // takes the RAW nightly mean — never `skin_temp_z`. That z is already scored + // against a mean/SD that holds the earlier fever nights, so it decays night + // over night, and standardising it again let a real multi-night rise fall + // under the flag's two-night persistence threshold. + // + // Raw values are in their device family's unit (gen4 ADC counts, gen5 + // centi-°C, a wearable's own scale), which the z-score had been hiding. Only + // nights in the newest stamped scored night's family are kept; the rest are + // null rather than mixed into one baseline. + // + // An unstamped night (written before the stamp existed, or a day spanning + // two straps) is a band's, as `methodOf` below and the per-day baseline + // cache already treat it. Comparing the raw stamp instead made the first + // stamped night after an upgrade drop the whole unstamped history, and a + // mixed-strap newest night drop every stamped one, leaving the watch on + // `need_baseline` for a week either way. A wearable's nights still never + // take an unstamped one. + final rawTemp = [ + for (final d in days) _numOrNull(d['skin_temp_adc']), + ]; + final tempFamily = [ + for (final d in days) d['skin_temp_family'] as String?, + ]; + String? latestTempFamily; + for (var i = n - 1; i >= 0; i--) { + if (rawTemp[i] != null && tempFamily[i] != null) { + latestTempFamily = tempFamily[i]; + break; + } + } + final unstampedFits = latestTempFamily == null || + latestTempFamily == 'gen4' || + latestTempFamily == 'gen5'; + final tempRawList = [ + for (var i = 0; i < n; i++) + tempFamily[i] == latestTempFamily || + (tempFamily[i] == null && unstampedFits) + ? rawTemp[i] + : null, + ]; // A day flagged `unsettled` (today, until it was derived from a complete // overnight: the data edge at least an hour past wake) is a truncated @@ -77,6 +125,17 @@ Map buildCrossDayBundle( final unsettled = [for (final d in days) d['unsettled'] == true]; T? settled(int i, T? v) => unsettled[i] ? null : v; + // The skin-temp input both temperature-reading alerts take: the raw nightly + // mean in the newest family, minus any WHOOP 4 night whose skin temp moved + // with the band's ambient channel (`skin_temp_illness_ok`, see + // `skinTempIllnessEligible`) — a warm bedroom is not a fever. A record + // without the key (built by an older caller) keeps the old behaviour. + // Withholding `settled()` is applied at each use. + final tempAlertList = [ + for (var i = 0; i < n; i++) + days[i]['skin_temp_illness_ok'] == false ? null : tempRawList[i], + ]; + // ── illness CUSUM (NightSignal) on nightly RHR ───────────────────────────── final illness = ana.illnessCusum(dates, [ for (var i = 0; i < n; i++) settled(i, rhrList[i]), @@ -86,12 +145,18 @@ Map buildCrossDayBundle( // Build one AnomalyFeatures per day (same length as dates). Days with all // features null still occupy a slot — the detector handles the nulls // internally (needs ≥2 present features tonight to compute a distance). + // + // temp is the raw nightly mean, not `skin_temp_z`, for the same reason as + // the temperature flag: the detector standardises every column itself, and + // the z had already been scored against a baseline holding the earlier fever + // nights, so the temp term shrank night over night and a sustained rise + // could fall out of the two-night persistence run. final feats = [ for (var i = 0; i < n; i++) ana.AnomalyFeatures( rhr: settled(i, rhrList[i]), hrv: settled(i, rmssdList[i]), - temp: settled(i, tempList[i]), + temp: settled(i, tempAlertList[i]), resp: settled(i, respList[i]), ), ]; @@ -120,16 +185,8 @@ Map buildCrossDayBundle( // exception still fires, with the confound named. Suppressing a real illness // is worse than the false alarm. final luteal = _lutealFlags(dates, cycleStartDates); - // - // A WHOOP 4 night whose skin temp moved with the band's ambient channel is - // left out (`skin_temp_illness_ok`, see `skinTempIllnessEligible`): the - // illness watch should not fire on a warm bedroom. A record without the key - // (built by an older caller) keeps the old behaviour. final tempIllness = ana.tempIllnessFlag(dates, [ - for (var i = 0; i < n; i++) - days[i]['skin_temp_illness_ok'] == false - ? null - : settled(i, tempList[i]), + for (var i = 0; i < n; i++) settled(i, tempAlertList[i]), ], luteal: luteal); // ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ── @@ -177,13 +234,35 @@ Map buildCrossDayBundle( : allDurH.sublist(allDurH.length - 7); final sleepDebt = ana.sleepDebt(recentDurH, freeDurH); + // A ring's vendor RMSSD or a 1-min watch's resting HR is not the same + // quantity as a band's 1 Hz one, and the headline readiness keeps a separate + // baseline per method family for exactly that reason. The breakdown below + // ranks tonight against its history, so that history has to be split the + // same way, or the first weeks on a new wearable read as "held you back" + // against the old one's numbers while the headline calls them normal. + // `skin_temp_family` is the day's device family (it is not temperature- + // specific); an unstamped day is a band's, as in the baseline cache. + String? methodOf(int i) { + final device = (days[i]['skin_temp_family'] as String?) ?? 'gen4'; + return methodFamilies[device] ?? device; + } + + final tonightMethod = n == 0 ? null : methodOf(n - 1); + List sameMethod(List series) => [ + for (var i = 0; i < n; i++) + i == n - 1 || methodOf(i) == tonightMethod ? series[i] : null, + ]; + final rmssdOwn = sameMethod(rmssdList); + final rhrOwn = sameMethod(rhrList); + final respOwn = sameMethod(respList); + // ── percentile-of-you for today vs history (history = all-but-last) ──────── // rhr is LOWER-is-better — the same orientation `_glassInput` ten lines below // has always passed for this exact list. Unoriented, a night with the user's // lowest resting HR in a month came back labelled "among your worst". final percentiles = { - 'rmssd': _pctOfYou(rmssdList, ana.Better.higher), - 'rhr': _pctOfYou(rhrList, ana.Better.lower), + 'rmssd': _pctOfYou(rmssdOwn, ana.Better.higher), + 'rhr': _pctOfYou(rhrOwn, ana.Better.lower), 'readiness': _pctOfYou(readyList, ana.Better.higher), }; @@ -191,12 +270,12 @@ Map buildCrossDayBundle( // rmssd higher-better; rhr/resp lower-better; skin_temp_z lower-ABS better // (so we orient temp by its absolute deviation, lower=better). final gbInputs = []; - final gbRmssd = _glassInput('hrv', rmssdList, ana.wHrv, lowerIsBetter: false); + final gbRmssd = _glassInput('hrv', rmssdOwn, ana.wHrv, lowerIsBetter: false); if (gbRmssd != null) gbInputs.add(gbRmssd); final gbRhr = - _glassInput('rhr', rhrList, ana.wRhr, lowerIsBetter: true, quantum: 1); + _glassInput('rhr', rhrOwn, ana.wRhr, lowerIsBetter: true, quantum: 1); if (gbRhr != null) gbInputs.add(gbRhr); - final gbResp = _glassInput('resp', respList, ana.wResp, lowerIsBetter: true); + final gbResp = _glassInput('resp', respOwn, ana.wResp, lowerIsBetter: true); if (gbResp != null) gbInputs.add(gbResp); // temp: use absolute z so "further from your baseline" is worse. `tempList` // here is `skin_temp_z` — already a z-score against the raw-ADC baseline @@ -484,7 +563,8 @@ Map buildCrossDayBundle( // publishing it would hide a run that is still open. So each family // publishes the newest settled night that carried ITS inputs (illness: the // nightly RHR; anomaly: the ≥2 features it needs to score a night; - // temperature: the skin-temperature z). Outputs are parallel to `dates`. + // temperature: the raw skin-temperature mean it was allowed to read). + // Outputs are parallel to `dates`. // The entry carries its OWN `date`; consumers must date findings with it, // never with `recent.last`. int? newestScored(bool Function(int i) hasInput) { @@ -499,12 +579,15 @@ Map buildCrossDayBundle( final latestAnomaly = at( anomaly, newestScored((i) => - [rhrList[i], rmssdList[i], tempList[i], respList[i]] + [rhrList[i], rmssdList[i], tempAlertList[i], respList[i]] .where((v) => v != null) .length >= 2), ); - final latestTemp = at(tempIllness, newestScored((i) => tempList[i] != null)); + // Same predicate that built the flag's input: an ambient-excluded night + // still has a raw value, but the flag saw null and held it normal. + final latestTemp = + at(tempIllness, newestScored((i) => tempAlertList[i] != null)); // per-day flags (for notifications / trends): asleep/illness/anomaly/temp, // plus the nightly RESTING HR the "your resting HR trend shifted" CUSUM @@ -718,8 +801,12 @@ bool _isFreeDay(String? date) { /// four-week luteal phase — it stops counting. List _lutealFlags(List dates, List cycleStartDates) { final out = List.filled(dates.length, false); + // Labels parsed as UTC midnight, as in [_isNextDay]: at local midnight a + // span holding the 23 h spring-forward day floors one day short in `inDays`, + // which shortens that cycle and moves its midpoint night. + DateTime? day(String s) => DateTime.tryParse('${s}T00:00:00Z'); final starts = [ - for (final s in cycleStartDates) ?DateTime.tryParse(s), + for (final s in cycleStartDates) ?day(s), ]..sort(); if (starts.isEmpty) return out; @@ -730,7 +817,7 @@ List _lutealFlags(List dates, List cycleStartDates) { final medianGap = _median(gaps); for (var i = 0; i < dates.length; i++) { - final d = DateTime.tryParse(dates[i]); + final d = day(dates[i]); if (d == null) continue; // Nearest PRECEDING start (inclusive: the start day is cycle day 1). var si = -1; diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 342cec164..563fb7455 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -60,6 +60,7 @@ import 'inputs/garmin_inputs.dart' show isWearableFamily, kHrCadenceSec, + kMethodFamily, kOneHzRunGapSec, kWearableFamilies, methodFamily; @@ -1811,7 +1812,20 @@ import 'vendor_sleep.dart'; // store `skin_temp_ambient_r` and the illness watch leaves out WHOOP 4 nights // whose skin temp tracks ambient (|r| >= 0.8, or unmeasured). One-shot, // raw-free: days derived before v106 drop naps overlapping the next night. -const int kAlgoVersion = 113; +// 113 → 116 (edge-only; 114 and 115 are claimed by the open sleep-continuation +// change): the temperature illness flag scores the raw nightly skin temp, not +// skin_temp_z, and the illness anomaly standardises it once; the skin temp +// baseline leaves out unsettled nights and the nightly mean drops the cold +// warm-up samples; a warm-bedroom night no longer hides an open skin temp run +// and unstamped nights no longer blind the watch for a week; the Plews +// rolling-7 lnRMSSD history is dated by calendar night and a night with no +// sleep-session RMSSD stores no band; BRV honours the breathing-rate artifact +// gate; the readiness breakdown ranks tonight against the same device's +// history; the cycle-phase flag counts days across DST; the day strain curve +// carries a workout-raised headline; a logged nap over the main sleep is +// counted once; an implausible stored age is named as the reason strain, +// calories and zones are absent. +const int kAlgoVersion = 116; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -2393,6 +2407,60 @@ Future debugBaselineSignature() async => (await _BaselineHistoryCache.load()).toArtifactJson()['signature'] as String; +/// How long the device has to sit in a new timezone (>=3h from the guard's +/// baseline) before a heavy pass releases the travel hold on its own. Long +/// enough that a layover or a same-day hop does not trigger it. +const int kTzTravelSettleMs = 24 * 60 * 60 * 1000; +const int _tzJumpThresholdMin = 180; + +/// One step of the timezone-travel guard, pure so it can be tested without a +/// database or a real clock. [stored] is the decoded `tz_travel_guard` +/// payload; `write` is what to persist (null = leave it as it is). +/// +/// `offset_min` is the baseline zone and only moves when no jump stands. +/// While one does, `away_offset_min`/`away_since_ms` record which zone the +/// device is in and since when, so the hold can end once that zone has +/// stood for [kTzTravelSettleMs] instead of waiting for a manual restage. +@visibleForTesting +({bool held, bool settled, Map? write}) evaluateTzTravelGuard({ + required Map? stored, + required int nowOffsetMin, + required int nowMs, +}) { + final lastOffsetMin = (stored?['offset_min'] as num?)?.toInt(); + final jumped = lastOffsetMin != null && + (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + if (!jumped) { + return ( + held: false, + settled: false, + write: {'offset_min': nowOffsetMin}, + ); + } + final awayOffsetMin = (stored?['away_offset_min'] as num?)?.toInt(); + final awaySinceMs = (stored?['away_since_ms'] as num?)?.toInt(); + // A different away zone (a second hop) or a clock that went backwards + // restarts the wait rather than counting time spent somewhere else. + if (awayOffsetMin != nowOffsetMin || + awaySinceMs == null || + awaySinceMs > nowMs) { + return ( + held: true, + settled: false, + write: { + 'offset_min': lastOffsetMin, + 'away_offset_min': nowOffsetMin, + 'away_since_ms': nowMs, + }, + ); + } + return ( + held: true, + settled: nowMs - awaySinceMs >= kTzTravelSettleMs, + write: null, + ); +} + @visibleForTesting ({List days, String reason}) selectLightDeriveDays({ required Set rawDays, @@ -2718,6 +2786,17 @@ class _BaselineHistoryCache { return [for (var i = from; i < end; i++) series[i].value]; } + /// The `yyyy-MM-dd` dates of exactly the entries [valuesBefore] returns for + /// the same arguments, index for index. A rows window can't tell a night + /// from yesterday from one three weeks back across a wear gap; the dates + /// let a calendar-windowed baseline drop the stale ones. + List datesBefore(String key, String beforeDate, {String? family}) { + final series = _seriesFor(key, family); + final end = _beforeIndex(series, beforeDate); + final from = end <= _baselineWindowDays ? 0 : end - _baselineWindowDays; + return [for (var i = from; i < end; i++) series[i].date]; + } + static List _trailing(List<_DatedValue> samples) { final from = samples.length <= _baselineWindowDays ? 0 @@ -3007,6 +3086,17 @@ class DerivationEngine { Map snapshot() => Map.from(_diag); + /// What the last [run] that actually started could not do. Its return value + /// is the days it derived, so a pass that threw and a pass with nothing to + /// derive both come back as 0; a caller reporting the outcome to the user + /// reads these to tell them apart. Reset when a run takes the latch, so a + /// run dropped by the latch leaves the previous run's values in place. + /// Any later pass on this shared engine (a drain's light pass) resets them + /// too, so a caller that must attribute the outcome to its own run passes + /// [run]'s `onOutcome` instead of reading these after other awaits. + String? lastRunError; + int lastRunFailedDays = 0; + /// Run a derivation pass. [heavy]=false runs a bounded light pass over the /// freshness-critical day: TODAY when raw has reached today, else the latest /// pending day. [heavy]=true sweeps every recomputable day. @@ -3018,7 +3108,9 @@ class DerivationEngine { Profile profile, { bool heavy = false, bool force = false, + Set editedDays = const {}, void Function(String day, int index, int total)? onDayDone, + void Function(int failedDays, String? error)? onOutcome, }) async { if (_running) return 0; _running = true; @@ -3044,7 +3136,9 @@ class DerivationEngine { ..['active_days'] = [] ..['concurrency'] = _deriveConcurrency ..['last_error'] = null; - + lastRunError = null; + lastRunFailedDays = 0; + FirebaseTraceHandle? runTrace; try { // Heavy/force passes only. Light passes run many times a day (including @@ -3145,6 +3239,10 @@ class DerivationEngine { ...await LocalDb.sleepOverrideDays(), // A nap edit on a finalized day has to take effect too — same reason. ...await LocalDb.napEditDays(), + // A day whose override was just CLEARED is no longer listed above, but + // its finalized row still holds the user's window (or the rejected + // night) and has to be re-derived back to automatic. + ...editedDays, }; final todoDays = [ for (final day in scope.targetDays) @@ -3293,12 +3391,23 @@ class DerivationEngine { ); } } + lastRunFailedDays = failures; return done; } catch (e, st) { _diag['last_error'] = '$e'; + lastRunError = '$e'; _log('derive ERROR: $e\n$st'); return 0; } finally { + // Before any await: this run still holds the latch, so the fields are + // its own on every exit path, early returns included. + // Guarded: a throwing callback would skip `_running = false` below and + // leave every later derive taking the busy skip until restart. + try { + onOutcome?.call(lastRunFailedDays, lastRunError); + } catch (e) { + _log('[derive] outcome callback threw: $e'); + } // Storage housekeeping runs here, after everything, still holding // `_running`. See _runStorageHousekeeping — this is the only place every // entry path and every early return actually reaches. @@ -4485,7 +4594,26 @@ class DerivationEngine { // silently landing on an already-finalized day_id and looking lost. Once // that's detected, hold off auto-deriving anything adjacent to finalized // data until "Re-analyze data" (full restage) resolves it properly. - if (await _timezoneTravelSuspected()) { + final tz = await _tzTravelGuard(); + if (tz.held) { + // Holding forever is worse than the relabel risk: every day that lands + // next to a newly finalized one is held too, for the whole trip, and + // nothing on screen tells the user that only "Re-analyze data" lets + // them go. Once the new zone has stood for a day, a heavy pass does what + // that restage would — derive every pending raw day under the current + // zone — and the end of run() clears the hold if none of them failed. + if (heavy && tz.settled) { + _log( + 'derive: new timezone stable — deriving the held days and ' + 'releasing the timezone hold', + ); + return _scopeForDays( + rawDays, + reason: 'tz-travel-settled', + fullHistory: true, + rawDays: rawDays, + ); + } final adjacent = { for (final day in finalized) ..._adjacentDayIds(day), }; @@ -4543,40 +4671,37 @@ class DerivationEngine { /// True right after the device timezone jumps by more than a real DST shift /// ever would (>=3h) — a strong signal of actual cross-timezone travel /// rather than a seasonal clock change. Stays true across repeated calls - /// (derive runs many times a day) by only ever updating the persisted - /// baseline offset when NO jump is detected — updating it unconditionally + /// (derive runs many times a day) by only ever moving the persisted + /// baseline offset when NO jump is detected — moving it unconditionally /// would make the very next call see lastOffset == nowOffset and silently - /// drop the guard after a single pass. `force` (full restage) is what - /// resets it, per _deriveScope. - static const int _tzJumpThresholdMin = 180; - Future _timezoneTravelSuspected() async { - final nowOffsetMin = DateTime.now().timeZoneOffset.inMinutes; + /// drop the guard after a single pass. A full restage resets it, and so + /// does a heavy pass once the new zone has stood for + /// [kTzTravelSettleMs] (see _deriveScope). + Future _timezoneTravelSuspected() async => + (await _tzTravelGuard()).held; + + Future<({bool held, bool settled})> _tzTravelGuard() async { final row = await LocalDb.baseline('tz_travel_guard'); final raw = row?['payload_json']; - int? lastOffsetMin; + Map? stored; if (raw is String && raw.isNotEmpty) { try { final d = jsonDecode(raw); - if (d is Map) lastOffsetMin = (d['offset_min'] as num?)?.toInt(); + if (d is Map) stored = d; } catch (_) { // fall through — treat as unknown } } - if (lastOffsetMin == null) { - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': nowOffsetMin}), - ); - return false; - } - final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; - if (!jumped) { - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': nowOffsetMin}), - ); + final step = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: DateTime.now().timeZoneOffset.inMinutes, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + final write = step.write; + if (write != null) { + await LocalDb.putBaseline('tz_travel_guard', jsonEncode(write)); } - return jumped; + return (held: step.held, settled: step.settled); } _DeriveScope _scopeForDays( @@ -4958,6 +5083,55 @@ class DerivationEngine { _localDayLabelToSec(dayId) - kNocturnalSearchLookbackSec) < prunedBeforeSec; + /// [windowTruncatedByPrune] as the derive's prune guard applies it to a day + /// whose night came from [sleepSource]: a rejected night has nothing to + /// protect, and a user-set window ('manual'/'confirmed') only needs its own + /// onset ([sleepOnsetSec]) kept. + static bool sleepWindowTruncatedByPrune( + String dayId, + int? prunedBeforeSec, { + String? sleepSource, + int? sleepOnsetSec, + }) => + sleepSource != 'rejected' && + windowTruncatedByPrune( + dayId, + prunedBeforeSec, + // A user window the segmenter abstained on carries onset 0 (no + // staged window), and 0 is older than any prune cursor: fall back to + // the day's own lookback rather than calling every such day cut. + forcedOnsetSec: (sleepSource == 'manual' || + sleepSource == 'confirmed') && + sleepOnsetSec != null && + sleepOnsetSec > 0 + ? sleepOnsetSec + : null, + ); + + /// Whether a re-derive of [dayId] with its night from [sleepSource] (and + /// [sleepOnsetSec] for a user-set window) would be declined by the prune + /// guard in [_derivePreparedDay] and keep the stored row. Lets a sleep + /// correction be refused up front instead of stored and never applied. + static Future pruneDeclinesSleepRederive( + String dayId, { + required String sleepSource, + int? sleepOnsetSec, + }) async { + if (!sleepWindowTruncatedByPrune( + dayId, + await LocalDb.getCursorInt(_prunedBeforeCursor), + sleepSource: sleepSource, + sleepOnsetSec: sleepOnsetSec, + )) { + return false; + } + final existing = await LocalDb.dayResult(dayId); + return existing != null && + (existing['rhr'] != null || + existing['rmssd'] != null || + existing['readiness'] != null); + } + /// A stable, cheap signature of the CURRENT rolling baseline — the same inputs /// the readiness/illness baselines fold over. We take the trailing /// _baselineWindowDays derived rows and the median of each baseline series @@ -5427,9 +5601,16 @@ class DerivationEngine { // cut keeps the oldest kept day's own rows but drops the evening half of // its night, so sleepSub is non-empty and the scalars come back from a // partial night. Every path (algo bump, force, override, rescan) lands here. + // A user-set window is staged from the override, not from a replayed + // candidate, so an empty night substrate there means the window itself + // had too little recording (or no sleep), not that raw aged out — the + // prune leg below answers that against the override's own onset. + final userSetWindow = + day.sleepSource == 'manual' || day.sleepSource == 'confirmed'; if (!producedNothing && (nightSubstrateRegressed( sleepRejected: day.sleepSource == 'rejected', + userSetWindow: userSetWindow, sleepSubEmpty: sleepSub.isEmpty, nightScalarsNull: scMap == null || (scMap['rhr'] == null && @@ -5439,14 +5620,15 @@ class DerivationEngine { // An import brings its own substrate, the prune can't have cut it. // A rejected night has nothing to protect either. (!suppliedSubstrate && - day.sleepSource != 'rejected' && - windowTruncatedByPrune( + sleepWindowTruncatedByPrune( day.date, await LocalDb.getCursorInt(_prunedBeforeCursor), - forcedOnsetSec: day.sleepSource == 'manual' || - day.sleepSource == 'confirmed' + sleepSource: day.sleepSource, + sleepOnsetSec: day.sleepOnsetSec > 0 || !userSetWindow ? day.sleepOnsetSec - : null, + : ((await LocalDb.getSleepOverride(day.date))?[ + 'onset_ts'] as num?) + ?.toInt(), )))) { final existingNight = await LocalDb.dayResult(day.date); final existingHadNight = existingNight != null && @@ -5658,6 +5840,7 @@ class DerivationEngine { dataNowSec: dataNowSec, tonightSleepOnsetSec: day.tonightSleepOnsetSec, tonightSleepOffsetSec: day.tonightSleepOffsetSec, + rejectedSleepSpans: await _rejectedSleepSpans(day.date), ); await debugSecondHalfHook?.call(day.date); final blocks = @@ -5734,8 +5917,7 @@ class DerivationEngine { } } // A session floor raised the scalar above the HR-only envelope: the - // envelope carries the same number and says why. `series.strain_curve` - // stays the HR build-up through the day. + // envelope carries the same number and says why. final strainEnv = (bundle['clinical'] as Map?)?['strain']; final scStrain = scMap?['strain']; if (strainEnv is Map && @@ -5748,6 +5930,23 @@ class DerivationEngine { 'logged this day can raise (heart-rate strain ' '${hrStrain.toStringAsFixed(1)})'; } + // `series.strain_curve` is drawn right under that headline on the same + // 0-21 axis. Left as the HR-only build-up it ended below a floored + // headline — a flat line at 0 under "0.6" — so it gets the same floor + // from each session's end onward. Copied into a fresh dynamic-valued map + // for the same reason as `coverage` above: the pipeline's series map + // literal has a narrower inferred value type. + final seriesNow = bundle['series']; + final curveNow = seriesNow is Map ? seriesNow['strain_curve'] : null; + if (scStrain is num && curveNow is List && curveNow.isNotEmpty) { + final seriesMap = Map.from(seriesNow as Map); + seriesMap['strain_curve'] = strainCurveFlooredAtSessions( + curveNow, + savedSessions, + capSec: localNextMidnightSecForDayLabel(day.date) - 1, + ); + bundle['series'] = seriesMap; + } // Method, family and class on each envelope of a wearable's day // (inputs/canonical.dart). A band's day is untouched. tagEnvelopes(bundle['clinical'] as Map?, daySub.deviceFamily); @@ -5817,10 +6016,13 @@ class DerivationEngine { if (_isRealDayResult(existing)) { final prev = _decodeBundle(existing!['payload_json']); if (prev != null) { - final recovered = carryForwardDetail(prev, bundle); + final sleepMoved = sleepWindowChanged(prev, bundle); + final recovered = + carryForwardDetail(prev, bundle, carrySleep: !sleepMoved); final prevVersion = (existing['algo_version'] as num?)?.toInt(); final outcome = recoveryOutcome( recovered: recovered, + sleepMoved: sleepMoved, prevPartial: (existing['partial'] as num?)?.toInt() == 1, prevVersion: prevVersion, prevFinalized: (existing['finalized'] as num?)?.toInt() == 1, @@ -6176,11 +6378,20 @@ class DerivationEngine { /// [sleepRejected] (the user said "not sleep") is never a regression: the /// null night is what they asked for, and the existing row is the very /// night they rejected. + /// + /// [userSetWindow] (a 'manual' or 'confirmed' override) is not one either: + /// that window is staged from the override itself, so an empty night + /// substrate means too little of it was recorded, and the existing row is + /// usually the auto night being corrected — declining kept that night on + /// screen and re-declined every later pass. Whether the prune cut the + /// user's window is the prune check's job, on the override's own onset. static bool nightSubstrateRegressed({ bool sleepRejected = false, + bool userSetWindow = false, required bool sleepSubEmpty, required bool nightScalarsNull, - }) => !sleepRejected && sleepSubEmpty && nightScalarsNull; + }) => + !sleepRejected && !userSetWindow && sleepSubEmpty && nightScalarsNull; /// Whether [row] is a REAL derived day result worth protecting — i.e. not a /// skip marker and not an all-absent shell. @@ -6203,14 +6414,23 @@ class DerivationEngine { /// explicitly and a null there is a real "we could not measure this today" /// that must survive. Only keys the failed second half never got to add are /// restored — a freshly computed value always wins. + /// + /// [carrySleep] false withholds the blocks the second half derives from the + /// main sleep window ([kSleepOwnedDetailKeys], plus `nap_min`). When the + /// night was retimed or rejected since [prev] was written, those blocks + /// describe the OLD night: carried onto the new window they listed a rejected + /// stretch as a sleep period, and put the old period times beside the new + /// hypnogram. @visibleForTesting static bool carryForwardDetail( Map prev, - Map next, - ) { + Map next, { + bool carrySleep = true, + }) { var carried = false; for (final e in prev.entries) { if (e.key == 'scalars' || e.key == 'series') continue; + if (!carrySleep && kSleepOwnedDetailKeys.contains(e.key)) continue; if (next.containsKey(e.key) || e.value == null) continue; next[e.key] = e.value; carried = true; @@ -6237,6 +6457,7 @@ class DerivationEngine { // only replaced when something was actually carried into it.) Map? merged; for (final e in p.entries) { + if (!carrySleep && sub == 'scalars' && e.key == 'nap_min') continue; if (n.containsKey(e.key) || e.value == null) continue; (merged ??= Map.from(n))[e.key] = e.value; carried = true; @@ -6246,6 +6467,38 @@ class DerivationEngine { return carried; } + /// Second-half bundle keys computed against the main sleep window. Only the + /// offloaded second half writes them, so a failed pass has none of its own. + @visibleForTesting + static const kSleepOwnedDetailKeys = { + 'sleep_periods', + 'sleep_charging', + 'naps', + 'restlessness', + 'restlessness_map', + 'wrist_orientation', + 'advanced_sleep', + 'daytime_hrv', + }; + + /// Whether the main sleep window in [next] (isolate 1's, always present) + /// differs from the one [prev] was derived against — a retimed night, a + /// rejected one, or a cleared override. Both absent is no change. + @visibleForTesting + static bool sleepWindowChanged( + Map prev, + Map next, + ) { + (Object?, Object?) edges(Map b) { + final v = ((b['sleep'] as Map?)?['window'] as Map?)?['value']; + // An absent envelope stores '—', not a window object. + if (v is! Map) return (null, null); + return (v['onset_ms'], v['offset_ms']); + } + + return edges(prev) != edges(next); + } + /// A night staged off a paired device's records (its hypnogram, or our /// window over its HR), not off the primary band's own rows. Not /// 'auto_fallback' by itself: the band's own HR-led night carries it too. @@ -6335,6 +6588,10 @@ class DerivationEngine { /// those blocks are computed differently now. A cross-version carry therefore /// stays partial and unfinalized, so a later pass recomputes it for real /// instead of locking last version's curves in under this version's number. + /// + /// [sleepMoved]: the night changed since [prevPartial]'s row, so its sleep + /// blocks were withheld and the new night has none yet. That row is thinner + /// than the one it replaces and must stay open for a pass that completes. @visibleForTesting static ({bool partial, bool finalized}) recoveryOutcome({ required bool recovered, @@ -6342,9 +6599,10 @@ class DerivationEngine { required int? prevVersion, required bool prevFinalized, required bool finalizedByAge, + bool sleepMoved = false, }) { final sameVersion = prevVersion == kAlgoVersion; - if (!recovered || prevPartial || !sameVersion) { + if (!recovered || prevPartial || !sameVersion || sleepMoved) { // Stays partial, but keep whatever the caller had already decided about // finalizing: an IMPORT force-finalizes even a partial day, because there // is no stored raw to ever recompute it from. @@ -6435,6 +6693,8 @@ class DerivationEngine { List before(String key) => history.valuesBefore(key, date, family: _baselineFamily(key, input.deviceFamily)); m['ln_rmssd_history'] = before('ln_rmssd'); + m['ln_rmssd_dates'] = history.datesBefore('ln_rmssd', date, + family: _baselineFamily('ln_rmssd', input.deviceFamily)); m['rhr_history'] = before('rhr'); m['resp_history'] = before('resp_rate'); // Robust nocturnal RMSSD history (the `rmssd` series) — feeds the EWMA hrv @@ -6539,6 +6799,7 @@ class DerivationEngine { profileMap, cycleStartDates: cycleStarts, sessionTypesByDate: sessionTypes, + methodFamilies: kMethodFamily, ) ..['algo_version'] = kAlgoVersion ..['built_for_day'] = builtForDay @@ -6862,6 +7123,13 @@ class DerivationEngine { 'readiness': col('readiness') ?? sc('readiness'), 'resp_rate': sc('resp_rate'), 'skin_temp_z': sc('skin_temp_z'), + // The RAW nightly mean and the family whose unit it is in, for the + // temperature illness flag: it standardises its input itself, and a + // z-score handed to it is standardised twice against a baseline that + // already holds the earlier fever nights, so a multi-night rise fades + // below its threshold before the persistence rule can fire. + 'skin_temp_adc': sc('skin_temp_adc'), + 'skin_temp_family': payload['device_family'] as String?, // Only the refusal is written: an eligible night reads as before. if (!skinTempIllnessEligible( payload['device_family'] as String?, sc('skin_temp_ambient_r'))) @@ -7470,6 +7738,63 @@ class DerivationEngine { return best; } + /// [curve] (`series.strain_curve`, `{t, v}` points) with each point raised + /// to the strain of every finished, non-private session that had ENDED by + /// then — the same sessions [strainFlooredAtSessions] lifts the headline to. + /// Without this the headline could read 0.6 above a line pinned at 0 all + /// day. A session ending after the last point adds one point at its end + /// (capped at [capSec]) so the line still finishes on the headline. An + /// empty curve stays empty: there is no HR build-up to draw. + @visibleForTesting + static List> strainCurveFlooredAtSessions( + List curve, + List> sessions, { + int? capSec, + }) { + final points = >[ + for (final p in curve.whereType()) + if (p['t'] is num && p['v'] is num) + {'t': p['t'], 'v': p['v']}, + ]; + if (points.isEmpty) return points; + final floors = <(int, double)>[]; + for (final s in sessions) { + if (s['status'] != 'done') continue; + if ((s['private'] as num?)?.toInt() == 1) continue; + final v = (s['strain'] as num?)?.toDouble(); + final end = (s['end_ts'] as num?)?.toInt(); + if (v == null || !v.isFinite || end == null) continue; + floors.add((end, v)); + } + if (floors.isEmpty) return points; + floors.sort((a, b) => a.$1.compareTo(b.$1)); + var fi = 0; + var floor = double.negativeInfinity; + for (final p in points) { + final t = (p['t'] as num).toInt(); + while (fi < floors.length && floors[fi].$1 <= t) { + if (floors[fi].$2 > floor) floor = floors[fi].$2; + fi++; + } + if (floor > (p['v'] as num)) p['v'] = floor; + } + var lastT = (points.last['t'] as num).toInt(); + var lastV = (points.last['v'] as num).toDouble(); + for (; fi < floors.length; fi++) { + final (end, v) = floors[fi]; + if (v <= lastV) continue; + final t = capSec != null && end > capSec ? capSec : end; + if (t <= lastT) { + points.last['v'] = v; + } else { + points.add({'t': t, 'v': v}); + lastT = t; + } + lastV = v; + } + return points; + } + static void _applyWakeDayFeatures( Map bundle, Map? scMap, @@ -8045,8 +8370,8 @@ class DerivationEngine { // age/weight are read by `wakeDayEnergy` straight off the profile now, so // they are no longer unpacked here — TRIMP only needs the sex constant. final sex = profile.sex?.toLowerCase(); - // ONE definition (hr_max.dart): null when age is unknown OR when we cannot - // say which strap measured this day. + // ONE definition (hr_max.dart): null when the age is unknown or not a + // plausible age. 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 @@ -8076,7 +8401,10 @@ class DerivationEngine { // gates on exactly these inputs; the two must not disagree about WHY. final ceilingAbsent = hrMax != null ? null - : profile.ageYears == null + // An age stored before the profile forms checked it (a birth year, 0) + // has no ceiling either, and it is the age that needs fixing, not the + // strap. + : profile.ageYears == null || !plausibleAgeYears(profile.ageYears!) ? needInputNote('age') // We know the age; we do not know what measured the HR, so we // have no ceiling to band it against. @@ -9198,6 +9526,7 @@ class DerivationEngine { int? napExcludeEndSec, int tonightSleepOnsetSec = 0, int tonightSleepOffsetSec = 0, + List> rejectedSleep = const [], }) { try { final n = s.length; @@ -9282,6 +9611,15 @@ class DerivationEngine { t0 + nap.endSec > tonightSleepOnsetSec) { return false; } + // A night the user marked 'Not sleep' leaves no main window behind, + // so the detector finds the same still, low-HR stretch again and + // would ask "did you nap?" about the time they just said was not + // sleep. + for (final r in rejectedSleep) { + if (t0 + nap.startSec < r[1] && t0 + nap.endSec > r[0]) { + return false; + } + } if (attributionEndSec == null) return true; return t0 + nap.startSec < attributionEndSec; }).toList(); @@ -9585,6 +9923,7 @@ class DerivationEngine { napExcludeEndSec: inp.napExcludeEndSec ?? offset, tonightSleepOnsetSec: inp.tonightSleepOnsetSec, tonightSleepOffsetSec: inp.tonightSleepOffsetSec, + rejectedSleep: inp.rejectedSleepSpans, ); bundlePatch['sleep_periods'] = _sleepPeriods( onset, @@ -10181,6 +10520,30 @@ class DerivationEngine { Substrate s, int endSec) => _hrrForBout(s, endSec); + @visibleForTesting + static Future>> debugRejectedSleepSpans(String date) => + _rejectedSleepSpans(date); + + /// Main-sleep windows the user rejected for this day's night and for + /// tonight's (the next label's, which can begin inside this day's nap + /// window). Read on the main isolate; the worker has no database. + static Future>> _rejectedSleepSpans(String date) async { + final out = >[]; + // The next CALENDAR label, not the label of the day's last sample: on a + // day whose data stops before midnight that is `date` again, and a reject + // stored under tomorrow would never be read. + for (final label in {date, dayLabelBefore(date, -1) ?? date}) { + final ov = await LocalDb.getSleepOverride(label); + if (ov == null || ov['source'] != 'rejected') continue; + final on = (ov['onset_ts'] as num).toInt(); + final off = (ov['offset_ts'] as num).toInt(); + // A reject with no detected window stores a one-second placeholder at + // midnight; it marks nothing and must not knock out a nap spanning it. + if (off - on > 60) out.add([on, off]); + } + return out; + } + /// Test seam for [_attachNaps] — the day-boundary attribution rules (drop /// tomorrow's leading nap, drop yesterday's trailing one) decide which day a /// nap's minutes are credited to, and are cheap to state directly. @@ -10200,6 +10563,7 @@ class DerivationEngine { int? napExcludeEndSec, int tonightSleepOnsetSec = 0, int tonightSleepOffsetSec = 0, + List> rejectedSleep = const [], }) => _attachNaps( bundle, @@ -10216,6 +10580,7 @@ class DerivationEngine { napExcludeEndSec: napExcludeEndSec, tonightSleepOnsetSec: tonightSleepOnsetSec, tonightSleepOffsetSec: tonightSleepOffsetSec, + rejectedSleep: rejectedSleep, ); void _log(String m) { @@ -10337,6 +10702,10 @@ class _DayBlocksInput { /// See `PreparedDerivationDay.tonightSleepOnsetSec`. 0 = none. final int tonightSleepOnsetSec; final int tonightSleepOffsetSec; + + /// Windows the user marked 'Not sleep' (this night and tonight's), as + /// `[startSec, endSec]`. Nap suggestions overlapping one are dropped. + final List> rejectedSleepSpans; const _DayBlocksInput({ required this.daySub, required this.napSub, @@ -10358,6 +10727,7 @@ class _DayBlocksInput { this.quietHrrHistory = const [], required this.savedSessions, this.napEdits = const [], + this.rejectedSleepSpans = const [], required this.wristOffSpans, required this.chargingSpans, required this.mainTstMin, diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index fbbfc7ce9..c0cf67b8b 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -78,7 +78,15 @@ class DeriveScheduler { bool _pendingLight = false; bool _pendingHeavy = false; Timer? _timer; - bool _refreshing = false; + + // The in-flight snapshot refresh, and whether another was asked for while + // it ran. A request that lands mid-refresh can't just return: the caller + // (an enqueue, say) arms its settle timer off the snapshot it awaited, and + // the in-flight query may predate its insert — or the in-flight refresh is + // one that never arms. Either way the job sat queued with no timer until + // some unrelated event re-armed the scheduler. + Future? _refreshing; + bool _refreshAgain = false; Future init() async { await LocalDb.recoverComputeJobs(); @@ -262,21 +270,31 @@ class DeriveScheduler { } } - Future _refreshSnapshot() async { - if (_refreshing) return; - _refreshing = true; + Future _refreshSnapshot() { + final inFlight = _refreshing; + if (inFlight != null) { + _refreshAgain = true; + return inFlight; + } + return _refreshing = _refreshLoop(); + } + + Future _refreshLoop() async { try { - final jobs = await LocalDb.computeJobs(state: 'queued', limit: 50); - _pendingLight = jobs.any( - (job) => - job['type']?.toString() == 'derive_light', - ); - _pendingHeavy = jobs.any( - (job) => - job['type']?.toString() == 'derive_heavy', - ); + do { + _refreshAgain = false; + final jobs = await LocalDb.computeJobs(state: 'queued', limit: 50); + _pendingLight = jobs.any( + (job) => + job['type']?.toString() == 'derive_light', + ); + _pendingHeavy = jobs.any( + (job) => + job['type']?.toString() == 'derive_heavy', + ); + } while (_refreshAgain && !_disposed); } finally { - _refreshing = false; + _refreshing = null; onChanged(); } } diff --git a/lib/compute/hr_max.dart b/lib/compute/hr_max.dart index 57884a24f..649c29d58 100644 --- a/lib/compute/hr_max.dart +++ b/lib/compute/hr_max.dart @@ -24,6 +24,8 @@ import 'dart:math' as math; import 'package:openstrap_analytics/onehz.dart' as ana; +import 'profile.dart' show plausibleAgeYears; + // ── THE app's HR ceiling for zones / TRIMP / calories ──────────────────────── // // There used to be five of these. `220 − age` at local_repository_impl.dart, @@ -63,7 +65,9 @@ import 'package:openstrap_analytics/onehz.dart' as ana; /// the band actually MEASURED — is `observedCeilingBpm`, and that one still /// refuses for an unknown family, in analytics' `observed_max_hr.dart`. double? estimatedMaxHr(num? age, String? deviceFamily) { - if (age == null || age <= 0) return null; + // An age already stored before the profile forms checked it (a birth year, + // a negative) gets no ceiling rather than a negative one. + if (age == null || !plausibleAgeYears(age)) return null; return 208.0 - 0.7 * age.toDouble(); } diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index 5dabed301..f70006233 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -61,6 +61,13 @@ enum ManualWindowError { /// The window intersects one already in the log. Overlap would double-count /// the same minutes and confuse `savedSpans` auto-detect exclusion. overlapsExisting, + + /// Retiming a scored session whose new window has no 1 Hz heart rate left + /// behind it (pruned past retention, or never on the band — a WHOOP import). + /// The rescore would come back empty and the replace-write would blank the + /// banked strain, calories, HR, zones and trace with nothing to rebuild + /// them from, so the edit is refused instead. + scoreNotRecomputable, } extension ManualWindowErrorMessage on ManualWindowError { @@ -73,6 +80,9 @@ extension ManualWindowErrorMessage on ManualWindowError { ManualWindowError.inFuture => "That window hasn't happened yet.", ManualWindowError.overlapsExisting => 'That overlaps a workout already in your log.', + ManualWindowError.scoreNotRecomputable => + "The heart rate behind this workout is no longer stored, so moving " + "its times would erase its strain and calories.", }; } diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart index cc042c9de..ccc49887d 100644 --- a/lib/compute/nap_edits.dart +++ b/lib/compute/nap_edits.dart @@ -64,6 +64,11 @@ bool manualNapWindowIsValid(int startSec, int endSec) { return d >= kMinManualNapSec && d <= kMaxManualNapSec; } +/// Whether a logged nap has already finished at [nowSec]. A window that ends +/// later is a nap that has not happened yet: it would credit today's sleep need +/// with time not slept, and suppress the real nap when the detector finds it. +bool manualNapWindowHasEnded(int endSec, int nowSec) => endSec <= nowSec; + /// Apply [edits] to the detector's [detected] naps. /// /// A rejected window removes every detected nap it OVERLAPS, not only one that diff --git a/lib/compute/nap_overlap_cleanup.dart b/lib/compute/nap_overlap_cleanup.dart index 21834bae5..e3c2a5671 100644 --- a/lib/compute/nap_overlap_cleanup.dart +++ b/lib/compute/nap_overlap_cleanup.dart @@ -16,12 +16,11 @@ import 'nap_edits.dart' show napMinutes; /// `compute_freshness` key marking the cleanup as applied (an import clears it). const String kNapNextNightCleanupKey = LocalDb.kNapNextNightCleanupKey; -/// Last algo version that could book tonight's sleep as a nap. -const int kNapNextNightFixedIn = 106; - /// [naps] without the detected ones overlapping the next day's main sleep -/// [nextOnsetSec, nextOffsetSec). A nap the user logged (it carries a -/// `source`) is kept. Null when nothing is dropped. +/// [nextOnsetSec, nextOffsetSec). A nap the user logged or confirmed is kept. +/// A `legacy` one is not: that is an old detection copied aside when its day +/// was rewritten, so it carries the same double count. Null when nothing is +/// dropped. List>? napsWithoutNextNight( List> naps, int nextOnsetSec, @@ -29,7 +28,8 @@ List>? napsWithoutNextNight( ) { final kept = [ for (final n in naps) - if (n['source'] != null || + if (n['source'] == 'manual' || + n['source'] == 'confirmed' || n['start'] is! num || n['end'] is! num || (n['end'] as num) <= nextOnsetSec || @@ -39,27 +39,37 @@ List>? napsWithoutNextNight( return kept.length == naps.length ? null : kept; } -/// Runs the cleanup once over every stored day below [kNapNextNightFixedIn]. -/// Returns the days rewritten. Marked done only after it finishes. +/// Runs the cleanup once over every stored day. Returns the days rewritten. +/// Marked done only after it finishes. +/// +/// Not gated on the stored algo version: the raw-free readiness and strain +/// backfills restamp an old bundle at the current version while copying its +/// naps through untouched, so a pre-v106 double count can sit under a v112+ +/// stamp. A day derived at v106+ has no detected nap inside the next night, +/// so the check is a no-op there. Future cleanNextNightNapsOnce() async { if (await LocalDb.computeFreshness(kNapNextNightCleanupKey) != null) return 0; final versions = await LocalDb.dayResultVersions(); final days = versions.keys.toList()..sort(); var cleaned = 0; for (final day in days) { - if (versions[day]! >= kNapNextNightFixedIn) continue; // Calendar arithmetic on the label (noon avoids any DST edge). final d = DateTime.parse(day); final nextDay = dayLabelOf(DateTime(d.year, d.month, d.day + 1, 12)); if (!versions.containsKey(nextDay)) continue; + // '—' on a night with no sleep: a cast would throw and end the pass. + final win = ((_decode((await LocalDb.dayResult(nextDay))?['payload_json']) + ?['sleep'] as Map?)?['window'] as Map?)?['value']; + if (win is! Map) continue; + final on = win['onset_ms'], off = win['offset_ms']; + if (on is! num || off is! num) continue; + // A rewrite of this day may already have copied the phantom aside as a + // legacy edit, and every later derive would attach it again. + await LocalDb.deleteLegacyNapsOverlapping(day, on ~/ 1000, off ~/ 1000); final row = await LocalDb.dayResult(day); final payload = _decode(row?['payload_json']); final naps = (payload?['naps'] as Map?)?['value']; if (payload == null || naps is! List || naps.isEmpty) continue; - final win = (((_decode((await LocalDb.dayResult(nextDay))?['payload_json']) - ?['sleep'] as Map?)?['window'] as Map?)?['value']) as Map?; - final on = win?['onset_ms'], off = win?['offset_ms']; - if (on is! num || off is! num) continue; final kept = napsWithoutNextNight( [for (final n in naps) if (n is Map) n.cast()], on ~/ 1000, diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index d2f2880e2..9c83f05f4 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -39,7 +39,7 @@ import 'hr_max.dart' smoothedMaxHr, smoothedMinHr, trainingZones; -import 'profile.dart' show workoutSex; +import 'profile.dart' show plausibleAgeYears, workoutSex; import 'step_cadence.dart' show cadenceSpmForMinutes; // Same argument: a pure `DateTime` lookup, no DB / IO / Flutter binding. It is // the ONE definition of "the UTC offset in effect at this instant" in the tree, @@ -347,6 +347,11 @@ class DayBundleInput { // ── profile + trailing baseline history ─────────────────────────────────── final Map profile; final List lnRmssdHistory; + + /// `yyyy-MM-dd` dates parallel to [lnRmssdHistory]. Without them the Plews + /// rolling-7 baseline is the last 7 ROWS, which after a wear gap are nights + /// from weeks ago scored as if they were last week. + final List lnRmssdDates; final List rhrHistory; final List respHistory; @@ -428,6 +433,7 @@ class DayBundleInput { this.tonightSleepOnsetSec = 0, required this.profile, this.lnRmssdHistory = const [], + this.lnRmssdDates = const [], this.rhrHistory = const [], this.respHistory = const [], this.rmssdHistory = const [], @@ -461,6 +467,7 @@ class DayBundleInput { 'tonight_sleep_onset_sec': tonightSleepOnsetSec, 'profile': profile, 'ln_rmssd_history': lnRmssdHistory, + 'ln_rmssd_dates': lnRmssdDates, 'rhr_history': rhrHistory, 'resp_history': respHistory, 'rmssd_history': rmssdHistory, @@ -513,6 +520,7 @@ class DayBundleInput { (m['tonight_sleep_onset_sec'] as num?)?.toInt() ?? 0, profile: ((m['profile'] as Map?) ?? const {}).cast(), lnRmssdHistory: dbls('ln_rmssd_history'), + lnRmssdDates: strs('ln_rmssd_dates'), rhrHistory: dbls('rhr_history'), respHistory: dbls('resp_history'), rmssdHistory: dbls('rmssd_history'), @@ -741,7 +749,8 @@ Map deriveDayBundle(Map inputJson) { // ── BREATHING-RATE VARIABILITY (per-window RSA over the sleep NN) ────────── // Window the cleaned sleep NN into ~30-min bins, take each bin's RSA resp rate, // then BRV = dispersion + Theil-Sen trend of those per-window rates. - final respWindows = _respPerWindow(nn, nnTimes); + final respWindows = + respPerWindow(nn, nnTimes, artifactFraction: artifactFraction); final brv = respWindows.length >= 3 ? breathingRateVariability(respWindows) : Metric.absent( @@ -840,11 +849,31 @@ Map deriveDayBundle(Map inputJson) { // every number that leans on it below is served at half confidence and // marked provisional. Always false on a band, so its payload is unchanged. final tempProvisional = settledTemp.value?.provisional ?? false; - // STEP 2 — z-score today's RAW mean against the RAW-ADC baseline history (NOT + // The night's value is the mean of its SETTLED samples whenever the family + // has a settle band. The fraction gate below only refuses nights under 0.80; + // a night at 0.85 still carries 15% warm-up or off-body samples reading + // hundreds of counts low, and the raw mean folds them in — tens of counts + // cold against a ~6.5-count between-night SD, which readiness reads as a + // cool night and the baseline banks as an outlier. A family with no band + // (gen5) has no settled mean and keeps the raw one. + final double? skinTempNightly = settledTemp.value?.mean ?? skinTempAdc; + // The nightly mean every LATER night is measured against. A night the strap + // spent partly warming up (or half off the wrist) reads hundreds of counts + // low; readiness already refuses it as tonight's input, but banking it as + // `skin_temp_adc` would sit in the next 28 nights' baseline, drag the median + // down and roughly double the SD — normal nights then read warm and a real + // elevation's skin_temp_z is damped below 2. So an unsettled night neither + // enters the series nor gets a z of its own. A null fraction (no settle band + // for the family) is unmeasured, not unsettled, and passes as before. + final double? skinTempBanked = (skinTempSettledFrac != null && + skinTempSettledFrac < kMinSettledFraction) + ? null + : skinTempNightly; + // STEP 2 — z-score today's nightly mean against the RAW-ADC baseline history (NOT // the previously-computed z-scores; that unit mismatch was the bug). Gated on // ≥3 prior raw means. double? skinTempZ; - if (skinTempAdc != null && d.skinTempAdcHistory.length >= 3) { + if (skinTempBanked != null && d.skinTempAdcHistory.length >= 3) { final base = _mean(d.skinTempAdcHistory)!; final sd = _stddev(d.skinTempAdcHistory); // Quantum floor matches analytics' tempInput(quantum: 1) guard @@ -852,7 +881,7 @@ Map deriveDayBundle(Map inputJson) { // oscillating between two adjacent ADC counts has a nonzero but // sub-quantum SD, which standardizes ordinary quantization noise into // an inflated z. Below 1 ADC count of dispersion, abstain instead. - if (sd != null && sd >= 1) skinTempZ = (skinTempAdc - base) / sd; + if (sd != null && sd >= 1) skinTempZ = (skinTempBanked - base) / sd; } // WHOOP 5 stores skin temperature in real °C (centi-°C in this array), so @@ -889,12 +918,13 @@ Map deriveDayBundle(Map inputJson) { // own oriented robust-z internally (consistent with the other inputs, which // pass raw values + their raw baselines). // - // The mean stays RAW — value and baseline have to be the same quantity, and - // the stored history is a series of raw nightly means. The settled fraction - // is the GATE on using it at all: below 0.80 the driver is refused for this - // night, by name, and readiness renormalises over the three that are left. + // Value and baseline have to be the same quantity: this is the same + // settled nightly mean that is banked as `skin_temp_adc`. The settled + // fraction is the GATE on using it at all: below 0.80 the driver is refused + // for this night, by name, and readiness renormalises over the three that + // are left. tempInput( - skinTempAdc, + skinTempNightly, d.skinTempAdcHistory, settledFraction: skinTempSettledFrac, ), @@ -961,15 +991,30 @@ Map deriveDayBundle(Map inputJson) { // (`_attachHistory` → `_BaselineHistoryCache.valuesBefore`), which is what // makes this single append the correct, non-duplicating one. final lnHist = [...d.lnRmssdHistory, ?lnToday]; - final lnReadiness = lnHist.length >= 4 - ? readinessLnRmssd(lnHist) - : Metric.absent( + // Dated so the rolling 7 is 7 CALENDAR nights: after a wear gap the last 7 + // rows are weeks old, and scoring tonight against them gave a confident + // band against a stale centre instead of abstaining until it refills. + // Only when the dates line up with the values; otherwise the rows window. + final lnDates = d.lnRmssdDates.length == d.lnRmssdHistory.length + ? [...d.lnRmssdDates, if (lnToday != null) d.date] + : null; + final lnReadiness = lnHist.length < 4 + ? Metric.absent( tier: Tier.high, inputs_used: const ['ln_rmssd_history'], // A BASELINE shortfall, so it gets the baseline grammar the UI // already turns into "Need N more nights". note: needBaselineNote(have: lnHist.length, need: 4), - ); + ) + // Without tonight's value the last element is the previous night, and + // scoring it would store yesterday's band under today. + : lnToday == null + ? const Metric.absent( + tier: Tier.high, + inputs_used: ['ln_rmssd_history', 'sleep_session_rmssd'], + note: 'no sleep-session RMSSD tonight, so there is no night to score', + ) + : readinessLnRmssd(lnHist, dates: lnDates); // ── STRAIN: Banister TRIMP over the WAKE span (per-minute day HR) ────────── final prof = d.profile; @@ -1026,7 +1071,9 @@ Map deriveDayBundle(Map inputJson) { // `zone_timeline`, so they gate on the set and not on the age estimate. final ceilingAbsentNote = hrMax != null ? null - : age == null + // A pre-validation age (a birth year, 0) also yields no ceiling; name + // the age, not the strap. + : age == null || !plausibleAgeYears(age) ? needInputNote('age') // The age is known; what measured this HR is not, so there is no ceiling // to band it against (analytics' device.dart contract). @@ -1645,7 +1692,7 @@ Map deriveDayBundle(Map inputJson) { 'resp': baselineBlock(d.respHistory, respToday, Baselines.respCfg), 'skin_temp': baselineBlock( d.skinTempAdcHistory, - skinTempAdc, + skinTempBanked, _skinTempAdcCfg, ), }; @@ -1754,11 +1801,13 @@ Map deriveDayBundle(Map inputJson) { 'ln_rmssd': lnToday, 'resp_rate': respToday, 'skin_temp_z': skinTempZ, - // RAW nightly skin-temp ADC mean — the personal BASELINE series for + // Nightly skin-temp ADC mean (of the settled samples, where the family + // has a settle band) — the personal BASELINE series for // skin_temp_z. ALWAYS present when there's sleep+temp data (even while z is // still null in the ≤3-day bootstrap), so the series fills and z starts // computing from ~day 4. This is the series _attachHistory must feed back. - 'skin_temp_adc': skinTempAdc, + // Null on an unsettled night (see `skinTempBanked`). + 'skin_temp_adc': skinTempBanked, // WHOOP 5 only: °C above (+) or below (-) the user's usual night. // Key only when present: no new key on every other night. if (skinTempDevC != null) 'skin_temp_dev_c': _round(skinTempDevC, 2), @@ -1894,9 +1943,16 @@ double? sleepClockOffsetSec(int epochSec) { /// Per-window RSA respiratory rates (br/min) for the BRV estimator. Buckets the /// cleaned NN into [windowMs] (~30-min) bins by beat time and runs [rsaRespRate] /// on each bin with ≥[minBeats] beats; keeps only resolved windows. -List _respPerWindow( +/// +/// [artifactFraction] is the night's share of corrected beats. The night-level +/// [rsaRespRate] call refuses the breathing rate when that share is above its +/// gate, because interpolated beats move the RSA peak; the bins are cut from +/// the same corrected NN, so they carry the same gate. With 0 here, BRV was +/// published on nights whose breathing rate was withheld as unmeasurable. +List respPerWindow( List nn, List nnTimes, { + required double artifactFraction, double windowMs = 1800000.0, int minBeats = 60, }) { @@ -1914,8 +1970,8 @@ List _respPerWindow( for (final idx in idxs) { final segNn = binsNn[idx]!; if (segNn.length < minBeats) continue; - // NN is already artifact-corrected upstream → artifactFraction 0. - final r = rsaRespRate(segNn, binsTs[idx]!, artifactFraction: 0.0); + final r = + rsaRespRate(segNn, binsTs[idx]!, artifactFraction: artifactFraction); final b = r.present ? r.value!.brpm : null; if (b != null) out.add(b); } diff --git a/lib/compute/profile.dart b/lib/compute/profile.dart index 1caaeec71..6b6d73413 100644 --- a/lib/compute/profile.dart +++ b/lib/compute/profile.dart @@ -79,6 +79,24 @@ class Profile { ageYears != null && weightKg != null && sex != null; } +/// The ranges a typed body measurement has to fall in to be believed. +/// +/// They are typo traps, not a population model: a birth year typed into the +/// age box (1990) put Tanaka's ceiling at -1185 bpm, and an imperial height +/// typed as feet.inches ("5.10") stored 13 cm. Anything inside these bounds is +/// a real person; anything outside is a mis-entry, and scoring it would band +/// every minute of every day off a body that does not exist. +const kPlausibleAgeYears = (min: 1, max: 120); +const kPlausibleHeightCm = (min: 50.0, max: 272.0); +const kPlausibleWeightKg = (min: 20.0, max: 400.0); + +bool plausibleAgeYears(num v) => + v.round() >= kPlausibleAgeYears.min && v.round() <= kPlausibleAgeYears.max; +bool plausibleHeightCm(num v) => + v >= kPlausibleHeightCm.min && v <= kPlausibleHeightCm.max; +bool plausibleWeightKg(num v) => + v >= kPlausibleWeightKg.min && v <= kPlausibleWeightKg.max; + /// Body mass index (kg/m²) off a weight and a height, or null without both. double? bmiOf(num? weightKg, num? heightCm) => weightKg == null || heightCm == null || heightCm <= 0 diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart index c3b3e5b08..4ee91d72b 100644 --- a/lib/compute/strain_backfill.dart +++ b/lib/compute/strain_backfill.dart @@ -31,6 +31,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:openstrap_analytics/onehz.dart' as ana; import '../data/day_label.dart'; @@ -136,7 +137,7 @@ Future backfillStrainScale({ final days = [ for (final r in strainRows) ?(r['date'] as String?), ]..sort(); - final cutoff = _shiftDays(days.last, -rawRetentionDays); + final cutoff = strainRescaleCutoff(days.last); var seriesDays = 0; var bundleDays = 0; @@ -280,6 +281,16 @@ Map? _decode(Object? json) { } } -/// Shift a 'YYYY-MM-DD' label by [days] calendar days. -String _shiftDays(String day, int days) => - dayLabelOf(DateTime.parse(day).add(Duration(days: days))); +/// The first day still treated as having raw, [rawRetentionDays] CALENDAR days +/// before the data edge [lastDay]. +/// +/// Calendar arithmetic, matching the pruner's cut on local day labels. An +/// absolute `Duration` walk back from local midnight across a 23 h +/// spring-forward day lands at 23:00 the day before, so the cutoff came out a +/// day early and that day — already pruned — was skipped as "still has raw", +/// then the one-shot marked itself done and left it on the old scale for good. +/// A malformed label falls back to itself, which skips every day: leaving a +/// day for a re-derive is the safe side. +@visibleForTesting +String strainRescaleCutoff(String lastDay) => + dayLabelBefore(lastDay, rawRetentionDays) ?? lastDay; diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index c0de5916b..08d8664f3 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -1150,7 +1150,9 @@ class PhysioDay { /// 'auto_fallback' — HR-led fallback (van Hees found nothing); LOW confidence, /// surface a "is this right?" prompt /// 'vendor_staged' — the band's own hypnogram (window + stages), gated - /// 'manual' — user typed the window (Approach 1) + /// 'manual' — user typed the window (Approach 1); with no + /// [sleep] present when the window staged to nothing + /// (its `absence_reason` says why) /// 'confirmed' — user accepted the fallback's proposal /// 'none' — no sleep at all final String sleepSource; @@ -1320,6 +1322,10 @@ List calendarDays( ana.SleepSegmentation s; String src; + // The HR-led fallback's own absence, when it found a night-shaped HR dip + // ending in this day but the band recorded too little of that window to + // stage it. Kept so the day can say why instead of a bare "no sleep". + ana.SleepSegmentation? fallbackUnobserved; if (ov != null && ov.source == 'rejected') { // The user's word again, the other direction: this was NOT sleep at // all. Skip detection/staging entirely rather than force a window — @@ -1366,8 +1372,11 @@ List calendarDays( ); } else { // Not an error and not "no sleep" — just no accel evidence. Fall - // through to the HR-led path below, which is exactly the degraded - // mode for this and is already marked low-confidence. + // through to the HR-led path below. It can still stage a night whose + // missing accel sits outside the HR dip (a gappy evening), but not + // one whose dip itself lacks gravity: the forced-window stager only + // counts a second as observed when it has a valid vector, so that + // window comes back unobserved and its reason is kept below. s = ana.SleepSegmentation.absent; } src = 'auto'; @@ -1394,6 +1403,10 @@ List calendarDays( if (s2.present) { s = s2; src = 'auto_fallback'; + } else if (s2.absenceReason != null && + cand.offsetSec >= dayStart && + cand.offsetSec < dayEnd) { + fallbackUnobserved = s2; } } } @@ -1463,6 +1476,37 @@ List calendarDays( // it was a rejection rather than an ordinary absence — sleep_detail // reads this to keep the "not sleep" state from re-prompting. sleepSource = 'rejected'; + } else if (ov != null) { + // A user-set window that staged to nothing — most often "I was + // asleep" on a night the band spent on the charger, so most of the + // window was never observed. Labelling it 'none' made it look like no + // override existed: the screen then blamed a busy re-analysis and sent + // the user to a re-run that gives the same answer. Keep the source and + // the segmenter's absence (it carries `absence_reason`) so the screen + // can say why the saved window has no night in it. + // + // A window with no samples at all comes back as the bare `absent` + // (no reason); give it the same unobserved reason the segmenter gives + // a thinly-recorded one, so "the band wasn't recording" isn't shown as + // "no sleep found". + final span = ov.offsetSec - ov.onsetSec; + final have = _lowerBound(sub.tsSec, ov.offsetSec) - + _lowerBound(sub.tsSec, ov.onsetSec); + seg = (s.absenceReason == null && + span > 0 && + have < span * ana.kMinObservedFractionForSleep) + ? ana.SleepSegmentation.unobservedWindow( + 'only ${have}s of a ${span}s window were recorded; below the ' + '${(ana.kMinObservedFractionForSleep * 100).round()}% floor') + : s; + sleepSource = src; + } else if (fallbackUnobserved != null) { + // The HR dip says a night happened and the band could not record + // enough of it. The source stays 'none' (nothing was staged, and + // 'auto_fallback' promises a window), but the reason rides along so the + // screen can say the band missed the night rather than that no sleep + // was found. + seg = fallbackUnobserved; } } diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index f84c12324..6c69eedea 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -298,19 +298,7 @@ class ActivityStore { whereArgs: [e, s], ); if (naps.isNotEmpty) return true; - final day = dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s * 1000)); - final end = DateTime.fromMillisecondsSinceEpoch(e * 1000); - // An evening interval can overlap the main sleep owned by tomorrow's - // wake date. Walk calendar dates rather than adding a fixed 24 hours. - final next = dayLabelOf(DateTime(end.year, end.month, end.day + 1)); - final nights = await tx.rawQuery( - '''SELECT d.* FROM day_result d - WHERE d.day_id BETWEEN ? AND ? AND d.algo_version = - (SELECT MAX(r.algo_version) FROM day_result r WHERE r.day_id = d.day_id AND r.algo_version <= ?)''', - [day, next, kAlgoVersion], - ); - for (final r in nights) { - final b = jsonDecode(r['payload_json'] as String) as Map; + for (final b in await _nightsAround(tx, s, e)) { // Before its first recompute, a pre-upgrade nap still lives only in the // day bundle. It is already logged and takes precedence just as a nap // that has been copied into the accepted-record table does. @@ -321,17 +309,55 @@ class ActivityStore { if (ns != null && ne != null && s < ne && e > ns) return true; } } - final value = ((b['sleep'] as Map?)?['window'] as Map?)?['value']; - // Absent metric envelopes store '—', not a window object. - final w = value is Map ? value : null; - final ns = w?['onset_ms'] as num?, ne = w?['offset_ms'] as num?; - if (ns != null && ne != null && s * 1000 < ne && e * 1000 > ns) { - return true; - } + if (_mainSleepOverlaps(b, s, e)) return true; } return (await _overlappingSessions(tx, s, e)).isNotEmpty; } + /// Whether [s, e) overlaps a main sleep the derived history already holds — + /// the night that ends on [s]'s date, or the one that begins that evening + /// and belongs to tomorrow. A nap logged inside one is time the night's TST + /// already counts, and every reader sums naps on top of it. + static Future overlapsMainSleep( + DatabaseExecutor tx, + int s, + int e, + ) async { + for (final b in await _nightsAround(tx, s, e)) { + if (_mainSleepOverlaps(b, s, e)) return true; + } + return false; + } + + static Future> _nightsAround( + DatabaseExecutor tx, + int s, + int e, + ) async { + final day = dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s * 1000)); + final end = DateTime.fromMillisecondsSinceEpoch(e * 1000); + // An evening interval can overlap the main sleep owned by tomorrow's + // wake date. Walk calendar dates rather than adding a fixed 24 hours. + final next = dayLabelOf(DateTime(end.year, end.month, end.day + 1)); + final nights = await tx.rawQuery( + '''SELECT d.* FROM day_result d + WHERE d.day_id BETWEEN ? AND ? AND d.algo_version = + (SELECT MAX(r.algo_version) FROM day_result r WHERE r.day_id = d.day_id AND r.algo_version <= ?)''', + [day, next, kAlgoVersion], + ); + return [ + for (final r in nights) jsonDecode(r['payload_json'] as String) as Map, + ]; + } + + static bool _mainSleepOverlaps(Map bundle, int s, int e) { + final value = ((bundle['sleep'] as Map?)?['window'] as Map?)?['value']; + // Absent metric envelopes store '—', not a window object. + final w = value is Map ? value : null; + final ns = w?['onset_ms'] as num?, ne = w?['offset_ms'] as num?; + return ns != null && ne != null && s * 1000 < ne && e * 1000 > ns; + } + static Future supersede( DatabaseExecutor tx, ActivityKind kind, diff --git a/lib/data/db.dart b/lib/data/db.dart index b0b7eeb7d..08604659c 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -8937,7 +8937,9 @@ class LocalDb { /// `compute_freshness` key marking the raw-free next-night nap cleanup /// (compute/nap_overlap_cleanup.dart) as applied. Cleared by an import. - static const String kNapNextNightCleanupKey = 'nap_next_night_cleanup_v1'; + /// v2: the v1 pass skipped every day the readiness backfill had restamped + /// at v112+, so installs that already ran it run it once more. + static const String kNapNextNightCleanupKey = 'nap_next_night_cleanup_v2'; /// `compute_freshness` key for [bumpUsageCounter]'s local counts. static const String kUsageCountersKey = 'usage_counters'; @@ -10704,8 +10706,11 @@ class LocalDb { // import. Rewind it. if ((counts['day_result'] ?? 0) > 0) { await putComputeFreshness(kReencodeCursorKey, jsonEncode({})); - // Same for the raw-free nap cleanup: imported old days may carry the - // next-night nap double count it removes, so it runs again. + } + // Same for the raw-free nap cleanup: imported old days, or legacy nap + // edits recomposed into kept days, may carry the next-night nap double + // count it removes, so it runs again. + if ((counts['day_result'] ?? 0) > 0 || (counts['sleep_nap'] ?? 0) > 0) { await deleteComputeFreshness(kNapNextNightCleanupKey); } // Last, so it can never be mistaken for a table row count by anything that @@ -11608,7 +11613,14 @@ class LocalDb { nowSec: nowSec, )?.wire; } + final flags = decoded['flags']; + // 'Not sleep' is the user's own verdict on the night, so it needs no + // settling and is as final an overnight as a detected absence. Without + // this a rejected today was never the overnight day: Home held the + // previous night over as "still being worked out" for the rest of it. + final rejected = flags is List && flags.contains('SLEEP_REJECTED'); if (dayId == today && + !rejected && !overnightSettled( sleepOffsetSec: wakeSec, dataEdgeSec: edgeSec, @@ -11624,9 +11636,9 @@ class LocalDb { if (latestOvernightDay == null) { final sleep = ((decoded['sleep'] as Map?)?['accounting'] as Map?)?['value']; - final flags = decoded['flags']; final hasSleep = sleep is Map && sleep['tst_sec'] != null; - final noSleep = flags is List && flags.contains('NO_SLEEP_DETECTED'); + final noSleep = rejected || + (flags is List && flags.contains('NO_SLEEP_DETECTED')); if (hasSleep || noSleep) { latestOvernightDay = dayId; latestOvernightComputedAt = (row['computed_at'] as num?)?.toInt(); @@ -12150,6 +12162,15 @@ class LocalDb { }) async { final db = await instance; await db.transaction((tx) async { + // A logged nap is credited whole on top of the main night's TST (nap_min, + // total asleep, sleep-need credit), so one typed over the end of the + // night — the doze after the alarm — counts those minutes twice. + if (source == 'manual' && + await ActivityStore.overlapsMainSleep(tx, startTs, endTs)) { + throw const ActivityReviewException( + 'That overlaps your main sleep, which already counts that time.', + ); + } String? snapshot; if (source == 'rejected') { // A pre-upgrade nap may still live only in its retained day result. @@ -12219,6 +12240,22 @@ class LocalDb { }); } + /// Whether [startTs, endTs) overlaps a main sleep already derived — the + /// check [putNapEdit] enforces for a logged nap, readable ahead of the write + /// so the entry screen can say why in the user's language. + static Future napOverlapsMainSleep(int startTs, int endTs) async => + ActivityStore.overlapsMainSleep(await instance, startTs, endTs); + + /// Drops [dayId]'s `legacy` nap edits overlapping [startSec, endSec). Logged, + /// confirmed and rejected edits are left alone. + static Future deleteLegacyNapsOverlapping( + String dayId, int startSec, int endSec) async { + final db = await instance; + return db.delete('sleep_nap', + where: "day_id = ? AND source = 'legacy' AND start_ts < ? AND end_ts > ?", + whereArgs: [dayId, endSec, startSec]); + } + static Future>> napEdits(String dayId) async { final db = await instance; return db.query( diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 4cb1bb776..504f64f5c 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -1031,12 +1031,20 @@ class LocalRepositoryImpl extends LocalRepository { // elsewhere. The periods ride along so the screen can show what it does // know instead of claiming nothing happened. final napPeriods = _periodsWithMainStages(b, const {}); + // Why a window produced nothing, when the segmenter knows — a user-set + // window the band barely recorded is the case the Sleep screen explains. + final absence = _sub(b, 'sleep')?['absence_reason'] as String?; if (napPeriods.isEmpty) { - return {'has_sleep': false, 'sleep_source': sleepSource}; + return { + 'has_sleep': false, + 'sleep_source': sleepSource, + 'absence_reason': ?absence, + }; } return { 'has_sleep': false, 'sleep_source': sleepSource, + 'absence_reason': ?absence, 'periods': napPeriods, 'total_asleep_min': _totalAsleepMin(b, napPeriods), }; @@ -2967,6 +2975,16 @@ class LocalRepositoryImpl extends LocalRepository { if (existing['status'] == 'live') { throw StateError('setWorkoutWindow: session $id is still live'); } + // The rescore reads only the 1 Hz substrate, which is pruned after + // `rawRetentionDays` and never existed for an imported session. With none + // over the new window the replace-write would land every banked figure as + // null — strain, calories, HR, zones, the frozen trace — and nothing could + // rebuild them. Refuse before touching the Health store or the row. + if (_hasBankedScore(existing) && + (await LocalDb.hrSamplesInRange(startTs, endTs)).isEmpty) { + throw const ManualWindowException( + ManualWindowError.scoreNotRecomputable); + } // Clear the OLD Health window before the retimed row overwrites it — the // caller's subsequent exportWorkoutId only deletes inside the NEW window, // so a narrowed/moved retime would otherwise leave the previous sample @@ -2987,6 +3005,13 @@ class LocalRepositoryImpl extends LocalRepository { ); } + static bool _hasBankedScore(Map row) => + row['strain'] != null || + row['calories'] != null || + row['max_hr'] != null || + row['avg_hr'] != null || + row['trace_json'] != null; + /// Shared writer for both user-timed paths: score the window from the 1 Hz /// substrate, persist, and retire any auto-detect suggestion it covers. /// @@ -4398,7 +4423,9 @@ class LocalRepositoryImpl extends LocalRepository { final age = _profileAge(); final zonesNote = set != null ? null - : age == null + // A pre-validation age (a birth year, 0) has no ceiling estimate + // either; the fix is the age, not the strap. + : age == null || !plausibleAgeYears(age) ? needInputNote('age') : ana.unknownFamilyNote(family); // TS-05 — the 28-day distribution is ABSENT, not captioned, until BOTH zone diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 721df205d..1605f31b5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -421,6 +421,7 @@ "napsWokeUpHelp": "AUFGEWACHT UM", "napsInvalidWindow": "Ein Nickerchen dauert zwischen 5 Minuten und 6 Stunden. Alles Längere ist Schlaf und gehört zur Nacht, wo die Schlafphasen ausgewertet werden.", "napsOverlap": "Das überschneidet sich mit einem Nickerchen, das an diesem Tag schon eingetragen ist. Entferne zuerst das andere, damit dieselbe Zeit nicht doppelt zählt.", + "napsOverlapsSleep": "Das überschneidet sich mit deinem Hauptschlaf, der diese Zeit schon zählt. Trage nur den Teil nach dem Aufstehen ein oder korrigiere stattdessen die Nacht.", "napsNotReanalysed": "Der Tag wurde nicht neu ausgewertet, weil gerade schon eine andere Auswertung lief. Deine Änderung ist gespeichert und wird beim nächsten Mal berücksichtigt.", "napsTitle": "Nickerchen", "napsNoReadingTitle": "Keine Daten zu Nickerchen an diesem Tag", @@ -1699,6 +1700,11 @@ "sleepDetailBedTimeHelp": "INS BETT GEGANGEN UM", "sleepDetailWakeTimeHelp": "AUFGESTANDEN UM", "sleepDetailReanalyseFailed": "Die Nacht wurde nicht neu ausgewertet – entweder lief bereits eine andere Auswertung, oder sie ist fehlgeschlagen. Deine Zeiten sind gespeichert; „Alles neu analysieren“ unter „Deine Daten“ wendet sie an.", + "sleepDetailWindowUnrecorded": "Deine Zeiten sind gespeichert, aber das Band hat in diesen Stunden größtenteils nichts aufgezeichnet (nicht getragen oder beim Laden), daher gibt es keine Nacht zum Auswerten.", + "sleepDetailWindowNoSleep": "Deine Zeiten sind gespeichert, aber die Aufzeichnungen des Bands aus diesen Stunden zeigen keinen Schlaf zum Auswerten.", + "sleepDetailNightUnrecorded": "Deine Herzfrequenz deutet auf eine Nacht Schlaf hin, aber das Band hat dabei größtenteils zu wenige Bewegungsdaten aufgezeichnet, daher gibt es keine Nacht zum Auswerten.", + "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", + "sleepDetailCorrectionEveningPruned": "Der Abend vor dieser Nacht ist nicht mehr auf dem Telefon, daher lassen sich nur eine Schlafenszeit nach Mitternacht oder „Kein Schlaf“ übernehmen.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", "sleepDetailNoHypnogramBody": "Für die Schlafphasen werden Bewegung und Herzschlag-Abstände benötigt. Eines davon fehlte.", "sleepDetailThroughTheNight": "Im Verlauf der Nacht", @@ -2779,5 +2785,9 @@ }, "deviceUseSheetOff": "Du kannst es jederzeit ausschalten. Was es beigetragen hat, wird dann wieder entfernt.", "deviceUseSheetConfirm": "Nutzen", - "deviceUseSheetCancel": "Nicht jetzt" + "deviceUseSheetCancel": "Nicht jetzt", + "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", + "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "profileImplausibleFields": "Bitte {fields} prüfen: außerhalb des gültigen Bereichs, daher wurde nichts gespeichert." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1686208b5..2cf7ca953 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1951,6 +1951,10 @@ "@napsOverlap": { "description": "Validation error when a manually logged nap overlaps an existing one." }, + "napsOverlapsSleep": "That overlaps your main sleep, which already counts that time. Log only the part after you got up, or correct the night instead.", + "@napsOverlapsSleep": { + "description": "Validation error when a manually logged nap overlaps the main sleep window." + }, "napsNotReanalysed": "The day was not re-analysed — another analysis was already running. Your edit is saved and will apply next time.", "@napsNotReanalysed": { "description": "Fallback error when a nap edit saved but the day could not be re-derived because another analysis was already running." @@ -8466,6 +8470,26 @@ "description": "Help text on the time picker for when the user got up." }, "sleepDetailReanalyseFailed": "The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.", + "sleepDetailWindowUnrecorded": "Your times are saved, but the band wasn’t recording for most of those hours (off your wrist or charging), so there’s no night to score.", + "sleepDetailWindowNoSleep": "Your times are saved, but the band’s recordings for those hours show no sleep to score.", + "sleepDetailNightUnrecorded": "Your heart rate shows a night’s sleep, but the band recorded too little movement data for most of it, so there’s no night to score.", + "@sleepDetailNightUnrecorded": { + "description": "Shown on an automatically detected night with no sleep, when heart rate found a night but the band recorded too little movement data across it to stage one." + }, + "@sleepDetailWindowUnrecorded": { + "description": "Shown after the user set their own sleep window (e.g. \"I was asleep\") and the band recorded too little of it to stage a night." + }, + "@sleepDetailWindowNoSleep": { + "description": "Shown after the user set their own sleep window and the band recorded it, but staging found no sleep in it." + }, + "sleepDetailCorrectionUnavailable": "This night’s detailed recordings are no longer on the phone, so its sleep can’t be corrected now.", + "sleepDetailCorrectionEveningPruned": "The evening before this night is no longer on the phone, so only a bedtime after midnight, or Not sleep, can be applied.", + "@sleepDetailCorrectionEveningPruned": { + "description": "Shown on the oldest night still kept, or after a correction there was refused: the evening part of the night has been pruned, so a bedtime before midnight, confirming the detected times, or reverting to automatic cannot be applied." + }, + "@sleepDetailCorrectionUnavailable": { + "description": "Shown instead of the sleep correction buttons, or after one was refused, when the night's raw recordings have been pruned and it can no longer be re-analysed." + }, "@sleepDetailReanalyseFailed": { "description": "Fallback error message when re-analysis after a window edit could not be confirmed." }, @@ -15639,7 +15663,7 @@ "@reportDatabaseCovered": { "description": "Issue report: line under the 2-week switch while the full database switch is on." }, - "reportStageSlice": "Preparing last 2 weeks\u2026", + "reportStageSlice": "Preparing last 2 weeks…", "@reportStageSlice": { "description": "Report screen: progress text inside the busy button while the 2-week database is built." }, @@ -15787,5 +15811,36 @@ "type": "String" } } + }, + "dataReanalyzeFailed": "Re-analysis failed: {error}", + "@dataReanalyzeFailed": { + "description": "Re-analyze result: the pass itself failed.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{1 day could not be analyzed. If this keeps happening, please report it.} other{{n} days could not be analyzed. If this keeps happening, please report it.}}", + "@dataReanalyzeDaysFailed": { + "description": "Re-analyze result: N days could not be derived.", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "napsInFuture": "That nap hasn't finished yet. Log it once you're awake.", + "@napsInFuture": { + "description": "Validation error when a manually logged nap ends after the current time." + }, + "profileImplausibleFields": "Check {fields}: out of range, so nothing was saved.", + "@profileImplausibleFields": { + "description": "Shown when a typed age, height or weight is outside the believable range.", + "placeholders": { + "fields": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 874f3d060..29be05423 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "CUÁNDO TE DESPERTASTE", "napsInvalidWindow": "Una siesta dura entre 5 minutos y 6 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", "napsOverlap": "Eso se superpone con una siesta ya registrada este día. Elimina esa primero, en lugar de contar la misma hora dos veces.", + "napsOverlapsSleep": "Eso se superpone con tu sueño principal, que ya cuenta ese tiempo. Registra solo la parte posterior a levantarte o corrige la noche.", "napsNotReanalysed": "El día no se ha vuelto a analizar: ya había otro análisis en curso. Tu edición está guardada y se aplicará la próxima vez.", "napsTitle": "Siestas", "napsNoReadingTitle": "Sin datos de siestas para este día", @@ -1659,6 +1660,11 @@ "sleepDetailBedTimeHelp": "CUÁNDO TE ACOSTASTE", "sleepDetailWakeTimeHelp": "CUÁNDO TE LEVANTASTE", "sleepDetailReanalyseFailed": "La noche no se ha vuelto a analizar: ya había otro reanálisis en curso, o falló. Las horas que estableciste están guardadas; \"Reanalizar todo\" en Tus datos las aplicará.", + "sleepDetailWindowUnrecorded": "Tus horas están guardadas, pero la pulsera no estuvo registrando durante la mayor parte de ese tiempo (no la llevabas puesta o se estaba cargando), así que no hay noche que puntuar.", + "sleepDetailWindowNoSleep": "Tus horas están guardadas, pero los registros de la pulsera de esas horas no muestran sueño que puntuar.", + "sleepDetailNightUnrecorded": "Tu frecuencia cardíaca indica una noche de sueño, pero la pulsera registró muy pocos datos de movimiento durante la mayor parte, así que no hay noche que puntuar.", + "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", + "sleepDetailCorrectionEveningPruned": "La tarde anterior a esta noche ya no está en el teléfono, así que solo se puede aplicar una hora de acostarse después de medianoche o «No es sueño».", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", "sleepDetailNoHypnogramBody": "El cálculo de fases necesita movimiento y ritmo cardíaco. Faltaba uno de los dos.", "sleepDetailThroughTheNight": "Durante la noche", @@ -2683,5 +2689,9 @@ }, "deviceUseSheetOff": "Puedes desactivarlo cuando quieras. Lo que haya aportado se quitará.", "deviceUseSheetConfirm": "Usarlo", - "deviceUseSheetCancel": "Ahora no" + "deviceUseSheetCancel": "Ahora no", + "dataReanalyzeFailed": "El reanálisis falló: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", + "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "profileImplausibleFields": "Revisa {fields}: fuera de rango, así que no se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0e7349f8f..d7a377c37 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "QUAND VOUS VOUS ÊTES RÉVEILLÉ", "napsInvalidWindow": "Une sieste dure entre 5 minutes et 6 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", "napsOverlap": "Cela chevauche une sieste déjà enregistrée ce jour-là. Supprimez d'abord celle-ci plutôt que de compter la même heure deux fois.", + "napsOverlapsSleep": "Cela chevauche votre sommeil principal, qui compte déjà ce temps. Enregistrez seulement la partie après votre lever, ou corrigez plutôt la nuit.", "napsNotReanalysed": "La journée n'a pas été réanalysée — une autre analyse était déjà en cours. Votre modification est enregistrée et s'appliquera la prochaine fois.", "napsTitle": "Siestes", "napsNoReadingTitle": "Aucune donnée de sieste pour cette journée", @@ -1649,6 +1650,11 @@ "sleepDetailBedTimeHelp": "HEURE DU COUCHER", "sleepDetailWakeTimeHelp": "HEURE DU LEVER", "sleepDetailReanalyseFailed": "La nuit n'a pas été réanalysée : une autre réanalyse était déjà en cours, ou elle a échoué. Les horaires que vous avez définis sont enregistrés ; « Tout réanalyser » dans Vos données les appliquera.", + "sleepDetailWindowUnrecorded": "Vos horaires sont enregistrés, mais le bracelet n'a pas enregistré pendant la majeure partie de ces heures (retiré du poignet ou en charge) : il n'y a donc pas de nuit à évaluer.", + "sleepDetailWindowNoSleep": "Vos horaires sont enregistrés, mais les enregistrements du bracelet pour ces heures ne montrent aucun sommeil à évaluer.", + "sleepDetailNightUnrecorded": "Votre fréquence cardiaque indique une nuit de sommeil, mais le bracelet a enregistré trop peu de données de mouvement pendant la majeure partie : il n'y a donc pas de nuit à évaluer.", + "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", + "sleepDetailCorrectionEveningPruned": "La soirée précédant cette nuit n’est plus sur le téléphone : on peut seulement indiquer une heure de coucher après minuit, ou marquer la nuit comme n’étant pas du sommeil.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", "sleepDetailNoHypnogramBody": "L'analyse des phases nécessite le mouvement et le rythme cardiaque. L'un des deux manquait.", "sleepDetailThroughTheNight": "Au fil de la nuit", @@ -2654,5 +2660,9 @@ }, "deviceUseSheetOff": "Vous pouvez le désactiver à tout moment. Ce qu'il a apporté sera alors retiré.", "deviceUseSheetConfirm": "L'utiliser", - "deviceUseSheetCancel": "Pas maintenant" + "deviceUseSheetCancel": "Pas maintenant", + "dataReanalyzeFailed": "La réanalyse a échoué : {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", + "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "profileImplausibleFields": "Vérifiez {fields} : hors limites, rien n'a donc été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 05e9ffcac..ffa8ee646 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "आप कब जागे", "napsInvalidWindow": "झपकी 5 मिनट से 6 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", "napsOverlap": "यह इस दिन पहले से मौजूद एक झपकी से टकराता है। उसे पहले हटाएँ, बजाय इसके कि उसी घंटे को दो बार गिना जाए।", + "napsOverlapsSleep": "यह आपकी मुख्य नींद से टकराता है, जो उस समय को पहले से गिनती है। केवल उठने के बाद का हिस्सा दर्ज करें, या इसके बजाय रात को ठीक करें।", "napsNotReanalysed": "दिन का पुनः विश्लेषण नहीं हुआ — पहले से ही एक अन्य विश्लेषण चल रहा था। आपका बदलाव सहेज लिया गया है और अगली बार लागू होगा।", "napsTitle": "झपकियाँ", "napsNoReadingTitle": "इस दिन के लिए कोई झपकी रीडिंग नहीं", @@ -1649,6 +1650,11 @@ "sleepDetailBedTimeHelp": "आप बिस्तर पर कब गए", "sleepDetailWakeTimeHelp": "आप कब उठे", "sleepDetailReanalyseFailed": "रात का दोबारा विश्लेषण नहीं हो पाया — या तो कोई और पुनः विश्लेषण पहले से चल रहा था, या यह विफल हो गया। आपके सेट किए गए समय सेव हैं; 'आपका डेटा' में 'सब कुछ दोबारा विश्लेषण करें' उन्हें लागू कर देगा।", + "sleepDetailWindowUnrecorded": "आपके समय सेव हैं, लेकिन उन घंटों में ज़्यादातर समय बैंड रिकॉर्ड नहीं कर रहा था (कलाई पर नहीं था या चार्ज हो रहा था), इसलिए स्कोर करने के लिए कोई रात नहीं है।", + "sleepDetailWindowNoSleep": "आपके समय सेव हैं, लेकिन उन घंटों की बैंड रिकॉर्डिंग में स्कोर करने लायक कोई नींद नहीं दिखती।", + "sleepDetailNightUnrecorded": "आपकी हृदय गति रात की नींद दिखाती है, लेकिन ज़्यादातर समय बैंड ने बहुत कम मूवमेंट डेटा रिकॉर्ड किया, इसलिए स्कोर करने के लिए कोई रात नहीं है।", + "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", + "sleepDetailCorrectionEveningPruned": "इस रात से पहले की शाम अब फ़ोन पर नहीं है, इसलिए केवल आधी रात के बाद का सोने का समय दिया जा सकता है, या इस रात को नींद नहीं के रूप में चिह्नित किया जा सकता है।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", "sleepDetailNoHypnogramBody": "चरण तय करने के लिए हलचल और हृदय-धड़कन की टाइमिंग चाहिए। इनमें से एक मौजूद नहीं था।", "sleepDetailThroughTheNight": "पूरी रात के दौरान", @@ -2654,5 +2660,9 @@ }, "deviceUseSheetOff": "आप इसे कभी भी बंद कर सकते हैं। तब इसका जोड़ा हुआ सब हटा दिया जाएगा।", "deviceUseSheetConfirm": "इस्तेमाल करें", - "deviceUseSheetCancel": "अभी नहीं" + "deviceUseSheetCancel": "अभी नहीं", + "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", + "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "profileImplausibleFields": "{fields} जाँचें: सीमा से बाहर, इसलिए कुछ भी सहेजा नहीं गया।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5a4780491..c2c980120 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -821,6 +821,7 @@ "napsWokeUpHelp": "КОГДА ВЫ ПРОСНУЛИСЬ", "napsInvalidWindow": "Дневной сон длится от 5 минут до 6 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", "napsOverlap": "Этот период пересекается с уже записанным дневным сном. Сначала удалите прежнюю запись, чтобы не учитывать одно время дважды.", + "napsOverlapsSleep": "Этот период пересекается с основным сном, который уже учитывает это время. Запишите только часть после подъёма или исправьте ночь.", "napsNotReanalysed": "Повторный анализ дня не выполнен: уже идёт другой анализ. Изменение сохранено и будет учтено в следующий раз.", "napsTitle": "Дневной сон", "napsNoReadingTitle": "Нет данных о дневном сне за этот день", @@ -3904,6 +3905,11 @@ "sleepDetailBedTimeHelp": "КОГДА ВЫ ЛЕГЛИ В ПОСТЕЛЬ", "sleepDetailWakeTimeHelp": "КОГДА ВЫ ВСТАЛИ", "sleepDetailReanalyseFailed": "Ночь не пересчитана: шёл другой пересчёт или произошла ошибка. Время сохранено: откройте «Ваши данные» и запустите повторный анализ.", + "sleepDetailWindowUnrecorded": "Время сохранено, но браслет почти всё это время ничего не записывал (был снят или заряжался), поэтому оценивать нечего.", + "sleepDetailWindowNoSleep": "Время сохранено, но в записях браслета за эти часы нет сна, который можно оценить.", + "sleepDetailNightUnrecorded": "Пульс указывает на ночной сон, но большую часть ночи браслет записал слишком мало данных о движении, поэтому оценивать нечего.", + "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", + "sleepDetailCorrectionEveningPruned": "Вечер перед этой ночью уже удалён с телефона, поэтому можно применить только время отхода ко сну после полуночи или «Не сон».", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", "sleepDetailNoHypnogramBody": "Для определения стадий нужны движение и межударные интервалы. Одного из источников не было.", "sleepDetailThroughTheNight": "В течение ночи", @@ -7523,5 +7529,30 @@ "type": "String" } } + }, + "dataReanalyzeFailed": "Повторный анализ не удался: {error}", + "@dataReanalyzeFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{Не удалось проанализировать {n} день. Если это повторяется, пожалуйста, сообщите об этом.} few{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.} many{Не удалось проанализировать {n} дней. Если это повторяется, пожалуйста, сообщите об этом.} other{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.}}", + "@dataReanalyzeDaysFailed": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", + "profileImplausibleFields": "Проверьте: {fields} — вне допустимого диапазона, поэтому ничего не сохранено.", + "@profileImplausibleFields": { + "placeholders": { + "fields": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 598b2069f..7b9b51220 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "醒来时间", "napsInvalidWindow": "小睡的时长应在 5 分钟到 6 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", "napsOverlap": "该时间段与当天已有的一次小睡重叠。请先删除那一次记录,而不是把同一小时计算两次。", + "napsOverlapsSleep": "该时间段与你的主睡眠重叠,主睡眠已经计入了这段时间。请只记录起床之后的部分,或改为修正当晚的睡眠。", "napsNotReanalysed": "当天未被重新分析——已有另一项分析正在运行。你的修改已保存,将在下次生效。", "napsTitle": "小睡", "napsNoReadingTitle": "当天没有小睡数据", @@ -1649,6 +1650,11 @@ "sleepDetailBedTimeHelp": "上床时间", "sleepDetailWakeTimeHelp": "起床时间", "sleepDetailReanalyseFailed": "这一夜未能重新分析——可能另一次重新分析正在进行,也可能分析失败了。你设置的时间已保存;在“你的数据”中点击“重新分析全部”即可应用。", + "sleepDetailWindowUnrecorded": "你设置的时间已保存,但这段时间里手环大部分时候没有记录(没戴在手腕上或在充电),所以没有可评分的夜晚。", + "sleepDetailWindowNoSleep": "你设置的时间已保存,但手环在这段时间的记录中没有可评分的睡眠。", + "sleepDetailNightUnrecorded": "你的心率显示有一夜睡眠,但手环在大部分时间里记录的动作数据太少,所以没有可评分的夜晚。", + "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", + "sleepDetailCorrectionEveningPruned": "这一夜之前的傍晚记录已不在手机上,因此只能设置午夜之后的入睡时间,或将这一夜标记为不是睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", "sleepDetailNoHypnogramBody": "阶段判定需要运动和心跳时序数据,其中有一项缺失。", "sleepDetailThroughTheNight": "整夜变化", @@ -2654,5 +2660,9 @@ }, "deviceUseSheetOff": "你可以随时关闭它。届时它添加的内容会被移除。", "deviceUseSheetConfirm": "使用", - "deviceUseSheetCancel": "暂不" + "deviceUseSheetCancel": "暂不", + "dataReanalyzeFailed": "重新分析失败:{error}", + "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", + "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。" } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 32a5bc874..be270de04 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -53,7 +53,11 @@ import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; import '../compute/derive_scheduler.dart'; import '../compute/inputs/canonical.dart' - show flagOnOrPrimary, onWearableDaysChanged, refreshSessionSensorSources; + show + flagOnOrPrimary, + onWearableDaysChanged, + refreshSessionSensorSources, + substrateDays; import '../compute/manual_session.dart' show supersededSuggestionIds; import '../compute/hr_max.dart'; import '../compute/inputs/measurement_inputs.dart' @@ -192,6 +196,10 @@ bool recoveryNightSettled({ ); } +/// What "Re-analyze data" did: days derived, days the pass could not derive, +/// and the error when the pass itself failed. +typedef ReanalyzeOutcome = ({int days, int failedDays, String? error}); + class AppState extends ChangeNotifier { late final BleEngine engine; @@ -2516,10 +2524,13 @@ class AppState extends ChangeNotifier { } /// User-initiated "Re-analyze data": force-derive EVERY day that has raw, - /// ignoring the derived cursor, then refresh the UI. Returns the number of days - /// derived (for a result message). Use when screens are empty despite stored raw. - Future reanalyzeAll() async { - if (reanalyzing) return 0; + /// ignoring the derived cursor, then refresh the UI. Returns the days derived, + /// the days that could not be, and the error when the pass itself failed (for + /// a result message). Use when screens are empty despite stored raw — which + /// is exactly when a failing derive is the likely cause, so a failure must + /// not come back looking like an ordinary "0 days". + Future reanalyzeAll() async { + if (reanalyzing) return (days: 0, failedDays: 0, error: null); reanalyzing = true; reanalyzeProgress = 'Analyzing…'; notifyListeners(); @@ -2535,10 +2546,20 @@ class AppState extends ChangeNotifier { } await LocalDb.releaseFrozenHeadline(LocalDb.localDayLabelNow()); } while (_derive.running); + // The outcome comes back through onOutcome rather than the engine's + // lastRun* fields: run() releases its latch before this resumes, and a + // drain's light pass taking it during the awaits below would reset those + // fields and hand its result back as this re-analysis's. + var failedDays = 0; + String? runError; final n = await _derive.run( _profile, heavy: true, force: true, + onOutcome: (failed, error) { + failedDays = failed; + runError = error; + }, // Per-day callback: surface progress AND refresh the UI so each real day's // metrics appear as soon as it's derived (Today fills in one day at a time). onDayDone: (day, index, total) async { @@ -2550,10 +2571,14 @@ class AppState extends ChangeNotifier { ); await LocalDb.refreshComputeFreshness(); bumpInsights(); - return n; + return ( + days: n, + failedDays: failedDays, + error: runError, + ); } catch (e) { _log('[derive] reanalyze failed: $e'); - return 0; + return (days: 0, failedDays: 0, error: '$e'); } finally { reanalyzing = false; reanalyzeProgress = ''; @@ -2563,6 +2588,50 @@ class AppState extends ChangeNotifier { // ── SLEEP OVERRIDE (manual entry + fallback confirm) ──────────────────────── + /// Whether a sleep correction on [date] can reach the derived night. The + /// forced re-derive only covers days that still have substrate (decoded band + /// rows or the active wearable's rows); once retention prunes a day's raw, + /// nothing re-stages it, so an override stored for it would never show and + /// "Re-analyze everything" couldn't apply it either. + /// + /// The prune cuts on local midnight, so the oldest kept day still has its + /// own rows but lost the evening half of its night. The derive declines to + /// overwrite a stored night from that partial window, so on that day a + /// correction only lands when the night it asks for doesn't reach below the + /// cut: [sleepSource] is what the night would become ('rejected', 'manual', + /// 'confirmed', or 'auto' when clearing) and [onsetSec] the user's bedtime + /// for a user-set window. + Future sleepCorrectable( + String date, { + String sleepSource = 'auto', + int? onsetSec, + }) async => + (await substrateDays()).contains(date) && + !await DerivationEngine.pruneDeclinesSleepRederive( + date, + sleepSource: sleepSource, + sleepOnsetSec: onsetSec, + ); + + /// Refuses a correction [sleepCorrectable] rules out, before anything is + /// written: the row would sit in sleep_override unapplied. + Future _requireSleepSubstrate( + String date, { + required String sleepSource, + int? onsetSec, + }) async { + if (!(await substrateDays()).contains(date)) { + throw SleepCorrectionUnavailable(date); + } + if (await DerivationEngine.pruneDeclinesSleepRederive( + date, + sleepSource: sleepSource, + sleepOnsetSec: onsetSec, + )) { + throw SleepCorrectionUnavailable(date, eveningPruned: true); + } + } + /// Manual sleep entry (Approach 1): the user gives the in-bed window for [date] /// (local YYYY-MM-DD). Stored as the source of truth, then a force re-derive /// restages that day FROM the window — even if it was finalized/locked. @@ -2575,6 +2644,7 @@ class AppState extends ChangeNotifier { final onsetSec = onset.millisecondsSinceEpoch ~/ 1000; final offsetSec = offset.millisecondsSinceEpoch ~/ 1000; if (offsetSec <= onsetSec) return; + await _requireSleepSubstrate(date, sleepSource: source, onsetSec: onsetSec); await LocalDb.putSleepOverride( dayId: date, onsetTs: onsetSec, @@ -2593,6 +2663,8 @@ class AppState extends ChangeNotifier { final onset = (sleep['onset_ts'] as num?)?.toInt(); final offset = (sleep['wake_ts'] as num?)?.toInt(); if (onset == null || offset == null || offset <= onset) return; + await _requireSleepSubstrate(date, + sleepSource: 'confirmed', onsetSec: onset); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset, @@ -2614,11 +2686,12 @@ class AppState extends ChangeNotifier { final sleep = await repo!.getDaySleep(date); final onset = (sleep['onset_ts'] as num?)?.toInt(); final offset = (sleep['wake_ts'] as num?)?.toInt(); - // The rejected window is stored for the record, same as a rejected nap — - // it is never read back once source is 'rejected' (staging is skipped - // outright), so an absent detected window falls back to a harmless - // same-day placeholder rather than blocking the rejection. + // The rejected window is stored like a rejected nap. Staging never reads + // it (it is skipped outright); nap detection does, so the same stretch is + // not offered back as a nap. An absent detected window falls back to a + // one-second placeholder rather than blocking the rejection. final fallback = DateTime.parse(date).millisecondsSinceEpoch ~/ 1000; + await _requireSleepSubstrate(date, sleepSource: 'rejected'); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset ?? fallback, @@ -2629,13 +2702,19 @@ class AppState extends ChangeNotifier { } /// Remove a manual/confirmed override for [date] — revert to auto/fallback. + /// Refused on a pruned day like the others: deleting the row there would + /// leave the derived night still showing the user's window. Future clearSleepOverride(String date) async { + await _requireSleepSubstrate(date, sleepSource: 'auto'); await LocalDb.deleteSleepOverride(date); await _reanalyzeForOverride(date); } /// Force-derive after a sleep-override change so the affected day restages from - /// the user's window (the engine force-includes override days even if locked). + /// the user's window. A force pass re-derives finalized override days, but + /// only among days that still have substrate, and it keeps the stored night + /// when the prune has cut into the asked-for window — callers check both + /// first ([sleepCorrectable]). Future _reanalyzeForOverride([String? editedDay]) async { // run() returns 0 without queueing while ANY pass holds the derive latch // (a drain's light pass, the post-drain rescan, another edit), and only a @@ -2657,7 +2736,11 @@ class AppState extends ChangeNotifier { } if (editedDay != null) await LocalDb.releaseFrozenHeadline(editedDay); } while (_derive.running); - await _derive.run(_profile, force: true); + await _derive.run( + _profile, + force: true, + editedDays: {?editedDay}, + ); if (editedDay != null) { // A finalized day behind the export cursor is never re-written, so // the corrected (or rejected) night would never reach the health store. @@ -5482,3 +5565,16 @@ class AppState extends ChangeNotifier { } } } + +/// A sleep correction no re-derive can apply, so nothing was written: the +/// day's substrate has been pruned, or ([eveningPruned]) the day is the oldest +/// one kept and the night asked for starts before the midnight prune cut. +class SleepCorrectionUnavailable implements Exception { + SleepCorrectionUnavailable(this.day, {this.eveningPruned = false}); + final String day; + final bool eveningPruned; + @override + String toString() => eveningPruned + ? 'the evening before $day is no longer recorded' + : 'no recordings left to re-analyse $day'; +} diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index b61d29c43..276b94fc0 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -169,6 +169,21 @@ class UnitsController extends ChangeNotifier { return isImperial ? v * _kgPerLb : v; } + /// [weightToKg], except that a field still showing exactly what + /// [weightField] wrote for [shownKg] gives [shownKg] back. Imperial shows + /// whole pounds, so an untouched field otherwise drifts off the stored value + /// on every save (20 kg -> "44" -> 19.96 kg, under the plausible floor). + double? weightToKgKeeping(String text, num? shownKg) => + shownKg != null && text.trim() == weightField(shownKg) + ? shownKg.toDouble() + : weightToKg(text); + + /// [heightToCm] with the same untouched-field rule as [weightToKgKeeping]. + double? heightToCmKeeping(String text, num? shownCm) => + shownCm != null && text.trim() == heightField(shownCm) + ? shownCm.toDouble() + : heightToCm(text); + /// Parse a height field (display units = total inches when imperial) → cm. double? heightToCm(String text) { final v = double.tryParse(text.trim()); diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index d0492030b..9d6c2b4e3 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -1836,6 +1836,22 @@ void sayUnreadable(BuildContext c, List fields) { ); } +/// Say which fields hold a number that cannot be a real body — a birth year +/// in the age box, feet.inches in an inches box. Same contract as +/// [sayUnreadable]: the form stops and nothing is saved. +void sayImplausible(BuildContext c, List fields) { + if (fields.isEmpty) return; + final joined = fields.join(', '); + ScaffoldMessenger.of(c).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(c)?.profileImplausibleFields(joined) ?? + 'Check $joined: out of range, so nothing was saved.', + ), + ), + ); +} + /// The one destructive confirm. Names what goes and what stays, and there is /// no undo behind any caller of it. /// diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index bf7ab1d58..429de0f83 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -15,6 +15,8 @@ import '../../l10n/display_text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../compute/profile.dart' + show plausibleAgeYears, plausibleHeightCm, plausibleWeightKg; import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; import '../../state/app_state.dart'; @@ -106,6 +108,11 @@ class _ProfileSetupViewState extends State { static String _str(Object? v) => v == null ? '' : '$v'; + double? _heightCm() => _u.heightToCmKeeping( + _height.text, widget.initial['height_cm'] as num?); + double? _weightKg() => _u.weightToKgKeeping( + _weight.text, widget.initial['weight_kg'] as num?); + /// Null until touched: then it follows the sex picked, on for female. bool? _cycle; bool get _cycleOn => _cycle ?? (widget.cycleTracking == true || _sex == 'f'); @@ -124,8 +131,8 @@ class _ProfileSetupViewState extends State { Map _fields() => { if (_sex != null) 'sex': _sex, if (Typed.of(_age.text).value case final v?) 'age': v.round(), - 'height_cm': ?_u.heightToCm(_height.text), - 'weight_kg': ?_u.weightToKg(_weight.text), + 'height_cm': ?_heightCm(), + 'weight_kg': ?_weightKg(), }; /// Continue, unless something typed cannot be read — a typo is not a blank, @@ -140,6 +147,21 @@ class _ProfileSetupViewState extends State { sayUnreadable(context, bad); return; } + final age = Typed.of(_age.text).value; + final height = _heightCm(); + final weight = _weightKg(); + final off = [ + if (age != null && !plausibleAgeYears(age)) + AppLocalizations.of(context)?.settingsAgeFieldLabel ?? 'Age', + if (height != null && !plausibleHeightCm(height)) + uiText(context, _u.heightLabel), + if (weight != null && !plausibleWeightKg(weight)) + uiText(context, _u.weightLabel), + ]; + if (off.isNotEmpty) { + sayImplausible(context, off); + return; + } await widget.onCycleTracking?.call(_cycleOn); await widget.onSave(_fields()); } diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index 242243095..5524a2e0f 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -217,7 +217,17 @@ class _DataScreenState extends State { r['date'] as String: (r['value'] as num).round(), }; final before = await recovery(); - final n = await app.reanalyzeAll(); + final (days: n, failedDays: failedDays, error: error) = + await app.reanalyzeAll(); + // The derive reports a failed pass as 0 days, the same as nothing to do; + // shown as an ordinary "0 days re-analyzed." it reads as "there is nothing + // here" on the very screens this button exists to repair. + if (error != null) { + return ( + l?.dataReanalyzeFailed(error) ?? 'Re-analysis failed: $error', + true + ); + } final after = await recovery(); // Which days' recovery a re-analysis actually moved, newest first. // ponytail: first 5 only; the note is a heads-up, not an audit log. @@ -230,13 +240,18 @@ class _DataScreenState extends State { final label = d == null ? c.day : ml.formatShortMonthDay(d); return '$label ${c.from} → ${c.to}'; }).join(', '); - final done = + var done = l?.dataDaysReanalyzed(n) ?? '$n day${n == 1 ? '' : 's'} re-analyzed.'; + if (failedDays > 0) { + final notDone = l?.dataReanalyzeDaysFailed(failedDays) ?? + '$failedDays day${failedDays == 1 ? '' : 's'} could not be analyzed.'; + done = '$done $notDone'; + } return ( changed.isEmpty ? done : '$done ${l?.dataRecoveryChanged(changed) ?? 'Recovery changed: $changed'}', - false + failedDays > 0 ); } diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 843e95d4c..8378e8857 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -19,6 +19,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../compute/profile.dart' + show plausibleAgeYears, plausibleHeightCm, plausibleWeightKg; import '../../data/off_lookup.dart'; import '../../health/health_export.dart' show HealthLinkState; import '../../health/health_import_state.dart'; @@ -1537,6 +1539,12 @@ class _EditProfileViewState extends State { TextEditingController(text: _u.weightField(widget.initial['weight_kg'] as num?)); late String? _sex = (widget.initial['sex'] as String?)?.toLowerCase(); + /// The metric values the height/weight fields were last filled from, so an + /// untouched field saves the value it came from (see + /// [UnitsController.weightToKgKeeping]). + late num? _shownHeightCm = widget.initial['height_cm'] as num?; + late num? _shownWeightKg = widget.initial['weight_kg'] as num?; + /// When the store last gave us something. Null is "never", which is also /// what an unreadable preference reads as — the first-run word on the button /// is the safe one either way. @@ -1574,8 +1582,10 @@ class _EditProfileViewState extends State { if (!mounted) return; if (fields != null) { _age.text = _s(fields['age']); - _height.text = _u.heightField(fields['height_cm'] as num?); - _weight.text = _u.weightField(fields['weight_kg'] as num?); + _shownHeightCm = fields['height_cm'] as num?; + _shownWeightKg = fields['weight_kg'] as num?; + _height.text = _u.heightField(_shownHeightCm); + _weight.text = _u.weightField(_shownWeightKg); _sex = (fields['sex'] as String?)?.toLowerCase() ?? _sex; } setState(() { @@ -1625,13 +1635,33 @@ class _EditProfileViewState extends State { sayUnreadable(context, bad); return; } + final heightCm = + height.value == null + ? null + : _u.heightToCmKeeping(_height.text, _shownHeightCm); + final weightKg = + weight.value == null + ? null + : _u.weightToKgKeeping(_weight.text, _shownWeightKg); + final off = [ + if (age.value case final a? when !plausibleAgeYears(a)) + (l?.settingsAgeFieldLabel ?? 'Age'), + if (heightCm != null && !plausibleHeightCm(heightCm)) + uiText(context, _u.heightLabel), + if (weightKg != null && !plausibleWeightKg(weightKg)) + uiText(context, _u.weightLabel), + ]; + if (off.isNotEmpty) { + sayImplausible(context, off); + return; + } widget.onSave({ 'name': _name.text.trim().isEmpty ? null : _name.text.trim(), 'sex': _sex, 'age': age.value?.round(), // Typed in the units on the label, stored in metric. - 'height_cm': height.value == null ? null : _u.heightToCm(_height.text), - 'weight_kg': weight.value == null ? null : _u.weightToKg(_weight.text), + 'height_cm': heightCm, + 'weight_kg': weightKg, }); } diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index 56c371811..c3e97e442 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -226,12 +226,26 @@ class _NapsScreenState extends State { 'stages can be read.'); return; } + if (!manualNapWindowHasEnded( + e, DateTime.now().millisecondsSinceEpoch ~/ 1000)) { + setState(() => _failed = l?.napsInFuture ?? + "That nap hasn't finished yet. Log it once you're awake."); + return; + } if (napOverlapsExisting(s, e, _d?.naps ?? const [])) { setState(() => _failed = l?.napsOverlap ?? 'That overlaps a nap already on this day. ' 'Remove that one first, rather than counting the same hour twice.'); return; } + if (await LocalDb.napOverlapsMainSleep(s, e)) { + if (!mounted) return; + setState(() => _failed = l?.napsOverlapsSleep ?? + 'That overlaps your main sleep, which already counts that time. ' + 'Log only the part after you got up, or correct the night instead.'); + return; + } + if (!mounted) return; await _edit(() => LocalDb.putNapEdit( dayId: day, startTs: s, endTs: e, source: 'manual')); } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 2839b72d8..5990af466 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -430,6 +430,23 @@ class _SleepDetailState extends State with RevisionReload { /// Why the last correction did not take. Null when it did. String? _overrideFailed; + /// Whether the shown night can still be corrected. False once the day's + /// recordings have been pruned: no re-analysis reaches it any more, so the + /// correction buttons would only ever fail (see AppState.sleepCorrectable). + /// Unknown counts as correctable, which is how the screen behaved before. + bool _correctable = true; + + /// Whether the night's whole derive window is still kept. False on the + /// oldest kept day: the prune cut at its midnight took the evening half, so + /// confirming the detected times or going back to automatic would be + /// declined; only a bedtime after midnight, or Not sleep, can land. + bool _wholeNightKept = true; + + /// Whether "These times are right" can land. Not the same as + /// [_wholeNightKept]: on the oldest kept day a fallback window that starts + /// after the prune cut can still be confirmed. + bool _confirmable = true; + /// Last night's state against the user's own nights, when it was rough enough /// to say so and has not been dismissed. See `rough_night.dart` — it REPLACES /// the elevated-sleeping-HR card rather than joining it, so one night can @@ -488,8 +505,29 @@ class _SleepDetailState extends State with RevisionReload { if (mounted && gen == _loadGen) setState(() => _loading = false); return; } + AppState? app; + try { + app = context.read(); + } catch (_) {} try { final d = await SleepData.load(repo, want: _day); + var correctable = true; + var wholeNightKept = true; + var confirmable = true; + if (app != null && d.day != null) { + try { + correctable = + await app.sleepCorrectable(d.day!, sleepSource: 'rejected'); + wholeNightKept = + correctable && await app.sleepCorrectable(d.day!); + final onset = (d.night['onset_ts'] as num?)?.toInt(); + confirmable = wholeNightKept || + (correctable && + onset != null && + await app.sleepCorrectable(d.day!, + sleepSource: 'confirmed', onsetSec: onset)); + } catch (_) {} + } // LAST NIGHT ONLY. The card names the luteal phase, and `getCycle` knows // today's phase and no other day's — so a card offered while stepping // back through the record would either drop that half or invent it. @@ -500,7 +538,14 @@ class _SleepDetailState extends State with RevisionReload { ? await loadRoughNight(repo, d.day!, c: mounted ? context : null) : null; if (mounted && gen == _loadGen) { - setState(() => (_d = d, _rough = rough, _loading = false)); + setState(() => ( + _d = d, + _rough = rough, + _correctable = correctable, + _wholeNightKept = wholeNightKept, + _confirmable = confirmable, + _loading = false, + )); } } catch (_) { if (mounted && gen == _loadGen) setState(() => _loading = false); @@ -554,19 +599,35 @@ class _SleepDetailState extends State with RevisionReload { ? (l?.sleepDetailRejectedBody ?? 'You told us this stretch was not sleep, so nothing is ' 'scored for it.') - : (l?.sleepDetailNoNightBody ?? + : (_userWindowEmpty(d.night, l) ?? + _autoNightUnrecorded(d.night, l) ?? + l?.sleepDetailNoNightBody ?? 'No stretch of band recordings long enough to score.'), fix: rejected ? '' : (l?.sleepDetailNoNightFix ?? 'Wear the band overnight and sync in the morning'), icon: rejected ? LucideIcons.undo2 : LucideIcons.moon, ), - if (rejected) ...[ + // Undo goes back to automatic, which needs the whole night window. + // "I was asleep" does not: a bedtime after midnight still lands, and + // the write is refused (and said so) when it doesn't. + if (rejectedDay != null && + (!_correctable || (rejected && !_wholeNightKept))) ...[ + const SizedBox(height: S.x2), + _correctable ? _eveningPrunedNote(c, l) : _uncorrectableNote(c, l), + ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( onPressed: _saving ? null : () => _clearWindow(rejectedDay), child: Text( l?.sleepDetailUndoRejection ?? 'Undo — go back to automatic'), ), + if (!_saving && _overrideFailed != null) + StatusCard( + l?.sleepDetailCorrectionFailedTitle ?? + 'That correction has not been applied', + _overrideFailed!, + icon: LucideIcons.triangleAlert, + ), ] else if (rejectedDay != null) ...[ // A night the detector missed is the other half of "it got it // wrong": the same manual window a mis-staged night gets, stored as @@ -578,6 +639,7 @@ class _SleepDetailState extends State with RevisionReload { : () => _addWindow(rejectedDay), child: Text(l?.sleepDetailIWasAsleep ?? 'I was asleep'), ), + if (!_wholeNightKept) _eveningPrunedNote(c, l), if (_saving) Text(l?.sleepDetailReanalysing ?? 'Re-analysing the night…', style: F.cap.copyWith(color: P.of(c).ink3)), @@ -839,8 +901,11 @@ class _SleepDetailState extends State with RevisionReload { ), ], const SizedBox(height: S.x2), + if (!_correctable) + _uncorrectableNote(c, l) + else Wrap(spacing: S.x2, children: [ - if (fallback) + if (fallback && _confirmable) TextButton( onPressed: busy ? null : () => _confirmWindow(day), child: Text(l?.sleepDetailConfirmTimes ?? 'These times are right'), @@ -851,7 +916,7 @@ class _SleepDetailState extends State with RevisionReload { ? (l?.sleepDetailChangeTimes ?? 'Change the times') : (l?.sleepDetailSetTimesMyself ?? 'Set the times myself')), ), - if (mine) + if (mine && _wholeNightKept) TextButton( onPressed: busy ? null : () => _clearWindow(day), child: @@ -865,6 +930,7 @@ class _SleepDetailState extends State with RevisionReload { child: Text(l?.sleepDetailNotSleep ?? 'Not sleep'), ), ]), + if (_correctable && !_wholeNightKept) _eveningPrunedNote(c, l), if (busy) ...[ const SizedBox(height: S.x2), Text(l?.sleepDetailReanalysing ?? 'Re-analysing the night…', @@ -884,6 +950,23 @@ class _SleepDetailState extends State with RevisionReload { ]; } + /// Stands in for the correction buttons on a night that can no longer be + /// re-analysed, so it isn't offered a fix that can't land. + Widget _uncorrectableNote(BuildContext c, AppLocalizations? l) => Text( + l?.sleepDetailCorrectionUnavailable ?? + 'This night\u2019s detailed recordings are no longer on the phone, ' + 'so its sleep can\u2019t be corrected now.', + style: F.cap.copyWith(color: P.of(c).ink3), + ); + + /// On the oldest kept night: says why only some corrections are offered. + Widget _eveningPrunedNote(BuildContext c, AppLocalizations? l) => Text( + l?.sleepDetailCorrectionEveningPruned ?? + 'The evening before this night is no longer on the phone, so only ' + 'a bedtime after midnight, or Not sleep, can be applied.', + style: F.cap.copyWith(color: P.of(c).ink3), + ); + Future _confirmWindow(String day) => _runOverride( day, () => context.read().confirmSleep(day), @@ -964,15 +1047,33 @@ class _SleepDetailState extends State with RevisionReload { _saving = true; _overrideFailed = null; }); + final l = AppLocalizations.of(context); String? failed; try { await write(); + } on SleepCorrectionUnavailable catch (e) { + // Nothing was written, and re-analysing everything can't reach this + // night either, so the generic "tap Re-analyze everything" advice is + // wrong. + failed = e.eveningPruned + ? (l?.sleepDetailCorrectionEveningPruned ?? + 'The evening before this night is no longer on the phone, so ' + 'only a bedtime after midnight, or Not sleep, can be ' + 'applied.') + : (l?.sleepDetailCorrectionUnavailable ?? + 'This night\u2019s detailed recordings are no longer on the ' + 'phone, so its sleep can\u2019t be corrected now.'); } catch (e) { failed = '$e'; } finally { if (mounted) setState(() => _saving = false); } final before = _source; + // Opened without a day, the screen reloads onto whichever night Home + // treats as last night, and an edit (Not sleep above all) can change + // that answer to another night. Pin the edited night unless the user has + // already stepped away from it. + if (_day == null && _d?.day == day) _day = day; if (mounted) await _load(); if (!mounted) return; // EVERY failure below this screen is silent: the forced re-derive catches @@ -988,13 +1089,54 @@ class _SleepDetailState extends State with RevisionReload { ? applied(_d?.night ?? const {}) : _source != before; if (failed != null || !took) { - final l = AppLocalizations.of(context); setState(() => _overrideFailed = failed ?? + _userWindowEmpty(_d?.night ?? const {}, l) ?? l?.sleepDetailReanalyseFailed ?? 'The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.'); } } + /// Why a night the user set the times for still has nothing to show, or + /// null when it isn't one. The re-analysis ran and kept the window; the band + /// just recorded too little of it (charger, off the wrist) or no sleep in it, + /// so the generic "another run was busy, re-analyse everything" would send + /// the user to a re-run that gives the same answer. + static String? _userWindowEmpty( + Map n, AppLocalizations? l) { + final src = n['sleep_source']; + if ((src != 'manual' && src != 'confirmed') || n['has_sleep'] == true) { + return null; + } + return n['absence_reason'] != null + ? (l?.sleepDetailWindowUnrecorded ?? + 'Your times are saved, but the band wasn\u2019t recording for most ' + 'of those hours (off your wrist or charging), so there\u2019s ' + 'no night to score.') + : (l?.sleepDetailWindowNoSleep ?? + 'Your times are saved, but the band\u2019s recordings for those ' + 'hours show no sleep to score.'); + } + + /// Why an automatic night has nothing to show, when the derive knows: the + /// heart rate found a night but the band recorded too little movement + /// across it to stage one. Null otherwise, so an ordinary empty night keeps + /// the generic message. + static String? _autoNightUnrecorded( + Map n, AppLocalizations? l) { + final src = n['sleep_source']; + if (src == 'manual' || + src == 'confirmed' || + src == 'rejected' || + n['has_sleep'] == true || + n['absence_reason'] == null) { + return null; + } + return l?.sleepDetailNightUnrecorded ?? + 'Your heart rate shows a night\u2019s sleep, but the band recorded ' + 'too little movement data for most of it, so there\u2019s no ' + 'night to score.'; + } + /// Where the drawn window came from: 'auto', 'auto_fallback', 'manual' or /// 'confirmed'. String? get _source => (_d?.night['sleep_source'] as String?) ?? 'auto'; diff --git a/test/activity_review_regression_test.dart b/test/activity_review_regression_test.dart index c19f34d80..c7e2c0c06 100644 --- a/test/activity_review_regression_test.dart +++ b/test/activity_review_regression_test.dart @@ -62,6 +62,50 @@ void main() { }), ); + test('a logged nap over the end of the main sleep is refused', () async { + // The night ran 02:00 to 10:15; logging 10:00-10:30 as a nap would credit + // the 15 minutes the night already counts a second time. + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + windowJson: '{}', + finalized: true, + payloadJson: jsonEncode({ + 'scalars': {'nap_min': 0}, + 'naps': {'value': [], 'count': 0}, + 'sleep': { + 'window': { + 'value': { + 'onset_ms': (start - 8 * 3600) * 1000, + 'offset_ms': (start + 900) * 1000, + }, + }, + }, + }), + ); + expect(await LocalDb.napOverlapsMainSleep(start, start + 1800), isTrue); + await expectLater( + LocalDb.putNapEdit( + dayId: day, + startTs: start, + endTs: start + 1800, + source: 'manual', + ), + throwsA(isA()), + ); + expect(await LocalDb.napEdits(day), isEmpty); + // The part after getting up is still a nap. + expect(await LocalDb.napOverlapsMainSleep(start + 900, start + 1800), + isFalse); + await LocalDb.putNapEdit( + dayId: day, + startTs: start + 900, + endTs: start + 1800, + source: 'manual', + ); + expect(await LocalDb.napEdits(day), hasLength(1)); + }); + test('a normal absent sleep window permits new nap detection', () async { await result(day, absentSleep: true); expect( diff --git a/test/brv_artifact_gate_test.dart b/test/brv_artifact_gate_test.dart new file mode 100644 index 000000000..becedac93 --- /dev/null +++ b/test/brv_artifact_gate_test.dart @@ -0,0 +1,40 @@ +// Bug: the night's breathing rate (`rsaRespRate` over the corrected sleep NN) +// is withheld when more than 30% of beats needed correction, but the per-window +// rates feeding breathing-rate variability were computed from the same +// corrected NN with `artifactFraction: 0.0` hard-coded. On a loose-strap night +// the screen showed resp_rate as "—" and still published brv_cv/brv_slope +// directly underneath it, built from interpolated beats. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +void main() { + // 3 h of beats at ~60 bpm with a clean 15 br/min (0.25 Hz) RSA modulation. + (List, List) rsaNight() { + final nn = []; + final ts = []; + var t = 1780000000000.0; + while (t < 1780000000000.0 + 3 * 3600 * 1000) { + final rr = 1000.0 + 60.0 * math.sin(2 * math.pi * 0.25 * t / 1000.0); + t += rr; + nn.add(rr); + ts.add(t); + } + return (nn, ts); + } + + test('windows resolve on a clean night', () { + final (nn, ts) = rsaNight(); + final clean = respPerWindow(nn, ts, artifactFraction: 0.0); + expect(clean.length, greaterThanOrEqualTo(3)); + // Below the gate the fraction only moves confidence, never the rate. + expect(respPerWindow(nn, ts, artifactFraction: 0.10), clean); + }); + + test('windows carry the night-level artifact gate', () { + final (nn, ts) = rsaNight(); + expect(respPerWindow(nn, ts, artifactFraction: 0.38), isEmpty); + }); +} diff --git a/test/cadence_decimation_rig_test.dart b/test/cadence_decimation_rig_test.dart index 9d17ada12..a89d4ddaa 100644 --- a/test/cadence_decimation_rig_test.dart +++ b/test/cadence_decimation_rig_test.dart @@ -23,7 +23,7 @@ // the RR beats attached to it — no interpolation, no bucket averaging. That is // also the physically honest model: a 60 s band emits fewer records, it does // not emit averages of the records it skipped. Per-metric notes below say where -// a metric's own contract would have justified averaging (none of the twelve +// a metric's own contract would have justified averaging (none of the eleven // declare a mean input; `relative_odi` computes its own AC/DC windows and // `cardio_stager` its own epoch means, both from raw samples). // @@ -245,19 +245,6 @@ _Probe _hrZonesProbe(List<_Row> rows) { return (t?.total, t == null); } -_Probe _loadTrimpProbe(List<_Row> rows) { - final valid = [for (final r in rows) if (r.hr > 0) r]; - final m = trimpStrain( - [for (final r in valid) r.hr.toDouble()], - [for (final r in valid) r.recTs.toDouble()], - // Fixed anchors: the rig compares a metric against ITSELF at 1 Hz, so the - // anchors only have to be constant across cadences, not personal. - maxHr: 190, - restingHr: 50, - ); - return (m.value, !m.present); -} - _Probe _orientationProbe(List<_Row> rows) { final tilts = positionSeries([ for (final r in rows) @@ -457,7 +444,6 @@ void main() { run('cardioStager.wakePct', '%', n, _cardioStagerProbe(nRows, nBeats)); run('hrRecovery.dropBpm', 'bpm', n, _hrRecoveryProbe(dRows)); run('hrZones.totalSec', 's', n, _hrZonesProbe(dRows)); - run('loadTrimp.strain', '0-100', n, _loadTrimpProbe(dRows)); run('orientation.epochs', 'n', n, _orientationProbe(nRows)); run('cycles.n', 'n', n, _cyclesProbe(nBeats, nightA, nightB)); run('respRate.riiv', 'brpm', n, _riivProbe(nRows)); @@ -498,7 +484,7 @@ void main() { // NO correctness assertions. Only that the rig produced a full grid — // if a probe starts throwing, this is what notices. - expect(cells.length, _cadences.length * 12 + 1); + expect(cells.length, _cadences.length * 11 + 1); }, timeout: const Timeout(Duration(minutes: 10))); } } diff --git a/test/carry_forward_detail_test.dart b/test/carry_forward_detail_test.dart index deb983a31..773cb90b5 100644 --- a/test/carry_forward_detail_test.dart +++ b/test/carry_forward_detail_test.dart @@ -104,4 +104,82 @@ void main() { expect(next['scalars'], same(scalars)); expect(next['series'], same(series)); }); + + // A retimed or rejected night re-derives the day. If that pass's second half + // fails, the previous row's sleep blocks describe the OLD night and must not + // be grafted onto the new window. + group('a changed night does not carry the old night\'s sleep blocks', () { + Map withWindow(Map b, int? on, int? off) { + final sleep = Map.from(b['sleep'] as Map); + sleep['window'] = { + 'value': on == null ? '\u2014' : {'onset_ms': on, 'offset_ms': off}, + }; + return b..['sleep'] = sleep; + } + + const oldOn = 1786750200 * 1000, oldOff = 1786775400 * 1000; + Map prevRow() { + final prev = withWindow(_storedCompleteRow(), oldOn, oldOff); + prev['sleep_periods'] = { + 'periods': [ + {'is_main': true, 'onset_ts': oldOn ~/ 1000, 'wake_ts': oldOff ~/ 1000}, + ], + }; + prev['restlessness'] = {'value': 0.2}; + prev['hr_ceiling'] = {'value': 170}; + return prev; + } + + for (final (label, on, off) in [ + ('retimed', 1786744800 * 1000, 1786777200 * 1000), + ('rejected', null, null), + ]) { + test(label, () { + final prev = prevRow(); + final next = withWindow(_freshBundle(), on, off); + final moved = DerivationEngine.sleepWindowChanged(prev, next); + expect(moved, isTrue); + expect( + DerivationEngine.carryForwardDetail(prev, next, carrySleep: !moved), + isTrue); + for (final k in DerivationEngine.kSleepOwnedDetailKeys) { + expect(next.containsKey(k), isFalse, reason: k); + } + expect((next['scalars'] as Map).containsKey('nap_min'), isFalse); + // Detail that does not hang off the night still comes across. + expect(next['hr_ceiling'], prev['hr_ceiling']); + expect((next['series'] as Map)['hrv_day'], + (prev['series'] as Map)['hrv_day']); + + final outcome = DerivationEngine.recoveryOutcome( + recovered: true, + prevPartial: false, + prevVersion: kAlgoVersion, + prevFinalized: true, + finalizedByAge: false, + sleepMoved: moved, + ); + expect(outcome.partial, isTrue); + expect(outcome.finalized, isFalse); + }); + } + + test('the same night still carries its sleep blocks', () { + final prev = prevRow(); + final next = withWindow(_freshBundle(), oldOn, oldOff); + expect(DerivationEngine.sleepWindowChanged(prev, next), isFalse); + DerivationEngine.carryForwardDetail(prev, next); + expect(next['sleep_periods'], prev['sleep_periods']); + expect(next['naps'], prev['naps']); + expect((next['scalars'] as Map)['nap_min'], 25.0); + }); + + test('no night either side is no change', () { + expect( + DerivationEngine.sleepWindowChanged( + withWindow(_storedCompleteRow(), null, null), + withWindow(_freshBundle(), null, null)), + isFalse); + }); + }); } diff --git a/test/cleared_override_finalized_day_test.dart b/test/cleared_override_finalized_day_test.dart new file mode 100644 index 000000000..0eb36a91f --- /dev/null +++ b/test/cleared_override_finalized_day_test.dart @@ -0,0 +1,81 @@ +// Clearing a sleep override deletes its row before the re-derive, so the day +// is no longer an override day. A night old enough to be finalized has to be +// re-derived anyway, or "Back to automatic" / "Undo" on Not sleep leaves the +// user's window (or the rejected night) on screen. + +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 _profile = Profile( + ageYears: 35, + sex: 'm', + weightKg: 75, + heightCm: 178, + restingHrManual: 55, +); + +Future _seedDay(String day) async { + final d = DateTime.parse(day); + final ts = DateTime(d.year, d.month, d.day, 12).millisecondsSinceEpoch ~/ 1000; + await (await LocalDb.instance).insert( + 'decoded_onehz', + { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': ts % 100000, + 'hr': 60, + 'ax': 0.0, + 'ay': -0.5, + 'az': 0.98, + 'device_family': 'gen4', + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_cleared_override_finalized_day_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 force pass re-derives a finalized day whose override was cleared', + () async { + const day = '2026-08-20'; + await _seedDay(day); + // Written finalized while the override was in place. + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: '{}', + windowJson: '{}', + finalized: true, + ); + expect(await LocalDb.sleepOverrideDays(), isNot(contains(day))); + + final engine = DerivationEngine(); + await engine.run(_profile, force: true); + expect(engine.snapshot()['todo_days'], 0, + reason: 'without the edited day a finalized day is skipped'); + + await engine.run(_profile, force: true, editedDays: {day}); + expect(engine.snapshot()['todo_days'], 1); + }); +} diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 53383d294..758e2e978 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -7,6 +7,8 @@ // injected RHR spike trips the illness flag, and that absent inputs degrade to // honest absent envelopes (never a thrown exception, never a fabricated number). +import 'dart:math' as math; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/crossday_pipeline.dart'; @@ -48,6 +50,7 @@ List> _synthDays( 'readiness': readiness, 'resp_rate': resp, 'skin_temp_z': temp, + 'skin_temp_adc': 3000.0 + wobble * 8.0, if (withTrimp) 'trimp': trimp, if (withSleep) 'onset_sec': onsetSec, if (withSleep) 'wake_sec': wakeSec, @@ -738,4 +741,249 @@ void _wiredFamilies() { expect(sc['value'], '—'); }); }); + + // The temperature flag standardises its own input. It was handed + // `skin_temp_z`, which is already scored against a baseline that holds the + // earlier fever nights, so the z it saw decayed night over night and a real + // multi-night rise never stayed above threshold for two nights. + group('the temperature flag reads the raw nightly mean', () { + // Deterministic noise around 3000 ADC, SD ~8. + const noise = [-9.0, 4.0, 11.0, -3.0, -12.0, 7.0, 1.0, -6.0, 13.0, -5.0, + 2.0, -10.0, 8.0, -1.0, 5.0, -7.0, 10.0, -4.0, 0.0, 6.0]; + + // The edge z exactly as the per-day pipeline computes it: trailing 28 raw + // nightly means strictly before the night, sample SD, gated at SD >= 1. + double? edgeZ(List raw, int i) { + final hist = raw.sublist(i - 28 < 0 ? 0 : i - 28, i); + if (hist.length < 3) return null; + final mean = hist.reduce((a, b) => a + b) / hist.length; + var ss = 0.0; + for (final v in hist) { + ss += (v - mean) * (v - mean); + } + final sd = ss == 0 ? 0.0 : math.sqrt(ss / (hist.length - 1)); + return sd >= 1 ? (raw[i] - mean) / sd : null; + } + + List> feverDays({ + required double feverSd, + String? Function(int i)? family, + double Function(int i, double v)? shift, + }) { + const baseline = 40; + final raw = [ + for (var i = 0; i < baseline; i++) 3000 + noise[i % noise.length], + for (var i = 0; i < 3; i++) 3000 + feverSd * 8, + ]; + var dt = DateTime(2024, 1, 1); + final out = >[]; + for (var i = 0; i < raw.length; i++) { + final v = shift == null ? raw[i] : shift(i, raw[i]); + out.add({ + 'date': '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}', + 'skin_temp_z': edgeZ(raw, i), + 'skin_temp_adc': v, + if (family != null) 'skin_temp_family': family(i), + }); + dt = dt.add(const Duration(days: 1)); + } + return out; + } + + List flags(List> days) => [ + for (final r in buildCrossDayBundle(days, const {})['recent'] as List) + (r as Map)['temp'] as bool, + ]; + + test('a three-night rise is flagged on its second and third nights', () { + final f = flags(feverDays(feverSd: 3)); + expect(f.sublist(f.length - 3), [false, true, true]); + }); + + // A warm, ambient-correlated night is left out of the flag's input but + // still carries its raw value. Publishing its held-normal entry hid the + // open run from every alert surface. + test('an ambient-excluded last night does not hide an elevated run', () { + final days = feverDays(feverSd: 3); + days.last['skin_temp_illness_ok'] = false; + final b = buildCrossDayBundle(days, const {}); + final tmp = b['temp_illness'] as Map; + expect(tmp['date'], days[days.length - 2]['date']); + expect(tmp['flag'], 'elevated'); + }); + + test('nights in another family\'s unit are not mixed into the baseline', + () { + // A gen4 history in ADC counts, then a swap to gen5 reading centi-°C + // around 3400 for the last five nights. No fever anywhere: mixed into + // one series the swap itself reads as a sustained rise. + final days = feverDays( + feverSd: 0, + family: (i) => i < 38 ? 'gen4' : 'gen5', + shift: (i, v) => i < 38 ? v : 3400 + noise[i % noise.length] / 4, + ); + final f = flags(days); + expect(f.contains(true), isFalse); + final b = buildCrossDayBundle(days, const {}); + expect((b['temp_illness'] as Map)['date'], days.last['date']); + }); + + test('an unstamped history stays in the baseline once nights are stamped', + () { + // Rows from before the family stamp existed, then five stamped nights + // that include the rise. Too few stamped nights to baseline on their own. + final f = flags(feverDays( + feverSd: 3, + family: (i) => i < 38 ? null : 'gen4', + )); + expect(f.sublist(f.length - 3), [false, true, true]); + }); + + test('an unstamped newest night keeps the stamped history', () { + // A strap-swap day carries no single family; it is still a band night. + final f = flags(feverDays( + feverSd: 3, + family: (i) => i == 42 ? null : 'gen4', + )); + expect(f.sublist(f.length - 3), [false, true, true]); + }); + }); + + // The multivariate anomaly standardises every column itself, so its temp + // feature is the raw nightly mean. It was handed `skin_temp_z`, which is + // scored against a baseline that already holds the earlier fever nights, and + // it skipped the ambient exclusion the temperature flag applies. + group('the anomaly reads the raw, ambient-gated nightly temp', () { + const noise = [-9.0, 4.0, 11.0, -3.0, -12.0, 7.0, 1.0, -6.0, 13.0, -5.0, + 2.0, -10.0, 8.0, -1.0, 5.0, -7.0, 10.0, -4.0, 0.0, 6.0]; + + // 40 ordinary nights, then [tail] nights whose raw temp is [tailTemp]. + // `skin_temp_z` is whatever [z] returns, so a test can make it disagree + // with the raw mean. + List> nights({ + int tail = 3, + double tailTemp = 3040, + double? Function(int i)? z, + bool Function(int i)? ambient, + }) { + var dt = DateTime(2024, 1, 1); + final out = >[]; + for (var i = 0; i < 40 + tail; i++) { + final w = noise[i % noise.length]; + out.add({ + 'date': '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}', + 'rhr': 55.0 + w / 4, + 'rmssd': 45.0 + w / 2, + 'resp_rate': 14.0 + w / 20, + 'skin_temp_z': z == null ? null : z(i), + 'skin_temp_adc': i < 40 ? 3000 + w : tailTemp, + 'skin_temp_family': 'gen4', + if (ambient != null && ambient(i)) 'skin_temp_illness_ok': false, + }); + dt = dt.add(const Duration(days: 1)); + } + return out; + } + + double? tempDriver(List> days) { + final a = buildCrossDayBundle(days, const {})['anomaly'] as Map?; + if (a == null) return null; + for (final d in a['drivers'] as List) { + if ((d as Map)['label'] == 'temp') { + return (d['contribution'] as num).toDouble(); + } + } + return null; + } + + test('a raw rise reaches the anomaly even when no z was stored', () { + final days = nights(); + expect((buildCrossDayBundle(days, const {})['anomaly'] as Map)['date'], + days.last['date']); + final t = tempDriver(days); + expect(t, isNotNull); + expect(t!, greaterThan(2)); + }); + + test('the temp term follows the raw rise, not a z that disagrees', () { + // A z claiming every night is ordinary while the raw mean has risen. + final days = nights(z: (i) => 0.0); + expect(tempDriver(days)!, greaterThan(2)); + }); + + test('a night that tracked the ambient channel carries no temp term', () { + final days = nights( + tail: 1, + z: (i) => i < 40 ? noise[i % noise.length] / 8 : 4.0, + ambient: (i) => i == 40, + ); + final a = buildCrossDayBundle(days, const {})['anomaly'] as Map; + expect(a['date'], days.last['date']); + expect(tempDriver(days), isNull); + }); + }); + + group('the breakdown ranks tonight against its own method family', () { + const methods = {'gen4': 'hr_1hz', 'gen5': 'hr_1hz', 'oura': 'hr_5min'}; + + // 30 band nights, then 20 ring nights whose vendor HRV runs ~20 ms lower + // and whose resting HR runs ~8 bpm higher. Both series are steady within + // their own device; only the switch is a step. + List> switched(String later) { + final days = _synthDays(50); + for (var i = 0; i < days.length; i++) { + final wobble = (i % 5) - 2; + final ring = i >= 30; + days[i]['skin_temp_family'] = ring ? later : 'gen4'; + if (ring && later == 'oura') { + days[i]['rmssd'] = 25.0 + wobble * 1.5; + days[i]['rhr'] = 63.0 + wobble; + } + } + // Tonight is the ring's median night, not its worst. + days.last['rmssd'] = 25.0; + days.last['rhr'] = 63.0; + return days; + } + + Map pct(Map b, String k) => + ((b['percentiles'] as Map)[k] as Map)['value'] as Map; + + test('a new ring is not ranked against the band it replaced', () { + final b = buildCrossDayBundle(switched('oura'), const {}, + methodFamilies: methods); + // Only the 19 prior ring nights are history. + expect(pct(b, 'rmssd')['n'], 19); + expect(pct(b, 'rhr')['n'], 19); + // Tonight is the ring's median, so it ranks mid-history. Mixed with the + // 30 band nights it ranked near the bottom (~20th percentile for HRV, + // ~80th for resting HR). + expect(pct(b, 'rmssd')['percentile_of_you'], greaterThan(40)); + expect(pct(b, 'rhr')['percentile_of_you'], lessThan(60)); + final items = (((b['readiness_glassbox'] as Map)['value'] + as Map)['breakdown'] as List) + .cast(); + final hrv = items.firstWhere((m) => m['label'] == 'hrv'); + expect(hrv['percentile_of_you'], greaterThan(40)); + }); + + test('WHOOP 4 to WHOOP 5 keeps one history (same method family)', () { + final b = buildCrossDayBundle(switched('gen5'), const {}, + methodFamilies: methods); + expect(pct(b, 'rmssd')['n'], 49); + expect(pct(b, 'rhr')['n'], 49); + }); + + test('an unstamped night counts as a band night', () { + final days = switched('gen5'); + for (var i = 0; i < 20; i++) { + days[i].remove('skin_temp_family'); + } + final b = + buildCrossDayBundle(days, const {}, methodFamilies: methods); + expect(pct(b, 'rmssd')['n'], 49); + }); + }); } diff --git a/test/derivation_scope_test.dart b/test/derivation_scope_test.dart index 780f2d3f3..956d7cd3d 100644 --- a/test/derivation_scope_test.dart +++ b/test/derivation_scope_test.dart @@ -42,4 +42,116 @@ void main() { }, ); }); + + group('evaluateTzTravelGuard', () { + const t0 = 1760000000000; + const hour = 60 * 60 * 1000; + + test('first call seeds the baseline and does not hold', () { + final step = evaluateTzTravelGuard( + stored: null, + nowOffsetMin: -300, + nowMs: t0, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -300}); + }); + + test('a DST-sized shift moves the baseline without holding', () { + final step = evaluateTzTravelGuard( + stored: {'offset_min': -300}, + nowOffsetMin: -240, + nowMs: t0, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -240}); + }); + + test('a 3h+ jump holds, keeps the old baseline and starts the clock', () { + final step = evaluateTzTravelGuard( + stored: {'offset_min': -300}, + nowOffsetMin: 0, + nowMs: t0, + ); + expect(step.held, isTrue); + expect(step.settled, isFalse); + expect(step.write, { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }); + }); + + test('the hold settles once the new zone has stood for a day', () { + final stored = { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }; + final early = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: 0, + nowMs: t0 + 23 * hour, + ); + expect(early.held, isTrue); + expect(early.settled, isFalse); + expect(early.write, isNull); + + final later = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: 0, + nowMs: t0 + kTzTravelSettleMs, + ); + expect(later.held, isTrue); + expect(later.settled, isTrue); + expect(later.write, isNull); + }); + + test('a second hop restarts the wait', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: 540, + nowMs: t0 + 30 * hour, + ); + expect(step.held, isTrue); + expect(step.settled, isFalse); + expect(step.write, { + 'offset_min': -300, + 'away_offset_min': 540, + 'away_since_ms': t0 + 30 * hour, + }); + }); + + test('flying home clears the away state', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: -300, + nowMs: t0 + 5 * hour, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -300}); + }); + + test('a clock that went backwards restarts the wait', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: 0, + nowMs: t0 - hour, + ); + expect(step.settled, isFalse); + expect(step.write?['away_since_ms'], t0 - hour); + }); + }); } diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart index 511592fde..502dba79c 100644 --- a/test/derive_result_protection_test.dart +++ b/test/derive_result_protection_test.dart @@ -511,6 +511,18 @@ void main() { ); }); + test('a user-set window with too little recorded is not a regression, ' + 'so the correction replaces the auto night it was correcting', () { + expect( + DerivationEngine.nightSubstrateRegressed( + userSetWindow: true, + sleepSubEmpty: true, + nightScalarsNull: true, + ), + isFalse, + ); + }); + test('real night scalars computed is not a regression', () { expect( DerivationEngine.nightSubstrateRegressed( diff --git a/test/derive_scheduler_refresh_race_test.dart b/test/derive_scheduler_refresh_race_test.dart new file mode 100644 index 000000000..213bf5e87 --- /dev/null +++ b/test/derive_scheduler_refresh_race_test.dart @@ -0,0 +1,66 @@ +// A refresh of the scheduler's pending snapshot that is already in flight when +// an enqueue asks for its own must not swallow that request: the enqueue arms +// its settle timer off the snapshot it awaited, and a refresh that returned +// early left it reading "nothing pending" with the job sitting queued. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/derive_scheduler.dart'; +import 'package:openstrap_edge/data/db.dart'; + +import 'support/app_state_derive_harness.dart'; + +const _db = 'openstrap_derive_scheduler_refresh_race.db'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() => deriveDbSetUp(_db)); + tearDownAll(() => deriveDbTearDown(_db)); + setUp(deriveDbReset); + + Future<({int runs, DeriveScheduler s})> scenario({required bool flip}) async { + final db = await LocalDb.instance; + var runs = 0; + final s = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => runs++, + log: (_) {}, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 50), + ); + addTearDown(s.dispose); + // Hold the database so the enqueue transaction, and anything issued after + // it, queue up behind this one in a known order. + final gate = Completer(); + final holder = db.transaction((txn) => gate.future); + await Future.delayed(const Duration(milliseconds: 20)); + s.markStoredData(); + await Future.delayed(const Duration(milliseconds: 20)); + if (flip) { + // An offload settling while the enqueue is still in its transaction: + // with nothing pending yet, _arm starts a refresh that arms nothing. + s.setOffloadActive(true); + s.setOffloadActive(false); + } + gate.complete(); + await holder; + await until(() => runs > 0, + within: const Duration(seconds: 3), what: 'the queued job drained'); + return (runs: runs, s: s); + } + + test('a queued job drains with no concurrent refresh', () async { + final r = await scenario(flip: false); + expect(r.runs, 1); + }); + + test('a queued job still drains when an idle refresh was in flight', + () async { + final r = await scenario(flip: true); + expect(r.runs, 1); + await until(() => !r.s.pendingLight, what: 'snapshot caught up'); + expect(await LocalDb.computeJobs(state: 'queued'), isEmpty); + }); +} diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 82c4b8122..2509c77ca 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -76992,6 +76992,8 @@ "readiness": 27.033995461954163, "resp_rate": 13.51304972588547, "skin_temp_z": 3.808966711097082, + "skin_temp_adc": 30405.99974110511, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.03409387728019, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581920140166515, + "skin_temp_adc": 30405.999593165176, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.11544177079463, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083475796676784, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, @@ -77634,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 071bd2e28..4f489c854 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -76992,6 +76992,8 @@ "readiness": 27.03382323567632, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8090587513754603, + "skin_temp_adc": 30406.0, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.033601802905416, "resp_rate": 13.51304972588547, "skin_temp_z": 2.5820748428079483, + "skin_temp_adc": 30406.000332864856, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.1155535435143, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083348400888466, + "skin_temp_adc": 30406.00018492492, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, @@ -77634,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 985e1bd8f..080f8e0ad 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -76992,6 +76992,8 @@ "readiness": 27.03355259577805, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8092033860986256, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.033651010089166, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581998719140345, + "skin_temp_adc": 30406.00025889489, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.11559080104756, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083305937349391, + "skin_temp_adc": 30406.000110954952, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, @@ -77634,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index 6c4a2008d..c1752aa1d 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.808966711097082, + "skin_temp_adc": 30405.99974110511, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581920140166515, + "skin_temp_adc": 30405.999593165176, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083475796676784, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index 3109bf0c1..05bb93f35 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8090587513754603, + "skin_temp_adc": 30406.0, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.5820748428079483, + "skin_temp_adc": 30406.000332864856, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083348400888466, + "skin_temp_adc": 30406.00018492492, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index 781e9ee03..f09f6882b 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8092033860986256, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581998719140345, + "skin_temp_adc": 30406.00025889489, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083305937349391, + "skin_temp_adc": 30406.000110954952, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, diff --git a/test/implausible_age_absence_note_test.dart b/test/implausible_age_absence_note_test.dart new file mode 100644 index 000000000..b765cdd2c --- /dev/null +++ b/test/implausible_age_absence_note_test.dart @@ -0,0 +1,121 @@ +// A stored age the profile forms would now refuse (a birth year typed into the +// age box, or 0) gives no HRmax estimate, so strain, calories and the ceiling +// are absent. The reason on those figures has to name the age: it used to fall +// through to `unknown_device_family`, which the screens render as "these +// recordings are not stamped with which strap made them" on a correctly +// stamped WHOOP day, and nothing pointed at the profile. + +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'; + +const int _t0 = 1786699980; + +/// 300 waking minutes at 62 bpm then 60 at 130, one sample per second. +Substrate _sub() { + final ts = [], hr = []; + final ax = [], ay = [], az = []; + var i = 0; + for (final (mins, bpm) in const [(300, 62), (60, 130)]) { + 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', + ); +} + +Map _engineNotes(int age) { + final sub = _sub(); + final scalars = {}; + final bundle = {}; + DerivationEngine.applyDayActivity( + bundle: bundle, + scalars: scalars, + daySub: sub, + profile: + Profile(ageYears: age, sex: 'm', weightKg: 75, heightCm: 178), + sleepOnsetSec: 0, + sleepOffsetSec: 0, + dayStartSec: sub.tsSec.first, + dayCalendarEndSec: sub.tsSec.last + 1, + dataNowSec: sub.tsSec.last + 1, + restingHr: 55, + quietHrrHistory: List.filled(7, 0.20), + ); + expect(scalars['strain'], isNull); + return (bundle['absent_notes'] as Map).cast(); +} + +Map _pipelineNotes(int age) { + final sub = _sub(); + final bundle = 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': 55, + }, + deviceFamily: 'gen4', + quietHrrHistory: List.filled(7, 0.20), + ).toJson()); + return (bundle['absent_notes'] as Map).cast(); +} + +void main() { + for (final age in const [1990, 0]) { + group('stored age $age on a stamped gen4 day', () { + test('the engine half names the age, not the strap', () { + final notes = _engineNotes(age); + expect(notes['strain'], 'need_input:name=age'); + expect(notes['calories'], 'need_input:name=age'); + for (final n in notes.values) { + expect(n as String, isNot(startsWith('unknown_device_family'))); + } + }); + + test('the pure pipeline names the age, not the strap', () { + final notes = _pipelineNotes(age); + expect(notes['strain'], 'need_input:name=age'); + expect(notes['max_hr_used'], 'need_input:name=age'); + expect(notes['calories'], 'need_input:name=age'); + for (final n in notes.values) { + expect(n as String, isNot(startsWith('unknown_device_family'))); + } + }); + }); + } +} diff --git a/test/luteal_flags_dst_test.dart b/test/luteal_flags_dst_test.dart new file mode 100644 index 000000000..410f3e858 --- /dev/null +++ b/test/luteal_flags_dst_test.dart @@ -0,0 +1,82 @@ +// The cycle-phase flag counts calendar days between logged cycle starts. Parsed +// as local midnight, a span that holds the 23 h spring-forward day is an hour +// short of whole days and `inDays` floors it one day low, so a cycle measured +// across March came out a day short and the night at its midpoint flipped. The +// process zone is moved to America/New_York with setenv+tzset (see +// day_window_dst_test.dart) so this fails without the fix on any host. + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/crossday_pipeline.dart'; + +typedef _SetenvNative = Int32 Function(Pointer, Pointer, Int32); +typedef _SetenvDart = int Function(Pointer, Pointer, int); +typedef _UnsetenvNative = Int32 Function(Pointer); +typedef _UnsetenvDart = int Function(Pointer); +typedef _TzsetNative = Void Function(); +typedef _TzsetDart = void Function(); + +void _setProcessTz(String? tz) { + final lib = DynamicLibrary.process(); + final key = 'TZ'.toNativeUtf8(); + try { + if (tz == null) { + lib.lookupFunction<_UnsetenvNative, _UnsetenvDart>('unsetenv')(key); + } else { + final value = tz.toNativeUtf8(); + lib.lookupFunction<_SetenvNative, _SetenvDart>('setenv')(key, value, 1); + calloc.free(value); + } + lib.lookupFunction<_TzsetNative, _TzsetDart>('tzset')(); + } finally { + calloc.free(key); + } +} + +/// 30 nights ending 2024-01-30, each with a skin-temp reading so the +/// temperature flag has a newest night to publish. +List> _days() { + final out = >[]; + for (var i = 0; i < 30; i++) { + final d = DateTime.utc(2024, 1, 1).add(Duration(days: i)); + final wobble = (i % 5) - 2; + out.add({ + 'date': d.toIso8601String().substring(0, 10), + 'skin_temp_z': wobble * 0.4, + 'skin_temp_adc': 3000.0 + wobble * 8.0, + }); + } + return out; +} + +bool _lutealOn(List starts) { + final out = buildCrossDayBundle(_days(), const {}, cycleStartDates: starts); + return ((out['temp_illness'] as Map)['luteal'] as bool?) ?? false; +} + +void main() { + final originalTz = Platform.environment['TZ']; + setUpAll(() => _setProcessTz('America/New_York')); + tearDownAll(() => _setProcessTz(originalTz)); + + test('a cycle spanning spring-forward keeps its full length', () { + expect( + DateTime(2023, 3, 12, 12).difference(DateTime(2023, 3, 12)), + const Duration(hours: 11), + reason: 'setenv(TZ)+tzset() did not apply; the test would be vacuous', + ); + // 2023-02-26 → 2023-03-25 is 27 days and holds 2023-03-12, the US + // spring-forward day. That single completed gap is her median length, so + // the open cycle from 2024-01-17 is 27 days long and the newest night, + // 2024-01-30, is day 13 of it: 13 < 13.5, first half. + expect( + _lutealOn(const ['2023-02-26', '2023-03-25', '2024-01-17']), + isFalse, + ); + // Same shape a day later: day 14 of 27 is the second half. + expect(_lutealOn(const ['2023-02-26', '2023-03-25', '2024-01-16']), isTrue); + }); +} diff --git a/test/manual_workout_repo_test.dart b/test/manual_workout_repo_test.dart index 234640b3b..a253f71b4 100644 --- a/test/manual_workout_repo_test.dart +++ b/test/manual_workout_repo_test.dart @@ -657,4 +657,51 @@ void main() { expect(rows, hasLength(1)); }, ); + + test( + 'retiming a scored session whose heart rate is gone refuses rather than ' + 'blanking its strain, calories, HR and trace', + () async { + // A WHOOP-import row: its strain and calories came from WHOOP, and no + // band substrate exists over it (the same state a band session is in + // once its day is past raw retention). + final start = sessionStart - 60 * 86400; + final banked = { + 'id': 'w-banked', + 'start_ts': start, + 'end_ts': start + 3600, + 'type': 'run', + 'status': 'done', + 'duration_min': 60, + 'source': 'whoop', + 'strain': 14.2, + 'calories': 620.0, + 'max_hr': 181, + 'avg_hr': 152, + 'zone_min_json': '[5,10,20,15,10]', + 'trace_json': '[[0,150],[60,152]]', + 'trace_samples': 2, + 'created_at': start * 1000, + }; + await LocalDb.putSession(banked); + addTearDown(() => LocalDb.deleteSession('w-banked')); + + await expectLater( + repo.setWorkoutWindow('w-banked', + startTs: start, endTs: start + 3600 + 300), + throwsA(isA().having((e) => e.error, 'error', + ManualWindowError.scoreNotRecomputable)), + ); + + final row = await LocalDb.session('w-banked'); + expect(row!['end_ts'], start + 3600); + expect(row['strain'], 14.2); + expect(row['calories'], 620.0); + expect(row['max_hr'], 181); + expect(row['avg_hr'], 152); + expect(row['zone_min_json'], '[5,10,20,15,10]'); + expect(row['trace_json'], isNotNull); + expect(row['source'], 'whoop'); + }, + ); } diff --git a/test/nap_attribution_test.dart b/test/nap_attribution_test.dart index 08430c200..9e6a0ba9c 100644 --- a/test/nap_attribution_test.dart +++ b/test/nap_attribution_test.dart @@ -475,4 +475,57 @@ void main() { expect(attach(onset: bed, offset: up), isEmpty); }); }); + + group("a night marked 'Not sleep' is not re-proposed as a nap", () { + const midnight = 1750000800; + // The #566 shape: a still evening block 17:51-20:00 the detector booked as + // the night, which the user then rejected. A rejected night leaves no main + // window (onset/offset 0), so only the rejected span can stop it. + const from = 17 * 3600 + 51 * 60; + const to = 20 * 3600; + final s = _daySubstrate( + startSec: midnight, + lengthSec: 23 * 3600, + napFromSec: from, + napToSec: to, + ); + + List> attach(List> rejected) { + final candidates = >[]; + DerivationEngine.debugAttachNaps( + {}, + {}, + s, + 0, + 0, + candidates: candidates, + attributionStartSec: midnight, + attributionEndSec: midnight + 86400, + rejectedSleep: rejected, + ); + return candidates; + } + + test('without the rejected span the bout comes back as a nap', () { + expect(attach(const []), hasLength(1)); + }); + + test('with it, no nap is suggested over the rejected stretch', () { + expect( + attach(const [ + [midnight + from, midnight + to], + ]), + isEmpty, + ); + }); + + test('a rejected span elsewhere leaves a real nap alone', () { + expect( + attach(const [ + [midnight + 2 * 3600, midnight + 6 * 3600], + ]), + hasLength(1), + ); + }); + }); } diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart index a4a913e9d..2b745b4bf 100644 --- a/test/nap_edits_test.dart +++ b/test/nap_edits_test.dart @@ -224,6 +224,19 @@ void main() { }); }); + group('manualNapWindowHasEnded', () { + test('accepts a nap that is over', () { + expect(manualNapWindowHasEnded(14 * hour, 15 * hour), isTrue); + expect(manualNapWindowHasEnded(15 * hour, 15 * hour), isTrue); + }); + + test('rejects a window that ends after now', () { + // The default 14:00-15:00 pickers accepted at 09:30. + expect(manualNapWindowHasEnded(15 * hour, 9 * hour + 1800), isFalse); + expect(manualNapWindowHasEnded(15 * hour + 1, 15 * hour), isFalse); + }); + }); + group('napOverlapsExisting', () { final existing = [nap(13 * hour, 14 * hour)]; diff --git a/test/nap_overlap_cleanup_test.dart b/test/nap_overlap_cleanup_test.dart new file mode 100644 index 000000000..8a9c2aa21 --- /dev/null +++ b/test/nap_overlap_cleanup_test.dart @@ -0,0 +1,202 @@ +// The raw-free next-night nap cleanup reaches old bundles whatever version +// stamp they carry now: the readiness backfill restamps a pre-v106 day at the +// current version without touching its naps. + +import 'dart:convert'; +import 'dart:io'; + +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' show kAlgoVersion; +import 'package:openstrap_edge/compute/nap_overlap_cleanup.dart'; +import 'package:openstrap_edge/data/db.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_nap_overlap_cleanup_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 pre-v106 day restamped by the readiness backfill is still cleaned', + () async { + // 2026-08-02 22:30Z -> 2026-08-03 06:30Z: the next day's main sleep. + const onset = 1785709800; + const offset = onset + 8 * 3600; + final afternoon = { + 'start': onset - 7 * 3600, + 'end': onset - 7 * 3600 + 1800, + 'duration_min': 30, + }; + final phantom = {'start': onset, 'end': onset + 2400, 'duration_min': 40}; + final payload = { + 'naps': { + 'value': [afternoon, phantom], + 'count': 2, + }, + 'scalars': {'nap_min': 70.0, 'readiness': 64.0}, + }; + // Same bundle as a v100 derive, but stamped the way the backfill leaves it. + await LocalDb.putDayResult( + dayId: '2026-08-02', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode(payload), + windowJson: '{}', + finalized: true, + readiness: 64.0, + ); + await LocalDb.putDayResult( + dayId: '2026-08-03', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': { + 'value': {'onset_ms': onset * 1000, 'offset_ms': offset * 1000}, + }, + }, + }), + windowJson: '{}', + finalized: true, + ); + + // An earlier day whose next night has no sleep ('—') is skipped, not a + // throw that ends the pass before the day above is reached. + await LocalDb.putDayResult( + dayId: '2026-07-30', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'naps': { + 'value': [afternoon], + 'count': 1, + }, + }), + windowJson: '{}', + finalized: true, + ); + await LocalDb.putDayResult( + dayId: '2026-07-31', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': {'value': '—'}, + }, + }), + windowJson: '{}', + finalized: true, + ); + + expect(await cleanNextNightNapsOnce(), 1); + final row = await LocalDb.dayResult('2026-08-02'); + expect((row!['algo_version'] as num).toInt(), kAlgoVersion); + final out = jsonDecode(row['payload_json'] as String) as Map; + final naps = (out['naps'] as Map)['value'] as List; + expect(naps.map((n) => (n as Map)['start']), [afternoon['start']]); + expect((out['naps'] as Map)['count'], 1); + expect((out['scalars'] as Map)['nap_min'], 30.0); + expect((out['scalars'] as Map)['readiness'], 64.0); + final series = await LocalDb.metricSeries('nap_min'); + expect(series.where((r) => r['date'] == '2026-08-02').single['value'], 30.0); + + // One-shot: a second call does nothing. + expect(await cleanNextNightNapsOnce(), 0); + }); + + test('a phantom already copied aside as a legacy edit is dropped too', + () async { + await LocalDb.deleteComputeFreshness(kNapNextNightCleanupKey); + // 2026-09-02 22:30Z -> 2026-09-03 06:30Z. + const onset = 1788388200; + const offset = onset + 8 * 3600; + final legacy = { + 'start': onset, + 'end': onset + 2400, + 'duration_min': 40, + 'source': 'legacy', + }; + final manual = { + 'start': onset - 3 * 3600, + 'end': onset - 3 * 3600 + 1200, + 'duration_min': 20, + 'source': 'manual', + }; + await LocalDb.putDayResult( + dayId: '2026-09-02', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'naps': { + 'value': [manual, legacy], + 'count': 2, + }, + 'scalars': {'nap_min': 60.0}, + }), + windowJson: '{}', + finalized: true, + ); + await LocalDb.putDayResult( + dayId: '2026-09-03', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': { + 'value': {'onset_ms': onset * 1000, 'offset_ms': offset * 1000}, + }, + }, + }), + windowJson: '{}', + finalized: true, + ); + final db = await LocalDb.instance; + for (final n in [legacy, manual]) { + await db.insert('sleep_nap', { + 'day_id': '2026-09-02', + 'start_ts': n['start'], + 'end_ts': n['end'], + 'source': n['source'], + 'created_at': 0, + 'payload_json': jsonEncode(n), + }); + } + + expect(await cleanNextNightNapsOnce(), 1); + final out = jsonDecode( + (await LocalDb.dayResult('2026-09-02'))!['payload_json'] as String) as Map; + expect(((out['naps'] as Map)['value'] as List).map((n) => (n as Map)['source']), + ['manual']); + final edits = await LocalDb.napEdits('2026-09-02'); + expect(edits.map((e) => e['source']), ['manual']); + }); + + test('importing nap edits alone re-arms the cleanup', () async { + final db = await LocalDb.instance; + await db.insert('sleep_nap', { + 'day_id': '2026-09-02', + 'start_ts': 1788388200, + 'end_ts': 1788388200 + 2400, + 'source': 'legacy', + 'created_at': 0, + 'payload_json': jsonEncode({'duration_min': 40, 'source': 'legacy'}), + }); + final dir = Directory.systemTemp.createTempSync('nap-cleanup-import-'); + addTearDown(() => dir.deleteSync(recursive: true)); + final path = p.join(dir.path, 'backup.db'); + await db.execute('VACUUM INTO ?', [path]); + await db.delete('sleep_nap', + where: 'day_id = ? AND source = ?', whereArgs: ['2026-09-02', 'legacy']); + await LocalDb.putComputeFreshness(kNapNextNightCleanupKey, '{}'); + + final counts = await LocalDb.importFromDbFile(path); + expect(counts['day_result'] ?? 0, 0); + expect(counts['sleep_nap'], greaterThan(0)); + expect(await LocalDb.computeFreshness(kNapNextNightCleanupKey), isNull); + }); +} diff --git a/test/profile_plausibility_test.dart b/test/profile_plausibility_test.dart new file mode 100644 index 000000000..ee7bbf07b --- /dev/null +++ b/test/profile_plausibility_test.dart @@ -0,0 +1,143 @@ +// A number that parses is not yet a body. +// +// Both profile forms used to accept any finite number: a birth year typed into +// the age box saved as age 1990, and Tanaka put that user's HR ceiling at +// -1185 bpm, so every zone of every day was banded off it. An imperial height +// typed as feet.inches ("5.10") saved as 13 cm. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/hr_max.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Widget _frame(Widget child) => MaterialApp( + theme: buildTheme(Brightness.light), + home: child, + ); + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(_frame(child)); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('estimatedMaxHr', () { + test('a stored birth year gets no ceiling, not a negative one', () { + expect(estimatedMaxHr(1990, 'gen4'), isNull); + expect(estimatedMaxHr(-5, 'gen4'), isNull); + expect(estimatedMaxHr(0, 'gen4'), isNull); + }); + + test('a real age is unchanged', () { + expect(estimatedMaxHr(30, 'gen4'), closeTo(187.0, 1e-9)); + expect(estimatedMaxHr(120, 'gen4'), closeTo(124.0, 1e-9)); + }); + }); + + test('plausibility bounds', () { + expect(plausibleAgeYears(1990), isFalse); + expect(plausibleAgeYears(0.4), isFalse); + expect(plausibleAgeYears(34), isTrue); + expect(plausibleHeightCm(12.95), isFalse); + expect(plausibleHeightCm(178), isTrue); + expect(plausibleWeightKg(0), isFalse); + expect(plausibleWeightKg(72.4), isTrue); + }); + + testWidgets('edit profile refuses a birth year typed as age', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + onSave: (f) async => saved = f, + )); + + await t.enterText(find.widgetWithText(TextField, '34'), '1990'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNull); + expect(find.textContaining('out of range'), findsOneWidget); + }); + + testWidgets('edit profile still saves a real profile', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved?['age'], 34); + expect(saved?['height_cm'], 178.0); + }); + + testWidgets('onboarding refuses feet.inches typed into an inches box', + (t) async { + Map? saved; + await _pump( + t, + ProfileSetupView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + // 178 cm pre-fills as 70 total inches. + await t.enterText(find.widgetWithText(TextField, '70'), '5.10'); + await t.tap(find.text('Continue')); + await t.pumpAndSettle(); + + expect(saved, isNull); + expect(find.textContaining('out of range'), findsOneWidget); + }); + // Imperial pre-fills whole pounds: 20 kg shows "44", which reads back as + // 19.96 kg, under the floor. An untouched field keeps the stored value. + testWidgets('an untouched imperial weight on the floor still saves', + (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 20.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved?['weight_kg'], 20.0); + }); + + testWidgets('onboarding keeps an untouched imperial weight', (t) async { + Map? saved; + await _pump( + t, + ProfileSetupView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 20.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Continue')); + await t.pumpAndSettle(); + + expect(saved?['weight_kg'], 20.0); + }); +} diff --git a/test/readiness_lnrmssd_wear_gap_test.dart b/test/readiness_lnrmssd_wear_gap_test.dart new file mode 100644 index 000000000..6ce86f8c6 --- /dev/null +++ b/test/readiness_lnrmssd_wear_gap_test.dart @@ -0,0 +1,96 @@ +// The Plews rolling-7 lnRMSSD baseline was handed only values, so it took the +// last 7 stored ROWS. After a wear gap those are nights from weeks ago, and +// the first night back got a confident suppressed/normal/elevated band scored +// against them. With the dates passed through, the window is 7 calendar +// nights and abstains until it refills. + +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 = 2 * 3600; + +String _label(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}' + '-${d.day.toString().padLeft(2, '0')}'; + +Map _lnReadiness({ + required List historyDates, + bool withDates = true, + bool tonightRr = true, +}) { + 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); + final b = deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: tonightRr ? rr : const [], + sleepRrTsMs: tonightRr ? ts : const [], + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const {'tst_sec': _nightSec}, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: (ts.last / 1000).ceil() + 1, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + lnRmssdHistory: [ + for (var i = 0; i < historyDates.length; i++) 3.4 + 0.05 * (i % 3) + ], + lnRmssdDates: withDates ? historyDates : const [], + ).toJson(), + ); + return ((b['clinical'] as Map)['readiness_lnrmssd'] as Map) + .cast(); +} + +List _nightsEnding(DateTime last, int n) => [ + for (var i = n - 1; i >= 0; i--) + _label(DateTime(last.year, last.month, last.day - i)) + ]; + +void main() { + test('nights from before a three-week gap are not a rolling-7 baseline', () { + // 21 nights ending Jul 24, nothing since; tonight is Aug 14. + final env = + _lnReadiness(historyDates: _nightsEnding(DateTime(2026, 7, 24), 21)); + expect(env['value'], isNot(isA())); + expect(env['note'] as String, startsWith('need_baseline:')); + }); + + test('without dates it still falls back to the rows window', () { + final env = _lnReadiness( + historyDates: _nightsEnding(DateTime(2026, 7, 24), 21), + withDates: false); + expect(env['value'], isA()); + }); + + test('an unbroken week before tonight still computes', () { + final env = + _lnReadiness(historyDates: _nightsEnding(DateTime(2026, 8, 13), 21)); + expect(env['value'], isA()); + expect((env['value'] as Map)['band'], isNotNull); + }); + + test('a night with no RMSSD does not score the previous night as tonight', + () { + final env = _lnReadiness( + historyDates: _nightsEnding(DateTime(2026, 8, 13), 21), + tonightRr: false); + expect(env['value'], isNot(isA())); + expect(env['note'] as String, contains('no sleep-session RMSSD')); + }); +} diff --git a/test/reanalyze_outcome_test.dart b/test/reanalyze_outcome_test.dart new file mode 100644 index 000000000..70f1abb78 --- /dev/null +++ b/test/reanalyze_outcome_test.dart @@ -0,0 +1,85 @@ +// "Re-analyze data" exists for screens that are blank despite stored data, +// which is when a failing derive is the likely cause. run() returns the days it +// derived, so a pass that threw and a pass with nothing to derive both return +// 0; the re-analyze note used to render either one as an ordinary +// "0 days re-analyzed." These pin that the failure is told apart. +// +// Order matters: the first two tests run before any database factory exists, +// so every query inside the pass throws. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +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'; +import 'package:openstrap_edge/state/app_state.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('a pass that throws reports its error, not just 0 days', () async { + final eng = DerivationEngine(log: (_) {}); + String? reported; + final n = await eng.run( + const Profile(), + heavy: true, + force: true, + onOutcome: (_, error) => reported = error, + ); + expect(n, 0); + expect(eng.lastRunError, isNotNull); + // The per-call outcome survives a later pass on the same engine resetting + // the shared fields. + eng.lastRunError = null; + expect(reported, isNotNull); + }); + + test('reanalyzeAll hands a failed pass back as an error', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final out = await app.reanalyzeAll(); + expect(out.days, 0); + expect(out.error, isNotNull); + expect(app.reanalyzing, isFalse); + }); + + test('a pass with nothing to derive is 0 days and no error', () async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_reanalyze_outcome_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + addTearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + final eng = DerivationEngine(log: (_) {}); + // Left over from a failed pass on the same engine: a clean run resets it. + eng.lastRunError = 'stale'; + eng.lastRunFailedDays = 3; + var called = false; + int? failed; + String? error = 'unset'; + final n = await eng.run( + const Profile(), + heavy: true, + force: true, + onOutcome: (f, e) { + called = true; + failed = f; + error = e; + }, + ); + expect(n, 0); + expect(eng.lastRunError, isNull); + expect(eng.lastRunFailedDays, 0); + expect(called, isTrue); + expect(failed, 0); + expect(error, isNull); + }); +} diff --git a/test/rejected_sleep_next_label_test.dart b/test/rejected_sleep_next_label_test.dart new file mode 100644 index 000000000..83bd93088 --- /dev/null +++ b/test/rejected_sleep_next_label_test.dart @@ -0,0 +1,41 @@ +// A rejected main sleep stored under tomorrow's label has to reach today's nap +// filter even when today's data stops hours before midnight. + +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/db.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rejected_sleep_next_label_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 reject stored under the next calendar day is read', () async { + final on = DateTime(2026, 3, 4, 22).millisecondsSinceEpoch ~/ 1000; + final off = DateTime(2026, 3, 5, 6).millisecondsSinceEpoch ~/ 1000; + await LocalDb.putSleepOverride( + dayId: '2026-03-05', + onsetTs: on, + offsetTs: off, + source: 'rejected', + ); + expect(await DerivationEngine.debugRejectedSleepSpans('2026-03-04'), [ + [on, off], + ]); + }); +} diff --git a/test/sleep_override_rederive_test.dart b/test/sleep_override_rederive_test.dart index 9e91c6d66..76f816dc0 100644 --- a/test/sleep_override_rederive_test.dart +++ b/test/sleep_override_rederive_test.dart @@ -15,6 +15,28 @@ import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/health/health_export.dart'; import 'package:openstrap_edge/state/app_state.dart'; +/// One decoded band second at noon on [day], so the day has substrate for a +/// forced re-derive to reach. +Future _seedDay(String day) async { + final d = DateTime.parse(day); + final ts = DateTime(d.year, d.month, d.day, 12).millisecondsSinceEpoch ~/ 1000; + await (await LocalDb.instance).insert( + 'decoded_onehz', + { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': ts % 100000, + 'hr': 60, + 'ax': 0.0, + 'ay': -0.5, + 'az': 0.98, + 'device_family': 'gen4', + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,6 +59,7 @@ void main() { test('an edit made while another derive pass runs waits for it', () async { final app = AppState.forTesting(); addTearDown(app.dispose); + await _seedDay(todayLabel()); DerivationEngine.debugRunning = true; // a drain pass / rescan in flight addTearDown(() => DerivationEngine.debugRunning = false); @@ -63,6 +86,7 @@ void main() { addTearDown(() => DerivationEngine.debugRunning = false); final day = todayLabel(); + await _seedDay(day); await LocalDb.setFrozenHeadline(day, 45); final edit = app.setSleepOverride( day, @@ -83,6 +107,7 @@ void main() { test('an edit doesn\'t wait for a health export already running', () async { final app = AppState.forTesting(); addTearDown(app.dispose); + await _seedDay('2026-09-20'); final exporting = Completer(); final running = HealthExporter.shared.debugRunLocked( () => exporting.future, @@ -142,4 +167,131 @@ void main() { await LocalDb.deleteSleepOverride('2026-09-30'); expect(await LocalDb.frozenHeadline(), isNull); }); + + test('a correction on a night whose recordings were pruned is refused, ' + 'not stored', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + // No decoded rows for this day: the force re-derive can't reach it, so a + // stored override would never show and "Re-analyze everything" couldn't + // apply it either. + const day = '2026-08-01'; + expect(await app.sleepCorrectable(day), isFalse); + await expectLater( + app.setSleepOverride( + day, DateTime(2026, 7, 31, 23), DateTime(2026, 8, 1, 7)), + throwsA(isA()), + ); + expect(await LocalDb.getSleepOverride(day), isNull); + expect(app.reanalyzing, isFalse); + + // An override stored back when the day still had raw stays put: deleting + // it would leave the derived night showing the user's window anyway. + await LocalDb.putSleepOverride( + dayId: day, + onsetTs: 1000, + offsetTs: 2000, + source: 'manual', + ); + await expectLater( + app.clearSleepOverride(day), + throwsA(isA()), + ); + expect(await LocalDb.getSleepOverride(day), isNotNull); + await LocalDb.deleteSleepOverride(day); + + await _seedDay(day); + expect(await app.sleepCorrectable(day), isTrue); + }); + + test('on the oldest kept night only a correction the prune leaves whole is ' + 'offered or stored', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + // The prune cuts on local midnight: the day keeps its own rows but the + // evening before is gone, and the stored night came from the whole window. + const day = '2026-08-10'; + final midnight = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + await _seedDay(day); + await LocalDb.setCursor('decoded_pruned_before', '$midnight'); + addTearDown(() async => (await LocalDb.instance).delete('sync_cursor', + where: 'name = ?', whereArgs: ['decoded_pruned_before'])); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: '{}', + windowJson: '{}', + rhr: 52, + ); + + // Back to automatic / confirming a bedtime from the evening before would + // be declined by the derive and keep the stored night. + expect(await app.sleepCorrectable(day), isFalse); + expect( + await app.sleepCorrectable(day, + sleepSource: 'confirmed', onsetSec: midnight - 3600), + isFalse); + // Not sleep and a bedtime after midnight still land. + expect(await app.sleepCorrectable(day, sleepSource: 'rejected'), isTrue); + expect( + await app.sleepCorrectable(day, + sleepSource: 'manual', onsetSec: midnight + 1800), + isTrue); + + await expectLater( + app.setSleepOverride( + day, DateTime(2026, 8, 9, 23), DateTime(2026, 8, 10, 7)), + throwsA(isA() + .having((e) => e.eveningPruned, 'eveningPruned', isTrue)), + ); + expect(await LocalDb.getSleepOverride(day), isNull); + + await LocalDb.putSleepOverride( + dayId: day, + onsetTs: midnight + 1800, + offsetTs: midnight + 7 * 3600, + source: 'manual', + ); + await expectLater( + app.clearSleepOverride(day), + throwsA(isA() + .having((e) => e.eveningPruned, 'eveningPruned', isTrue)), + ); + expect(await LocalDb.getSleepOverride(day), isNotNull, + reason: 'refused before the row was deleted'); + await LocalDb.deleteSleepOverride(day); + }); + + test('the prune guard spares a rejected night and keeps a user window on ' + 'its own onset', () { + const day = '2026-08-10'; + final midnight = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + expect(DerivationEngine.sleepWindowTruncatedByPrune(day, midnight), isTrue); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'rejected'), + isFalse); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'manual', sleepOnsetSec: midnight + 60), + isFalse); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'confirmed', sleepOnsetSec: midnight - 60), + isTrue); + expect(DerivationEngine.sleepWindowTruncatedByPrune(day, null), isFalse); + }); + + test('a user window that staged to nothing (onset 0) is judged on the ' + "day's lookback, not as cut by every prune", () { + const day = '2026-08-12'; + final cut = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + for (final source in ['manual', 'confirmed']) { + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, cut, + sleepSource: source, sleepOnsetSec: 0), + isFalse, + reason: source); + } + }); } diff --git a/test/sleep_override_unobserved_test.dart b/test/sleep_override_unobserved_test.dart new file mode 100644 index 000000000..4a150e7b0 --- /dev/null +++ b/test/sleep_override_unobserved_test.dart @@ -0,0 +1,107 @@ +// "I was asleep" is offered on nights with no detected sleep, and the usual +// reason there is none is that the band was on the charger. A user-set window +// over those hours is mostly unobserved, so the segmenter abstains with a +// reason. The day used to derive with sleep_source 'none' — as if no override +// existed — which threw the reason away and left the Sleep screen blaming a +// busy re-analysis. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; + +int _sec(DateTime t) => t.millisecondsSinceEpoch ~/ 1000; + +/// Band worn [from, to) at 1 Hz: still wrist, resting HR. +void _wear(int from, int to, List ts, List hr, List ax, + List ay, List az) { + for (var t = from; t < to; t++) { + ts.add(t); + hr.add(60); + final rad = (t % 3) * 0.5 * math.pi / 180.0; + ax.add(math.sin(rad)); + ay.add(0.0); + az.add(math.cos(rad)); + } +} + +Substrate _sub(List<(int, int)> worn) { + final ts = [], hr = []; + final ax = [], ay = [], az = []; + for (final (a, b) in worn) { + _wear(a, b, ts, hr, ax, ay, az); + } + 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), + ); +} + +void main() { + final eve = DateTime(2025, 6, 14); + final day = DateTime(2025, 6, 15); + final onset = _sec(DateTime(2025, 6, 14, 23)); + final wake = _sec(DateTime(2025, 6, 15, 7)); + const dayId = '2025-06-15'; + + PhysioDay derive(Substrate sub, String source) => calendarDays( + sub, + override: SleepWindowOverride( + dayId: dayId, + onsetSec: onset, + offsetSec: wake, + source: source, + ), + ).firstWhere((d) => d.date == dayId); + + test('a mostly-unrecorded manual window keeps its source and its reason', + () { + // Worn 18:00–22:00, on the charger 22:00–06:00, worn 06:00–12:00. + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(eve.year, eve.month, eve.day, 22))), + (_sec(DateTime(day.year, day.month, day.day, 6)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'manual'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'manual'); + expect(d.sleep.absenceReason, isNotNull); + expect(d.flags, contains('NO_SLEEP_DETECTED')); + }); + + test('a manual window with no samples at all says it was unrecorded', () { + // Nothing between 20:00 and 08:00 — the window holds zero seconds. + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(eve.year, eve.month, eve.day, 20))), + (_sec(DateTime(day.year, day.month, day.day, 8)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'manual'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'manual'); + expect(d.sleep.absenceReason, isNotNull); + }); + + test('a rejected night is unchanged', () { + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'rejected'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'rejected'); + expect(d.flags, contains('SLEEP_REJECTED')); + }); +} diff --git a/test/strain_rescale_backfill_test.dart b/test/strain_rescale_backfill_test.dart index ef7c8fd55..751844356 100644 --- a/test/strain_rescale_backfill_test.dart +++ b/test/strain_rescale_backfill_test.dart @@ -14,8 +14,10 @@ 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' show kAlgoVersion; +import 'package:openstrap_edge/compute/derivation_engine.dart' + show kAlgoVersion, rawRetentionDays; import 'package:openstrap_edge/compute/strain_backfill.dart'; +import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/db.dart'; /// Seed a derived day the way the engine would have before the rescale. @@ -264,4 +266,20 @@ void main() { expect(await seriesValue('strain', '2026-06-02'), closeTo(12.79, 1e-9)); }); }); + + group('strainRescaleCutoff — calendar days back from the data edge', () { + test('counts calendar days across a spring-forward week', () { + // 2027-03-14 is the US spring-forward day (23 h). From local midnight on + // 2027-03-16 an absolute walk of rawRetentionDays * 24 h lands at 23:00 + // the day before the calendar answer, which the pruner has already + // cleared. Fails before the fix under TZ=America/New_York. + const edge = '2027-03-16'; + final expected = dayLabelOf(DateTime(2027, 3, 16 - rawRetentionDays)); + expect(strainRescaleCutoff(edge), expected); + }); + + test('a malformed edge label skips everything rather than throwing', () { + expect(strainRescaleCutoff('bad'), 'bad'); + }); + }); } diff --git a/test/study_scoring_rules_test.dart b/test/study_scoring_rules_test.dart index d11af913d..6625644e4 100644 --- a/test/study_scoring_rules_test.dart +++ b/test/study_scoring_rules_test.dart @@ -20,6 +20,56 @@ void main() { expect(DerivationEngine.strainFlooredAtSessions(null, s), isNull); }); + test('strain curve is floored at a session from its end, like the headline', + () { + // #471: a workout scored 0.6 while the day's HR build-up stayed at 0 — + // the headline read 0.6 above a line pinned at 0. + final curve = [ + for (var t = 1000; t <= 1300; t += 60) {'t': t, 'v': 0.0}, + ]; + final s = [ + {'status': 'done', 'strain': 0.6, 'end_ts': 1120}, + {'status': 'live', 'strain': 9.0, 'end_ts': 1000}, + {'status': 'done', 'strain': 9.0, 'end_ts': 1000, 'private': 1}, + ]; + final out = DerivationEngine.strainCurveFlooredAtSessions(curve, s); + expect([for (final p in out) p['v']], [0.0, 0.0, 0.6, 0.6, 0.6, 0.6]); + expect(out.last['v'], + DerivationEngine.strainFlooredAtSessions(0.0, s)); + // The stored curve is not mutated in place. + expect(curve.every((p) => p['v'] == 0.0), isTrue); + + // HR build-up already above the session keeps its own values. + final high = [ + {'t': 1000, 'v': 0.2}, + {'t': 1200, 'v': 4.0}, + ]; + expect( + [ + for (final p in DerivationEngine.strainCurveFlooredAtSessions( + high, [{'status': 'done', 'strain': 3.0, 'end_ts': 1100}])) + p['v'] + ], + [0.2, 4.0]); + + // A session ending after the last point adds a point at its end, capped. + final late = DerivationEngine.strainCurveFlooredAtSessions( + [{'t': 1000, 'v': 1.0}], + [{'status': 'done', 'strain': 5.0, 'end_ts': 9000}], + capSec: 2000, + ); + expect(late, [ + {'t': 1000, 'v': 1.0}, + {'t': 2000, 'v': 5.0}, + ]); + + // No HR build-up to draw: no curve is invented from a session. + expect( + DerivationEngine.strainCurveFlooredAtSessions( + const [], [{'status': 'done', 'strain': 5.0, 'end_ts': 900}]), + isEmpty); + }); + test('resting HR fallback is the median of three or more prior nights', () { expect(rhrFallbackFromHistory([55, 60]), isNull); expect(rhrFallbackFromHistory([55, 70, 60]), 60); @@ -50,6 +100,7 @@ void main() { {'start': 100, 'end': 200, 'duration_min': 2}, {'start': 900, 'end': 1200, 'duration_min': 5}, {'start': 950, 'end': 1000, 'duration_min': 1, 'source': 'manual'}, + {'start': 990, 'end': 1100, 'duration_min': 2, 'source': 'legacy'}, ]; final kept = napsWithoutNextNight(naps, 1000, 5000)!; expect(kept.map((n) => n['start']), [100, 950]); diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart index 8cce2a93e..2a4a37639 100644 --- a/test/substrate_accel_absence_test.dart +++ b/test/substrate_accel_absence_test.dart @@ -290,6 +290,52 @@ void main() { }); }); + // A night routed to the HR-led fallback because it has no gravity cannot be + // staged by it either: the forced-window stager only counts a second as + // observed when its gravity vector is valid. The fallback then returns an + // unobserved absence with a reason, which used to be dropped, so the day read + // as an ordinary "no sleep detected" while the band was worn all night. + group('a gravity-less night keeps the fallback\'s reason', () { + test('the day carries absence_reason, still with no window and no source', + () { + // 08:00 the day before -> 08:00, 1 Hz. Awake 70 bpm, asleep 50 bpm from + // 23:00 to 07:00. The morning before the search window gives the HR-led + // detector a daytime baseline, so it proposes the 23:00-07:00 dip. + final midnight = _localMidnightOf(1750000000); + final start = midnight - 16 * 3600; + const n = 24 * 3600; + final ts = [], hr = []; + for (var i = 0; i < n; i++) { + final t = start + i; + ts.add(t); + hr.add(t >= midnight - 3600 && t < midnight + 7 * 3600 ? 50 : 70); + } + final sub = Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: List.filled(n, 0), + ay: List.filled(n, 0), + az: List.filled(n, 0), + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); + final wake = midnight + 7 * 3600; + final day = calendarDays(sub) + .singleWhere((d) => d.startSec <= wake && wake < d.endSec); + expect(day.sleep.present, isFalse); + expect(day.sleepSource, 'none'); + expect(day.flags, contains('NO_SLEEP_DETECTED')); + expect(day.sleep.absenceReason, isNotNull, + reason: 'the band missed the night; that is not "no sleep found"'); + expect(day.sleep.toJson()['absence_reason'], isNotNull, + reason: 'the reason has to survive into the stored day'); + }); + }); + // CV-09 — daytime HRV had no motion gate at all: RR was filtered to // 300-2000 ms and successive deltas to 200 ms and nothing else, so a bin of // walking entered the average as low HRV and the number read as stress when diff --git a/test/today_no_sleep_overnight_test.dart b/test/today_no_sleep_overnight_test.dart index 101dad848..cd8101baa 100644 --- a/test/today_no_sleep_overnight_test.dart +++ b/test/today_no_sleep_overnight_test.dart @@ -85,6 +85,45 @@ void main() { reason: "$twoAgo's 77 is not $yesterday's readiness"); }); + test("a night marked 'Not sleep' is today's overnight, not a held-over one", + () async { + final today = todayLabel(); + final now = DateTime.now(); + final yesterday = + todayLabel(DateTime(now.year, now.month, now.day - 1, 12)); + await LocalDb.putDayResult( + dayId: yesterday, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'scalars': {'readiness': 77}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 25200}, + }, + }, + }), + windowJson: '{}', + ); + // No window and no data edge: before the fix this was held as unsettled + // until noon, then skipped for lacking NO_SLEEP_DETECTED. + await LocalDb.putDayResult( + dayId: today, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'scalars': {'steps': 300}, + 'flags': ['SLEEP_REJECTED'], + }), + windowJson: '{}', + ); + await LocalDb.refreshComputeFreshness(); + + final t = await LocalRepositoryImpl(getProfileMap: () => {}).getToday(); + final status = t['status'] as Map; + expect(status['overnight_day'], today); + expect(status['overnight_state'], 'ready'); + expect(status['showing_prior_overnight'], false); + }); + test('an import restamps the night getToday reads', () async { final today = todayLabel(); final now = DateTime.now(); diff --git a/test/unsettled_skin_temp_baseline_test.dart b/test/unsettled_skin_temp_baseline_test.dart new file mode 100644 index 000000000..5ee07d161 --- /dev/null +++ b/test/unsettled_skin_temp_baseline_test.dart @@ -0,0 +1,72 @@ +// A night the strap spent partly warming up reads low. Readiness refuses it as +// tonight's temp input; it must not be banked as `skin_temp_adc` either, or it +// sits in every later night's baseline and drags the median and SD with it. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +void main() { + Map scalars(List temps, List history) { + const t0 = 1780000000; + const n = 6 * 3600; + final b = deriveDayBundle( + DayBundleInput( + date: '2026-06-01', + dayTsSec: [for (var i = 0; i < n; i++) t0 + i], + dayHr: List.filled(n, 60), + sleepTsSec: const [], + sleepHr: const [], + sleepRrTsMs: const [], + sleepRrMs: const [], + sleepSkinTemp: temps, + sleepJson: const {}, + hypnoStages: const [], + sleepOnsetSec: 0, + sleepOffsetSec: 0, + profile: const {}, + skinTempAdcHistory: history, + deviceFamily: 'gen4', + ).toJson(), + ); + return (b['scalars'] as Map).cast(); + } + + const history = [995.0, 1000.0, 1005.0, 998.0, 1002.0]; + + test('a night with half of it cold is not banked and gets no z', () { + final temps = [ + ...List.filled(200, 500), + ...List.filled(200, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, lessThan(0.8)); + expect(s['skin_temp_adc'], isNull); + expect(s['skin_temp_z'], isNull); + }); + + test('a settled night is still banked and z-scored', () { + final temps = [ + ...List.filled(10, 500), + ...List.filled(390, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, greaterThanOrEqualTo(0.8)); + expect(s['skin_temp_adc'], isNotNull); + expect(s['skin_temp_z'], isNotNull); + }); + + test('a night that passes the gate banks its settled mean, not the raw one', + () { + // 15% of the night reads 250 counts low (warm-up after charging). That is + // over the 0.80 gate, so the night counts — but the cold samples must not + // be averaged into it: the raw mean is 962.5, ~10 SD below this baseline. + final temps = [ + ...List.filled(60, 750), + ...List.filled(340, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, closeTo(0.85, 1e-6)); + expect(s['skin_temp_adc'] as num, closeTo(1000, 1e-6)); + expect(s['skin_temp_z'] as num, closeTo(0, 1e-6)); + }); +}