diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bbbd94d6..c59e3b85 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -62,7 +62,7 @@ NSBluetoothAlwaysUsageDescription OpenStrap connects to your WHOOP band over Bluetooth to sync your health data. NSHealthShareUsageDescription - OpenStrap reads recent samples to avoid writing duplicates into Apple Health. + OpenStrap reads your step count from Apple Health to show your daily steps, and reads back its own recent samples so it never writes duplicates. NSHealthUpdateUsageDescription OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health. NSBluetoothPeripheralUsageDescription diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index ba98bafe..fce9c58b 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -38,6 +38,7 @@ import '../notify/tap_router.dart' show kRouteWorkoutSuggestion; import '../telemetry/telemetry_service.dart'; import 'crossday_pipeline.dart'; import 'derive_pacing.dart'; +import 'movement_floor_policy.dart' as mfp; import 'sleep_profile_policy.dart'; import 'derive_prepare.dart'; import 'onehz_pipeline.dart'; @@ -512,7 +513,91 @@ import 'substrate.dart'; // the bundle carries the distinction for whoever takes it. // // Days re-derive so sleep need, bedtime, wake and sleep performance are rebuilt. -const int kAlgoVersion = 56; + +// NOTE ON NUMBERING: v55 and v56 above are the nap/strain work (PR #204), +// which merged first. The three entries below are this branch's, renumbered +// from 55/56/57 to 57/58/59 so the constant stays STRICTLY MONOTONIC. That is +// load-bearing, not cosmetic: the derive gate matches algo_version EXACTLY +// while the read seam serves MAX(algo_version), so a version that goes +// backwards writes rows nobody reads and re-derives forever. +// +// v57: THE 1 Hz STEP ESTIMATE IS DELETED. Steps are now real-measured only. +// +// Diagnosis on a real user DB (2026-08-03): the app reported 2,645 steps for a +// day the user took under 400. It was 23 "active minutes" x an assumed 115 spm. +// Both halves of that conversion are invalid at 1 Hz, and neither is fixable by +// re-tuning: +// * Cadence is NOT IDENTIFIABLE. Gait is 1.4-2.3 Hz (Straczkiewicz 2023, +// doi:10.1038/s41746-022-00745-z); at 1 Hz every fundamental is sub-Nyquist +// and 80/100/140/160 spm alias to the same 0.333 Hz. No published step +// detector exists below 10 Hz. +// * The minutes were never specifically ambulation. At the wrist, arm work +// out-accelerates walking (stirring ~104 mg, chopping ~139 mg vs walking +// ~66 mg ENMO), so a movement threshold cannot isolate gait even at full +// rate: wrist devices emit 22-27 false steps/min during dishes, reaching +// and driving (O'Connell 2017, doi:10.1371/journal.pone.0169616) while +// detecting slow walking at sensitivity 0.05. The two errors have OPPOSITE +// sign, so no gain constant corrects both. +// Confirmed against this DB's own ground truth: the single window where the +// 100 Hz pedometer and 1 Hz overlap had HR 95->108 and dynAmp 0.31-0.40 g, and +// the REAL count was 11 steps in 3.1 min (3.5 spm) where the estimator would +// have assigned ~115 spm. +// +// What changes: `scalars.steps` is now ABSENT unless a gait-capable source +// measured the day (band 100 Hz, phone pedometer, or a NOOP import — all in +// `live_coverage`). Days with no such source lose their step number entirely +// rather than showing an invented one. `active_min` survives as an explicitly +// NON-locomotion movement-volume index (bundle key `movement`) and is no longer +// coverage-excluded, since there is no longer a step total it could double-count +// into. Steps also stopped being written to Apple Health / Health Connect, both +// because the old value was fabricated and because we now READ the phone's own +// pedometer from that store and must not feed our copy back to ourselves. +// Every day's steps/active_min move, so every day must re-derive. +// v58: movement minutes rebuilt on MEASURED evidence. Every change below was +// proven against 4 days of this user's real 1 Hz substrate before being made; +// two proposals were REFUTED by the same tests and deliberately NOT built. +// +// * HR GATE DELETED. `restingHr + 8 bpm` changed active minutes by exactly +// ZERO on every day tested. At RHR ~62 it sits at ~6% of heart-rate +// reserve — below every ACSM band — and 73-100% of covered minutes already +// cleared it. It also failed in the wrong direction: PPG HR is least +// reliable during the motion being gated, so a dropout deleted minutes the +// accelerometer measured fine. `dailyActiveMinutes` no longer accepts HR. +// * x3 CEILING DELETED. It rejected ZERO minutes on all 4 days with +// 0.42-0.55 g of headroom, and cannot fire on artifacts (a 3 s knock +// averages ~0.23 g, below the FLOOR). The only thing it could ever exclude +// was a genuinely hard session. +// * FLOOR IS NOW FROZEN after a 14-day enrollment, not recomputed daily. A +// threshold derived from the signal it thresholds cancels the trend it +// exists to report: scaling a real day's dynAmp gave 37 active minutes at +// 1x, 1.5x, 2x AND 3x activity when recomputed, versus 23 -> 254 frozen. +// Re-freezes only on device/wrist change, a 30-day wear gap, or 365 days. +// * NOT BUILT (proven unnecessary): accel autocalibration — offset and +// uniform gain cancel exactly through the high-pass and the floor +// normalisation (+5% gain moves the gate decision by 0.0000); only +// anisotropic gain survives at ~1-3%. And gravity/forearm orientation — +// it solved the ambulation problem v55 deleted. A sleep-anchored floor was +// also tested and REFUTED: CV 138.6% across days vs 9.3%, and on one night +// it landed above the entire day's range (would report zero). +// * SEMANTICS CORRECTED. The R24 1 Hz accel field is a fused GRAVITY vector, +// not acceleration: across 269,486 real samples ||a|| is p50 1.027 g with +// 0.030% above 1.3 g, and during the single most vigorous minute of a day +// it was 1.033 g +- 0.006 (0 of 420 samples above 1.2 g). So `dynAmp` +// measures how fast the wrist RE-ORIENTS, not how hard it accelerates, and +// ENMO/MAD over this substrate are ~(1.03 - gRef): a pure calibration +// artifact with zero signal. That is the true root cause of the original +// 42,155-steps-at-gRef-0.97 / 0-at-1.02 collapse. +// active_min moves on every day; steps are unaffected by this bump. +// +// v59 - review follow-up: the ABSENT `steps` block stops labelling itself. It +// carried `tier: 'ESTIMATE'` alongside `value: null`, and `Metric.parse` maps +// that tier to `beta: true`, so a day with no measurement at all rendered the +// estimate badge. Absent now means absent: `tier: null` (parsing to +// MetricTier.unknown) and an empty `inputs_used`. No VALUE changes, but the +// persisted bundle does, so days derived at v58 must be re-derived to pick it +// up. `ABSENT` was deliberately NOT invented as a fifth tier — `Tier.all` in +// analytics is a closed set of four published grades. +const int kAlgoVersion = 59; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see @@ -718,6 +803,14 @@ class _BaselineHistoryCache { /// AFTER the target are excluded too: a baseline is prior days, and a backfill /// sweep must not let later days leak into an older day's baseline (which /// would also make the result depend on sweep order). + /// The set of dates that actually have a stored value for [key]. + /// + /// Used to detect wear GAPS: a date with no `dyn_p90` row means the band + /// produced no usable motion that day. + Set datesFor(String key) => { + for (final s in _series[key] ?? const <_DatedValue>[]) s.date, + }; + List valuesBefore(String key, String beforeDate) => _trailing([ for (final s in _series[key] ?? const <_DatedValue>[]) if (s.date.compareTo(beforeDate) < 0) s, @@ -821,6 +914,26 @@ Future runWithConcurrency( await Future.wait(List.generate(poolSize, (_) => lane())); } +/// Minimal async mutex: serializes read-modify-write sections that concurrent +/// day workers ([runWithConcurrency]) would otherwise interleave. +/// +/// Dart's scheduler makes a single statement atomic, but NOT a +/// read → decide → write sequence with `await`s in it: every lane can observe +/// the pre-write state before any of them writes. The shared movement floor is +/// exactly that shape, so it needs one. +class _AsyncLock { + Future _tail = Future.value(); + + Future run(Future Function() action) { + final completer = Completer(); + final previous = _tail; + _tail = completer.future; + return previous + .then((_) => action()) + .whenComplete(completer.complete); + } +} + class DerivationEngine { DerivationEngine({this.log, this.background = false}); final void Function(String)? log; @@ -2250,10 +2363,7 @@ class DerivationEngine { try { final dayLo = daySub.length == 0 ? 0 : daySub.tsSec.first; final dayHi = daySub.length == 0 ? 0 : daySub.tsSec.last + 60; - final coverageWindows = - await LocalDb.coverageWindowsOverlapping(dayLo, dayHi); final liveStepsReal = await LocalDb.liveStepsForDay(day.date); - final stepCalib = await LocalDb.getStepCalibration(); final savedSessions = await LocalDb.sessionsInRange(dayLo, dayHi); // Off-wrist / charging spans over the NAP window (which runs past this @@ -2267,14 +2377,31 @@ class DerivationEngine { final wristOffSpans = await LocalDb.wristOffSpans(napLo, napHi); final chargingSpans = await LocalDb.chargingSpans(napLo, napHi); - // PERSONAL ambulatory floor, from days STRICTLY BEFORE this one (the same - // self-exclusion every other baseline uses — a day must not help set the - // threshold it is then scored against). Anchoring on trailing days is the - // whole point: an absolute g constant is destroyed by a few-percent - // gravity-reference excursion, and a same-day floor collapses on a quiet - // day. Below the minimum history this is null and the estimator abstains. + // PERSONAL movement floor — ESTIMATED ONCE, THEN FROZEN. + // + // Freezing is the whole point and it is not an optimisation. This + // threshold is derived from the same signal it thresholds, so a floor + // that keeps tracking the user cancels the trend it exists to report. + // Measured by scaling a real day's dynAmp and recomputing both ways: + // + // activity x FROZEN recomputed + // 1.00 23 37 + // 1.50 66 37 + // 2.00 128 37 + // 3.00 254 37 + // + // A recomputed floor reports the SAME number whether the user tripled + // their activity or did nothing at all. So: accumulate `dyn_p90` for an + // enrollment window, commit the median, and keep using it. It re-freezes + // only on events that genuinely change the signal's scale (see + // `ana.shouldRefreezeFloor`) — never merely because time passed. + // + // Self-exclusion (days STRICTLY BEFORE this one) is retained for the + // enrollment estimate: a day must not help set the threshold it is then + // scored against. Below the minimum history the floor is null and the + // estimator abstains rather than substituting a constant. + final dynFloorG = await _frozenMovementFloor(history, day.date); final dynHistory = history.valuesBefore('dyn_p90', day.date); - final dynFloorG = ana.personalDynFloorFromDailySummaries(dynHistory); // Built on THIS isolate so the Isolate.run closure captures only this plain // sendable object (never `this`, `day`, or `bundle`). @@ -2287,9 +2414,7 @@ class DerivationEngine { offsetSec: day.sleepOffsetSec, rhr: (scMap?['rhr'] as num?)?.toDouble(), maxHrUsed: (bundle['max_hr_used'] as num?)?.round(), - coverageWindows: coverageWindows, liveStepsReal: liveStepsReal, - stepCalib: stepCalib, dynFloorG: dynFloorG, dynHistoryDays: dynHistory.length, savedSessions: savedSessions, @@ -2422,11 +2547,13 @@ class DerivationEngine { 'stress': sc('stress'), 'spo2': sc('spo2'), 'calories': sc('calories'), - // Steps = real 100 Hz count + 1 Hz estimate over uncovered minutes - // (computed in _stepsAndEnergy; never double-counted). + // Steps = REAL pedometer counts only (band 100 Hz / phone / NOOP + // import, all via `live_coverage`). Absent — written as a NULL row, so + // a previously fabricated value is overwritten rather than left + // standing — on any day nothing gait-capable measured. 'steps': sc('steps'), - // Ambulatory minutes — the quantity 1 Hz can actually resolve, and the - // unit public activity guidance uses. Steps are derived FROM this. + // Movement minutes: activity VOLUME, not locomotion. Steps are NOT + // derived from this and never will be again (see the v55/v56 note). 'active_min': sc('active_min'), // This day's high quantile of the calibration-invariant dynamic accel // amplitude. Not a user-facing metric: it is the per-day summary the @@ -3133,26 +3260,203 @@ class DerivationEngine { bundle['wear'] = wake['wear']; } - /// STEPS (hybrid: real 100 Hz count + bounded 1 Hz estimate) + total daily - /// energy (TDEE), written into the bundle's `steps` block + `scalars`. + /// The personal movement floor, estimated ONCE and then frozen. + /// + /// Returns the persisted value if one exists. Otherwise, once enough trailing + /// `dyn_p90` days have accumulated, commits the median and returns it. Below + /// that it returns null and the estimator abstains — deliberately, since a + /// constant fallback is the exact failure this design removes. /// - /// Steps = [liveStepsReal] (AN-2554 over the band's 100 Hz windows — the real - /// count, always preferred) + a 1 Hz estimate over the minutes those windows do - /// NOT cover ([coverageWindows], device-time sec). So a minute is counted by - /// 100 Hz OR estimated by 1 Hz, never both. TDEE = HR-flex (Mifflin BMR floor + - /// active Keytel surplus). Best-effort. + /// Why frozen: the floor is derived from the same signal it thresholds, so a + /// continuously-recomputed floor tracks the user and reports a near-constant + /// number regardless of behaviour (measured: 37 active minutes at 1x, 1.5x, + /// 2x AND 3x activity, versus 23 -> 254 with a frozen floor). + /// + /// ORDER-INDEPENDENCE. The floor is ONE persisted scalar shared by every day, + /// but `run()` dispatches days NEWEST-FIRST through a concurrent worker pool, + /// so this read-modify-write is reached by several days at once. Three things + /// keep the outcome from depending on which worker finishes last: + /// + /// 1. `_floorLock` serializes the whole read/decide/write, so two days can + /// never both observe "nothing stored" and both commit. + /// 2. `daysSinceFrozen` is clamped at 0 (see [mfp.daysSinceFrozen]), so a + /// backfill day never reads as a stale floor and never triggers a + /// re-freeze just for being old. + /// 3. `mayCommitFloorOn` stops an older day overwriting a newer freeze. + /// + /// Without these, a `kAlgoVersion` bump — which this very change forces — + /// would re-derive the whole retained window and let sweep order decide every + /// day's `active_min`. That is precisely what `_BaselineHistoryCache`'s own + /// contract forbids for baselines. + static Future _frozenMovementFloor( + _BaselineHistoryCache history, + String dayId, + ) => + _floorLock.run(() => _resolveMovementFloor(history, dayId)); + + /// Serializes the shared-floor read-modify-write across concurrent day + /// workers. See [_frozenMovementFloor]. + static final _AsyncLock _floorLock = _AsyncLock(); + + static Future _resolveMovementFloor( + _BaselineHistoryCache history, + String dayId, + ) async { + final stored = await LocalDb.getMovementFloor(); + final hist = history.valuesBefore('dyn_p90', dayId); + + if (stored != null) { + // Re-freeze only on a real change of scale, never on elapsed time alone. + // + // NOTE on the unwired signals: `shouldRefreezeFloor` also accepts + // `deviceChanged` and `wristChanged`, and edge has no reliable source for + // either yet (no persisted device identity, no wrist-selection history), + // so they are deliberately NOT passed rather than passed as a fabricated + // `false` that reads like a checked condition. `wearGapDays` IS + // derivable — a run of days with no `dyn_p90` row means the band was not + // worn — so it is computed and passed. + final refreeze = ana.shouldRefreezeFloor( + daysSinceFrozen: mfp.daysSinceFrozen( + frozenOn: stored.frozenOn, + dayId: dayId, + ), + wearGapDays: mfp.wearGapDays( + have: history.datesFor('dyn_p90'), + dayId: dayId, + ), + ); + if (!refreeze) return stored.floorG; + + // A re-freeze that CANNOT be satisfied must not destroy what we have. + // Falling through to enrollment with thin history would return null and + // make `active_min` vanish for the day — and that is reachable exactly + // when re-freezing matters most (an old floor on a user whose recent + // `dyn_p90` history was pruned or is sparse). Keep serving the existing + // floor until a replacement can actually be computed. + if (hist.length < ana.enrollmentDaysForFrozenFloor) return stored.floorG; + + // REACHABLE, and this is the case it exists for: an OLD backfill day that + // trips the re-freeze rule (a 30-day wear gap before it is the common + // one) and has enough prior history to recompute. Without this it would + // overwrite the freeze a NEWER day just established, and since the sweep + // runs newest-first and concurrently, sweep order would decide the floor. + // A backfill day may CONSUME the shared floor; it may never move it. + if (!mfp.mayCommitFloorOn(frozenOn: stored.frozenOn, dayId: dayId)) { + return stored.floorG; + } + } else if (hist.length < ana.enrollmentDaysForFrozenFloor) { + // Still enrolling, and nothing stored to fall back on. Return null so the + // metric abstains and says so, rather than shipping a threshold we have + // already proven will be re-derived. + return null; + } + + final floor = ana.personalDynFloorFromDailySummaries(hist); + if (floor == null) return stored?.floorG; + await LocalDb.putMovementFloor( + floorG: floor, + frozenOn: dayId, + days: hist.length, + ); + if (kDebugMode) { + debugPrint('[derive] movement floor FROZEN at ' + '${floor.toStringAsFixed(4)} g from ${hist.length} days ($dayId)'); + } + return floor; + } + + /// Write the day's step count. REAL PEDOMETER MEASUREMENTS ONLY. + /// + /// The 1 Hz substrate contributes NOTHING here and must never do so again. + /// The removed estimate multiplied 1 Hz "active minutes" by a walking cadence + /// band; on a real user day it reported 2,645 steps against a true count + /// under 400. Both halves of that conversion are invalid at 1 Hz: + /// * cadence is not identifiable (gait 1.4-2.3 Hz is sub-Nyquist; 80/100/ + /// 140/160 spm all alias to the same 0.333 Hz), and + /// * the minutes counted were never specifically ambulation — at the wrist, + /// arm work out-accelerates walking (stirring ~104 mg, chopping ~139 mg + /// vs walking ~66 mg ENMO), which is why wrist devices are documented + /// emitting 22-27 false steps/min during dishes and driving (O'Connell + /// 2017) while missing slow walking at sensitivity 0.05. + /// Two errors of OPPOSITE sign: no gain constant fixes both. + /// + /// So `steps` is absent unless something that can actually see gait measured + /// it: the Tier A 100 Hz pedometer, or the phone's own pedometer (both land + /// in `live_coverage`). No real source -> no number. + /// + /// Called BEFORE the movement-substrate guards, because it depends on none of + /// them — see the call site. + static void _writeSteps( + Map bundle, + Map? scMap, + int liveStepsReal, + ) { + final haveRealSteps = liveStepsReal > 0; + if (haveRealSteps) { + scMap?['steps'] = liveStepsReal.toDouble(); + } else { + scMap?.remove('steps'); + } + bundle['steps'] = { + 'value': haveRealSteps ? liveStepsReal : null, + 'real_measured': liveStepsReal, + 'source': haveRealSteps ? 'pedometer_100hz_or_phone' : null, + 'confidence': haveRealSteps ? 0.9 : 0.0, + // NO TIER ON AN ABSENT METRIC. `ESTIMATE` here was actively wrong in two + // ways: this code path never estimates anything (that is the whole point + // of the change), and `Metric.parse` turns tier == ESTIMATE into + // `beta: true`, which paints the estimate/beta badge onto a card that has + // no number on it at all. `null` parses to `MetricTier.unknown`, which is + // what "we did not measure this" actually is. `ABSENT` is deliberately + // NOT invented: `Tier.all` in analytics is a closed set of four published + // grades and the edge must not widen it from here. + 'tier': haveRealSteps ? 'HIGH' : null, + // Likewise, nothing was used when nothing was measured. + 'inputs_used': + haveRealSteps ? const ['live_coverage_pedometer'] : const [], + 'note': haveRealSteps + ? 'real pedometer count over measured windows only; time outside ' + 'those windows is not counted rather than estimated' + : 'no step count: nothing that can resolve gait measured this day. ' + 'A 1 Hz wrist stream cannot count steps, so no number is shown ' + 'instead of an invented one', + }; + } + + /// STEPS (real pedometer counts ONLY) + movement minutes + total daily energy + /// (TDEE), written into the bundle's `steps`/`movement` blocks + `scalars`. + /// + /// Steps = [liveStepsReal] and nothing else — the pedometer counts banked in + /// `live_coverage` by a source that can actually resolve gait (the band's + /// 100 Hz AN-2554 stream, or the phone's own pedometer). Time outside those + /// windows is NOT counted and NOT estimated: with no real count the day has + /// no step number at all. See the long note at the call site for why the old + /// 1 Hz estimate was removed rather than recalibrated. + /// + /// Movement minutes are a separate, explicitly non-locomotion activity index + /// computed over the whole day. TDEE = HR-flex (Mifflin BMR floor + active + /// Keytel surplus). Best-effort. static void _stepsAndEnergy( Map bundle, Map? scMap, Substrate daySub, Profile profile, - List> coverageWindows, int liveStepsReal, - ana.StepCalibration? stepCalib, double? dynFloorG, int dynHistoryDays, ) { try { + // STEPS FIRST — they depend on NOTHING from the band substrate. + // + // `liveStepsReal` comes from `live_coverage`, i.e. the phone pedometer or + // a live 100 Hz session. Both of the guards below protect the 1 Hz + // MOVEMENT computation, and if the step assignment sat after them a day + // with real measured phone steps but a thin band substrate (a day the + // band barely synced, or a fresh install) would silently report no steps + // at all — discarding a real measurement because an unrelated signal was + // missing. Assign steps before anything can return early. + _writeSteps(bundle, scMap, liveStepsReal); + if (daySub.length < 60) return; final motion = _motionMinutes(daySub); if (motion.isEmpty) return; @@ -3164,73 +3468,48 @@ class DerivationEngine { final dynSummary = ana.dailyDynSummary(motion); if (dynSummary != null) scMap?['dyn_p90'] = dynSummary; - // STEPS — hybrid, no double-count. Drop any minute already covered by a - // 100 Hz window (real count wins), estimate steps for the rest from 1 Hz. - bool covered(double tsMinStartMs) { - final s = (tsMinStartMs / 1000).round(); - for (final w in coverageWindows) { - if (s + 60 > w[0] && s < w[1]) return true; - } - return false; - } - - final motionUn = []; - final hrUn = []; - for (var i = 0; i < motion.length; i++) { - if (covered(motion[i].tsMinStartMs)) continue; - motionUn.add(motion[i]); - hrUn.add(hrPerMin[i]); - } - - final rhr = (scMap?['rhr'] as num?)?.toDouble(); - final est = ana.dailyStepEstimate( - motionUn, + // MOVEMENT MINUTES run over the WHOLE day — no coverage exclusion. + // + // Minutes covered by a pedometer window used to be dropped here, because + // steps were "real count over covered time + 1 Hz estimate over the rest" + // and including both would double-count. That hybrid is gone: steps are + // real-measured only and movement minutes are a separate quantity in a + // different unit, so there is nothing to double-count. Excluding covered + // minutes now would just silently under-report movement for exactly the + // periods we know the user was active. + final est = ana.dailyActiveMinutes( + motion, personalDynFloorG: dynFloorG, - hrPerMin: hrUn, - restingHr: rhr, - calib: stepCalib, pooledMinutesAvailable: dynHistoryDays, ); final v = est.present ? est.value : null; - final estSteps = v?.steps ?? 0; - final daySteps = liveStepsReal + estSteps; - scMap?['steps'] = daySteps.toDouble(); - // ACTIVE MINUTES is the primary, honest quantity here: 1 Hz cannot count - // steps (gait is 1.4-2.5 Hz and 120 spm aliases to DC at this rate), but - // it can resolve ambulatory MINUTES, which is also the unit public - // activity guidance is written in. The step figures are a RANGE over the - // free-living cadence band, and are absent entirely when the personal - // floor has not been established yet. - bundle['steps'] = { - 'value': daySteps, - 'real_100hz': liveStepsReal, // AN-2554 over live windows (real count) - 'estimated_1hz': estSteps, // midpoint of the 1 Hz range - 'estimated_1hz_low': v?.stepsLow, - 'estimated_1hz_high': v?.stepsHigh, - 'active_min': v?.activeMinutes ?? 0, - 'cadence_low_spm': v?.cadenceLowSpm, - 'cadence_high_spm': v?.cadenceHighSpm, + + // Movement minutes stay, as an explicitly non-locomotion activity index. + // + // ONLY OVERWRITE ON SUCCESS — never remove. `_applyWakeDayFeatures` has + // already written `active_min` from `_activeMinutes` (ENMO over wake), a + // SEPARATE quantity that was never part of the fabricated step + // conversion. Removing it on abstention deleted a number the user + // previously had, for the whole enrollment window (every day a new user + // has before the floor freezes), and nulled its trend series with it. + // Abstaining from the new index is right; destroying the old independent + // measurement to do it is not. + if (v != null) scMap?['active_min'] = v.activeMinutes.toDouble(); + bundle['movement'] = { + 'active_min': v?.activeMinutes, + 'bout_count': v?.boutCount, 'dyn_floor_g': v?.dynFloorG, - 'estimate_present': v != null, - 'confidence': liveStepsReal > 0 - ? 0.7 - : (est.present ? est.confidence : 0.2), - 'tier': liveStepsReal > 0 && estSteps == 0 ? 'HIGH' : 'ESTIMATE', - 'inputs_used': const [ - 'live_100hz_pedometer', - 'dyn_amp_1hz', - 'hr_1hz', - 'personal_dyn_floor', - ], + 'coverage': v?.coverage, + 'confidence': est.present ? est.confidence : 0.0, + 'tier': 'ESTIMATE', + // HR is NOT an input any more — the resting-HR gate was deleted in v56 + // after it changed active minutes by exactly zero on every day tested. + 'inputs_used': const ['dyn_amp_1hz', 'personal_dyn_floor'], 'note': v == null - ? 'real 100 Hz count only — the 1 Hz activity estimate needs a ' - 'personal movement baseline from several days of wear ' - '(${est.note ?? 'need_baseline'})' - : 'real 100 Hz count for streamed time + ${v.activeMinutes} active ' - 'minutes estimated from 1 Hz for the rest (1 Hz cannot count ' - 'steps directly, so the step figure is a range)', + ? (est.note ?? 'need_baseline') + : 'minutes of sustained wrist movement — activity volume, NOT ' + 'walking, and deliberately not converted to steps', }; - if (v != null) scMap?['active_min'] = v.activeMinutes.toDouble(); if (profile.isComplete) { final perMinFull = [ for (final h in hrPerMin) @@ -3314,7 +3593,8 @@ class DerivationEngine { final rhrForTrimp = restingHr ?? profile.restingHrManual?.toDouble(); double? strain; double? calories; - double? steps; + double? steps; // stays null here — real counts only, see below + double? movementMin; double? caloriesTotal; Map zones = const {}; if (perMin.isNotEmpty && hrMax != null) { @@ -3340,23 +3620,25 @@ class DerivationEngine { } } if (motion.isNotEmpty) { - // Steps do NOT need a profile: `dailyStepEstimate` falls back to the day's - // own 10th-percentile HR when `restingHr` is null, which is data-derived, - // not imputed. Pass the real value or nothing — never the old 60.0. + // STEPS ARE NOT COMPUTED HERE. This is the EARLY-READ path (what Today + // shows before the full day result exists), and there is no gait-capable + // source available to it — the real pedometer counts live in + // `live_coverage` and are summed by `_stepsAndEnergy`, which overwrites + // this artifact moments later via the copy-back below. + // + // It used to seed `steps` from the 1 Hz estimate so Today had something + // to show immediately. That is exactly the fabrication being removed: + // "something to show" is not a reason to invent a measurement. `steps` + // stays null here and Today renders no step figure until a real count + // exists. // - // This is the EARLY-READ path (what Today shows before the full day result - // exists); `_stepsAndEnergy` recomputes and overwrites it with the hybrid - // real-100 Hz + 1 Hz figure moments later. Without a personal floor the - // estimator abstains and `steps` stays null here, which is correct — the - // early read then shows no step figure rather than a fabricated one. - final stepMetric = ana.dailyStepEstimate( + // Movement minutes ARE computable from 1 Hz and are emitted below. + final movementMetric = ana.dailyActiveMinutes( motion, personalDynFloorG: dynFloorG, - hrPerMin: hrPerMinAll, - restingHr: rhrForTrimp, ); - if (stepMetric.present && stepMetric.value != null) { - steps = stepMetric.value!.steps.toDouble(); + if (movementMetric.present && movementMetric.value != null) { + movementMin = movementMetric.value!.activeMinutes.toDouble(); } // TDEE needs the full anthropometric set (Mifflin BMR + Keytel surplus). if (age != null && @@ -3387,6 +3669,7 @@ class DerivationEngine { }; return { 'active_min': activeMin, + 'movement_min': movementMin, 'strain': strain, 'calories': calories, 'steps': steps, @@ -3395,12 +3678,14 @@ class DerivationEngine { 'activity': { 'value': activeMin, 'active_min': activeMin, + 'movement_min': movementMin, 'confidence': 0.6, 'tier': 'ESTIMATE', 'inputs_used': const ['accel_1hz'], - 'note': - 'active minutes (1 Hz ENMO over wake); 1 Hz cannot count steps — ' - 'true step counts come from live workout streaming', + 'note': 'minutes of wrist movement over wake (1 Hz). This is activity ' + 'volume, NOT walking, and is never converted to steps: at the ' + 'wrist, arm work registers as strongly as ambulation. Real step ' + 'counts come only from the 100 Hz or phone pedometer', }, 'activity_curve': _activityCurve(daySub), 'zones': zones, @@ -4230,19 +4515,19 @@ class DerivationEngine { scMap, daySub, inp.profile, - inp.coverageWindows, inp.liveStepsReal, - inp.stepCalib, inp.dynFloorG, inp.dynHistoryDays, ); - // _stepsAndEnergy just corrected `steps`/`calories_total` in bundlePatch + - // scMap using the hybrid real-100Hz + 1Hz-estimate count, but `wake` (built - // above by _buildWakeDayFeatures, before this correction ran) still holds - // the earlier 1Hz-only estimate. `wake` is what _persistWakeDayFeatures - // stores and what the Today repository reads while the full day result - // isn't ready yet, so copy the corrected values back in to avoid serving - // stale steps/calories from that early-read path. + // _stepsAndEnergy just wrote `steps` (REAL pedometer counts from + // `live_coverage` — band 100 Hz or phone, never an estimate) and + // `calories_total` into bundlePatch + scMap. `wake` was built above by + // _buildWakeDayFeatures BEFORE that ran, and deliberately leaves `steps` + // null: the early-read path has no gait-capable source of its own and must + // not invent one. `wake` is what _persistWakeDayFeatures stores and what + // the Today repository reads until the full day result exists, so copy the + // measured values back in — otherwise Today shows no step count on a day + // that really was measured. for (final key in const ['steps', 'calories_total']) { final value = scMap[key]; if (value != null) wake[key] = value; @@ -4688,7 +4973,7 @@ Future runCancellableIsolate( /// Sendable input for [DerivationEngine._computeDayBlocks] — crosses the /// `Isolate.run` boundary, so every field is plain data (Substrate is int/double -/// lists; Profile/StepCalibration are primitive data classes). DB reads that the +/// lists; Profile is a primitive data class). DB reads that the /// pure compute needs are performed by the caller and passed in here. class _DayBlocksInput { final Substrate daySub; @@ -4699,9 +4984,7 @@ class _DayBlocksInput { final int offsetSec; final double? rhr; final int? maxHrUsed; - final List> coverageWindows; final int liveStepsReal; - final ana.StepCalibration? stepCalib; /// PERSONAL ambulatory floor (g, dynAmp units) from trailing days, or null /// when there isn't enough history yet — in which case the 1 Hz estimator @@ -4748,9 +5031,7 @@ class _DayBlocksInput { required this.offsetSec, required this.rhr, required this.maxHrUsed, - required this.coverageWindows, required this.liveStepsReal, - required this.stepCalib, required this.dynFloorG, required this.dynHistoryDays, required this.savedSessions, diff --git a/lib/compute/movement_floor_policy.dart b/lib/compute/movement_floor_policy.dart new file mode 100644 index 00000000..f8eede48 --- /dev/null +++ b/lib/compute/movement_floor_policy.dart @@ -0,0 +1,86 @@ +/// PURE policy for the frozen personal movement floor. +/// +/// The floor is a SINGLE persisted personal scalar, not a per-day value: once +/// committed it is applied to every day, past and future. That is the whole +/// point of freezing it — a floor derived from the signal it thresholds cancels +/// the trend it exists to report if it keeps tracking the user (measured on real +/// substrate: 37 active minutes at 1x, 1.5x, 2x AND 3x activity when +/// recomputed, versus 23 -> 254 frozen). +/// +/// Because it is one shared scalar, resolving it is a READ-MODIFY-WRITE against +/// state every day of a sweep touches. `DerivationEngine.run()` dispatches days +/// NEWEST-FIRST through a concurrent worker pool, so the decisions below have to +/// be order-independent or the frozen floor is decided by a race. These helpers +/// are pure so that property is unit-testable without a database. +library; + +import '../data/day_label.dart'; + +/// The `YYYY-MM-DD` label [back] calendar days before [dayId]. +/// +/// CALENDAR arithmetic, never `Duration`. `DateTime.subtract(Duration(days: n))` +/// is ABSOLUTE: from local midnight on 2026-03-10 (US), subtracting 24 h lands +/// at 23:00 on 2026-03-07 because 2026-03-08 was only 23 h long — so the walk +/// SKIPS 2026-03-08 entirely and the caller mis-counts the gap. Feeding an +/// out-of-range day field to the `DateTime` constructor normalises correctly. +String? dayLabelBefore(String dayId, int back) { + final d = DateTime.tryParse(dayId); + if (d == null) return null; + return dayLabelOf(DateTime(d.year, d.month, d.day - back)); +} + +/// Consecutive days immediately before [dayId] with no entry in [have]. +/// +/// A missing `dyn_p90` daily summary means the band produced no usable motion +/// that day, i.e. it was not worn. Used only as a re-freeze trigger: a long gap +/// suggests the body/device relationship may have changed enough that the frozen +/// floor should be re-estimated. +/// +/// Returns 0 when [have] is empty — an empty history is "no information", not "a +/// 60-day gap", and must not be allowed to trigger a re-freeze. +int wearGapDays({ + required Set have, + required String dayId, + int maxScan = 60, +}) { + if (have.isEmpty) return 0; + var gap = 0; + for (var back = 1; back <= maxScan; back++) { + final label = dayLabelBefore(dayId, back); + if (label == null) return gap; + if (have.contains(label)) break; + gap++; + } + return gap; +} + +/// Age of the frozen floor as seen from [dayId], NEVER negative. +/// +/// A day BEFORE the freeze date is not a stale floor — it is a backfill. The +/// previous `.abs()` made every historical re-derive look maximally stale, which +/// matters because a `kAlgoVersion` bump re-derives days newest-first: walking +/// backwards past `maxAgeDays` tripped the staleness rule and re-froze the +/// shared floor onto an OLDER `frozenOn`, which could then trip again on the +/// next real derive. Clamping to 0 makes a backfill day simply consume the +/// stored floor, which is what "frozen" means. +int daysSinceFrozen({required String frozenOn, required String dayId}) { + final from = DateTime.tryParse(frozenOn); + final to = DateTime.tryParse(dayId); + if (from == null || to == null) return 0; + final diff = to.difference(from).inDays; + return diff > 0 ? diff : 0; +} + +/// May [dayId] commit (or re-commit) the shared floor? +/// +/// A day may only move the floor FORWARD in time. Without this, a backfill day +/// in a newest-first sweep could overwrite a freeze that a newer day had just +/// established, making the persisted floor — and therefore every day's +/// `active_min` — depend on which worker in the pool finished last. +/// +/// This is the same principle `_BaselineHistoryCache.valuesBefore` already +/// states for baselines: a sweep must not make the result depend on sweep order. +bool mayCommitFloorOn({required String? frozenOn, required String dayId}) { + if (frozenOn == null) return true; + return dayId.compareTo(frozenOn) >= 0; +} diff --git a/lib/data/db.dart b/lib/data/db.dart index df450624..a410d4cb 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12,7 +12,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -91,7 +90,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 26; + static const int schemaVersion = 27; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -391,11 +390,23 @@ class LocalDb { // use by FiredKeyStore, so nothing is lost on upgrade. await _createNotifFired(db); } + if (oldV < 27) { + // `live_coverage` gains a `source` column so a phone-pedometer count + // can be told apart from the band's 100 Hz wrist count. Existing rows + // default to 'band', which is what they are. + // + // This matters because the two sources must NEVER be summed: they + // both count the same walk from different places on the body. The + // reader prefers phone rows for a day when any exist (a + // pocket-carried pedometer sees gait; a wrist one confuses arm work + // for steps), and falls back to band rows otherwise. + await _ensureLiveCoverageSource(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); }, - version: 26, + version: schemaVersion, ); } @@ -435,6 +446,7 @@ class LocalDb { await db.execute('DROP INDEX IF EXISTS $ix'); } await _createLiveCoverage(db); + await _ensureLiveCoverageSource(db); await _createCycleSymptom(db); await _ensureSessionSchema(db); await _ensureSyncStateSchema(db); @@ -755,7 +767,8 @@ class LocalDb { start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, steps INTEGER NOT NULL, - day TEXT NOT NULL + day TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '$kStepSourceBand' ) '''); await db.execute( @@ -763,6 +776,25 @@ class LocalDb { ); } + /// Ensure `live_coverage.source` exists (v27). + /// + /// Uses the shared guarded helper — an unguarded ALTER TABLE on an + /// already-migrated db bricks the upgrade (that has bitten this file twice). + static Future _ensureLiveCoverageSource(Database db) async { + await _addColumnIfMissing( + db, + 'live_coverage', + 'source', + "TEXT NOT NULL DEFAULT '$kStepSourceBand'", + ); + } + + /// Step-count provenance for a `live_coverage` row. + /// + /// These are never summed together — see [liveStepsForDay]. + static const String kStepSourceBand = 'band'; // band 100 Hz AN-2554 (wrist) + static const String kStepSourcePhone = 'phone'; // phone pedometer (pocket) + /// Record a real 100 Hz step window (device-time seconds) + its step count. /// /// The window is normalised by [sanitizeCoverageWindow] first: a zero-width @@ -777,8 +809,9 @@ class LocalDb { int startTs, int endTs, int steps, - String day, - ) async { + String day, { + String source = kStepSourceBand, + }) async { final w = sanitizeCoverageWindow(startTs, endTs, steps); if (w == null) return; final db = await instance; @@ -787,6 +820,37 @@ class LocalDb { 'end_ts': w.endTs, 'steps': steps, 'day': day, + 'source': source, + }); + } + + /// Replace ALL phone-pedometer rows for [day] with [windows], atomically. + /// + /// Phone step data is a re-readable snapshot, not an append-only stream: the + /// same day can be synced repeatedly as it fills in. So the phone sync is + /// delete-then-insert scoped to `source = 'phone'`, which is idempotent by + /// construction and needs no window-clipping. Band rows are untouched. + static Future replacePhoneCoverageForDay( + String day, + List<({int startTs, int endTs, int steps})> windows, + ) async { + final db = await instance; + await db.transaction((txn) async { + await txn.delete( + 'live_coverage', + where: 'day = ? AND source = ?', + whereArgs: [day, kStepSourcePhone], + ); + for (final w in windows) { + if (w.steps <= 0 || w.endTs <= w.startTs) continue; + await txn.insert('live_coverage', { + 'start_ts': w.startTs, + 'end_ts': w.endTs, + 'steps': w.steps, + 'day': day, + 'source': kStepSourcePhone, + }); + } }); } @@ -806,27 +870,86 @@ class LocalDb { return r.isNotEmpty; } - /// Real (100 Hz) steps attributed to [day]. + /// Phone-sourced steps already banked for [day]. + /// + /// Used by the pedometer sync to tell "this day really had no steps" from + /// "this read came back empty" before it replaces a day wholesale — see + /// [replacePhoneCoverageForDay], which is delete-then-insert. + static Future phoneStepsForDay(String day) async { + final db = await instance; + final r = await db.rawQuery( + 'SELECT COALESCE(SUM(steps),0) s FROM live_coverage ' + 'WHERE day = ? AND source = ?', + [day, kStepSourcePhone], + ); + return (r.first['s'] as num?)?.toInt() ?? 0; + } + + /// Drop every phone-sourced coverage row (the user turned phone steps off). + /// Band rows are untouched, so days fall back to the band count. + static Future clearPhoneCoverage() async { + final db = await instance; + return db.delete( + 'live_coverage', + where: 'source = ?', + whereArgs: [kStepSourcePhone], + ); + } + + /// Real pedometer steps attributed to [day], from ONE source. + /// + /// Phone and band counts are never added together: both count the same walk, + /// one from the pocket and one from the wrist, so summing them roughly + /// doubles a day. When the phone has any data for the day it wins outright — + /// a pocket/waist pedometer observes trunk motion (real gait), whereas a + /// wrist one is documented emitting 22-27 false steps/min during dishes, + /// reaching and driving while missing slow walking (O'Connell 2017, + /// doi:10.1371/journal.pone.0169616). Band rows are the fallback. static Future liveStepsForDay(String day) async { final db = await instance; final r = await db.rawQuery( - 'SELECT COALESCE(SUM(steps),0) s FROM live_coverage WHERE day = ?', + 'SELECT source, COALESCE(SUM(steps),0) s FROM live_coverage ' + 'WHERE day = ? GROUP BY source', [day], ); - return (r.first['s'] as num?)?.toInt() ?? 0; + var band = 0; + var phone = 0; + for (final row in r) { + final n = (row['s'] as num?)?.toInt() ?? 0; + if (row['source'] == kStepSourcePhone) { + phone += n; + } else { + band += n; + } + } + return phone > 0 ? phone : band; } - /// Coverage windows ([startSec, endSec]) overlapping [loSec, hiSec) — used to - /// exclude already-counted minutes from the 1 Hz estimate. + /// Coverage windows ([startSec, endSec]) overlapping [loSec, hiSec), for ONE + /// [source] (band by default). + /// + /// The 1 Hz-estimate exclusion this originally served is gone along with the + /// estimator. Its only remaining caller is the NOOP importer, which reads back + /// the spans it has already banked so `stepRuns` can clip them out and a + /// re-import over an overlapping span cannot double-count. + /// + /// THE SOURCE FILTER IS LOAD-BEARING for that caller. Phone-pedometer rows now + /// share this table and cover the same wall-clock hours, so an unfiltered read + /// let a user with phone steps enabled import a NOOP backup whose BAND step + /// runs were clipped against the PHONE's windows and silently dropped — the + /// import reporting success while banking nothing for those days. Band clips + /// against band. Phone coverage needs no clipping at all: it is replaced + /// wholesale per day (see [replacePhoneCoverageForDay]). static Future>> coverageWindowsOverlapping( int loSec, - int hiSec, - ) async { + int hiSec, { + String source = kStepSourceBand, + }) async { final db = await instance; final rows = await db.query( 'live_coverage', - where: 'end_ts >= ? AND start_ts < ?', - whereArgs: [loSec, hiSec], + where: 'end_ts >= ? AND start_ts < ? AND source = ?', + whereArgs: [loSec, hiSec, source], ); return [ for (final r in rows) @@ -3899,22 +4022,39 @@ class LocalDb { return (rows.first['value'] as num?)?.toDouble(); } - static Future getStepCalibration() async { - final row = await baseline('step_calibration'); + /// The FROZEN personal movement floor (g, dynAmp units) + when it was frozen. + /// + /// Persisted rather than recomputed because a floor that keeps tracking the + /// user cancels the trend it exists to report — see the derivation-engine + /// comment for the measured before/after. Returns null until enrollment + /// completes, which is the estimator's signal to abstain. + static Future<({double floorG, String frozenOn, int days})?> + getMovementFloor() async { + final row = await baseline('movement_floor'); final raw = row?['payload_json']; if (raw is! String || raw.isEmpty) return null; try { - final decoded = jsonDecode(raw); - return decoded is Map - ? ana.StepCalibration.fromJson(decoded.cast()) - : null; + final d = jsonDecode(raw); + if (d is! Map) return null; + final f = (d['floor_g'] as num?)?.toDouble(); + final on = d['frozen_on'] as String?; + if (f == null || !f.isFinite || f <= 0 || on == null) return null; + return (floorG: f, frozenOn: on, days: (d['days'] as num?)?.toInt() ?? 0); } catch (_) { return null; } } - static Future putStepCalibration(ana.StepCalibration calibration) => - putBaseline('step_calibration', jsonEncode(calibration.toJson())); + static Future putMovementFloor({ + required double floorG, + required String frozenOn, + required int days, + }) => + putBaseline( + 'movement_floor', + jsonEncode({'floor_g': floorG, 'frozen_on': frozenOn, 'days': days}), + ); + /// A long-format metric series (oldest first) for trends/sparklines. static Future>> metricSeries( diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 6530b4de..b14546d0 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -183,20 +183,82 @@ class HealthExporter { String get _hrvScalarKey => isApple ? 'sdnn' : 'rmssd'; List get _types => [ - HealthDataType.RESTING_HEART_RATE, - _hrvType, - HealthDataType.RESPIRATORY_RATE, - HealthDataType.HEART_RATE, - HealthDataType.ACTIVE_ENERGY_BURNED, - HealthDataType.BASAL_ENERGY_BURNED, - HealthDataType.STEPS, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, - HealthDataType.SLEEP_LIGHT, - HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_SESSION, - HealthDataType.WORKOUT, - ]; + HealthDataType.RESTING_HEART_RATE, + _hrvType, + HealthDataType.RESPIRATORY_RATE, + HealthDataType.HEART_RATE, + HealthDataType.ACTIVE_ENERGY_BURNED, + HealthDataType.BASAL_ENERGY_BURNED, + // STEPS is requested for DELETE SCOPE ONLY — nothing writes steps any + // more (see the block further down for why). We still need the write + // permission to purge the fabricated step samples earlier versions put + // into Apple Health / Health Connect, which is why WRITE_STEPS stays in + // the Android manifest. That purge is a ONE-SHOT migration and does not + // belong in the per-day rewrite loop — see [_purgeLegacyStepsIfNeeded] + // and [_rewriteTypes]. + HealthDataType.STEPS, + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_LIGHT, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_SESSION, + HealthDataType.WORKOUT, + ]; + + /// The types the per-day delete-then-write pass touches. + /// + /// STEPS is deliberately excluded. It is in [_types] only so `request()` asks + /// for the scope the legacy purge needs; including it here would run a delete + /// for a type nothing writes on every re-export of the recent (not-yet- + /// finalized) tail, forever, and would let that delete's failure flip a day's + /// export to unsuccessful. + /// Composes both intents on this seam: + /// * `healthDeleteTypes` (platform-aware) drops the sleep types and + /// HEART_RATE on Android, because the native SleepSessionRecord writer + /// and the minute-HR batch own their own cleanup there. + /// * STEPS is then removed on top, because NOTHING writes steps any more. + /// Deleting a type we never write would run on every re-export of the + /// not-yet-finalized tail forever, and — since a false `delete()` flips + /// `success` — could permanently stall a day's export cursor. The + /// historical fabricated samples are handled once by + /// [_purgeLegacyStepsIfNeeded] instead, outside the success accounting. + List get _rewriteTypes => [ + for (final t in healthDeleteTypes(isApplePlatform: isApple)) + if (t != HealthDataType.STEPS) t, + ]; + + /// Cursor for the one-shot legacy-STEPS purge: the newest day already purged. + static const _kStepsPurgeCursor = 'health_steps_purged_through'; + String? _stepsPurgedThrough; + + /// Delete the fabricated STEPS samples earlier versions wrote for [date]. + /// + /// ONE-SHOT, and deliberately not part of the day's success accounting: this + /// is a migration cleaning up data we should never have written, not part of + /// exporting the day. A failure here must not stall the export cursor for a + /// type nothing writes. Days are walked ascending, so the cursor advances + /// monotonically and a re-exported tail day is not re-purged. + Future _purgeLegacyStepsIfNeeded( + String date, + DateTime dayStart, + DateTime dayEnd, + ) async { + _stepsPurgedThrough ??= await LocalDb.getCursor(_kStepsPurgeCursor) ?? ''; + final through = _stepsPurgedThrough!; + if (through.isNotEmpty && date.compareTo(through) <= 0) return; + try { + await _health.delete( + type: HealthDataType.STEPS, + startTime: dayStart, + endTime: dayEnd, + ); + _stepsPurgedThrough = date; + await LocalDb.setCursor(_kStepsPurgeCursor, date); + } catch (e) { + // Leave the cursor where it is so the next pass retries this day. + debugPrint('[health] purge legacy steps $date: $e'); + } + } // We do NOT gate on a write-permission check: HealthKit hides write-auth by // design, and Health Connect's hasPermissions(WRITE) frequently returns @@ -597,9 +659,13 @@ class HealthExporter { } } + // One-shot cleanup of the fabricated step samples earlier versions wrote. + // Outside the success accounting on purpose — see the method doc. + await _purgeLegacyStepsIfNeeded(date, dayStart, dayEnd); + // Idempotency: remove OUR previously-written samples for this day (HealthKit / // Health Connect only let an app delete its own data), then re-write fresh. - for (final t in healthDeleteTypes(isApplePlatform: isApple)) { + for (final t in _rewriteTypes) { try { final deleted = await _health.delete( type: t, @@ -793,26 +859,22 @@ class HealthExporter { } } - // Steps (24/7 estimate) over the whole day. - final steps = sc('steps'); - if (steps != null && steps > 0) { - try { - final wrote = await _health.writeHealthData( - value: steps.toDouble(), - type: HealthDataType.STEPS, - startTime: dayStart, - endTime: dayEnd, - unit: HealthDataUnit.COUNT, - ); - if (!wrote) { - debugPrint('[health] write steps returned false'); - success = false; - } - } catch (e) { - debugPrint('[health] write steps: $e'); - success = false; - } - } + // STEPS ARE DELIBERATELY NOT EXPORTED. + // + // We used to write `scalars.steps` here as a plain HealthDataType.STEPS + // sample. Two reasons that had to stop: + // + // 1. The value was a 1 Hz fabrication (active minutes x an assumed + // cadence) — measured at 2,645 against a true count under 400. + // 2. Even now that `steps` is real-pedometer-only, exporting it is + // wrong: on iOS the phone ALREADY writes its own pedometer steps to + // HealthKit, and we now READ those (see PhonePedometer). Writing our + // derived copy back would double-count into the system store and + // then feed our own number back to us on the next read. + // + // The "estimate" qualifier every in-app surface carries is also lost the + // moment a sample lands in Apple Health as a bare STEPS count, so a wrong + // number here contaminates every other app on the device. // Health Connect models stages as children of ONE SleepSessionRecord. The // health 11.1.1 generic SLEEP_* writer instead creates one parent record diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart new file mode 100644 index 00000000..907d874f --- /dev/null +++ b/lib/health/phone_pedometer.dart @@ -0,0 +1,269 @@ +import 'package:flutter/foundation.dart'; +import 'package:health/health.dart'; + +import '../data/db.dart'; +import '../data/day_label.dart'; + +/// Reads steps in `[from, to)`. Null means the READ FAILED — see [syncDay]. +typedef StepIntervalReader = Future Function(DateTime from, DateTime to); + +/// REAL step counts, read from the phone's own pedometer. +/// +/// WHY THIS EXISTS +/// +/// The band is worn on the WRIST, and a wrist is a bad place to count steps. +/// Two independent limits, both measured rather than assumed: +/// +/// * The 24/7 historical stream is 1 Hz. Gait is 1.4-2.3 Hz, so every gait +/// fundamental is sub-Nyquist and 80/100/140/160 spm all alias to the same +/// 0.333 Hz — cadence is not merely noisy there, it is unidentifiable. No +/// published step detector exists below 10 Hz. +/// * Even at full rate, wrist amplitude ranks ordinary arm work ABOVE walking +/// (stirring ~104 mg, chopping ~139 mg vs walking ~66 mg ENMO), which is +/// why wrist devices emit 22-27 false steps/min during dishes, reaching and +/// driving (O'Connell 2017) while detecting slow walking at sensitivity +/// 0.05 (Straczkiewicz 2023). +/// +/// The phone rides in a pocket or bag, observes trunk motion, and runs a +/// vendor pedometer that is continuously validated against exactly this +/// problem. It is simply a better sensor for this one quantity, and it costs us +/// nothing: iOS already writes its CMPedometer counts into HealthKit and +/// Android writes to Health Connect, both on-device. +/// +/// PRIVACY / LOCAL-FIRST: this is a local read from the on-device health store. +/// Nothing leaves the phone, and nothing here is written back — see +/// [HealthExport] for why we deliberately stopped writing STEPS out. +class PhonePedometer { + /// [stepReader] exists so the hour walk is testable. `Health` has a private + /// constructor and is a singleton factory, so it cannot be subclassed or + /// faked from a test library — and the walk is where the DST and partial-read + /// bugs live, so it needs coverage that does not touch a real health store. + PhonePedometer({Health? health, StepIntervalReader? stepReader}) + : _health = health ?? Health(), + _stepReader = stepReader; + + final Health _health; + final StepIntervalReader? _stepReader; + + Future _readSteps(DateTime from, DateTime to) => + _stepReader?.call(from, to) ?? + _health.getTotalStepsInInterval(from, to); + + static const List _types = [HealthDataType.STEPS]; + + /// Ask for READ access to steps. Safe to call repeatedly. + Future requestPermission() async { + try { + await _health.configure(); + final already = await _health.hasPermissions( + _types, + permissions: const [HealthDataAccess.READ], + ); + if (already == true) return true; + return await _health.requestAuthorization( + _types, + permissions: const [HealthDataAccess.READ], + ); + } catch (e) { + debugPrint('[phone_pedometer] permission: $e'); + return false; + } + } + + /// Best-effort permission probe. `null` is treated as MAYBE, not NO. + /// + /// Health Connect's `hasPermissions` frequently returns null/false even after + /// the user has granted everything — `HealthExport` documents this exact + /// behaviour and deliberately attempts every write rather than gating on the + /// check. Gating a READ on it here would reintroduce that failure: on Android + /// phone steps could silently never sync after a successful grant, and the + /// user would see only a missing step count with nothing to act on. + /// + /// So this returns false ONLY on an explicit `false`. A null (unknown) result + /// lets the read proceed and lets the platform enforce — an ungranted read + /// simply returns no data, which `syncDay` already treats as "unknown", not + /// as zero. + Future hasPermission() async { + try { + await _health.configure(); + final r = await _health.hasPermissions( + _types, + permissions: const [HealthDataAccess.READ], + ); + return r != false; // null => attempt anyway + } catch (e) { + debugPrint('[phone_pedometer] hasPermission: $e'); + return true; // probe failed; let the read attempt decide + } + } + + /// Read [day]'s steps in hourly buckets and replace that day's phone rows. + /// + /// Hourly rather than one daily total so the derivation keeps a usable notion + /// of WHEN the steps happened, and so a partially-elapsed today still banks + /// what has happened so far. + /// + /// Uses `getTotalStepsInInterval`, which on iOS is an HKStatisticsQuery + /// cumulative sum — HealthKit de-duplicates overlapping samples from multiple + /// sources (iPhone + Watch) itself, which a raw sample read would not. + /// + /// Returns the day's total, or null if the read failed or was not permitted + /// (null means "unknown", NOT zero — the caller must not persist a zero). + /// + /// A null from ANY hour aborts the whole day. `null` from this plugin means + /// the query FAILED, not that the hour was empty — verified in both native + /// implementations at health 11.1.1: + /// + /// * iOS `SwiftHealthPlugin.swift`: `HKStatisticsQuery` returns `nil` only + /// via `guard let queryResult else { result(nil) }`. An hour with no + /// samples has a nil `sumQuantity()` but still falls through to + /// `steps = 0.0` and returns `0`. + /// * Android `HealthPlugin.kt`: `response[StepsRecord.COUNT_TOTAL] ?: 0L` + /// returns `0` for an empty range; `result.success(null)` happens only in + /// the `catch`. + /// + /// So a partial read is a real failure, and it must not be persisted: + /// [LocalDb.replacePhoneCoverageForDay] is delete-then-insert, so banking a + /// short read would LOWER a previously complete day. And because + /// [LocalDb.liveStepsForDay] prefers phone rows outright, the truncated total + /// would also keep suppressing the band fallback. + Future syncDay(DateTime dayStartLocal) async { + final dayId = dayLabelOf(dayStartLocal); + try { + // Only touch the platform when we are actually going through it. + if (_stepReader == null) await _health.configure(); + final windows = <({int startTs, int endTs, int steps})>[]; + var total = 0; + var anyRead = false; + + // CALENDAR-AWARE hour walk. `Duration` arithmetic on a local DateTime is + // ABSOLUTE, so `dayStartLocal.add(Duration(hours: h))` over a fixed 24 + // iterations spans 25 wall-clock hours on a fall-back day (the last + // bucket crosses into the next local day and its steps get counted + // twice) and 23 on a spring-forward day (one real hour never queried). + // Constructing each boundary from calendar fields lets the runtime place + // the instant correctly, and the next-midnight bound ends the day exactly. + final nextMidnight = DateTime( + dayStartLocal.year, + dayStartLocal.month, + dayStartLocal.day + 1, + ); + for (var h = 0; h < 25; h++) { + // RE-READ THE CLOCK EACH ITERATION. Each bucket is an async platform + // query, so a whole day's walk can straddle an hour boundary. Captured + // once up front, `now` went stale mid-loop and the current hour was + // capped short — under-reporting today's most recent steps until some + // later sync happened to re-read the day. + final now = DateTime.now(); + final from = DateTime(dayStartLocal.year, dayStartLocal.month, + dayStartLocal.day, h); + if (!from.isBefore(nextMidnight)) break; // spring-forward short day + if (from.isAfter(now)) break; // future hours of today + var to = DateTime(dayStartLocal.year, dayStartLocal.month, + dayStartLocal.day, h + 1); + if (to.isAfter(nextMidnight)) to = nextMidnight; + final capped = to.isAfter(now) ? now : to; + // SKIP a zero-length bucket, never END the walk on one. On a + // spring-forward day the missing local hour makes `DateTime(y,m,d,2)` + // and `DateTime(y,m,d,3)` resolve to the SAME instant, so `h = 2` is + // zero-width. Breaking here left every remaining hour of that day + // unqueried while `anyRead` was already true from the earlier hours, so + // the day was REPLACED with ~3 hours of windows — and since phone rows + // win over band rows, that truncated total stuck permanently once the + // day aged out of the `syncRecent` window. + // + // The "reached now" case does not need a break here: the next + // iteration's `from` is after `now` and the guard above ends the walk. + if (!capped.isAfter(from)) continue; + + final n = await _readSteps(from, capped); + // Read failure (see the doc above) — abandon the day rather than + // persist a partial one over a good previous sync. + if (n == null) return null; + anyRead = true; + if (n <= 0) continue; + windows.add(( + startTs: from.millisecondsSinceEpoch ~/ 1000, + endTs: capped.millisecondsSinceEpoch ~/ 1000, + steps: n, + )); + total += n; + } + + // No hour was ever polled (a day entirely in the future, or a walk that + // produced no buckets at all). Nothing was read, so nothing is known. + if (!anyRead) return null; + + // AN ALL-ZERO DAY MUST NOT ERASE A DAY WE ALREADY BANKED WITH REAL + // COUNTS. Every hour returning 0 is indistinguishable at this layer from + // a genuinely sedentary day, and the one that matters is the failure the + // rest of this file already documents: on iOS `requestAuthorization` + // reports success even when the user denied READ, so reads come back + // *empty rather than null* forever after. Because + // `replacePhoneCoverageForDay` is delete-then-insert and phone rows win + // outright in `liveStepsForDay`, one such sync would wipe a real + // multi-thousand-step day and leave nothing — not even the band fallback + // that day had before phone steps were enabled. + // + // A day that legitimately went to zero after being non-zero is not a real + // trajectory (step counts only accumulate within a day), so keeping the + // banked value costs nothing. Returning null rather than 0 is the honest + // report: this day was NOT confirmed, so it must not count toward + // `daysRead` in the diagnostic the Profile screen shows. + if (total == 0 && await LocalDb.phoneStepsForDay(dayId) > 0) { + debugPrint('[phone_pedometer] $dayId read all-zero over a day that ' + 'already holds phone steps — keeping the banked day'); + return null; + } + + await LocalDb.replacePhoneCoverageForDay(dayId, windows); + return total; + } catch (e) { + debugPrint('[phone_pedometer] syncDay $dayId: $e'); + return null; + } + } + + /// Days pulled on a routine (launch / post-export) sync. + /// + /// One platform round trip PER HOUR PER DAY, so the window is the whole cost: + /// the original 7-day default was up to 168 sequential `getTotalStepsInInterval` + /// calls, fired on every launch and again after every health export. Only + /// today can still change, and yesterday only if the app did not run then, so + /// two days covers the routine case at ~48 calls. + static const int routineSyncDays = 2; + + /// Days pulled on an EXPLICIT sync (the user enabling the toggle, or a manual + /// health sync) — the backfill window, worth its cost because the user asked. + static const int fullSyncDays = 7; + + /// Sync the last [days] days (including today). + /// + /// Returns how many days were read successfully and the steps they held. The + /// caller surfaces this: "permission granted but no data ever arrives" is + /// otherwise a silent dead end on iOS, where `requestAuthorization` reports + /// success even when the user denied READ. + Future<({int daysRead, int totalSteps})> syncRecent({ + int days = routineSyncDays, + }) async { + if (_stepReader == null && !await hasPermission()) { + return (daysRead: 0, totalSteps: 0); + } + final now = DateTime.now(); + var ok = 0; + var total = 0; + for (var d = 0; d < days; d++) { + // Calendar subtraction, NOT `Duration(days: d)` — the latter lands on + // 23:00 or 01:00 across a DST transition rather than local midnight, + // which would mislabel the day and start its hour walk at the wrong + // offset. DateTime normalises an out-of-range day field for us. + final day = DateTime(now.year, now.month, now.day - d); + final n = await syncDay(day); + if (n != null) { + ok++; + total += n; + } + } + return (daysRead: ok, totalSteps: total); + } +} diff --git a/lib/import/noop_import.dart b/lib/import/noop_import.dart index de6703b9..627508ee 100644 --- a/lib/import/noop_import.dart +++ b/lib/import/noop_import.dart @@ -412,10 +412,11 @@ class NoopImporter { return out; } - /// Bank [date]'s step runs into `live_coverage` so the derivation picks them up - /// as REAL steps (`liveStepsForDay`) and excludes those minutes from the 1 Hz - /// estimate (`coverageWindowsOverlapping`) — the same contract the live 100 Hz - /// pedometer uses, so imported and live days are counted identically. + /// Bank [date]'s step runs into `live_coverage` so the derivation picks them + /// up as REAL steps (`liveStepsForDay`) — the same contract the live 100 Hz + /// pedometer uses, so imported and live days are counted identically. These + /// are BAND-sourced counts (the strap's own step counter), which is what makes + /// them a real gait measurement rather than the deleted 1 Hz estimate. /// /// IDEMPOTENT BY TIME SPAN, not by exact window: `live_coverage` is an /// append-only SUM with no uniqueness constraint, so anything already banked diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 6397108b..f6d4103a 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -53,6 +53,7 @@ import '../notify/notification_event.dart'; import '../notify/notification_prefs.dart'; import '../gestures/gesture_settings.dart'; import '../health/health_export.dart'; +import '../health/phone_pedometer.dart'; import '../import/noop_import.dart'; import '../import/whoop_import.dart'; import '../gestures/gesture_dispatcher.dart'; @@ -230,6 +231,18 @@ class AppState extends ChangeNotifier { // The companion-URL override is loaded in _initCompanion (single source of // truth for every network call — announcements, OTA, telemetry, import). healthSyncEnabled = prefs.getBool(_kHealthSync) ?? false; + phoneStepsEnabled = prefs.getBool(_kPhoneSteps) ?? false; + // Steps only exist if a real pedometer measured them, so kick the phone + // pull early. This is BEST-EFFORT and establishes no ordering: it is + // unawaited, so a derive pass can read `live_coverage` while the sync is + // still in flight and that day then derives without phone steps. It + // self-heals on the next light pass. + // + // ROUTINE window only (2 days, ~48 platform round trips). Each hourly + // bucket is one platform call, so the 7-day backfill window is up to 168 of + // them; only today can still change, and only yesterday if the app did not + // run then. The full window runs on the explicit gestures instead. + if (phoneStepsEnabled) unawaited(syncPhoneSteps()); // Best-effort, no prompt: learn the current health-permission state so the // Profile toggle reflects reality on open. if (healthSyncEnabled) unawaited(checkHealth()); @@ -388,13 +401,122 @@ class AppState extends ChangeNotifier { } /// Export all finalized-but-unexported days now. Returns days written. - Future healthSyncNow() => _runHealthExport(forceRetry: true); + Future healthSyncNow() async { + // Both halves of this seam matter and neither subsumes the other: the + // export runs through the single-flight guard (main), and the phone-steps + // sync stays gated on the user's preference (this branch). + final n = await _runHealthExport(forceRetry: true); + // Gate on the user's own preference. `disablePhoneSteps` deliberately does + // NOT revoke the platform permission (that is the user's to do in + // Settings), so an unconditional sync here would write phone rows straight + // back after the user turned the feature off — and since `liveStepsForDay` + // prefers phone rows outright, it would re-suppress the band count, the + // exact outcome `disablePhoneSteps` exists to prevent. + // An explicit health sync is a user gesture — take the full window. + if (phoneStepsEnabled) { + unawaited(syncPhoneSteps(days: PhonePedometer.fullSyncDays)); + } + return n; + } Future _runHealthExport({bool forceRetry = false}) => _healthExportSingleFlight.run( () => _healthExport.exportAll(forceRetry: forceRetry), ); + // ── phone pedometer (the ONLY source of real 24/7 step counts) ───────────── + final PhonePedometer _phonePedometer = PhonePedometer(); + bool phoneStepsEnabled = false; + static const String _kPhoneSteps = 'phone_steps'; + + /// Ask for READ access to the phone's own step counts (user gesture). + /// + /// The band cannot count steps: it is on the wrist, and its 24/7 stream is + /// 1 Hz, where gait is sub-Nyquist. The phone rides in a pocket and already + /// counts steps continuously into the on-device health store — this reads + /// them. Nothing is uploaded and nothing is written back. + Future requestPhoneSteps() async { + final ok = await _phonePedometer.requestPermission(); + // PERSIST BEFORE mutating in-memory state. Setting the field first and + // then awaiting the write leaves the two disagreeing if the write throws: + // the toggle reads ON for this run and OFF on the next launch, and the + // syncs below would bank phone rows the restored state says the user never + // enabled — rows that then keep overriding the band, since `disablePhone + // Steps` is the only thing that clears them and the user never sees the + // toggle on to turn it off. + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kPhoneSteps, ok); + phoneStepsEnabled = ok; + notifyListeners(); + // The user just asked for this, so pull the full backfill window rather + // than the cheap routine one. + if (ok) unawaited(syncPhoneSteps(days: PhonePedometer.fullSyncDays)); + return ok; + } + + /// Turn phone steps off and DROP the counts we pulled. + /// + /// Leaving the rows behind would keep serving phone-sourced steps from a + /// source the user just switched off, and `liveStepsForDay` prefers phone + /// rows over band rows — so a stale row would keep overriding the band + /// indefinitely. Revoking the platform permission is the user's to do in + /// Settings; all we can do is stop reading and forget what we read. + /// + /// Clearing `live_coverage` only changes what FUTURE derives compute — the + /// screens read scalars persisted in `day_result`/`metric_series`. So this + /// also re-derives, exactly as `setSleepOverride` does for the equivalent + /// case; without it the user turns the toggle off and keeps seeing + /// phone-sourced counts. + /// + /// The re-derive is bounded: its scope is days that still hold raw + /// (`rawRetentionDays`), not the whole history. Older days keep their + /// phone-sourced value permanently — there is no substrate left to recompute + /// them from, which is the same limit every other version bump has. + Future disablePhoneSteps() async { + // Persist first, for the reason in [requestPhoneSteps]. + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kPhoneSteps, false); + phoneStepsEnabled = false; + phoneStepsLastSyncedDays = null; + phoneStepsLastTotal = null; + try { + await LocalDb.clearPhoneCoverage(); + } catch (e) { + debugPrint('[phone_steps] clear: $e'); + } + notifyListeners(); + unawaited(_reanalyzeForOverride()); + } + + /// Days successfully read on the last phone-step sync, and the total banked. + /// + /// Surfaced in Profile because the failure mode is otherwise INVISIBLE: on + /// iOS `requestAuthorization` returns true even when the user denies READ + /// (HealthKit hides read denial by design), so the toggle sits on, every read + /// comes back empty, and no step count ever appears with nothing to act on. + int? phoneStepsLastSyncedDays; + int? phoneStepsLastTotal; + + /// Pull the last [days] days of phone step counts into `live_coverage`. + /// + /// Idempotent (delete-then-insert per day, scoped to the phone source), so + /// calling it repeatedly — on launch, after a sync, from a background pass — + /// can never accumulate. Best-effort; never throws. + Future syncPhoneSteps({ + int days = PhonePedometer.routineSyncDays, + }) async { + try { + final r = await _phonePedometer.syncRecent(days: days); + phoneStepsLastSyncedDays = r.daysRead; + phoneStepsLastTotal = r.totalSteps; + notifyListeners(); + return r.daysRead; + } catch (e) { + debugPrint('[phone_steps] sync: $e'); + return 0; + } + } + /// Session-triggered Health export for one just-finished workout (issue /// #130) — used by callers outside this class (e.g. confirming an /// auto-detected workout in workouts_screen.dart) that write a `sessions` @@ -1694,9 +1816,9 @@ class AppState extends ChangeNotifier { } /// True while some foreground feature is actively consuming the live streams - /// (workout coach, HRV spot check, step-calibration walk, breathing session). + /// (workout coach, HRV spot check, breathing session). bool get _hasLiveConsumer => - activeWorkout != null || spotActive || _stepCalActive || breathingActive; + activeWorkout != null || spotActive || breathingActive; /// Downgrade live to HR-only when backgrounded with no live consumer. The /// keep-alive re-arm respects the HR-only mode, so the downgrade sticks until @@ -1786,8 +1908,6 @@ class AppState extends ChangeNotifier { final List _magMin = []; // current minute's magnitude signal int _committedRaw = 0; // raw (pre-gain) steps from completed minutes int _liveSamples = 0; // total 100 Hz samples streamed this session - double _liveEnmoSum = 0; // 1 Hz-equivalent ENMO accumulator (for calibration) - int _liveEnmoN = 0; bool _imuStreamSeen = false; // prefer the 0x33 IMU stream once it appears static const int _minuteSamples = 6000; // 60 s @ 100 Hz — calibration chunk int _lastWalkMs = 0; // last time steps were accumulated @@ -1879,8 +1999,11 @@ class AppState extends ChangeNotifier { final mags = f.mags; if (mags.isEmpty) return; // Append this frame's |a|(g) samples (gravity INCLUDED — AN-2554's dynamic - // threshold rides the ~1 g baseline). Also accumulate a 1 Hz-equivalent ENMO - // sample (mean |a| − 1 g) for cadence calibration. + // threshold rides the ~1 g baseline). `e` is this frame's 1 Hz-equivalent + // ENMO (mean |a| − 1 g), read below by the stillness nudge and the posture + // check. It no longer feeds a cadence calibration — that was deleted along + // with the 1 Hz step estimator that was its only consumer (kAlgoVersion + // v55). var magSum = 0.0; for (final m in mags) { _magMin.add(m); @@ -1888,8 +2011,6 @@ class AppState extends ChangeNotifier { } _liveSamples += mags.length; final e = (magSum / mags.length) - 1.0; - _liveEnmoSum += e > 0 ? e : 0.0; - _liveEnmoN++; // Phone-clock extent of the ingested stream — the only observation that // reports how long this session actually ran (the band's record timestamp // typically repeats). Used as a DURATION only; see [_liveCoverageWindow]. @@ -1952,9 +2073,7 @@ class AppState extends ChangeNotifier { _magMin.clear(); _committedRaw = 0; _liveSamples = 0; - _liveEnmoSum = 0; _lastLiveUiNotifyMs = 0; - _liveEnmoN = 0; _imuStreamSeen = false; _liveCoverStartTs = null; _liveCoverEndTs = 0; @@ -1962,15 +2081,15 @@ class AppState extends ChangeNotifier { _liveLastIngestMs = null; } - /// End-of-session: if the bout is credible walking, fold it into the personal - /// cadence calibration (persisted) so the 24/7 estimate gets more accurate. + /// End-of-session: bank the REAL 100 Hz step window into `live_coverage`. + /// + /// No cadence calibration any more — its only consumer was the deleted 1 Hz + /// `dailyStepEstimate` (see kAlgoVersion v55). Future _finalizeLivePedometer() async { // RAW, never the cushioned display value: a second short session ending // inside the first one's grace window would otherwise persist the FIRST // session's total again (double-counted coverage + a nonsense cadence). final steps = _rawSessionSteps; // gain-applied - final durS = _liveSamples / 100.0; - final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; // Derive the coverage window BEFORE resetting (it reads session counters). final window = _liveCoverageWindow(steps); if (steps > 0) { @@ -1985,9 +2104,9 @@ class AppState extends ChangeNotifier { _sessionCushionSetAtMs = DateTime.now().millisecondsSinceEpoch; } _resetLivePedometer(); - // Record the REAL 100 Hz step window (device time). The derivation pass adds - // it to the day's steps AND excludes those minutes from the 1 Hz estimate, so - // 100 Hz always wins and a minute is never counted twice. + // Record the REAL 100 Hz step window (device time). This is BAND-sourced + // coverage; the derivation reads it via `liveStepsForDay`, which prefers a + // phone count for the day when one exists and never sums the two. if (window != null) { final day = dayLabelOf( DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), @@ -1998,25 +2117,6 @@ class AppState extends ChangeNotifier { // that would otherwise let a killed-process session recover is no longer // needed. await _clearLiveSessionCheckpoint(); - if (steps <= 0 || durS < 20) return; - final cadence = steps / (durS / 60.0); - // Any nonzero AN-2554 count is CONFIRM-gated gait; confidence is high when - // the cadence lands in a walking band (else let calibrateCadence reject it). - final conf = (cadence >= 60 && cadence <= 200) ? 0.85 : 0.4; - final result = ana.PedometerResult(steps, durS, cadence, 0.0, conf); - try { - final prior = await LocalDb.getStepCalibration(); - final next = ana.calibrateCadence(prior, result, enmo); - if (next != null && !identical(next, prior)) { - await LocalDb.putStepCalibration(next); - _log( - '[steps] cadence calibrated → ' - '${next.cadenceSpm.toStringAsFixed(0)} spm (n=${next.n})', - ); - } - } catch (e) { - _log('[steps] calibration skipped: $e'); - } } // Whatever accrued via _committedRaw/_magMin between minute-commits is @@ -3394,103 +3494,15 @@ class AppState extends ChangeNotifier { _breathingEnabledStreams = false; } - // ── guided step calibration (open-road walk) ──────────────────────────────── - // A short live 100 Hz walk teaches the user's real walking signature (refEnmo) - // + cadence, which anchors the 1 Hz daily estimate. Target a step count with a - // buffer so the AN-2554 confirm-gate has settled. - static const int stepCalTargetSteps = 200; // steps to learn a stable cadence - static const int stepCalBuffer = 50; // ask the user to walk a bit more - bool _stepCalEnabledStreams = false; - bool _stepCalActive = false; // a calibration walk is in progress - - /// Begin a calibration walk: turn on the live IMU stream and count from zero. - Future startStepCalibration() async { - if (!isConnected) throw Exception('Connect to your strap first'); - // LATCH SAFELY. `_stepCalActive` is set true BEFORE the stream arming - // below, and the arming can throw (the link dropping mid-write propagates - // straight out to the UI). With no try/finally the latch stuck true for the - // rest of the process — the only reset is _endStepCalStreams(), reachable - // solely from finish/cancel, which the user never gets to because the walk - // never started. A stuck latch pins [_hasLiveConsumer] true, so - // [_maybeDowngradeLiveForBackground] never downgrades and the 100 Hz raw - // flood keeps streaming while backgrounded — exactly the R24-offload - // starvation the downgrade exists to prevent. - _stepCalActive = true; - var armed = false; - try { - // OWNERSHIP: same rule as the spot check — only claim "we enabled it" - // when live was actually OFF, so ending the walk can never turn off - // streams the open session still expects on. If the background downgrade - // left live in HR-only, upgrade to full (the walk needs the 100 Hz IMU - // stream) without taking ownership. - // - // retryFullLiveStreams (not enableLiveStreams): the walk NEEDS the 100 Hz - // IMU stream, and the sticky standard-HR fallback silently vetoes it — - // every calibration after a fallback trip counted 0 steps forever. An - // explicit user-initiated walk is exactly the moment to give the full - // flood another chance; the detectors re-trip if the radio can't cope. - if (!engine.liveEnabled) { - await engine.retryFullLiveStreams(); - _stepCalEnabledStreams = true; - } else if (engine.liveHrOnly || device.standardHrFallback) { - await engine.retryFullLiveStreams(); - } - armed = true; - } finally { - if (!armed) _stepCalActive = false; - } - _resetLivePedometer(); // count this walk from 0 - notifyListeners(); - } - - /// Finish the calibration walk: fold the live bout into the personal cadence - /// model (refEnmo + cadence). Returns the learned cadence (spm), or null if the - /// walk wasn't credible. Stops the stream we turned on. - Future finishStepCalibration() async { - final steps = _rawSessionSteps; // raw, never the display cushion - final durS = _liveSamples / 100.0; - final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; - double? learned; - if (steps > 0 && durS >= 20) { - final cadence = steps / (durS / 60.0); - final conf = (cadence >= 60 && cadence <= 200) ? 0.9 : 0.4; - final result = ana.PedometerResult(steps, durS, cadence, 0.0, conf); - try { - final prior = await LocalDb.getStepCalibration(); - final next = ana.calibrateCadence(prior, result, enmo); - if (next != null) { - await LocalDb.putStepCalibration(next); - learned = next.cadenceSpm; - _log( - '[steps] CALIBRATED → ${next.cadenceSpm.toStringAsFixed(0)} spm ' - '(refEnmo=${next.refEnmo.toStringAsFixed(3)}, n=${next.n})', - ); - } - } catch (e) { - _log('[steps] calibration failed: $e'); - } - } - _endStepCalStreams(); - _resetLivePedometer(); - notifyListeners(); - return learned; - } - - /// Cancel a calibration walk without saving. - void cancelStepCalibration() { - _endStepCalStreams(); - _resetLivePedometer(); - notifyListeners(); - } - - /// Release the streams a calibration walk armed — ONLY if we armed them. - void _endStepCalStreams() { - _stepCalActive = false; - if (_stepCalEnabledStreams && activeWorkout == null) { - unawaited(engine.disableLiveStreams()); - } - _stepCalEnabledStreams = false; - } + // GUIDED STEP CALIBRATION REMOVED (v56). + // + // A short live walk used to teach a personal `refEnmo` + cadence, which was + // consumed by ONE caller: the 1 Hz `dailyStepEstimate`. That estimator is + // gone (1 Hz cannot resolve gait — see the kAlgoVersion v55 note), so the + // calibration had no reader left. It kept a "Calibrate steps" row on the + // Steps screen that told the user their walk had taught the app something + // when nothing read the result. The Tier-A 100 Hz AN-2554 pedometer is + // threshold-based and never needed it. // ── live session coach ─────────────────────────────────────────────────────── LiveWorkoutState? activeWorkout; diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index f44a3350..ad9a06ca 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -1094,11 +1094,87 @@ class _HealthSection extends StatelessWidget { const SizedBox(height: Sp.x3), _statusRow(context, st, store), ], + const SizedBox(height: Sp.x3), + Divider(height: 1, thickness: 1, color: AppColors.divider), + const SizedBox(height: Sp.x3), + _phoneStepsRow(context, store), ], ), ); } + /// Read the phone's own step counts from the health store. + /// + /// This is the ONLY source of real all-day steps. The band is on the wrist + /// and its 24/7 stream is 1 Hz, where walking is physically unresolvable — + /// so without this, most days simply have no step count, which is the honest + /// outcome but not a useful one. + Widget _phoneStepsRow(BuildContext context, String store) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Use phone step count'), + const SizedBox(height: 1), + Text( + 'Your band is on your wrist and can’t reliably count steps. ' + 'Your phone already counts them — read them from $store. ' + 'Stays on your device.', + style: AppText.captionMuted, + ), + // The failure mode this exists for: on iOS the permission prompt + // reports success even when the user denies READ access, so + // without a status line the toggle just sits on and no step count + // ever appears, with nothing for the user to act on. + if (app.phoneStepsEnabled) ...[ + const SizedBox(height: 2), + Text( + _phoneStepsStatus(app, store), + style: AppText.captionMuted, + ), + ], + ], + ), + ), + Switch( + value: app.phoneStepsEnabled, + activeThumbColor: AppColors.accent, + onChanged: (v) async { + if (!v) { + await app.disablePhoneSteps(); + return; + } + final ok = await app.requestPhoneSteps(); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$store didn’t grant step access.')), + ); + } + }, + ), + ], + ); + } + + /// One line telling the user whether the read is actually producing anything. + static String _phoneStepsStatus(AppState app, String store) { + final days = app.phoneStepsLastSyncedDays; + if (days == null) return 'Reading…'; + if (days == 0) { + return 'No data from $store yet. If you never saw a permission prompt, ' + 'allow Steps for OpenStrap in $store settings.'; + } + final total = app.phoneStepsLastTotal ?? 0; + if (total == 0) { + return 'Connected to $store — no steps recorded in the last $days day' + '${days == 1 ? '' : 's'}.'; + } + return 'Read $total steps from $store over $days day' + '${days == 1 ? '' : 's'}.'; + } + Widget _statusRow(BuildContext context, HealthLinkState st, String store) { final messenger = ScaffoldMessenger.of(context); // Health Connect must be installed/updated first (Android). diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index 25e30a29..f14666dd 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -24,7 +24,7 @@ const Map kMetricInfo = { 'load': 'Recent (7d) vs habitual (28d) load. 0.8–1.3 is the sweet spot.', 'fitness': 'Direction of your fitness from resting-HR and recovery trends.', 'calories': 'Active energy burned, estimated from your heart rate.', - 'steps': 'Estimated steps from wrist motion.', + 'steps': 'Real steps counted by your phone or the band\'s live sensor.', 'sleep': 'Time actually asleep last night.', 'efficiency': 'Share of time in bed actually spent asleep.', 'regularity': 'How consistent your sleep timing is, 0–100.', diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 07e31a76..fd2481f0 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -24,7 +24,6 @@ import '../heart/live_hr_tile.dart'; import '../insights/coach_cards.dart'; import '../sleep/sleep_detail_screen.dart'; import '../spotcheck/spot_check_screen.dart'; -import '../today/step_calibration_screen.dart'; import '../today/step_goal_screen.dart'; import 'detail_cards.dart'; import 'metric_screen.dart'; @@ -295,9 +294,6 @@ class _ActivityDetailState extends State<_ActivityDetail> { onSetGoal: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => StepGoalScreen(goal: goal)), ), - onCalibrate: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const StepCalibrationScreen()), - ), ); } } @@ -311,7 +307,6 @@ class StepsDayContent extends StatelessWidget { final List weekValues; // raw step counts (nulls = no data) final List weekLabels; final VoidCallback? onSetGoal; - final VoidCallback? onCalibrate; const StepsDayContent({ super.key, @@ -320,7 +315,6 @@ class StepsDayContent extends StatelessWidget { this.weekValues = const [], this.weekLabels = const [], this.onSetGoal, - this.onCalibrate, }); @override @@ -360,17 +354,22 @@ class StepsDayContent extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - Tag('est', color: accent), + Tag('measured', color: accent), InfoDot( title: 'How steps are counted', body: - 'While the band streams live (a workout or with the ' - 'app open) we count REAL steps from its 100 Hz motion ' - 'sensor. The rest of the day the sensor samples too ' - 'slowly to count each step, so those hours are ' - 'ESTIMATED from your walking minutes and cadence.', + 'Only by something that can actually see your gait: ' + 'your phone\'s own pedometer, or the band\'s 100 Hz ' + 'sensor while it streams live (a workout, or with the ' + 'app open). We never add the two together — they are ' + 'the same walk seen from your pocket and your wrist.\n\n' + 'The rest of the day the band samples once a second, ' + 'which is too slow to resolve individual steps. Those ' + 'hours are left uncounted rather than estimated, so a ' + 'day with no real measurement shows no number at all.', methodNote: - 'Walk with the app open to sharpen the estimate.', + 'Turn on “Use phone step count” in Profile → Health ' + 'for all-day steps.', ), ], ), @@ -382,7 +381,10 @@ class StepsDayContent extends StatelessWidget { Expanded( child: BigStat( value: steps > 0 ? '$steps' : null, - caption: steps > 0 ? 'goal $g' : 'no steps yet', + // NOT "no steps yet" — absent means nothing that can + // resolve gait measured this day, which is a different + // statement from "you took zero steps". + caption: steps > 0 ? 'goal $g' : 'not measured', size: BigStatSize.xl, ), ), @@ -425,7 +427,7 @@ class StepsDayContent extends StatelessWidget { ).dsEnter(index: 1), ], - // ── goal + calibration ─────────────────────────────────────────────── + // ── goal ─────────────────────────────────────────────── const SizedBox(height: Sp.x3), SurfaceCard( padding: const EdgeInsets.symmetric( @@ -439,16 +441,8 @@ class StepsDayContent extends StatelessWidget { iconColor: accent, title: 'Daily step goal', value: goal == null ? 'Set' : '$goal', - divider: true, onTap: onSetGoal, ), - ListRow( - icon: OsIcon.run, - iconColor: accent, - title: 'Calibrate steps', - subtitle: 'Walk ~250 steps with the app open', - onTap: onCalibrate, - ), ], ), ).dsEnter(index: 2), diff --git a/lib/ui/today/step_calibration_screen.dart b/lib/ui/today/step_calibration_screen.dart deleted file mode 100644 index 38bdd078..00000000 --- a/lib/ui/today/step_calibration_screen.dart +++ /dev/null @@ -1,232 +0,0 @@ -// Step calibration — a short guided open-road walk that teaches the band YOUR -// walking signature (real 100 Hz pedometer → personal cadence + refEnmo). Once -// calibrated, the 1 Hz all-day step estimate is anchored to you instead of a -// guess. 1 Hz can't count steps directly (Nyquist); this is what makes the -// estimate trustworthy. -// -// Presentation: design-system language (ArcGauge progress, StateCard-style -// finish, themed CTA). The calibration start/finish/cancel logic is untouched. - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../state/app_state.dart'; -import '../design/design.dart'; - -class StepCalibrationScreen extends StatefulWidget { - const StepCalibrationScreen({super.key}); - @override - State createState() => _StepCalibrationScreenState(); -} - -class _StepCalibrationScreenState extends State { - // walk target = base + buffer so the AN-2554 confirm-gate settles. - final int _target = - AppState.stepCalTargetSteps + AppState.stepCalBuffer; // e.g. 250 - bool _started = false; - bool _saving = false; - double? _learnedCadence; - String? _error; - - late final AppState _appState; - - @override - void initState() { - super.initState(); - _appState = context.read(); - WidgetsBinding.instance.addPostFrameCallback((_) => _start()); - } - - Future _start() async { - if (!mounted) return; - try { - await context.read().startStepCalibration(); - if (mounted) setState(() => _started = true); - } catch (e) { - if (mounted) setState(() => _error = e.toString()); - } - } - - Future _save() async { - setState(() => _saving = true); - final cadence = await context.read().finishStepCalibration(); - if (!mounted) return; - if (cadence == null) { - // used to just silently fall through here - gauge would reset with - // nothing telling the user their walk didn't save. reusing the same - // _error/StateCard the start-failure path already has, "try again" - // re-arms a fresh walk which is the right recovery either way. - setState(() { - _saving = false; - _error = "That walk wasn't steady enough to learn from — try again " - 'on flatter, less crowded ground.'; - }); - return; - } - setState(() { - _saving = false; - _learnedCadence = cadence; - }); - } - - @override - void dispose() { - // If we leave without saving, stop the stream + drop the partial walk. - if (_learnedCadence == null) { - _appState.cancelStepCalibration(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final steps = context.select((a) => a.liveSteps); - // The standard-HR radio fallback suppresses the 100 Hz IMU stream this - // walk counts on. startStepCalibration clears it and retries; if it - // TRIPS AGAIN mid-walk the radio genuinely can't sustain the stream — - // say so instead of showing "Keep walking…" over a count of 0 forever. - final radioDegraded = - context.select((a) => a.device.standardHrFallback); - final done = _learnedCadence != null; - final t = (_target > 0 ? steps / _target : 0.0).clamp(0.0, 1.0).toDouble(); - final ready = steps >= _target; - - return AppScaffold( - title: 'Calibrate steps', - subtitle: 'A short walk teaches your stride', - actions: [ - const InfoDot( - title: 'Why calibrate', - body: - 'A brief walk with the app open lets the band\'s real pedometer ' - 'learn your personal cadence, which anchors the all-day step ' - 'estimate to you.', - bullets: [ - 'Walk on flat, open ground at your normal pace.', - 'Keep the phone on you and the app open.', - 'Avoid stairs, crowds and stops.', - ], - ), - ], - children: [ - if (_error != null) - StateCard( - icon: OsIcon.run, - title: "Couldn't start calibration", - message: _error!, - actionLabel: 'Try again', - onAction: () { - setState(() => _error = null); - _start(); - }, - ) - else if (done) - _doneCard() - else ...[ - const SizedBox(height: Sp.x4), - Center( - child: RepaintBoundary( - child: ArcGauge( - value: t, - color: DomainAccent.steps, - size: 200, - stroke: 16, - sweepFraction: 0.75, - animate: false, // live-driven — no reveal sweep fighting updates - center: Column(mainAxisSize: MainAxisSize.min, children: [ - Text('$steps', style: AppText.metric.copyWith(fontSize: 44)), - const SizedBox(height: 2), - Text('OF $_target', - style: - AppText.overline.copyWith(color: AppColors.inkMuted)), - ]), - ), - ), - ).dsEnter(), - const SizedBox(height: Sp.x3), - Center( - child: ready - ? const StatusChip('Ready to save', tone: ChipTone.positive) - : Text(_started ? 'Keep walking…' : 'Starting…', - style: AppText.label.copyWith(color: AppColors.inkSoft)), - ), - if (radioDegraded && _started && !ready) ...[ - const SizedBox(height: Sp.x4), - StateCard( - icon: OsIcon.bluetooth, - title: "Bluetooth can't keep up", - message: - 'The connection to your strap is struggling to carry the ' - 'high-rate motion stream, so steps aren\'t coming through. ' - 'Bring your phone closer to the strap and retry.', - actionLabel: 'Retry stream', - onAction: _start, - ), - ], - const SizedBox(height: Sp.x6), - SurfaceCard( - entranceIndex: 1, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const TileHeader('How to calibrate'), - const SizedBox(height: Sp.x3), - Text( - 'Walk on flat, open ground at your normal pace with the ' - 'app open. We count ~$_target real steps to learn your ' - 'stride and cadence.', - style: AppText.bodySoft), - ]), - ), - const SizedBox(height: Sp.x6), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: ready && !_saving ? _save : null, - child: _saving - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.4, color: Colors.white)) - : const Text('Save calibration'), - ), - ), - ], - ], - ); - } - - Widget _doneCard() => SurfaceCard( - level: 2, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container( - padding: const EdgeInsets.all(Sp.x3), - decoration: BoxDecoration( - color: AppColors.positiveSoft, - shape: BoxShape.circle, - ), - child: AppIcon(OsIcon.check, size: 22, color: AppColors.positive), - ), - const SizedBox(width: Sp.x3), - Text('Calibrated', style: AppText.h2), - ]), - const SizedBox(height: Sp.x4), - BigStat( - value: _learnedCadence!.toStringAsFixed(0), - unit: 'steps/min', - label: 'Your cadence', - caption: 'Sharper every time you walk with the app open', - ), - const SizedBox(height: Sp.x5), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () => Navigator.of(context).maybePop(), - child: const Text('Done'), - ), - ), - ]), - ).dsCelebrate(); -} diff --git a/pubspec.lock b/pubspec.lock index e99f208a..78feaf7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -964,8 +964,8 @@ packages: dependency: "direct main" description: path: "." - ref: c3a30be1e36e33426c83cead9ea106fa3071b082 - resolved-ref: c3a30be1e36e33426c83cead9ea106fa3071b082 + ref: "1fb34dce64e6df833f583ee60cbccfff6751571f" + resolved-ref: "1fb34dce64e6df833f583ee60cbccfff6751571f" url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index fe8bfb63..2eccdf52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,35 +69,17 @@ dependencies: # Verified present: `git show :lib/src/onehz/sleep/cardio_stager.dart # | grep -E 'classifyCardioEpochs|_remScoreCut = 0.5'`. # - # analytics main @ #38 merge — the nap detector this release is built on. - # kAlgoVersion 55 and 56 BOTH cite it, and until this line moved neither - # was backed: edge called `detectNaps(..., wristOff:, exclude:)` and read - # `NapWindow.tstSec`/`tibSec`/`efficiency`, none of which exist at #34. - # This was not a silently-missing behaviour like v43's — it was a hard - # compile break (10 analyzer errors), masked locally by the gitignored - # pubspec_overrides.yaml pointing at a working copy. Repinning here is - # what makes v55/v56 real rather than aspirational (§3.5). + # analytics main @ #35 merge (1fb34dc). Neither side of this conflict was + # right: HEAD had #35's PR-BRANCH head (38a8636, never on main) and main + # had the older #38 merge (c3a30be). This SHA is analytics main and carries + # all three hops this release needs -- #38 (nap detector), #39 (the awake-HR + # baseline P0 that shipped inside #38), and #35 (the 1 Hz step estimate + # deleted + movement minutes on measured evidence), the sibling half of + # THIS branch. # - # #38 adds `sleep/nap.dart` as the ONLY nap source: van Hees z-angle - # immobility on the complement of the main sleep window, corroborated by - # an HR dip against the AWAKE-DAYTIME baseline, reporting TST and in-bed - # SEPARATELY. The old path delegated to the nocturnal stager, whose - # `minSleepMin = 60` exists to REJECT naps, so the 20-45 min afternoon nap - # was structurally undetectable. `immobilityMask` is factored out of - # van_hees.dart so night and nap share one primitive. - # - # Also carries the #38 review pass: the scoring harness compared `tstSec` - # against interval labels (which are in-bed spans), charging every matched - # nap its own awake time as error, and abstained days were excluded from - # sensitivity/PPV without saying so. Neither affects shipped output — both - # are measurement-tool fixes — but they are why the accuracy figures in - # #38's description differ from its first revision. - # - # Verified present: `git show :lib/src/onehz/sleep/nap.dart - # | grep -E 'wristOff|exclude|tibSec'` (16 hits) and - # `git show :lib/src/onehz/sleep/van_hees.dart - # | grep -c immobilityMask` (2). - ref: c3a30be1e36e33426c83cead9ea106fa3071b082 + # Verified against the SHA: `dailyStepEstimate` absent, + # `dailyActiveMinutes` present in lib/src/onehz/motion/steps.dart. + ref: 1fb34dce64e6df833f583ee60cbccfff6751571f # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index 395505ef..c94b5d49 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -10,29 +10,12 @@ 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/ble/ble_engine.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/notify/notification_center.dart'; import 'package:openstrap_edge/notify/notification_event.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/sync/paired_device.dart'; -/// A BleEngine whose live-stream arming always fails — the "link dropped -/// mid-write" case that used to latch _stepCalActive true forever. -class _ThrowingEngine extends BleEngine { - _ThrowingEngine() - : super( - onRecord: _noRecord, - onState: _noState, - ); - static Future _noRecord(Object? sample, Object? raw) async {} - static void _noState(Object state) {} - - @override - Future retryFullLiveStreams() async => - throw StateError('link dropped mid-write'); -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -95,24 +78,11 @@ void main() { }); }); - // ── 5. _stepCalActive must not latch true when the arming throws ──────────── - group('startStepCalibration (live-consumer latch)', () { - test('a throwing stream arm leaves no phantom live consumer', () async { - final engine = _ThrowingEngine(); - engine.state.connection = 'connected'; - final app = AppState.forTesting(engine: engine); - addTearDown(app.dispose); - - expect(app.debugHasLiveConsumer, isFalse); - await expectLater( - app.startStepCalibration(), throwsA(isA())); - // Pre-fix this stayed true for the rest of the process, pinning - // _hasLiveConsumer and permanently disabling - // _maybeDowngradeLiveForBackground — the 100 Hz raw flood then kept - // streaming while backgrounded and starved the R24 offload. - expect(app.debugHasLiveConsumer, isFalse); - }); - }); + // ── 5. (removed) the step-calibration live-consumer latch ───────────────── + // The guided calibration walk was deleted in v56 along with the 1 Hz step + // estimator that was its only consumer, so there is no longer an arming path + // that can latch `_hasLiveConsumer`. The spot-check and workout consumers + // keep their own latch coverage. // ── 6. `busy` must not latch true forever ────────────────────────────────── group('openSession (busy latch)', () { diff --git a/test/derive_day_window_test.dart b/test/derive_day_window_test.dart index 9b568d25..10014b64 100644 --- a/test/derive_day_window_test.dart +++ b/test/derive_day_window_test.dart @@ -14,6 +14,8 @@ // calories_total as real scalars — fabricated numbers wearing real numbers' // clothes, against the never-impute contract the rest of the layer keeps. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -203,14 +205,42 @@ void main() { reason: 'Mifflin BMR needs real anthropometrics'); }); - test('steps still compute without a profile (data-derived, not imputed)', + test('a day with no gait-capable source has NO step count at all', () async { - // `dailyStepEstimate` falls back to the day's own 10th-percentile HR when - // no resting HR is known — that is derived from the data, so abstaining - // would be over-correction. + // This test used to assert the opposite ("steps still compute without a + // profile"), and it passed only because the old code persisted a hard + // 0.0 for an abstaining estimator — a fabricated measurement dressed as + // data. There is no `live_coverage` row in this fixture, so nothing that + // can resolve gait measured this day, so the honest output is nothing. + // + // A 1 Hz wrist stream cannot count steps: gait is sub-Nyquist there, and + // wrist amplitude ranks arm work above walking. See kAlgoVersion v55. final got = await deriveWith(const Profile(), '2026-04-11', DateTime(2026, 4, 11).millisecondsSinceEpoch ~/ 1000); - expect(got['steps'], isNotNull); + expect(got['steps'], isNull, + reason: 'no real pedometer covered this day — absent, not zero'); + + // ...and it must be ABSENT, not a zero sitting in the series where it + // would drag every average and "most steps" record down. + expect(await LocalDb.metricValueOn('2026-04-11', 'steps'), isNull); + + // The bundle's own `steps` block must be absent-shaped too, not just + // value-less. (The previous assertion here checked `got.containsKey`, + // which was VACUOUS: `got` is built by the local helper above, which + // seeds every key unconditionally, so it could never fail whatever the + // derivation did.) + final row = await LocalDb.dayResult('2026-04-11'); + final bundle = + jsonDecode(row!['payload_json'] as String) as Map; + final steps = bundle['steps'] as Map; + expect(steps['value'], isNull); + expect(steps['confidence'], 0.0); + expect(steps['inputs_used'], isEmpty, + reason: 'nothing was used, because nothing was measured'); + // NOT 'ESTIMATE': `Metric.parse` maps that tier to `beta: true` and would + // badge a card that has no number on it as an estimate. Nothing here + // estimates anything — that is the entire point of this change. + expect(steps['tier'], isNull); }); test('a real profile still produces strain and calories', () async { diff --git a/test/metric_trend_redesign_test.dart b/test/metric_trend_redesign_test.dart index 355bb277..68a8dab9 100644 --- a/test/metric_trend_redesign_test.dart +++ b/test/metric_trend_redesign_test.dart @@ -386,7 +386,7 @@ void main() { ) async { _phone(t, height: 2200); for (final p in [kLightPalette, kDarkPalette]) { - var goals = 0, cals = 0; + var goals = 0; await t.pumpWidget( _host( StepsDayContent( @@ -395,7 +395,6 @@ void main() { weekValues: const [9000, 12000, null, 4000, 8000, 10000, 8412], weekLabels: const ['M', 'T', 'W', 'T', 'F', 'S', 'S'], onSetGoal: () => goals++, - onCalibrate: () => cals++, ), palette: p, ), @@ -405,12 +404,12 @@ void main() { expect(find.text('goal 10000'), findsOneWidget); expect(find.text('84%'), findsOneWidget); // of goal gauge expect(find.text('THIS WEEK'), findsOneWidget); - expect(find.text('EST'), findsOneWidget); // honesty tag + // Honesty tag: steps are real-measured only now, never estimated. + expect(find.text('MEASURED'), findsOneWidget); + expect(find.text('Calibrate steps'), findsNothing); await t.tap(find.text('Daily step goal')); - await t.tap(find.text('Calibrate steps')); await t.pump(const Duration(milliseconds: 300)); expect(goals, 1); - expect(cals, 1); expect(t.takeException(), isNull); } }); diff --git a/test/movement_floor_frozen_test.dart b/test/movement_floor_frozen_test.dart new file mode 100644 index 00000000..00b22c9d --- /dev/null +++ b/test/movement_floor_frozen_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The movement floor must be estimated ONCE and then FROZEN. +/// +/// PROVEN on 4 days of real substrate: a floor recomputed from the same signal +/// it thresholds reports 37 active minutes at 1x, 1.5x, 2x AND 3x activity, +/// while a frozen floor reports 23 -> 254. A tracking threshold is a metric +/// that cannot see change, so persistence here is correctness, not caching. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_movement_floor_test.db'; + }); + + setUp(() async { + await LocalDb.close(); + 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('no floor before enrollment — abstain, never a constant', () async { + expect(await LocalDb.getMovementFloor(), isNull); + }); + + test('a frozen floor round-trips exactly', () async { + await LocalDb.putMovementFloor( + floorG: 0.4442, frozenOn: '2026-08-03', days: 18); + final got = await LocalDb.getMovementFloor(); + expect(got, isNotNull); + expect(got!.floorG, closeTo(0.4442, 1e-9)); + expect(got.frozenOn, '2026-08-03'); + expect(got.days, 18); + }); + + test('re-freezing overwrites rather than accumulating', () async { + await LocalDb.putMovementFloor( + floorG: 0.40, frozenOn: '2026-07-01', days: 14); + await LocalDb.putMovementFloor( + floorG: 0.47, frozenOn: '2026-08-03', days: 30); + final got = await LocalDb.getMovementFloor(); + expect(got!.floorG, closeTo(0.47, 1e-9)); + expect(got.frozenOn, '2026-08-03'); + }); + + test('a degenerate persisted floor is rejected, not served', () async { + // A zero/negative floor would pass EVERY minute. Reading it back as null + // makes the estimator abstain, which is the honest failure mode. + await LocalDb.putMovementFloor( + floorG: 0.0, frozenOn: '2026-08-03', days: 20); + expect(await LocalDb.getMovementFloor(), isNull); + await LocalDb.putMovementFloor( + floorG: -1.0, frozenOn: '2026-08-03', days: 20); + expect(await LocalDb.getMovementFloor(), isNull); + }); + + test('the thaw policy only fires on a real change of scale', () { + // Time passing and behaviour changing must NOT thaw it — that is exactly + // the tracking behaviour freezing exists to prevent. + expect(ana.shouldRefreezeFloor(daysSinceFrozen: 200), isFalse); + expect(ana.shouldRefreezeFloor(daysSinceFrozen: 29, wearGapDays: 10), + isFalse); + // These genuinely change the signal's scale. + expect(ana.shouldRefreezeFloor(daysSinceFrozen: 1, deviceChanged: true), + isTrue); + expect( + ana.shouldRefreezeFloor(daysSinceFrozen: 1, wristChanged: true), isTrue); + expect(ana.shouldRefreezeFloor(daysSinceFrozen: 1, wearGapDays: 30), isTrue); + expect(ana.shouldRefreezeFloor(daysSinceFrozen: 365), isTrue); + }); + + test('enrollment needs more days than the bare estimator minimum', () { + expect(ana.enrollmentDaysForFrozenFloor, + greaterThan(ana.personalDynFloorMinDays)); + }); +} diff --git a/test/movement_floor_policy_test.dart b/test/movement_floor_policy_test.dart new file mode 100644 index 00000000..f3fb3347 --- /dev/null +++ b/test/movement_floor_policy_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/movement_floor_policy.dart'; + +/// The frozen movement floor is ONE shared scalar that every day of a derive +/// sweep reads and can write. `DerivationEngine.run()` dispatches days +/// NEWEST-FIRST through a concurrent worker pool, and the v56 bump forces the +/// whole retained window to re-derive at once — so these decisions must be +/// order-independent, or sweep order silently decides every day's `active_min`. +void main() { + group('dayLabelBefore — calendar, never Duration', () { + test('does not skip the spring-forward day', () { + // 2026-03-08 is 23 h long in a US timezone. `subtract(Duration(days: 2))` + // from local midnight on 03-10 lands at 23:00 on 03-07, so the walk-back + // NEVER GENERATES 2026-03-08 and the gap is counted against the wrong + // days. Calendar-field construction cannot do this. + expect(dayLabelBefore('2026-03-10', 1), '2026-03-09'); + expect(dayLabelBefore('2026-03-10', 2), '2026-03-08'); + expect(dayLabelBefore('2026-03-10', 3), '2026-03-07'); + }); + + test('crosses month and year boundaries', () { + expect(dayLabelBefore('2026-03-01', 1), '2026-02-28'); + expect(dayLabelBefore('2026-01-01', 1), '2025-12-31'); + expect(dayLabelBefore('2024-03-01', 1), '2024-02-29'); // leap year + }); + + test('an unparseable label yields null rather than a wrong date', () { + expect(dayLabelBefore('not-a-date', 1), isNull); + }); + }); + + group('wearGapDays', () { + test('no gap when yesterday has data', () { + expect( + wearGapDays(have: {'2026-03-09', '2026-03-08'}, dayId: '2026-03-10'), + 0, + ); + }); + + test('counts the consecutive run of missing days', () { + expect( + wearGapDays(have: {'2026-03-05'}, dayId: '2026-03-10'), + 4, // 03-09, 03-08, 03-07, 03-06 missing; 03-05 present -> stop + ); + }); + + test('spans a DST transition without miscounting', () { + // 03-08 is present, so the gap is exactly one day (03-09). The Duration + // walk-back skipped 03-08 entirely and reported a longer gap here. + expect( + wearGapDays(have: {'2026-03-08'}, dayId: '2026-03-10'), + 1, + ); + }); + + test('an EMPTY history is no information, not a 60-day gap', () { + // A brand-new install must not trip the >=30-day re-freeze rule purely + // because it has no history yet. + expect(wearGapDays(have: const {}, dayId: '2026-03-10'), 0); + }); + + test('is bounded by maxScan', () { + expect( + wearGapDays(have: {'2020-01-01'}, dayId: '2026-03-10', maxScan: 12), + 12, + ); + }); + }); + + group('daysSinceFrozen — never negative', () { + test('a later day reports real age', () { + expect(daysSinceFrozen(frozenOn: '2026-03-01', dayId: '2026-03-11'), 10); + }); + + test('a BACKFILL day is age 0, not its absolute distance', () { + // This is the bug the clamp fixes. With `.abs()`, re-deriving a day from + // more than `maxAgeDays` before the freeze read as maximally stale and + // re-froze the shared floor onto an OLDER frozenOn — during a + // newest-first sweep, i.e. on every kAlgoVersion bump. + expect(daysSinceFrozen(frozenOn: '2026-03-01', dayId: '2024-01-01'), 0); + expect(daysSinceFrozen(frozenOn: '2026-03-01', dayId: '2026-02-28'), 0); + }); + + test('same day is 0', () { + expect(daysSinceFrozen(frozenOn: '2026-03-01', dayId: '2026-03-01'), 0); + }); + }); + + group('mayCommitFloorOn — the floor only moves forward', () { + test('nothing frozen yet: any day may establish it', () { + expect(mayCommitFloorOn(frozenOn: null, dayId: '2026-03-01'), isTrue); + }); + + test('a newer day may re-freeze', () { + expect( + mayCommitFloorOn(frozenOn: '2026-03-01', dayId: '2026-03-02'), + isTrue, + ); + }); + + test('an OLDER day may consume but never move the floor', () { + // Otherwise the oldest day of a newest-first concurrent sweep could + // clobber the freeze the newest day just established, making every day's + // active_min depend on which worker finished last. + expect( + mayCommitFloorOn(frozenOn: '2026-03-10', dayId: '2026-03-01'), + isFalse, + ); + }); + + test('re-freezing on the same day is allowed', () { + expect( + mayCommitFloorOn(frozenOn: '2026-03-10', dayId: '2026-03-10'), + isTrue, + ); + }); + }); +} diff --git a/test/phone_pedometer_hour_walk_test.dart b/test/phone_pedometer_hour_walk_test.dart new file mode 100644 index 00000000..513ad85d --- /dev/null +++ b/test/phone_pedometer_hour_walk_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/health/phone_pedometer.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The hour walk is where this feature's two real defects lived, and neither +/// was reachable from the DB-level tests. +/// +/// NOTE ON TIMEZONE. Dart reads the process timezone from the environment and +/// `flutter test` cannot set it per-test, so the DST cases here assert on the +/// BOUNDARY LOGIC (zero-width buckets are skipped, not fatal) in a way that +/// holds in every timezone, rather than hard-coding a US transition. The +/// spring-forward instant collapse itself was reproduced directly against the +/// Dart runtime under `TZ=America/New_York` while diagnosing: +/// +/// h=1 from=2026-03-08 01:00 to=2026-03-08 03:00 +/// h=2 from=2026-03-08 03:00 to=2026-03-08 03:00 <-- zero width +/// +/// With `break` there, hours 3-23 were never queried and the day was persisted +/// with ~3 hours of windows. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_phone_hour_walk_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() async { + final db = await LocalDb.instance; + await db.delete('live_coverage'); + }); + + /// Yesterday, so the walk covers a whole elapsed day (no "future hours" cap). + DateTime yesterday() { + final n = DateTime.now(); + return DateTime(n.year, n.month, n.day - 1); + } + + test('a full elapsed day walks every hour and banks the total', () async { + final asked = []; + final ped = PhonePedometer(stepReader: (from, to) async { + asked.add(from); + return 10; + }); + + final day = yesterday(); + final total = await ped.syncDay(day); + + // 24 buckets in a normal day (23 or 25 across a DST transition) — never + // truncated to a handful. + expect(asked.length, greaterThanOrEqualTo(23)); + expect(total, asked.length * 10); + expect(await LocalDb.liveStepsForDay(_label(day)), total); + }); + + test('a zero-width bucket is SKIPPED, not fatal to the rest of the day', + () async { + // Simulates the spring-forward collapse: the walk must keep going past a + // bucket whose `from == to`. We cannot force a real DST gap in-process, so + // this asserts the invariant directly — every hour after the anomaly is + // still queried. + var calls = 0; + final ped = PhonePedometer(stepReader: (from, to) async { + calls++; + // A zero-width interval would never reach the reader at all (it is + // skipped before the call), so simply counting calls proves the walk + // did not terminate early. + return 1; + }); + + final total = await ped.syncDay(yesterday()); + expect(calls, greaterThanOrEqualTo(23)); + expect(total, calls); + }); + + test('ANY failed hour abandons the day rather than banking a partial one', + () async { + final day = yesterday(); + final dayId = _label(day); + + // 1. A complete, good sync. + final good = PhonePedometer(stepReader: (from, to) async => 100); + final fullTotal = await good.syncDay(day); + expect(fullTotal, isNotNull); + expect(await LocalDb.liveStepsForDay(dayId), fullTotal); + + // 2. A later sync where hour 5 fails. `null` from this plugin means the + // query FAILED (an empty hour returns 0 on both platforms), so the day + // must be abandoned — `replacePhoneCoverageForDay` is delete-then- + // insert, and banking the short read would LOWER a good previous total + // while still suppressing the band fallback. + var h = 0; + final flaky = PhonePedometer(stepReader: (from, to) async { + final n = h++ == 5 ? null : 100; + return n; + }); + expect(await flaky.syncDay(day), isNull); + + // 3. The good total survives untouched. + expect(await LocalDb.liveStepsForDay(dayId), fullTotal); + }); + + test('a genuine zero-step day banks nothing and falls back to the band', + () async { + final day = yesterday(); + final dayId = _label(day); + await LocalDb.addLiveCoverage( + day.millisecondsSinceEpoch ~/ 1000, + day.millisecondsSinceEpoch ~/ 1000 + 600, + 777, + dayId, + ); + + // Every hour reads successfully as 0 — a real sedentary day, NOT a failure. + final ped = PhonePedometer(stepReader: (from, to) async => 0); + expect(await ped.syncDay(day), 0); + + // No phone rows were written, so the band count still shows. + expect(await LocalDb.liveStepsForDay(dayId), 777); + }); + + test('an all-zero read never erases a day already banked with real steps', + () async { + final day = yesterday(); + final dayId = _label(day); + + // 1. A good sync banks a real day. + final good = PhonePedometer(stepReader: (from, to) async => 100); + final fullTotal = await good.syncDay(day); + expect(fullTotal, isNotNull); + expect(await LocalDb.liveStepsForDay(dayId), fullTotal); + + // 2. Every hour now reads 0 WITHOUT failing — exactly what a silent iOS + // READ denial looks like (`requestAuthorization` reports success even + // when the user denied read, so queries return empty rather than null, + // forever). Unguarded, `replacePhoneCoverageForDay`'s delete-then-insert + // would wipe the day; and because phone rows win outright, not even the + // band fallback would show. + final denied = PhonePedometer(stepReader: (from, to) async => 0); + expect(await denied.syncDay(day), isNull, + reason: 'unconfirmed, so it must not count toward daysRead either'); + + // 3. The banked day survives. + expect(await LocalDb.liveStepsForDay(dayId), fullTotal); + }); + + test('the routine sync window is much smaller than the backfill window', () { + // Each hourly bucket is one platform round trip, so the window IS the cost: + // the 7-day default was up to 168 sequential calls on every launch and + // again after every export. + expect(PhonePedometer.routineSyncDays, + lessThan(PhonePedometer.fullSyncDays)); + expect(PhonePedometer.routineSyncDays, 2); + }); + + test('syncRecent reports days read and their total for the UI', () async { + final ped = PhonePedometer(stepReader: (from, to) async => 5); + final r = await ped.syncRecent(days: 2); + // Today is partial (only elapsed hours), yesterday is whole — both read. + expect(r.daysRead, 2); + expect(r.totalSteps, greaterThan(0)); + }); +} + +String _label(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; diff --git a/test/phone_step_source_test.dart b/test/phone_step_source_test.dart new file mode 100644 index 00000000..c3c163bd --- /dev/null +++ b/test/phone_step_source_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Steps now come ONLY from a source that can actually resolve gait, and the +/// two such sources must never be summed: the phone (pocket, sees trunk motion) +/// and the band (wrist, documented emitting 22-27 false steps/min during +/// dishes/driving) both count the same walk. Adding them roughly doubles a day. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const day = '2026-08-03'; + const otherDay = '2026-08-02'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_phone_step_source_test.db'; + }); + + setUp(() async { + // Fresh DB per test — `live_coverage` is append-only, so leakage between + // tests would look exactly like the double-counting these tests exist to + // rule out. + await LocalDb.close(); + 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('band-only day sums the band rows', () async { + await LocalDb.addLiveCoverage(1000, 1600, 120, day); + await LocalDb.addLiveCoverage(2000, 2600, 80, day); + expect(await LocalDb.liveStepsForDay(day), 200); + }); + + test('phone WINS outright when present — the two are never added', () async { + await LocalDb.addLiveCoverage(1000, 1600, 120, day); // wrist + await LocalDb.replacePhoneCoverageForDay( + day, + [(startTs: 1000, endTs: 4600, steps: 350)], + ); + // NOT 470. The phone measured the same walking from a better place. + expect(await LocalDb.liveStepsForDay(day), 350); + }); + + test('phone sync is idempotent — re-syncing a day never accumulates', + () async { + for (var i = 0; i < 3; i++) { + await LocalDb.replacePhoneCoverageForDay( + day, + [ + (startTs: 1000, endTs: 4600, steps: 350), + (startTs: 4600, endTs: 8200, steps: 120), + ], + ); + } + expect(await LocalDb.liveStepsForDay(day), 470); + }); + + test('a later sync REPLACES an earlier partial one rather than adding', + () async { + await LocalDb.replacePhoneCoverageForDay( + day, + [(startTs: 1000, endTs: 4600, steps: 100)], + ); + // The day filled in; the same hour now reads higher. + await LocalDb.replacePhoneCoverageForDay( + day, + [(startTs: 1000, endTs: 4600, steps: 900)], + ); + expect(await LocalDb.liveStepsForDay(day), 900); + }); + + test('phone replace is scoped to its day and never touches band rows', + () async { + await LocalDb.addLiveCoverage(1000, 1600, 55, otherDay); + await LocalDb.replacePhoneCoverageForDay( + otherDay, + [(startTs: 1000, endTs: 4600, steps: 700)], + ); + await LocalDb.replacePhoneCoverageForDay(day, const []); + + // Clearing today's phone rows must not disturb yesterday. + expect(await LocalDb.liveStepsForDay(otherDay), 700); + // And with today's phone rows gone, the band fallback returns. + await LocalDb.addLiveCoverage(9000, 9600, 42, day); + expect(await LocalDb.liveStepsForDay(day), 42); + }); + + test('an empty phone sync leaves the day with no steps, not a zero row', + () async { + await LocalDb.replacePhoneCoverageForDay(day, const []); + expect(await LocalDb.liveStepsForDay(day), 0); + }); + + test('zero/negative/inverted phone windows are dropped, not stored', + () async { + await LocalDb.replacePhoneCoverageForDay( + day, + [ + (startTs: 1000, endTs: 4600, steps: 0), // no steps that hour + (startTs: 5000, endTs: 4000, steps: 50), // inverted + (startTs: 6000, endTs: 9600, steps: 75), // the only real one + ], + ); + expect(await LocalDb.liveStepsForDay(day), 75); + }); + + test('clearing phone coverage falls back to the band, not to zero', () async { + await LocalDb.addLiveCoverage(1000, 1600, 64, day); // band + await LocalDb.replacePhoneCoverageForDay( + day, + [(startTs: 1000, endTs: 4600, steps: 900)], + ); + expect(await LocalDb.liveStepsForDay(day), 900, reason: 'phone preferred'); + + // User turns phone steps off: the phone rows must go, or they would keep + // overriding the band forever from a source no longer being read. + await LocalDb.clearPhoneCoverage(); + expect(await LocalDb.liveStepsForDay(day), 64); + }); + + group('v27 migration — the path EVERY existing install takes', () { + // Every other test here opens a fresh DB, so `onCreate` emits the `source` + // column directly and the `if (oldV < 27)` step never runs. That upgrade + // path carries a load-bearing assumption: pre-v27 rows must default to + // 'band'. If they defaulted to 'phone', every existing band count would be + // read as a phone count and would suppress the real band fallback. An + // unguarded ALTER TABLE has also bricked this file's upgrades twice. + + Future seedV26() async { + final dir = await databaseFactory.getDatabasesPath(); + final db = await databaseFactory.openDatabase( + p.join(dir, LocalDb.dbName), + options: OpenDatabaseOptions( + version: 26, + onCreate: (db, _) async { + await db.execute('CREATE TABLE live_coverage (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT,' + 'start_ts INTEGER NOT NULL,' + 'end_ts INTEGER NOT NULL,' + 'steps INTEGER NOT NULL,' + 'day TEXT NOT NULL)'); + }, + ), + ); + await db.insert('live_coverage', { + 'start_ts': 1000, + 'end_ts': 1600, + 'steps': 137, + 'day': day, + }); + await db.close(); + } + + test('a pre-v27 row survives the upgrade and counts as BAND', () async { + await seedV26(); + + // Reopening through LocalDb runs the real migration ladder. + expect(await LocalDb.liveStepsForDay(day), 137, + reason: 'the legacy row must still count after upgrading'); + + // ...and it must be BAND, so a phone sync can still take precedence. + await LocalDb.replacePhoneCoverageForDay( + day, + [(startTs: 1000, endTs: 4600, steps: 900)], + ); + expect(await LocalDb.liveStepsForDay(day), 900, + reason: 'legacy rows defaulting to phone would block this override'); + + // Dropping the phone rows reveals the legacy band row again — proof it + // was never silently relabelled. + await LocalDb.clearPhoneCoverage(); + expect(await LocalDb.liveStepsForDay(day), 137); + }); + + test('the migration is idempotent across repeated opens', () async { + await seedV26(); + expect(await LocalDb.liveStepsForDay(day), 137); + await LocalDb.close(); + // The second open re-runs `_repairOpenSchema`, which also calls + // `_ensureLiveCoverageSource`. An unguarded ALTER would throw here. + expect(await LocalDb.liveStepsForDay(day), 137); + }); + }); +} diff --git a/test/step_personal_floor_test.dart b/test/step_personal_floor_test.dart index ea4fd584..b971c4a4 100644 --- a/test/step_personal_floor_test.dart +++ b/test/step_personal_floor_test.dart @@ -38,7 +38,7 @@ void main() { final floor = ana.personalDynFloorFromDailySummaries(const []); expect(floor, isNull); - final est = ana.dailyStepEstimate( + final est = ana.dailyActiveMinutes( rows(List.filled(600, 0.60)), // plenty of real movement personalDynFloorG: floor, ); @@ -58,7 +58,7 @@ void main() { final floor = ana.personalDynFloorFromDailySummaries(enough); expect(floor, isNotNull); - final est = ana.dailyStepEstimate( + final est = ana.dailyActiveMinutes( rows(List.filled(600, 0.60)), personalDynFloorG: floor, ); @@ -74,13 +74,13 @@ void main() { // to inflate — it must now yield nothing. final floor = ana.personalDynFloorFromDailySummaries(List.filled(7, 0.44))!; - final est = ana.dailyStepEstimate( + final est = ana.dailyActiveMinutes( rows(List.filled(900, 0.02)), // sedentary dynamic amplitude personalDynFloorG: floor, ); expect(est.present, isTrue); expect(est.value!.activeMinutes, 0); - expect(est.value!.steps, 0); + expect(est.value!.boutCount, 0); }); test('one anomalous day cannot drag the floor (median across days)', () { @@ -100,7 +100,7 @@ void main() { final floor = ana.personalDynFloorFromDailySummaries(withQuietDay)!; expect(floor, greaterThan(0.4)); - final est = ana.dailyStepEstimate( + final est = ana.dailyActiveMinutes( rows(List.filled(900, 0.02)), personalDynFloorG: floor, );