diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt index f2f5d9201..e2178de2a 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt @@ -6,9 +6,11 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent -import android.hardware.SensorEventListener +import android.hardware.SensorEventListener2 import android.hardware.SensorManager import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.SystemClock import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -52,7 +54,7 @@ import kotlin.math.roundToInt * adding a second service: Android 9+ withholds sensor events from background * processes, and a foreground service of any type lifts that. */ -object PhoneStepCounter : SensorEventListener { +object PhoneStepCounter : SensorEventListener2 { private const val CHANNEL = "openstrap/phone_steps" private const val PREFS = "openstrap_steps" @@ -111,6 +113,19 @@ object PhoneStepCounter : SensorEventListener { stopAndForget(app) result.success(null) } + // The listener batches for up to 60 s. A three-minute calibration + // routine read without a flush misses up to a third of itself. + // Answers when the flush has COMPLETED (onFlushCompleted, after the + // batched events are delivered), not when it was requested. False + // when it was refused or did not complete within FLUSH_TIMEOUT_MS. + "flush" -> flush(app, result) + // The sensor's own running total as last delivered — exact, unlike + // a range query, which prorates 5-minute bins. Calibration reads it + // at the start and end of a routine (after a flush) and differences. + "rawCount" -> { + val r = prefs(app).getFloat(KEY_LAST_RAW, -1f) + result.success(if (registered && r >= 0f) r.toLong() else null) + } "stepsInInterval" -> { val from = call.argument("fromMs")?.toLong() val to = call.argument("toMs")?.toLong() @@ -217,6 +232,37 @@ object PhoneStepCounter : SensorEventListener { override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + // ── flush ─────────────────────────────────────────────────────────────── + + private const val FLUSH_TIMEOUT_MS = 5_000L + private val main = Handler(Looper.getMainLooper()) + private val pendingFlushes = mutableListOf() + + private fun flush(ctx: Context, result: MethodChannel.Result) { + if (!registered) { + result.success(false) + return + } + val sm = ctx.getSystemService(Context.SENSOR_SERVICE) as SensorManager + pendingFlushes.add(result) + if (!sm.flush(this)) { + pendingFlushes.remove(result) + result.success(false) + return + } + main.postDelayed({ + if (pendingFlushes.remove(result)) result.success(false) + }, FLUSH_TIMEOUT_MS) + } + + // Delivered on the listener's thread (main: registered without a handler), + // after every batched event, so the totals are current when it fires. + override fun onFlushCompleted(sensor: Sensor?) { + val done = pendingFlushes.toList() + pendingFlushes.clear() + done.forEach { it.success(true) } + } + override fun onSensorChanged(event: SensorEvent) { val ctx = appContext ?: return val raw = event.values?.firstOrNull() ?: return diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart new file mode 100644 index 000000000..d3d7ea12e --- /dev/null +++ b/lib/compute/step_calibration_fit.dart @@ -0,0 +1,958 @@ +// step_calibration_fit.dart — pure policy for calibrating the strap's LIVE +// 100 Hz pedometer per user and per activity category. +// +// WHERE THE COUNTER LIVES. Not here. The counter is `pedometer()` / +// `livePedometer()` in analytics (`onehz/motion/steps.dart`), fed from the live +// IMU stream `edge` already ingests (`frameAccel`: gen5 0x2B, gen4 0x33 / R10) +// and banked as `live_coverage.source = 'strap_100hz'`. This file only turns a +// calibration routine into numbers that counter is configured with. Nothing +// here touches BLE or the strap: every value stays on the phone, so there is +// nothing a factory reset has to undo. +// +// WHY A CALIBRATION AT ALL. Analytics' own `StepParams.gain` docstring asks for +// it: "A real strap on a real wrist may still need trimming — that is what this +// knob is for. Measure it against ground truth, per user, and store it with the +// user; never bake a population constant back in here." On clean synthetic gait +// the raw count is already exact (raw/truth 1.00 from 60 to 180 spm), so a gain +// corrects the RESIDUAL a real wrist adds. Expect values near 1.0 for gait, and +// never a population constant. +// +// ── TWO CATEGORY FAMILIES, ONE MECHANISM ──────────────────────────────────── +// +// Everything below is a SCALE — `gain = truth / raw` — because the wearer +// determined the truth. What differs between the families is the REFERENCE and +// the RANGE, not the maths: +// +// * GAIT (slow / brisk / jog): the wearer counts their own steps during the +// calibration walk. `expectedSteps` is that count, and the gain lands near +// 1.0 because the detector is already close on real gait. +// +// * EVERYDAY (household: carrying, lifting, moving things around a room): the +// reference is the PHONE's own pedometer over the same span, because nobody +// can count steps while doing chores. The gain here lands LOW, and that is +// the point — at the wrist, ordinary arm work out-accelerates 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). A household routine produces mostly false +// peaks, so the honest correction is a large downward scale. +// +// WHY NOT ZERO. A household routine is not zero steps — the wearer really does +// walk to the next room. Rejecting the window outright would delete those real +// steps, and it would do so precisely where the wearer asked for a number. So +// the everyday gain is floored at [kEverydayGainMin], never 0, and a routine +// that produced no phone-counted steps at all is REFUSED as a calibration run +// (there is then no reference to fit, which is different from a measured zero). +// +// WHY NOT ONE GLOBAL GAIN. The codebase already settled this: "The two errors +// have OPPOSITE sign, so no gain constant corrects both" — the wrist +// over-counts arm work and under-counts slow walking. Splitting the correction +// by cadence family is the whole reason this file exists rather than one number. +// +// DRIVING IS NOT THIS FILE'S JOB. `lib/data/live_coverage_policy.dart` already +// owns it: a phone row confirming a MOTIONLESS hour drops the band credit +// overlapping it, unless the band span's own density already looks like gait +// ([kConfirmedStillMinSpm] = 40 spm, set above the measured 22-27 spm +// false-positive ceiling and below real walking). The everyday gain is a +// backstop on top of that rule, not a replacement. +// +// HONEST LIMIT. Analytics states its own ceiling: "Every independent free-living +// wrist evaluation puts peak detection between 60% and 230% error." Nothing here +// lifts that. It trims a measured bias per wearer and per movement family. It +// does not turn a wrist into a pedometer, and no copy built on this may say it +// did. +// +// PURE Dart — no I/O, no clock, no protocol import, plain values in and out. + +/// The four calibration categories. The first three are gait; `everyday` is the +/// household family and is fitted against the phone. See the file header. +enum StepCategory { + slow, + brisk, + jog, + everyday; + + /// Stable storage key. Persisted values are matched by NAME, so a reordered + /// enum cannot silently reattach a stored gain to another category. + String get key => switch (this) { + StepCategory.slow => 'slow', + StepCategory.brisk => 'brisk', + StepCategory.jog => 'jog', + StepCategory.everyday => 'everyday', + }; + + /// Null for an unknown key; never a silent fallback to a real category. + static StepCategory? parse(Object? v) => switch (v) { + 'slow' => StepCategory.slow, + 'brisk' => StepCategory.brisk, + 'jog' => StepCategory.jog, + 'everyday' => StepCategory.everyday, + _ => null, + }; + + /// True for the three foot-locomotion families. `everyday` is NOT gait, but + /// it is still a scale — see the file header for why it is not a rejection. + bool get isGait => this != StepCategory.everyday; + + /// The reference a run of this category is fitted against, for the UI to + /// state plainly rather than implying the wearer counts in both cases. + bool get referenceIsPhone => this == StepCategory.everyday; +} + +/// Where a run's ground truth came from. +enum StepReference { + /// The wearer counted their own steps during the calibration walk. + wearerCount, + + /// The phone's own pedometer over the same span. The only usable reference + /// while doing chores, and a better sensor for this quantity than the wrist — + /// see `lib/health/phone_pedometer.dart`. + phonePedometer, +} + +/// One supervised calibration run: a routine was performed, the strap's counter +/// saw what it saw, and something independent says how many steps were real. +class StepCalibrationRun { + const StepCalibrationRun({ + required this.category, + required this.reference, + required this.expectedSteps, + required this.rawSteps, + required this.cadenceSpm, + required this.peakToPeakG, + required this.confidence, + }); + + final StepCategory category; + final StepReference reference; + + /// Ground truth for this span: the wearer's own count for the gait families, + /// the phone's pedometer for `everyday`. Never an estimate we invented. + final int expectedSteps; + + /// `PedometerResult.steps` over the same span — the strap's raw count. + final int rawSteps; + + /// `PedometerResult.cadenceSpm` over the run's span. + final double cadenceSpm; + + /// `PedometerResult.peakToPeakG` — median stride amplitude, the motion + /// strength. Carrying and lifting push this above a walk's. + /// + /// NULLABLE BECAUSE IT IS OPTIONAL, NOT BECAUSE 0 IS MEANINGFUL. Nothing in + /// the fit reads it today; it is stored so the wearer can be shown WHY a + /// routine looked the way it did. A routine whose caller never measured it + /// stores NULL, and 0.0 would be a fabricated reading — a real still reading + /// is 0, so a made-up zero would be indistinguishable from a measurement at + /// exactly the end of the scale where the discriminator matters. + final double? peakToPeakG; + + /// `PedometerResult.confidence` — amplitude AND rhythm regularity, 0..1. + /// Nullable for the same reason as [peakToPeakG]. + final double? confidence; +} + +/// A run smaller than this cannot carry a ratio: the noise in a 40-step walk's +/// count is larger than the bias it is meant to measure. +const int kStepCalMinExpectedSteps = 50; + +/// The detector claiming almost nothing over a supervised routine is evidence +/// the run failed (band not worn, stream dropped), not a measurement of a 20x +/// gain. +const int kStepCalMinRawSteps = 20; + +/// Range a GAIT gain may take. Outside it the RUN went wrong, not the counter. +const double kGaitGainMin = 0.5; +const double kGaitGainMax = 2.0; + +/// Range an EVERYDAY gain may take, and it is a different axis on purpose. +/// +/// The floor is deliberately far below [kGaitGainMin] because a large downward +/// correction is the EXPECTED outcome here, and it is clamped above zero because +/// a household routine contains real steps: the wearer does walk to the next +/// room, and a hard zero would delete those. The ceiling is 1.0 — an everyday +/// gain above 1.0 would claim the wrist UNDER-counts arm work, which contradicts +/// the measured 22-27 false steps/min and would be a licence to inflate. +const double kEverydayGainMin = 0.05; +const double kEverydayGainMax = 1.0; + +/// Bump when the fit changes so stored profiles are ignored rather than +/// misread. Distinct namespace from `kStepCalibrationVersion` in +/// `lib/data/step_calibration.dart` (the band counter's factor) — two different +/// models must never be able to overwrite each other's stored value. +const int kLiveStepGainVersion = 100; + +/// Cadence search window for a GAIT category band, as a fraction of the fitted +/// centre. Wide enough to absorb stride variation inside one category, narrow +/// enough that neighbouring categories do not overlap. +const double kCategoryBandSpread = 0.20; + +/// Hard floor on band width (spm). A short run's median is not precise enough to +/// define a 2 spm window, and a band narrower than the wearer's own stride +/// variation would reject their real walking. +const double kCategoryBandMinWidthSpm = 20.0; + +/// The cadence band that identifies gait in this codebase, in spm. +/// +/// THESE MUST EQUAL `kMinCadenceSpm` / `kMaxCadenceSpm` in +/// `lib/ble/live_cadence.dart` — the same band `livePedometer` treats as +/// confident gait. Two definitions of "this minute was walking" would let the +/// calibration build a band the session logic disagrees with, which is the +/// drift class `step_cadence.dart` was created to prevent on the windows→minutes +/// side. +/// +/// They are redeclared rather than imported because this file is deliberately +/// dependency-free (pure, no analytics, no I/O), and pinned by a test that +/// asserts equality in both directions — the same DERIVED-OR-PINNED convention +/// `live_step_runs.dart` uses for `kRouteTypeKeys`. A silent drift is then +/// impossible; a deliberate change fails a test. +/// +/// NOT AN-2554's 0.5-5 steps/s (30-300 spm). That is the interval bound the +/// COUNTER accepts — the widest range a human gait interval may have — and it is +/// not the narrower band that identifies a minute as ambulation. A 40 spm +/// "minute" is a few steps to the kettle, which `live_cadence.dart` states +/// outright: "A minute below this is not sustained walking." +const double kGaitCadenceMinSpm = 60.0; +const double kGaitCadenceMaxSpm = 200.0; + +/// Why a run was refused. Every rejection is a claim about the RUN, and the +/// caller must show it rather than silently discarding a routine the wearer just +/// performed. +enum StepRunRejection { + expectedTooSmall, + rawTooSmall, + nonFiniteFeature, + rawExceedsExpectedByTooMuch, + referenceWrongSource, + + /// An `everyday` run whose reference counted no steps at all. That is not a + /// measured zero to scale toward — it is the absence of a reference (the phone + /// was on a table), and fitting a 0.0 gain from it would delete every real + /// step in every later household window. + noReferenceSteps, + + /// The ratio this run implies falls outside the category's gain range. + /// + /// A run is REFUSED here rather than clamped into range. Clamping a single + /// run silently converts "this routine did not measure what it claims" into a + /// value at the edge of the legal band — the exact shape of a fabricated + /// metric. A wearer who taps Done after a third of their walk produces + /// expected 100 against raw 30, i.e. a gain of 3.3; clamped to 2.0 that would + /// DOUBLE every later step they take. The clamp stays in [fittedGain] as + /// defence for stored or imported rows, but no run may reach it from here. + ratioOutsideGainRange, + + /// A TIMED routine that did not run for its full duration. The household + /// family has no step target to fall short of, so this is its equivalent — + /// and `stepRunRejection` cannot carry it, because a run does not know how + /// long its routine was asked to be. [CalibrationSession.finish] raises it. + routineTooShort, + + /// A gait routine whose band cadence is outside the walking range + /// ([kGaitCadenceMinSpm]..[kGaitCadenceMaxSpm]). [categoryBand] ignores such + /// a run, so saving it would count toward the fit without ever giving the + /// family a band to own its walks. + cadenceOutsideGaitRange, +} + +/// Null when the run may be used to fit; otherwise why it may not. +StepRunRejection? stepRunRejection(StepCalibrationRun r) { + if (!r.cadenceSpm.isFinite) return StepRunRejection.nonFiniteFeature; + // Absent is fine; PRESENT-BUT-NOT-A-NUMBER is not. A NaN that reaches the fit + // would poison every later run's ratio, so it is refused here rather than + // filtered silently downstream. + final p2p = r.peakToPeakG; + if (p2p != null && !p2p.isFinite) { + return StepRunRejection.nonFiniteFeature; + } + final conf = r.confidence; + if (conf != null && !conf.isFinite) { + return StepRunRejection.nonFiniteFeature; + } + if (r.category.referenceIsPhone != (r.reference == StepReference.phonePedometer)) { + return StepRunRejection.referenceWrongSource; + } + if (r.rawSteps < kStepCalMinRawSteps) return StepRunRejection.rawTooSmall; + + if (r.category.isGait) { + if (r.expectedSteps < kStepCalMinExpectedSteps) { + return StepRunRejection.expectedTooSmall; + } + // `raw / expected` is clamped to kGaitGainMax, so a walk beyond the clamp is + // no longer a measurement of the gain. + if (r.rawSteps > (r.expectedSteps * kGaitGainMax).ceil()) { + return StepRunRejection.rawExceedsExpectedByTooMuch; + } + if (r.cadenceSpm < kGaitCadenceMinSpm || r.cadenceSpm > kGaitCadenceMaxSpm) { + return StepRunRejection.cadenceOutsideGaitRange; + } + return _ratioAdmissible(r); + } + + // EVERYDAY. The band is EXPECTED to see far more than the phone — that is the + // over-count being measured — so the gait floor and ceiling both invert: + // a reference of 0 is refused (no reference), and the truth must not exceed + // what the band saw (that would be an under-count, the opposite family). + if (r.expectedSteps <= 0) return StepRunRejection.noReferenceSteps; + if (r.expectedSteps > r.rawSteps) { + return StepRunRejection.rawExceedsExpectedByTooMuch; + } + return _ratioAdmissible(r); +} + +/// The gain this run implies must lie inside the category's own range. +/// +/// Split out so both families cannot drift apart: the check is identical, only +/// the range differs, and the range already lives in [gainFloor]/[gainCeiling]. +/// This is what turns "the fit would have clamped it" into "the run is refused", +/// and it is the only guard against a routine that stopped early — the app can +/// never know how many steps the wearer really took, but it CAN know that the +/// count it saw is not consistent with the walk that was asked for. +StepRunRejection? _ratioAdmissible(StepCalibrationRun r) { + if (r.rawSteps <= 0) return StepRunRejection.rawTooSmall; + final ratio = r.expectedSteps / r.rawSteps; + if (ratio < gainFloor(r.category) || ratio > gainCeiling(r.category)) { + return StepRunRejection.ratioOutsideGainRange; + } + return null; +} + +/// A fitted per-user, per-category correction for the live 100 Hz pedometer. +class LiveStepGainProfile { + const LiveStepGainProfile({ + required this.version, + required this.category, + required this.gain, + required this.nRuns, + required this.updatedAt, + }); + + final int version; + final StepCategory category; + + /// Clamped to the category's own range: [kGaitGainMin]..[kGaitGainMax] for + /// gait, [kEverydayGainMin]..[kEverydayGainMax] for everyday. Exactly 1.0 when + /// nothing was learned — and 1.0 is not "unset", it is the shipped behaviour, + /// which is why an uncalibrated category and a fitted 1.0 behave identically. + final double gain; + + /// Admitted runs behind [gain]; 0 = no calibration, use the default. + final int nRuns; + final DateTime updatedAt; + + /// A category is calibrated as soon as one admitted run exists — the reference + /// is either the wearer's own count or a vendor pedometer, not an inference. + bool get isCalibrated => nRuns > 0; + + /// The profile every uncalibrated category resolves to. `gain` 1.0 is what + /// analytics ships as its default. + static LiveStepGainProfile uncalibrated(StepCategory category, DateTime? now) => + LiveStepGainProfile( + version: kLiveStepGainVersion, + category: category, + gain: 1.0, + nRuns: 0, + updatedAt: now ?? DateTime.fromMillisecondsSinceEpoch(0), + ); + + Map toJson() => { + 'v': version, + 'cat': category.key, + 'gain': gain, + 'n': nRuns, + 'ts': updatedAt.toIso8601String(), + }; + + /// Defensive: a stored profile is user data that may predate a field, and a + /// malformed one must yield null (fall back to default behaviour) rather than + /// throw inside the derive path or, worse, load as a fabricated gain. + static LiveStepGainProfile? tryParse(Object? raw) { + if (raw is! Map) return null; + final category = StepCategory.parse(raw['cat']); + if (category == null) return null; + if (raw['v'] != kLiveStepGainVersion) return null; + final gain = raw['gain']; + if (gain is! num || !gain.isFinite) return null; + final lo = gainFloor(category); + final hi = gainCeiling(category); + if (gain < lo || gain > hi) return null; + final nRuns = raw['n']; + if (nRuns is! int || nRuns < 0) return null; + final ts = raw['ts']; + final updatedAt = ts is String ? DateTime.tryParse(ts) : null; + if (updatedAt == null) return null; + return LiveStepGainProfile( + version: kLiveStepGainVersion, + category: category, + gain: gain.toDouble(), + nRuns: nRuns, + updatedAt: updatedAt, + ); + } +} + +/// Lowest gain the category may hold. `everyday` is allowed far below 1.0 +/// because a large downward correction is its expected outcome — see the file +/// header for why it is nonetheless never 0. +double gainFloor(StepCategory category) => + category.isGait ? kGaitGainMin : kEverydayGainMin; + +/// Highest gain the category may hold. +double gainCeiling(StepCategory category) => + category.isGait ? kGaitGainMax : kEverydayGainMax; + +/// The fitted gain and its evidence, or null when the category has no admitted +/// run. Split out so [creditLiveSteps] can apply a correction without inventing +/// a timestamp to wrap in a profile. +({double gain, int nRuns})? fittedGain( + StepCategory category, + List runs, +) { + final admitted = [ + for (final r in runs) + if (r.category == category && stepRunRejection(r) == null) r, + ]; + if (admitted.isEmpty) return null; + + final expected = admitted.fold(0, (a, r) => a + r.expectedSteps); + final raw = admitted.fold(0, (a, r) => a + r.rawSteps); + if (raw <= 0 || expected <= 0) return null; + + // Ratio of SUMS, not the mean of per-run ratios: each run is weighted by the + // evidence it carries, so a 500-step walk is not cancelled out by a 60-step + // one. Repeated runs then refine the estimate for free. + // + // NO PRIOR WEIGHT, deliberately deviating from `lib/data/step_calibration.dart` + // (which blends three pseudo-days of factor 1.0). That prior guards an + // INFERRED reference — a day counts only when the phone happened to be + // alongside. This fit's reference is either the wearer's own supervised count + // or the phone's pedometer over the SAME span. Admissibility and the clamp + // already bound a bad run, and the prior cost more than it bought: blending + // three pseudo-runs of 1.0 meant a first 100-said/110-seen calibration + // resolved to 0.977 and corrected a fifth of the bias just measured. A wearer + // who walks their calibration walk and sees nothing change has been told the + // feature does not work. + final ratio = expected / raw; + return ( + // `.toDouble()` is a NO-OP if Dart's static special case for `clamp` applies + // here (receiver and both bounds `double` ⇒ `double`), and it is REQUIRED if + // it does not. The shipped `lib/data/step_calibration.dart:122` returns + // `corrected.clamp(0, int)` straight out of an `int` function with no cast, + // which only compiles if the special case exists — so I believe this is a + // false positive. But the conversion costs nothing, the risk it removes is a + // build failure in a toolchain no check here can run, and neither reading + // argues against being explicit. + gain: ratio.clamp(gainFloor(category), gainCeiling(category)).toDouble(), + nRuns: admitted.length, + ); +} + +/// Fit one category's gain. The `now` parameter exists only so the stored +/// `updatedAt` is supplied by the caller, keeping this function clock-free. +LiveStepGainProfile estimateLiveStepGain( + StepCategory category, + List runs, { + required DateTime now, +}) { + final fit = fittedGain(category, runs); + if (fit == null) return LiveStepGainProfile.uncalibrated(category, now); + return LiveStepGainProfile( + version: kLiveStepGainVersion, + category: category, + gain: fit.gain, + nRuns: fit.nRuns, + updatedAt: now, + ); +} + +/// A cadence window a calibrated GAIT category owns. +class StepCategoryBand { + const StepCategoryBand({ + required this.category, + required this.minSpm, + required this.maxSpm, + }); + + final StepCategory category; + final double minSpm; + final double maxSpm; + + bool contains(double spm) => spm >= minSpm && spm <= maxSpm; +} + +/// Band for one gait category from its admitted runs: median cadence, opened by +/// [kCategoryBandSpread] and floored at [kCategoryBandMinWidthSpm]. +/// +/// The median, not the mean: one run walked at a wrong tempo must not drag the +/// window off the wearer's real cadence. Null for `everyday` (which has no gait +/// band by definition) and for an uncalibrated category. +StepCategoryBand? categoryBand( + StepCategory category, + List runs, +) { + if (!category.isGait) return null; + final cadences = [ + for (final r in runs) + if (r.category == category && + stepRunRejection(r) == null && + // A run whose own cadence sits outside the gait band did not walk what + // this band is meant to describe. Letting it define the band would + // widen or shift the window onto cadences no gait minute occupies. + r.cadenceSpm >= kGaitCadenceMinSpm && + r.cadenceSpm <= kGaitCadenceMaxSpm) + r.cadenceSpm, + ]..sort(); + if (cadences.isEmpty) return null; + + final n = cadences.length; + final median = + n.isOdd ? cadences[n ~/ 2] : (cadences[n ~/ 2 - 1] + cadences[n ~/ 2]) / 2; + final half = (median * kCategoryBandSpread) + .clamp(kCategoryBandMinWidthSpm / 2, kGaitCadenceMaxSpm) + .toDouble(); + var lo = median - half; + var hi = median + half; + // Clamp to human gait without letting the clamp collapse the band. + if (lo < kGaitCadenceMinSpm) { + hi += kGaitCadenceMinSpm - lo; + lo = kGaitCadenceMinSpm; + } + if (hi > kGaitCadenceMaxSpm) { + lo -= hi - kGaitCadenceMaxSpm; + hi = kGaitCadenceMaxSpm; + } + return StepCategoryBand( + category: category, + minSpm: lo < kGaitCadenceMinSpm ? kGaitCadenceMinSpm : lo, + maxSpm: hi > kGaitCadenceMaxSpm ? kGaitCadenceMaxSpm : hi, + ); +} + +/// Which calibrated GAIT category owns a live window's cadence. +/// +/// Nearest band centre among the calibrated gait categories, but only when the +/// cadence actually lands inside that band. A window between two bands, or +/// outside every calibrated band, belongs to NO gait category — it falls +/// through to the everyday gain rather than being forced into the nearest gait +/// band, because forcing it would apply a correction measured on a different +/// kind of movement. +StepCategory? gaitCategoryForCadence( + double spm, + List runs, +) { + if (!spm.isFinite || spm <= 0) return null; + StepCategory? best; + var bestDistance = double.infinity; + for (final c in StepCategory.values) { + final band = categoryBand(c, runs); + if (band == null || !band.contains(spm)) continue; + final distance = (spm - (band.minSpm + band.maxSpm) / 2).abs(); + if (distance < bestDistance) { + bestDistance = distance; + best = c; + } + } + return best; +} + +/// What a live window's raw count is finally credited as. +class StepCredit { + const StepCredit({ + required this.creditedSteps, + required this.rawSteps, + required this.category, + required this.gainApplied, + required this.calibrated, + }); + + final int creditedSteps; + final int rawSteps; + + /// The family the correction came from. Null only when nothing was applied. + final StepCategory? category; + + /// The gain actually used — 1.0 when nothing was calibrated, so a caller can + /// log "no correction applied" without a second lookup. + final double gainApplied; + + /// False when the wearer has not calibrated this family: the window is + /// credited EXACTLY as it is today. The feature must be invisible until the + /// wearer has calibrated. + final bool calibrated; +} + +/// Apply the calibration to one live window. +/// +/// The mapping is the whole policy: +/// 1. a window whose cadence falls in a calibrated gait band uses that band's +/// gain (slow / brisk / jog); +/// 2. every other window — including household arm work, which has no gait +/// cadence at all — uses the everyday gain; +/// 3. anything uncalibrated is credited exactly as today, raw with gain 1.0. +/// +/// Driving is NOT handled here. `lib/data/live_coverage_policy.dart` already +/// drops band credit that overlaps a phone-confirmed motionless hour unless the +/// span's own density looks like gait; the everyday gain only damps what +/// survives that rule. +StepCredit creditLiveSteps({ + required int rawSteps, + required double cadenceSpm, + required List runs, +}) { + if (rawSteps <= 0) { + return StepCredit( + creditedSteps: 0, + rawSteps: rawSteps < 0 ? 0 : rawSteps, + category: null, + gainApplied: 1.0, + calibrated: false, + ); + } + + final gait = gaitCategoryForCadence(cadenceSpm, runs); + + // A cadence INSIDE the gait band that no calibrated family owns is a walk the + // wearer has not calibrated — so it is credited EXACTLY as it is today. + // + // It must not fall through to the household gain. An earlier version did, on + // the reasoning that a household gain is capped at 1.0 and so "can only damp". + // Damping is not harmless: a wearer who calibrates only the household routine + // would then have every real walk scaled to 5-14% of what the band counted, + // which is a far worse error than the over-count the calibration exists to + // fix. "Uncalibrated changes nothing" has to hold for the walk too. + final inGaitBand = + cadenceSpm >= kGaitCadenceMinSpm && cadenceSpm <= kGaitCadenceMaxSpm; + if (gait == null && inGaitBand) { + return StepCredit( + creditedSteps: rawSteps, + rawSteps: rawSteps, + category: null, + gainApplied: 1.0, + calibrated: false, + ); + } + + // Below the gait band there is no ambulation to protect — that is the space + // the household family owns, and live_cadence.dart defines it as "a few steps + // to the kettle inside an otherwise still minute" rather than walking. The + // cost is written down rather than hidden: a genuine walk slower than + // kGaitCadenceMinSpm is damped with the household movement, and the answer is + // to calibrate the `slow` family, whose band would then own those minutes. + final family = gait ?? StepCategory.everyday; + + final fit = fittedGain(family, runs); + if (fit == null) { + return StepCredit( + creditedSteps: rawSteps, + rawSteps: rawSteps, + category: null, + gainApplied: 1.0, + calibrated: false, + ); + } + + final credited = (rawSteps * fit.gain).round(); + // A gain below 1.0 must not be able to manufacture steps, and none may be + // negative: the clamp mirrors `applyStepCalibration`'s. + return StepCredit( + creditedSteps: + credited.clamp(0, (rawSteps * kGaitGainMax).ceil()).toInt(), + rawSteps: rawSteps, + category: family, + gainApplied: fit.gain, + calibrated: true, + ); +} + +/// Credit one BANKED live run: the cadence is the run's own raw count over its +/// own sampled span, which is how `lib/ble/live_cadence.dart` already defines a +/// cadence ("a completed chunk is one minute, so its step count IS a +/// steps-per-minute reading"). +/// +/// The RAW count is what picks the category and the gain is applied once at the +/// end — the same "gate first, gain last" rule `live_cadence.dart` documents, +/// and for the same reason: a gain applied before the cadence test would stretch +/// the band by whatever the gain is. +StepCredit creditLiveRun({ + required int rawSteps, + required int seconds, + required List runs, +}) { + if (seconds <= 0) { + // No measurable span, so no cadence, so no basis for a category. Leave the + // count alone rather than letting it fall through to the everyday family, + // which would damp a run we merely failed to measure. + return StepCredit( + creditedSteps: rawSteps < 0 ? 0 : rawSteps, + rawSteps: rawSteps < 0 ? 0 : rawSteps, + category: null, + gainApplied: 1.0, + calibrated: false, + ); + } + return creditLiveSteps( + rawSteps: rawSteps, + cadenceSpm: rawSteps * 60.0 / seconds, + runs: runs, + ); +} + +/// THE single entry point for the persistence path: what a banked run is worth. +/// +/// [fallbackGain] is `ana.StepParams.gain`, the shipped trim, and it is used +/// ONLY when the wearer has calibrated nothing — so an uncalibrated app banks +/// exactly the numbers it banks today, to the digit. +/// +/// When a family IS calibrated the calibrated gain REPLACES the shipped one +/// rather than multiplying into it. That distinction is not cosmetic: +/// `app_state.dart` records that applying the gain at both ends of this path +/// once "shipped a silent x1.23". There is exactly one factor per run. +int creditedStepsForRun({ + required int rawSteps, + required int seconds, + required List runs, + required double fallbackGain, +}) { + final credit = creditLiveRun(rawSteps: rawSteps, seconds: seconds, runs: runs); + if (!credit.calibrated) return (rawSteps * fallbackGain).round(); + return credit.creditedSteps; +} + +/// How many steps a gait calibration asks the WEARER to count. +/// +/// 100 is the smallest round number where the counting error stays well under +/// the bias being measured, and short enough that the routine still gets done. +const int kCalibrationTargetSteps = 100; + +/// How long a household routine runs. Long enough that the phone's own pedometer +/// accumulates a meaningful count from the walking inside it, and that the +/// band's arm-work peaks accumulate enough to measure. +const int kCalibrationTargetSeconds = 180; + +/// One calibration routine in progress: what the wearer does, what the strap +/// counts, and what independent reference backs it. +/// +/// WHY THE LIVE BAND COUNT MAY NOT TERMINATE A GAIT RUN. +/// +/// The obvious design is "count the steps as the wearer walks and stop at 100". +/// It calibrates nothing: the run would end when the COUNTER reached 100, so +/// [StepCalibrationRun.rawSteps] would be 100 by construction and every fitted +/// gain would be exactly 1.0 for every wearer, forever — a number that looks +/// like a calibration and is a tautology. A reference must be independent of the +/// instrument it calibrates. So: +/// +/// * GAIT families — the WEARER counts. The band's live count is feedback on +/// the screen ("the band has seen 112 so far"), never the terminator, and +/// the wearer ends the run after counting [targetSteps]. +/// * EVERYDAY — the PHONE is the reference, over the routine's own span, so +/// the run is timed ([targetSeconds]) and the phone must be carried. +/// +/// The live count is still shown for gait, and the gap between it and the +/// target IS the measurement — 100 walked, 112 seen, gain 0.893. That is the +/// number the wearer came for; hiding it would hide the answer. +/// +/// This class holds no clock: the caller supplies elapsed time, so the logic +/// stays testable and nothing here can disagree with a screen's own timer. +class CalibrationSession { + CalibrationSession({ + required this.category, + this.targetSteps = kCalibrationTargetSteps, + this.targetSeconds = kCalibrationTargetSeconds, + }); + + final StepCategory category; + + /// Steps the wearer is asked to count. Gait families only. + final int targetSteps; + + /// How long to keep a household routine going. Everyday only. + final int targetSeconds; + + int _bandBaseline = 0; + int _bandTotal = 0; + int _startMs = 0; + int _nowMs = 0; + bool _started = false; + int? _phoneSteps; + double? _peakToPeakG; + double? _confidence; + bool _finished = false; + + /// The strap's RAW count for this ROUTINE — `pedometer()`'s count at the + /// current observation minus the count when the routine began, pre-gain + /// throughout, and the value that becomes the run's `rawSteps`. + /// + /// A DELTA, because a screen can only read the live session's cumulative + /// counter: what a routine measured is the difference between where that + /// counter stood at the start and where it stands now. + int get bandRawSteps => _bandTotal - _bandBaseline; + + /// Seconds the routine's clock covered. + /// + /// The routine's OWN wall span, not a sampled span — the screen has no other + /// clock, and a supervised routine is short and hand-started. A stalled link + /// does not inflate this into a wrong cadence silently: the live path refuses + /// to commit a window measuring under `kMinLiveSampleRateHz`, so a stall shows + /// up as the band count standing still. A cadence that still lands outside + /// 60-200 spm simply produces no gait band (see [categoryBand]), which costs + /// the wearer nothing but that band. + int get bandSeconds => + _started && _nowMs > _startMs ? (_nowMs - _startMs) ~/ 1000 : 0; + + /// The phone's count for the routine's window, or null when it has not been + /// read (everyday refuses without it rather than fitting against nothing). + int? get phoneSteps => _phoneSteps; + + bool get isFinished => _finished; + + /// Cadence the STRAP reports for the routine, in spm — its own reading, and + /// the one [categoryBand] will later compare against. 0 until it can be + /// measured, which is not a cadence. + double get bandCadenceSpm => + bandSeconds <= 0 ? 0.0 : bandRawSteps * 60.0 / bandSeconds; + + /// 0..1 for the progress affordance. + /// + /// GAIT measures the BAND's progress, because that is the only progress + /// anyone can compute live — and it is labelled as the band's count, not as + /// the wearer's. EVERYDAY measures elapsed time, since it has no step target. + double get progress { + if (category.isGait) { + if (targetSteps <= 0) return 0; + final p = bandRawSteps / targetSteps; + return p < 0 ? 0 : (p > 1 ? 1 : p); + } + if (targetSeconds <= 0) return 0; + final p = bandSeconds / targetSeconds; + return p < 0 ? 0 : (p > 1 ? 1 : p); + } + + /// Begin the routine. [bandBaseline] is the live session's cumulative raw + /// counter right now — everything the routine is credited with is measured + /// from here, so a walk that happened before the wearer pressed start cannot + /// land in their calibration. + void start({required int bandBaseline, required int nowMs}) { + if (_finished || _started) return; + _started = true; + _bandBaseline = bandBaseline < 0 ? 0 : bandBaseline; + _bandTotal = _bandBaseline; + _startMs = nowMs; + _nowMs = nowMs; + } + + /// The live session's cumulative raw counter, as the screen reads it. Never + /// allowed to go backwards: the counter is reset when a session ends, and a + /// routine that spans a reconnect must not be credited with a negative + /// correction. + void observe({required int bandTotal, required int nowMs}) { + if (_finished || !_started || _held) return; + _bandTotal = bandTotal < _bandBaseline ? _bandBaseline : bandTotal; + if (nowMs > _nowMs) _nowMs = nowMs; + } + + bool _held = false; + + /// Freeze the routine at the wearer's Done tap: the count and clock as of + /// [nowMs], ignoring observations while the screen asks what they counted. + /// Without it a dialog left open for ten seconds is added to the walk, and a + /// slow but valid routine drops below the gait cadence range. + void hold({required int bandTotal, required int nowMs}) { + observe(bandTotal: bandTotal, nowMs: nowMs); + _held = true; + } + + /// The wearer cancelled the question and is still walking. + void resume() => _held = false; + + /// The phone's count for this routine's window. Replaceable, because the + /// screen re-reads it as the routine runs. + void setPhoneSteps(int steps) { + if (_finished) return; + _phoneSteps = steps < 0 ? 0 : steps; + } + + /// Drop the phone reference: the routine's final read failed, and an + /// earlier read covers only part of it. + void clearPhoneSteps() { + if (_finished) return; + _phoneSteps = null; + } + + /// The live pedometer's own summary for the routine, when the caller has it. + /// + /// Stored, not judged: the fit does not read these. They are what lets the + /// screen tell the wearer "your household movement was measurably less + /// rhythmic than your walk" from a measurement instead of a guess. Omitted + /// entirely when the caller never measured them — an absent feature is honest, + /// a substituted 0.0 is not. + void setBandFeatures({double? peakToPeakG, double? confidence}) { + if (_finished) return; + _peakToPeakG = peakToPeakG; + _confidence = confidence; + } + + + + /// May the wearer end the routine yet? + /// + /// A gate on EVIDENCE, not on the target: ending a gait run at zero strap + /// steps produces a run the admissibility rules refuse anyway, and telling the + /// wearer that before they press the button is kinder than a refusal after. + bool get canFinish => + _started && + bandSeconds > 0 && + bandRawSteps > 0 && + // The household family has no step target, so its equivalent is the + // routine's own duration: stopping it a third of the way through measures + // a third of the movement and would be fitted as if it were all of it. + (!category.referenceIsPhone || bandSeconds >= targetSeconds); + + /// Finish the routine and produce the run, or the reason there is none. + /// + /// Refusals here are about the ROUTINE and are surfaced to the wearer: + /// "nothing was recorded" is a different message from "the phone was not + /// carried", and neither is a silent no-op that leaves them believing their + /// walk taught the app something. + /// + /// [countedSteps] is what the wearer says they counted, for a gait routine. + /// The screen asks rather than assumes [targetSteps]: Done is live from the + /// first band step, and a wearer who stops at 60 would otherwise be fitted + /// as if they had walked 100 — a 1.67 gain that the clamps still accept. + ({StepCalibrationRun? run, StepRunRejection? refusal, bool bandSilent}) + finish({int? countedSteps}) { + _finished = true; + if (!_started || bandSeconds <= 0 || bandRawSteps <= 0) { + // No usable band signal at all. Not a [StepRunRejection] — that enum + // judges a run that exists, and this one never happened. + return (run: null, refusal: null, bandSilent: true); + } + if (category.referenceIsPhone && bandSeconds < targetSeconds) { + // Raised HERE, not in [stepRunRejection]: a run does not know how long its + // routine was asked to last, so that function could not judge this. + return ( + run: null, + refusal: StepRunRejection.routineTooShort, + bandSilent: false, + ); + } + final run = StepCalibrationRun( + category: category, + reference: category.referenceIsPhone + ? StepReference.phonePedometer + : StepReference.wearerCount, + // GAIT: the wearer's own count, which is the whole point of the routine. + // EVERYDAY: the phone's count, and 0 when it was never read — which + // [stepRunRejection] turns into `noReferenceSteps` rather than a fitted + // 0.0 gain. + expectedSteps: category.referenceIsPhone + ? (_phoneSteps ?? 0) + : (countedSteps ?? targetSteps), + rawSteps: bandRawSteps, + cadenceSpm: bandCadenceSpm, + // Passed THROUGH, never defaulted: null here means the caller did not + // measure them, which is a different claim from a measured zero. + peakToPeakG: _peakToPeakG, + confidence: _confidence, + ); + return (run: run, refusal: stepRunRejection(run), bandSilent: false); + } +} diff --git a/lib/data/db.dart b/lib/data/db.dart index 1dfb259b8..9d961f976 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -53,6 +53,7 @@ import '../compute/nap_edits.dart' show NapEditKind; import '../compute/vendor_sleep.dart' show VendorEpoch, VendorNight; import 'journal_fields.dart'; import 'live_coverage_policy.dart'; +import '../compute/step_calibration_fit.dart'; import 'step_calibration.dart'; import '../sync/reset_gate.dart'; import 'med_store.dart'; @@ -314,6 +315,8 @@ class LocalDb { 'baselines', 'step_calibration', 'step_calibration_day', + 'live_step_calibration', + 'live_step_calibration_run', 'raw_archive', 'device_coverage', // Step windows. Every day's steps are read from here, and the band's live @@ -2101,6 +2104,47 @@ class LocalDb { PRIMARY KEY (day, device_family, wearing) ) '''); + // Live 100 Hz pedometer calibration — a DIFFERENT model from + // `step_calibration*` above and deliberately in its own tables. + // + // That pair calibrates the gen5 ON-CHIP counter: a multiplicative factor on + // raw ticks, per (device family, wearing). These calibrate the LIVE 100 Hz + // pedometer `pedometer()` feeds: a scale per ACTIVITY family (slow / brisk + // / jog against the wearer's own count, everyday against the phone), with + // its own version namespace. Sharing a table would let one model overwrite + // the other's stored value — the same reasoning that keeps + // `kLiveStepGainVersion` away from `kStepCalibrationVersion`. + // + // `live_step_calibration_run` keeps the individual runs, not just the fit, + // because re-calibrating is meant to REFINE: the estimator is a ratio of + // sums, so a stored history is what lets a second walk outweigh the first + // by the steps it carried. Same shape as `step_calibration_day`. + await db.execute(''' + CREATE TABLE IF NOT EXISTS live_step_calibration ( + category TEXT NOT NULL, + gain REAL NOT NULL, + n_runs INTEGER NOT NULL, + version INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (category) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS live_step_calibration_run ( + category TEXT NOT NULL, + run_ts INTEGER NOT NULL, + reference TEXT NOT NULL, + expected_steps INTEGER NOT NULL, + raw_steps INTEGER NOT NULL, + cadence_spm REAL NOT NULL, + -- Nullable on purpose: a routine whose caller never measured these + -- stores NULL rather than a fabricated 0.0, which would be + -- indistinguishable from a real still reading. + p2p_g REAL, + confidence REAL, + PRIMARY KEY (category, run_ts) + ) + '''); } /// Idempotent by key. NULL reference_steps = the phone did not cover the day. @@ -2231,6 +2275,216 @@ class LocalDb { ); } + // ── live 100 Hz pedometer calibration (per activity family) ─────────────── + + /// Admitted calibration runs, newest first. Read back into the pure fit; the + /// stored rows are the evidence the gain is a ratio of sums over. + static Future> liveStepCalibrationRuns({ + DatabaseExecutor? exec, + }) async { + final db = exec ?? await instance; + final rows = await db.query( + 'live_step_calibration_run', + orderBy: 'run_ts DESC', + ); + final out = []; + for (final r in rows) { + final category = StepCategory.parse(r['category']); + // A row whose category or reference this build does not know is SKIPPED, + // not guessed: attaching a run to the wrong family would corrupt that + // family's fit with another one's evidence. + if (category == null) continue; + final reference = switch (r['reference']) { + 'wearerCount' => StepReference.wearerCount, + 'phonePedometer' => StepReference.phonePedometer, + _ => null, + }; + if (reference == null) continue; + out.add(StepCalibrationRun( + category: category, + reference: reference, + expectedSteps: (r['expected_steps'] as num?)?.toInt() ?? 0, + rawSteps: (r['raw_steps'] as num?)?.toInt() ?? 0, + cadenceSpm: (r['cadence_spm'] as num?)?.toDouble() ?? 0, + // NULL PASSES THROUGH AS NULL. These two columns are nullable precisely + // so a routine whose caller never measured them stores an ABSENCE — and + // a `?? 0` here would turn that absence back into a real still reading at + // the bottom of the scale, which is the fabrication the nullable column + // exists to prevent. `cadence_spm` above keeps its `?? 0` because it is + // NOT NULL and 0 there means "no cadence measurable", not a measurement. + peakToPeakG: (r['p2p_g'] as num?)?.toDouble(), + confidence: (r['confidence'] as num?)?.toDouble(), + )); + } + return out; + } + + /// Bank one run. The caller must have admitted it first — a refused run is a + /// claim about the routine, not evidence, and storing it would let it weigh on + /// every later refit. + /// + /// Idempotent by (category, run_ts): re-banking the same routine replaces its + /// row rather than counting it twice. + static Future putLiveStepCalibrationRun( + StepCalibrationRun r, { + required int runTs, + DatabaseExecutor? exec, + }) async { + final db = exec ?? await instance; + await db.insert( + 'live_step_calibration_run', + { + 'category': r.category.key, + 'run_ts': runTs, + 'reference': switch (r.reference) { + StepReference.wearerCount => 'wearerCount', + StepReference.phonePedometer => 'phonePedometer', + }, + 'expected_steps': r.expectedSteps, + 'raw_steps': r.rawSteps, + 'cadence_spm': r.cadenceSpm, + 'p2p_g': r.peakToPeakG, + 'confidence': r.confidence, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Bank one accepted calibration run and refit the family it belongs to. + /// + /// ONE call from the calibration screen, because the two writes must not be + /// performed by hand in two places: a run stored without the profile it + /// implies — or the reverse — leaves the app correcting with a gain that does + /// not match the evidence that produced it, and nothing downstream could tell. + /// + /// The caller must clear [AppState.invalidateLiveStepCalibration] afterwards, + /// or a walk already in progress would bank with the previous gain. + /// + /// Returns the refitted profile, so the screen can show the wearer the number + /// their routine just produced instead of a generic acknowledgement. + static Future bankLiveStepCalibrationRun( + StepCalibrationRun run, { + required int runTs, + DateTime? now, + }) { + // SERIALISED, because two routines for the same category banking at once can + // each read a different history snapshot and write their profiles in the + // opposite order — leaving the older result in place while the newer run is + // already durable. One lock rather than one per category: a save is three + // writes and the contention case is a user tapping two routines, so the + // simpler invariant is worth more than the parallelism. + // ONE transaction: a run that is durable without the profile it implies + // (the refit read or the profile write failing after the insert) would be + // reported to the wearer as a failed save while later fits quietly used it. + final chained = _liveStepGainLock.then((_) async { + final db = await instance; + return db.transaction((txn) async { + await putLiveStepCalibrationRun(run, runTs: runTs, exec: txn); + return refitLiveStepGain(run.category, now: now, exec: txn); + }); + }); + // The chain must survive this save's own failure, or one bad routine would + // block every later one. + _liveStepGainLock = chained.then((_) {}, onError: (_) {}); + return chained; + } + + /// Serialises calibration saves — see [bankLiveStepCalibrationRun]. + static Future _liveStepGainLock = Future.value(); + + /// Recompute and store one family's gain from every admitted run on record. + /// + /// Separate from the banking call so a torn state is REPAIRABLE: if a save + /// ever leaves a run durable without the profile it implies — a process kill + /// between the two writes, a failed refit — running this again derives the + /// right answer from the rows, because both sides are idempotent by key and + /// the fit is a pure function of the stored evidence. + static Future refitLiveStepGain( + StepCategory category, { + DateTime? now, + DatabaseExecutor? exec, + }) async { + final rows = await liveStepCalibrationRuns(exec: exec); + final profile = estimateLiveStepGain( + category, + rows, + now: now ?? DateTime.now(), + ); + await putLiveStepGainProfile(profile, exec: exec); + return profile; + } + + /// Refit every family that has stored runs. A restore merges the backup's + /// runs into this phone's and its profile rows over this phone's, so neither + /// side's stored gain describes the combined evidence until this runs. + static Future refitAllLiveStepGains({DateTime? now}) { + final chained = _liveStepGainLock.then((_) async { + final db = await instance; + await db.transaction((txn) async { + final runs = await liveStepCalibrationRuns(exec: txn); + final categories = {for (final r in runs) r.category}; + for (final c in categories) { + await putLiveStepGainProfile( + estimateLiveStepGain(c, runs, now: now ?? DateTime.now()), + exec: txn, + ); + } + }); + }); + _liveStepGainLock = chained.then((_) {}, onError: (_) {}); + return chained; + } + + /// The stored gain for one family, or null when none was fitted or the stored + /// version is not this code's. Null means "behave exactly as before" — never + /// a fabricated 1.0 that hides a mismatch. + static Future liveStepGainProfile( + StepCategory category, + ) async { + final db = await instance; + final rows = await db.query( + 'live_step_calibration', + where: 'category = ?', + whereArgs: [category.key], + limit: 1, + ); + if (rows.isEmpty) return null; + final row = rows.first; + if ((row['version'] as num?)?.toInt() != kLiveStepGainVersion) return null; + final gain = (row['gain'] as num?)?.toDouble(); + // The row's own clamp is re-checked here: a hand-edited or restored row must + // not be able to present a gain the fit would never have produced. + if (gain == null || !gain.isFinite) return null; + if (gain < gainFloor(category) || gain > gainCeiling(category)) return null; + return LiveStepGainProfile( + version: kLiveStepGainVersion, + category: category, + gain: gain, + nRuns: (row['n_runs'] as num?)?.toInt() ?? 0, + updatedAt: DateTime.fromMillisecondsSinceEpoch( + ((row['updated_ts'] as num?)?.toInt() ?? 0) * 1000, + ), + ); + } + + static Future putLiveStepGainProfile( + LiveStepGainProfile p, { + DatabaseExecutor? exec, + }) async { + final db = exec ?? await instance; + await db.insert( + 'live_step_calibration', + { + 'category': p.category.key, + 'gain': p.gain, + 'n_runs': p.nRuns, + 'version': p.version, + 'updated_ts': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + /// Persist one accepted reading and its packets ATOMICALLY. Throws on any /// failure (including an id collision — a re-save of the same reading is a /// bug, not a merge), and the caller must not present a completed reading @@ -10333,6 +10587,8 @@ class LocalDb { // Calibration observations cannot be rebuilt once raw is pruned. 'step_calibration', 'step_calibration_day', + 'live_step_calibration', + 'live_step_calibration_run', // The devices this phone knows about — so a SECONDARY device's identity // survives a backup/restore round trip rather than leaving its rows in // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index 1440e13d2..3cd1f24ee 100644 --- a/lib/data/recent_slice.dart +++ b/lib/data/recent_slice.dart @@ -111,6 +111,9 @@ const Map kSliceTables = { 'signal_priority': (SliceKeep.all, null, 'which device feeds which signal'), 'step_calibration': (SliceKeep.all, null, 'step calibration'), 'step_calibration_day': (SliceKeep.all, null, 'step calibration days'), + 'live_step_calibration': (SliceKeep.all, null, 'live step calibration'), + 'live_step_calibration_run': + (SliceKeep.all, null, 'live step calibration runs'), 'sync_cursor': (SliceKeep.all, null, 'sync position'), 'alarm_schedule': (SliceKeep.all, null, 'alarm settings'), 'notifications': (SliceKeep.all, null, 'in-app notifications'), diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index 15721c29d..8a8319a00 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -53,7 +53,14 @@ class PhonePedometer { /// [stepReader] exists so the hour walk is testable without a device sensor — /// the walk is where the DST and partial-read bugs live, so it needs coverage /// that does not touch a platform channel. - PhonePedometer({StepIntervalReader? stepReader}) : _stepReader = stepReader; + PhonePedometer({ + StepIntervalReader? stepReader, + this.flushTimeout = const Duration(seconds: 6), + }) : _stepReader = stepReader; + + /// How long to wait for a flush to complete. The native side gives up first, + /// at five seconds; this only guards against a call that never returns. + final Duration flushTimeout; final StepIntervalReader? _stepReader; @@ -83,6 +90,62 @@ class PhonePedometer { } } + /// The motion coprocessor's own step count between two instants, or null when + /// the interval is not covered. + /// + /// The calibration routine needs a WINDOW, not a day: a household routine is + /// three minutes long and the reference has to be the steps inside exactly + /// those minutes. Null and [intervalNotCovered] both mean "no reference", and + /// the calibration refuses the run on either — a phone that was on a table + /// measured nothing, which must not be fitted as a measured zero. + Future stepsBetween(DateTime from, DateTime to) async { + if (!to.isAfter(from)) return null; + try { + final steps = await _readSteps(from, to); + if (steps == null || steps == intervalNotCovered || steps < 0) return null; + return steps; + } catch (_) { + return null; + } + } + + /// Asks the step sensor to deliver what it has batched (Android holds counts + /// for up to a minute before delivering them) and waits for it to finish. + /// + /// True only when the platform reports the flush COMPLETED, i.e. every + /// batched count has been delivered and banked; false when it was refused, + /// did not complete within [flushTimeout], or failed; null on a platform with + /// nothing to flush. + Future flushPending() async { + try { + final done = await phoneStepsChannel + .invokeMethod('flush') + .timeout(flushTimeout, onTimeout: () => false); + return done == true; + } on MissingPluginException { + return null; + } catch (_) { + return false; + } + } + + /// The running total after a completed flush, or null. + Future flushedTotal() async { + if (await flushPending() != true) return null; + return rawCount(); + } + + /// The step sensor's own running total, where the platform exposes one + /// (Android); null elsewhere or when it is not counting. Only a difference + /// between two reads within one boot means anything. + Future rawCount() async { + try { + return await phoneStepsChannel.invokeMethod('rawCount'); + } catch (_) { + return null; + } + } + /// Ask for access to the phone's step sensor. Safe to call repeatedly. /// /// THIS IS THE ONLY THING THAT MAKES THE PERMISSION EXIST. On iOS a @@ -102,6 +165,14 @@ class PhonePedometer { } } + /// Make sure the step sensor may be read before something depends on it. + /// + /// Always asks rather than checking first: [hasPermission] counts iOS's + /// `notDetermined` as yes, so a first-use iPhone would skip the request and + /// the prompt would only come up mid-routine. [requestPermission] is safe to + /// repeat and answers at once where access is already granted. + Future ensureAccess() => requestPermission(); + /// Whether the sensor is readable right now. /// /// Unlike the health plugin's `hasPermissions` — which returns null/false @@ -371,3 +442,42 @@ class PhonePedometer { return (daysRead: ok, totalSteps: total); } } + +/// A timed calibration routine's phone reference through its end, or null when +/// there is no honest one. +/// +/// On a platform with a running total (Android) only the difference of two +/// flushed totals is used: its range query prorates five-minute bins, so a +/// three-minute routine read that way picks up steps from outside it. A +/// missing total at either end means no reference, and the routine is refused. +/// Elsewhere the platform's interval query is exact and is used directly. +/// +/// [markEnd] is called at the instant the reference ends, so the caller can +/// freeze the band's count there too: on Android once the final flush has +/// completed, elsewhere at the end time handed to the interval query. +Future routinePhoneReference({ + required bool runningTotalPlatform, + required int? startTotal, + required Future Function() flush, + required Future Function() total, + required Future Function(DateTime end) interval, + required DateTime Function() now, + required void Function(DateTime end) markEnd, +}) async { + if (runningTotalPlatform) { + if (startTotal == null) return null; + if (await flush() != true) return null; + markEnd(now()); + final end = await total(); + if (end == null || end < startTotal) return null; + return end - startTotal; + } + final end = now(); + markEnd(end); + try { + final n = await interval(end); + return n == null || n < 0 ? null : n; + } catch (_) { + return null; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3e09adf20..44dec25da 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16111,5 +16111,97 @@ "type": "int" } } + }, + "settingsStepCalibration": "Step calibration", + "settingsStepCalibrationSub": "Learns how your strap over- or under-counts your own walking, and corrects it on the phone. Nothing is written to the strap and nothing leaves the device", + "stepCalTitle": "Step calibration", + "stepCalIntro": "Your strap counts steps off its own motion sensor, and how far off it is depends on you. These routines measure that. Nothing leaves your phone.", + "stepCalSlowTitle": "Slow walk", + "stepCalSlowSub": "walk slowly, phone in your pocket", + "stepCalSlowGuide": "Walk slowly and evenly, strap snug. Count 100 steps yourself.", + "stepCalBriskTitle": "Brisk walk", + "stepCalBriskSub": "walk at your usual pace", + "stepCalBriskGuide": "Walk at your normal pace. Count 100 steps yourself.", + "stepCalJogTitle": "Jog", + "stepCalJogSub": "jog steadily", + "stepCalJogGuide": "Jog steadily. Count 100 steps yourself.", + "stepCalEverydayTitle": "Around the house", + "stepCalEverydaySub": "tidy up for three minutes, phone in pocket", + "stepCalEverydayGuide": "Do ordinary things around the house — carry, lift, tidy — with your phone in your pocket. Three minutes. Do not pace deliberately.", + "stepCalCalibrated": "Calibrated — counted {n} times", + "stepCalNotCalibrated": "Not calibrated yet", + "stepCalBandSeen": "{count, plural, one{step the band has seen} other{steps the band has seen}}", + "@stepCalBandSeen": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "stepCalCountYourself": "Count your own steps. Stop when you reach {target}.", + "stepCalElapsed": "Keep going — this one is timed.", + "stepCalHowManyCounted": "How many steps did you count?", + "stepCalFinish": "Done", + "stepCalCancel": "Cancel", + "stepCalKeepWalking": "Nothing recorded yet — start walking with the strap snug.", + "stepCalKeepMoving": "Nothing recorded yet — keep the phone on you and keep moving.", + "stepCalNoSignal": "The band recorded nothing during that routine.", + "stepCalNoPhoneAccess": "This routine needs your phone’s step counter, and access was not granted. That counter is the reference for household movement.", + "stepCalPhoneNotReady": "Your phone’s step counter did not answer. Try again in a moment.", + "stepCalNoPhone": "Your phone counted no steps while you did that. Keep it in your pocket next time — it is the reference for household movement.", + "stepCalBandQuiet": "The band saw almost no movement. It is worth checking the strap is worn snugly and streaming.", + "stepCalTooShort": "That routine was too short to measure against. Walk the full count.", + "stepCalMismatch": "The band counted far more than you did. Something other than walking was being picked up.", + "stepCalWrongSource": "That routine used the wrong reference.", + "stepCalBadData": "That routine produced unreadable sensor data.", + "stepCalRatioOutOfRange": "Your count and the band’s are too far apart to use. Repeat the routine and walk the full count.", + "stepCalCadenceOutOfRange": "The band could not find a walking rhythm in that routine. Start walking right after you press start and keep a steady pace.", + "stepCalRoutineTooShort": "That routine ended before the timer finished. Keep going until it completes.", + "stepCalSaveFailed": "Could not save that calibration.", + "stepCalDoneGait": "Saved. You counted {count} steps and the band saw {band} steps. From now on this kind of walking is corrected by {pct}%.", + "stepCalDoneHousehold": "Saved. Your phone counted {count} steps of the {band} steps the band saw. Household movement is now scaled to {gain}.", + "@stepCalCalibrated": { + "description": "Badge on a category row: how many supervised routines are behind its gain.", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "@stepCalCountYourself": { + "description": "Instruction during a gait routine. The wearer counts their own steps; the band's live count is shown only as feedback, because a run that ended on the band's own count would always fit exactly 1.0.", + "placeholders": { + "target": { + "type": "int" + } + } + }, + "@stepCalDoneGait": { + "description": "Result of a gait calibration: what the wearer counted, what the band counted, and the percentage correction now applied. Signed, so a reduction reads as -12%.", + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "pct": { + "type": "int" + } + } + }, + "@stepCalDoneHousehold": { + "description": "Result of a household calibration: the phone's count (the reference), the band's count, and the resulting scale as a fixed-2 string.", + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "gain": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9010da858..0bb1922e0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7745,5 +7745,93 @@ "type": "int" } } + }, + "settingsStepCalibration": "Калибровка шагов", + "settingsStepCalibrationSub": "Узнаёт, насколько браслет завышает или занижает ваши шаги, и исправляет это на телефоне. На браслет ничего не записывается, и ничего не покидает устройство", + "stepCalTitle": "Калибровка шагов", + "stepCalIntro": "Браслет считает шаги по собственному датчику движения, и его погрешность зависит от вас. Эти упражнения её измеряют. Ничего не покидает ваш телефон.", + "stepCalSlowTitle": "Медленная ходьба", + "stepCalSlowSub": "идите медленно, телефон в кармане", + "stepCalSlowGuide": "Идите медленно и ровно, браслет плотно на руке. Сами отсчитайте 100 шагов.", + "stepCalBriskTitle": "Быстрая ходьба", + "stepCalBriskSub": "идите в обычном темпе", + "stepCalBriskGuide": "Идите в обычном темпе. Сами отсчитайте 100 шагов.", + "stepCalJogTitle": "Бег трусцой", + "stepCalJogSub": "бегите ровно", + "stepCalJogGuide": "Бегите ровно. Сами отсчитайте 100 шагов.", + "stepCalEverydayTitle": "Дома", + "stepCalEverydaySub": "три минуты наводите порядок, телефон в кармане", + "stepCalEverydayGuide": "Занимайтесь обычными домашними делами — носите, поднимайте, убирайте — с телефоном в кармане. Три минуты. Не ходите туда-сюда специально.", + "stepCalCalibrated": "Откалибровано — {n, plural, one{{n} замер} few{{n} замера} many{{n} замеров} other{{n} замера}}", + "@stepCalCalibrated": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "stepCalNotCalibrated": "Ещё не откалибровано", + "stepCalBandSeen": "{count, plural, one{шаг по данным браслета} few{шага по данным браслета} many{шагов по данным браслета} other{шага по данным браслета}}", + "@stepCalBandSeen": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "stepCalCountYourself": "Считайте шаги сами. Остановитесь на {target}.", + "@stepCalCountYourself": { + "placeholders": { + "target": { + "type": "int" + } + } + }, + "stepCalElapsed": "Продолжайте — это упражнение на время.", + "stepCalHowManyCounted": "Сколько шагов вы насчитали?", + "stepCalFinish": "Готово", + "stepCalCancel": "Отмена", + "stepCalKeepWalking": "Пока ничего не записано — начните идти, браслет плотно на руке.", + "stepCalKeepMoving": "Пока ничего не записано — держите телефон при себе и продолжайте двигаться.", + "stepCalNoSignal": "За это упражнение браслет ничего не записал.", + "stepCalNoPhoneAccess": "Для этого упражнения нужен шагомер телефона, а доступ к нему не предоставлен. Он служит эталоном для домашней активности.", + "stepCalPhoneNotReady": "Шагомер телефона не ответил. Попробуйте ещё раз через минуту.", + "stepCalNoPhone": "Телефон не насчитал ни одного шага. В следующий раз держите его в кармане — он служит эталоном для домашней активности.", + "stepCalBandQuiet": "Браслет почти не заметил движения. Проверьте, что он плотно сидит на руке и передаёт данные.", + "stepCalTooShort": "Упражнение было слишком коротким для сравнения. Пройдите весь счёт.", + "stepCalMismatch": "Браслет насчитал намного больше, чем вы. Он улавливал что-то кроме ходьбы.", + "stepCalWrongSource": "В этом упражнении использован не тот эталон.", + "stepCalBadData": "Упражнение дало нечитаемые данные датчиков.", + "stepCalRatioOutOfRange": "Ваш счёт и счёт браслета слишком расходятся, чтобы их использовать. Повторите упражнение и пройдите весь счёт.", + "stepCalCadenceOutOfRange": "Браслет не распознал в этом упражнении ритм ходьбы. Начинайте идти сразу после старта и держите ровный темп.", + "stepCalRoutineTooShort": "Упражнение закончилось раньше таймера. Продолжайте, пока он не истечёт.", + "stepCalSaveFailed": "Не удалось сохранить калибровку.", + "stepCalDoneGait": "Сохранено. Вы насчитали {count, plural, one{{count} шаг} few{{count} шага} many{{count} шагов} other{{count} шага}}, браслет — {band, plural, one{{band} шаг} few{{band} шага} many{{band} шагов} other{{band} шага}}. Теперь этот тип движения корректируется на {pct}%.", + "@stepCalDoneGait": { + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "pct": { + "type": "int" + } + } + }, + "stepCalDoneHousehold": "Сохранено. Телефон насчитал {count, plural, one{{count} шаг} few{{count} шага} many{{count} шагов} other{{count} шага}} из {band, plural, one{{band} шага} few{{band} шагов} many{{band} шагов} other{{band} шага}}, которые зафиксировал браслет. Домашняя активность теперь масштабируется с коэффициентом {gain}.", + "@stepCalDoneHousehold": { + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "gain": { + "type": "String" + } + } } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index a2fb7c240..40b291cb9 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -62,6 +62,7 @@ import '../compute/inputs/canonical.dart' refreshSessionSensorSources, substrateDays; import '../compute/manual_session.dart' show supersededSuggestionIds; +import '../compute/step_calibration_fit.dart'; import '../compute/hr_max.dart'; import '../compute/inputs/measurement_inputs.dart' show @@ -734,7 +735,23 @@ class AppState extends ChangeNotifier { // do not add it back here. Its inflate checks the gzip trailer, so a // truncated backup fails loudly; `gzip.decoder` returns partial output // without raising and would restore short while reporting success. - final counts = await LocalDb.importFromDbFile(path); + // Invalidated however the import ends: it commits page by page, so a later + // page that throws can still leave merged calibration runs behind, and the + // refit can fail after a clean import. The cache holds runs, not profiles. + final Map counts; + try { + counts = await LocalDb.importFromDbFile(path); + await LocalDb.refitAllLiveStepGains(); + } finally { + invalidateLiveStepCalibration(); + } + // An import can MERGE calibration rows (`live_step_calibration`) in from the + // backup without this process knowing, and the cached fit is the only thing + // `creditedStepsForRun` consults when a walk is banked. Without this, every + // walk banked after a restore would use the pre-restore gain — including the + // case where the backup is the reason the calibration exists at all (a new + // phone). The docstring on [invalidateLiveStepCalibration] names this path; + // the `finally` above is what makes that true. lastImportArchiveSkipped = counts['_substrate_archive_skipped'] ?? 0; lastImportArchiveRestored = counts['substrate_archive'] ?? 0; if (lastImportArchiveSkipped > 0) { @@ -1413,6 +1430,9 @@ class AppState extends ChangeNotifier { // holds the derive latch from before the gate went up, so no pass that // read the substrate before the wipe can write its days back after it. await LocalDb.wipeAll(); + // The rows are gone; the cached fit is not. Without this a walk banked + // after a data reset would still be corrected by the pre-reset gain. + invalidateLiveStepCalibration(); // The copies already handed to share sheets (exports, CSVs, issue // reports) are the same records in plaintext, sitting in the cache. await purgeSharedHealthCopies(); @@ -3545,6 +3565,45 @@ class AppState extends ChangeNotifier { /// something a source ladder can rank. final GaitRuns _gaitRuns = GaitRuns(); + /// Admitted calibration runs for the live 100 Hz pedometer, cached per + /// session. + /// + /// Lazily loaded rather than fetched per banked run: the fit reads the WHOLE + /// run history, and a session banks a handful of runs, so a query per run + /// would be a query per walk for an answer that cannot change mid-session. + /// + /// The calibration screen MUST call [invalidateLiveStepCalibration] once it has + /// saved, or a walk that started before the save would bank with the old gain. + List? _liveStepCalibration; + + /// Bumped by every invalidation. A load that started before an invalidation + /// must not put its result back: the calibration screen saves while a banked + /// walk may already have triggered a read, and a stale list restored after the + /// save would have every later walk fit the previous gain. + int _liveStepCalibrationGeneration = 0; + + Future> _liveStepCalibrationRuns() async { + while (true) { + final cached = _liveStepCalibration; + if (cached != null) return cached; + final generation = _liveStepCalibrationGeneration; + final rows = await LocalDb.liveStepCalibrationRuns(); + // Invalidated while the read was in flight: these rows may predate the + // save that invalidated them, so read again rather than hand the caller + // the previous calibration. + if (generation != _liveStepCalibrationGeneration) continue; + return _liveStepCalibration = rows; + } + } + + /// Drop the cached fit so the next banked run uses a newly saved calibration. + /// Also called after a database import, which merges calibration rows in + /// without this process knowing. + void invalidateLiveStepCalibration() { + _liveStepCalibration = null; + _liveStepCalibrationGeneration++; + } + /// Map a phone-clock instant onto the BAND's record-time base, the base /// `live_coverage` rows live in. /// @@ -3578,8 +3637,14 @@ class AppState extends ChangeNotifier { ); } - /// Steps counted on the live 100 Hz stream this connected session (real, - /// gain-applied). Used for cadence calibration. 0 when not streaming. + /// Steps counted on the live 100 Hz stream this connected session. Used for + /// cadence calibration. 0 when not streaming. + /// + /// This used to read "(real, gain-applied)". It is NOT, and cannot be: + /// `_committedRaw` holds "raw (pre-gain) steps from completed minutes" and + /// `ana.pedometer` returns "Raw count — no calibration gain". Corrected rather + /// than left standing, because the calibration reads this value and depends on + /// it being raw. /// /// The still-filling partial minute is dropped once the stream has been /// MEASURED below [kMinLiveSampleRateHz] — a count off a stream that slow is @@ -3597,6 +3662,16 @@ class AppState extends ChangeNotifier { return _committedRaw + ana.pedometer(_magMin); } + /// The strap's live RAW step count for this connected session — what the + /// calibration screen shows while the wearer walks. + /// + /// PUBLIC AND RAW ON PURPOSE. The screen's whole job is to show the gap + /// between what the wearer counted and what the band saw, and that gap IS the + /// correction. Applying the gain here would hide the answer behind the number + /// being calibrated, and at the moment of calibration the gain is the thing + /// the wearer is trying to change. + int get liveStrapRawSteps => _liveRaw; + // ── the two safety gates on this tier — see live_step_runs.dart ──────────── /// Phone-clock instant the current chunk's first sample arrived AFTER, i.e. /// the previous frame's arrival. The span from here to the frame that @@ -3843,13 +3918,26 @@ class AppState extends ChangeNotifier { /// Persist [runs] as `live_coverage` rows under the STRAP source. /// - /// `ana.StepParams.gain` is applied HERE and nowhere else on the persistence - /// path — the runs carry the raw count exactly as `pedometer()` returns it, - /// and this is the daily-sum layer `calcSteps` documents as the gain's home. - /// Applying it at both ends shipped a silent x1.23. + /// The step trim is applied HERE, and on the checkpoint path ONLY through the + /// same [creditedStepsForRun] call — the runs carry the raw count exactly as + /// `pedometer()` returns it, and this is the daily-sum layer `calcSteps` + /// documents as the gain's home. Two formulas, or one formula applied at both + /// ends, is the silent x1.23 this method's history records; one shared + /// function with one factor per run is the invariant that prevents it. Future _bankGaitRuns(List runs) async { + if (runs.isEmpty) return; + final calibration = await _liveStepCalibrationRuns(); for (final run in runs) { - final steps = (run.rawSteps * ana.StepParams.gain).round(); + // ONE factor per run. When the wearer has calibrated a family this file + // uses THAT gain; otherwise it is exactly the shipped `StepParams.gain` + // this line used before, to the digit. The two are never multiplied — + // multiplying them is the silent x1.23 this method's docstring records. + final steps = creditedStepsForRun( + rawSteps: run.rawSteps, + seconds: run.seconds, + runs: calibration, + fallbackGain: ana.StepParams.gain, + ); if (steps <= 0) continue; // Per RUN, so a walk either side of midnight lands on the right days // instead of both going to whichever day the session started on. @@ -3863,6 +3951,17 @@ class AppState extends ChangeNotifier { day, source: kStepSourceStrap, ); + // AGENTS.md §4.7 — the other two callers of `addLiveCoverage` are + // enumerated here so this capability cannot be wired into one path and + // forgotten in the rest: + // * `_recoverOrphanedLiveSessionOnce` (below) banks a checkpoint's + // numbers VERBATIM, and that checkpoint is written through this same + // `creditedStepsForRun`, so it carries the calibration too. + // * `lib/import/noop_ingest.dart` banks another app's exported step + // runs. Those are the vendor's own counts, already measured by a + // device that is not this strap's pedometer, so this calibration must + // NOT be applied to them — the gain describes our counter's residual, + // not theirs. } } @@ -3879,6 +3978,7 @@ class AppState extends ChangeNotifier { if (_gaitRuns.isEmpty) return; try { final prefs = await SharedPreferences.getInstance(); + final calibration = await _liveStepCalibrationRuns(); await prefs.setString( _kLiveSessionCheckpoint, // Runs are stored gain-applied, i.e. exactly the numbers @@ -3888,7 +3988,19 @@ class AppState extends ChangeNotifier { jsonEncode({ 'runs': [ for (final r in _gaitRuns.runs) - [r.startTs, r.endTs, (r.rawSteps * ana.StepParams.gain).round()], + [ + r.startTs, + r.endTs, + // The SAME single application as [_bankGaitRuns], because + // recovery banks these numbers verbatim. A second formula here + // would be a second application of the gain. + creditedStepsForRun( + rawSteps: r.rawSteps, + seconds: r.seconds, + runs: calibration, + fallbackGain: ana.StepParams.gain, + ), + ], ], }), ); diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 1133ff387..3e92ba294 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -1,4 +1,5 @@ import '../../l10n/display_text.dart'; +import 'step_calibration.dart'; // Settings, and editing the profile. // // Two deliberate departures from the reference design: @@ -833,6 +834,19 @@ class MoreSettingsView extends StatelessWidget { 'band doesn’t cover. Nothing leaves the device', value: phoneSteps ? on : off, onTap: onTogglePhoneSteps), + + // Step calibration. A per-wearer correction for the strap's own + // 100 Hz pedometer — see step_calibration.dart for why it must be + // measured rather than assumed, and for what makes it different + // from the guided calibration app_state.dart records as removed. + SetRow( + LucideIcons.footprints, + C.teal, + l?.settingsStepCalibration ?? 'Step calibration', + sub: l?.settingsStepCalibrationSub ?? + 'Teaches the strap your own walking, so its step count ' + 'fits you. Nothing leaves the device', + onTap: () => goto(c, const StepCalibrationScreen())), ]), settingsGroup(c, l?.settingsGroupPrivacy ?? 'Privacy', [ SetRow(LucideIcons.bug, C.orange, diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart new file mode 100644 index 000000000..701e4edb2 --- /dev/null +++ b/lib/ui2/profile/step_calibration.dart @@ -0,0 +1,615 @@ +// Per-user, per-activity calibration of the strap's live 100 Hz pedometer. +// +// WHAT THIS SCREEN IS FOR. The strap counts steps off its own 100 Hz IMU +// (`pedometer()` in analytics), and the count is a residual off on a real wrist +// in ways that depend on the wearer. Analytics asks for exactly this correction +// in its own `StepParams.gain` docstring: "Measure it against ground truth, per +// user, and store it with the user; never bake a population constant back in +// here." +// +// THE WEARER COUNTS, THE BAND DOES NOT. A gait routine cannot end when the band +// reaches the target: `rawSteps` would be the target by construction and every +// wearer would fit a gain of exactly 1.0 forever. So the screen asks the wearer +// to count their own steps and shows the band's live count as FEEDBACK — the gap +// between the two is the measurement they came for. +// +// Household movement is the other family, and the one that pays: at the wrist, +// arm work out-accelerates walking, which is why these devices emit 22-27 false +// steps per minute during dishes and driving (O'Connell 2017). Its reference is +// the PHONE, because nobody counts steps while doing chores, and a pocket-carried +// device is simply a better sensor for this one quantity +// (`lib/health/phone_pedometer.dart`). The correction there comes out LOW — and +// deliberately never zero, because a household routine does contain real steps. +// +// WHAT THIS SCREEN MUST NOT DO. `app_state.dart` records that a previous guided +// step calibration was removed because its only reader had been deleted and it +// "told the user their walk had taught the app something when nothing read the +// result". This one has a reader — `_bankGaitRuns` and the session checkpoint +// both bank through `creditedStepsForRun` — and this screen must not claim a +// correction took effect until that save has actually returned. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../compute/step_calibration_fit.dart'; +import '../../data/db.dart'; +import '../../health/phone_pedometer.dart'; +import '../../l10n/app_localizations.dart'; +import '../../state/app_state.dart'; +import '../screens/metric_detail.dart' show detailLinkRow, detailScaffold; +import '../ui2.dart'; + +class StepCalibrationScreen extends StatefulWidget { + const StepCalibrationScreen({super.key}); + + @override + State createState() => _StepCalibrationScreenState(); +} + +class _StepCalibrationScreenState extends State { + final Map _profiles = {}; + + /// ONE instance for the screen's lifetime. The ticker runs once a second for + /// three minutes, and a fresh `PhonePedometer` per tick would mean 180 objects + /// and 180 platform-channel queries for a value read at most twice a routine. + final PhonePedometer _pedometer = PhonePedometer(); + CalibrationSession? _session; + Timer? _ticker; + DateTime? _routineStart; + + /// The phone sensor's running total when the household routine began, on a + /// platform that has one. Read after a flush so earlier steps are excluded. + Future? _startRaw; + + /// What the last routine produced, in the wearer's words. Never set before a + /// save has returned: a message is a claim, and an unclaimed one is the exact + /// failure the previous calibration shipped. + String? _outcome; + + bool _busy = false; + + @override + void initState() { + super.initState(); + unawaited(_loadProfiles()); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + Future _loadProfiles() async { + for (final c in StepCategory.values) { + _profiles[c] = await LocalDb.liveStepGainProfile(c); + } + if (mounted) setState(() {}); + } + + /// Starting is busy while the phone side is being prepared; a second tap + /// must not start a second routine on top of it. + bool _starting = false; + + Future _start(StepCategory category) async { + if (_starting) return; + final app = context.read(); + _ticker?.cancel(); + setState(() { + _starting = true; + _outcome = null; + }); + final begun = await beginCalibrationRoutine( + category: category, + // Ask BEFORE the routine, not after: a household routine refused at the + // end for a permission the wearer was never asked for blames the wrong + // thing. + ensurePhoneAccess: _pedometer.ensureAccess, + runningTotalPlatform: defaultTargetPlatform == TargetPlatform.android, + startTotal: _pedometer.flushedTotal, + bandTotalNow: () => app.liveStrapRawSteps, + nowMs: () => DateTime.now().millisecondsSinceEpoch, + ); + if (!mounted) return; + final l = AppLocalizations.of(context); + setState(() { + _starting = false; + _session = begun.session; + _routineStart = begun.session == null ? null : DateTime.now(); + _startRaw = Future.value(begun.startTotal); + _outcome = switch (begun.refusal) { + null => null, + RoutineStartRefusal.noPhoneAccess => l?.stepCalNoPhoneAccess ?? + 'This routine needs your phone\u2019s step counter, and access ' + 'was not granted. It is the reference for household movement.', + RoutineStartRefusal.phoneNotReady => l?.stepCalPhoneNotReady ?? + 'Your phone\u2019s step counter did not answer. Try again in a ' + 'moment.', + }; + }); + if (begun.session != null) { + _ticker = Timer.periodic(Motion.tick, (_) => _tick()); + } + } + + void _tick() { + final s = _session; + if (!mounted || s == null) return; + final app = context.read(); + s.observe( + bandTotal: app.liveStrapRawSteps, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + // The household family's reference is the phone. Read it as the routine + // runs rather than only at the end, so a permission failure surfaces while + // the wearer is still standing there instead of after three minutes. + if (s.category.referenceIsPhone) unawaited(_readPhone(s)); + setState(() {}); + } + + + /// Sequence of phone reads started. Only the MOST RECENTLY STARTED read may + /// write a value: on Android a query can outlive the next tick, and without + /// this the older interval lands last and replaces the newer count. + int _phoneReadSeq = 0; + + Future _readPhone(CalibrationSession s) async { + final start = _routineStart; + if (start == null) return; + final seq = ++_phoneReadSeq; + try { + final steps = await _pedometer.stepsBetween(start, DateTime.now()); + // A read that a later read has already superseded must not land. + if (seq != _phoneReadSeq) return; + // A null or -1 answer is the phone saying "I did not cover that interval", + // which is not a zero. Leaving the reference absent makes `finish()` refuse + // the run rather than fit a 0.0 gain from a phone on a table. + if (steps != null && steps >= 0) s.setPhoneSteps(steps); + } catch (_) { + // Absent reference, not a fabricated one. finish() reports it. + } + } + + /// The household routine's reference through its END, or false when it + /// cannot be had — in which case an earlier partial read must not stand in. + /// + /// Android: the sensor's running total at end minus at start, each after a + /// flush. Exact, where a range query prorates 5-minute bins. Elsewhere (iOS + /// has no running total, and its interval query is exact) one final query. + Future _finalPhoneRead(CalibrationSession s) async { + // A periodic read still in flight must not land on top of this one. + ++_phoneReadSeq; + final start = _routineStart; + if (start == null) return false; + final app = context.read(); + final steps = await routinePhoneReference( + runningTotalPlatform: defaultTargetPlatform == TargetPlatform.android, + startTotal: await _startRaw, + flush: _pedometer.flushPending, + total: _pedometer.rawCount, + interval: (end) => _pedometer.stepsBetween(start, end), + now: DateTime.now, + // The band's count stops where the phone's reference stops. + markEnd: (end) => s.hold( + bandTotal: app.liveStrapRawSteps, + nowMs: end.millisecondsSinceEpoch, + ), + ); + if (steps == null) return false; + s.setPhoneSteps(steps); + return true; + } + + void _cancel() { + _ticker?.cancel(); + setState(() { + _session = null; + _routineStart = null; + _outcome = null; + }); + } + + Future _askCounted(int target) async { + final l = AppLocalizations.of(context); + final ctrl = TextEditingController(text: '$target'); + final out = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text(l?.stepCalHowManyCounted ?? 'How many steps did you count?'), + content: TextField( + controller: ctrl, + autofocus: true, + keyboardType: TextInputType.number, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(c).pop(), + child: Text(l?.stepCalCancel ?? 'Cancel'), + ), + TextButton( + onPressed: () { + final n = int.tryParse(ctrl.text.trim()); + if (n != null && n > 0) Navigator.of(c).pop(n); + }, + child: Text(l?.stepCalFinish ?? 'Done'), + ), + ], + ), + ); + ctrl.dispose(); + return out; + } + + Future _finish() async { + final s = _session; + // `_busy` guards re-entry: the awaited final read leaves the buttons live, + // and a second tap would finish a session that is already finished. + if (s == null || _busy) return; + // Gait: the wearer is the reference, so ask what they counted rather than + // assume they reached the target. Cancelling keeps the routine running. + int? counted; + if (!s.category.referenceIsPhone) { + // The walk ends at the tap, not when the question is answered. + s.hold( + bandTotal: context.read().liveStrapRawSteps, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + counted = await _askCounted(s.targetSteps); + if (!mounted) return; + if (counted == null) { + s.resume(); + return; + } + } + _ticker?.cancel(); + // Locked BEFORE the read, not after it. + setState(() => _busy = true); + // One final read, so the reference covers the WHOLE routine instead of + // whatever the last tick happened to see — and so it is taken while the + // wearer is still standing there, which is the difference between a measured + // reference and a refused routine. + if (s.category.referenceIsPhone) { + // Android delivers step counts in batches up to a minute late; without a + // flush the last stretch of the routine is missing from its reference. + if (!await _finalPhoneRead(s)) s.clearPhoneSteps(); + } + // §4.5: `context` may not be used after an await without this. + if (!mounted) return; + final out = s.finish(countedSteps: counted); + final l = AppLocalizations.of(context); + + if (out.bandSilent) { + setState(() { + _busy = false; + _session = null; + _outcome = l?.stepCalNoSignal ?? 'The band recorded nothing during that routine.'; + }); + return; + } + if (out.refusal != null) { + setState(() { + _busy = false; + _session = null; + _outcome = _describeRefusal(out.refusal!, l); + }); + return; + } + + final app = context.read(); + try { + final profile = await LocalDb.bankLiveStepCalibrationRun( + out.run!, + runTs: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + // Only after the save returned: a walk already in progress would otherwise + // bank with the previous gain. + app.invalidateLiveStepCalibration(); + // The run is stored from here on. A failed reload of the rows below is + // not a failed save: reporting it as one invites a retry that stores the + // same routine twice. + // The bank's own result is the saved category's profile, so that row + // is current even if the reload of the others fails. + _profiles[out.run!.category] = profile; + try { + await _loadProfiles(); + } catch (_) {} + if (!mounted) return; + setState(() { + _busy = false; + _session = null; + _routineStart = null; + _outcome = _describeOutcome(out.run!, profile, l); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _session = null; + _routineStart = null; + _outcome = + '${l?.stepCalSaveFailed ?? 'Could not save that calibration.'} ($e)'; + }); + } + } + + /// Every refusal is a claim about the ROUTINE, and the wearer is told which — + /// a silent no-op that leaves them believing their walk taught the app + /// something is the failure this screen exists to avoid. + String _describeRefusal(StepRunRejection r, AppLocalizations? l) => + switch (r) { + StepRunRejection.noReferenceSteps => + l?.stepCalNoPhone ?? + 'Your phone counted no steps while you did that. Keep it in your ' + 'pocket next time — it is the reference for household movement.', + StepRunRejection.rawTooSmall => + l?.stepCalBandQuiet ?? + 'The band saw almost no movement. It is worth checking the strap is ' + 'worn snugly and streaming.', + StepRunRejection.expectedTooSmall => + l?.stepCalTooShort ?? + 'That routine was too short to measure against. Walk the full ' + 'count.', + StepRunRejection.rawExceedsExpectedByTooMuch => + l?.stepCalMismatch ?? + 'The band counted far more than you did. Something other than ' + 'walking was being picked up.', + StepRunRejection.referenceWrongSource => + l?.stepCalWrongSource ?? 'That routine used the wrong reference.', + StepRunRejection.nonFiniteFeature => + l?.stepCalBadData ?? 'That routine produced unreadable sensor data.', + StepRunRejection.ratioOutsideGainRange => + l?.stepCalRatioOutOfRange ?? + 'Your count and the band\u2019s are too far apart to use. Repeat ' + 'the routine and walk the full count.', + StepRunRejection.cadenceOutsideGaitRange => + l?.stepCalCadenceOutOfRange ?? + 'The band could not find a walking rhythm in that routine. Start ' + 'walking right after you press start and keep a steady pace.', + StepRunRejection.routineTooShort => + l?.stepCalRoutineTooShort ?? + 'That routine ended before the timer finished. Keep going until ' + 'it completes.', + }; + + String _describeOutcome( + StepCalibrationRun run, + LiveStepGainProfile profile, + AppLocalizations? l, + ) { + // Signed, so a reduction reads as one: -12% is a different claim from 12%. + final pct = ((profile.gain - 1) * 100).round(); + if (run.category.isGait) { + return l?.stepCalDoneGait(run.expectedSteps, run.rawSteps, pct) ?? + 'Saved. You counted ${run.expectedSteps} steps and the band saw ' + '${run.rawSteps}. From now on this kind of walking is corrected ' + 'by $pct%.'; + } + return l?.stepCalDoneHousehold( + run.expectedSteps, + run.rawSteps, + profile.gain.toStringAsFixed(2), + ) ?? + 'Saved. Your phone counted ${run.expectedSteps} steps of the ' + '${run.rawSteps} the band saw. Household movement is now scaled to ' + '${profile.gain.toStringAsFixed(2)}.'; + } + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final s = _session; + return detailScaffold( + c, + l?.stepCalTitle ?? 'Step calibration', + s == null ? _overview(c, l) : _running(c, l, s), + ); + } + + List _overview(BuildContext c, AppLocalizations? l) { + final p = P.of(c); + return [ + Text( + l?.stepCalIntro ?? + 'Your strap counts steps off its own motion sensor, and how far off ' + 'it is depends on you. These routines measure that. Nothing ' + 'leaves your phone.', + style: F.cap.copyWith(color: p.ink2, height: 1.5), + ), + if (_outcome != null) ...[ + const SizedBox(height: S.x4), + _feedback(c, _outcome!), + ], + const SizedBox(height: S.x4), + for (final category in StepCategory.values) ...[ + detailLinkRow( + c, + _iconFor(category), + _titleFor(category, l), + _subtitleFor(category, l), + () => _start(category), + ), + const SizedBox(height: S.x3), + ], + ]; + } + + List _running(BuildContext c, AppLocalizations? l, CalibrationSession s) { + final p = P.of(c); + final isHousehold = s.category.referenceIsPhone; + return [ + Text( + _guideFor(s.category, l), + style: F.cap.copyWith(color: p.ink2, height: 1.5), + ), + const SizedBox(height: S.x5), + // The band's LIVE count, labelled as the band's — never as progress the + // wearer should trust as their own count. + Center( + child: Text( + '${s.bandRawSteps}', + style: F.display.copyWith(color: p.ink), + ), + ), + const SizedBox(height: S.x2), + Center( + child: Text( + // Counts the number shown above it, so languages that inflect the + // noun by count can agree with it. + l?.stepCalBandSeen(s.bandRawSteps) ?? 'steps the band has seen', + style: F.cap.copyWith(color: p.ink2), + ), + ), + const SizedBox(height: S.x4), + ClipRRect( + borderRadius: R.rSm, + child: LinearProgressIndicator( + value: s.progress, + minHeight: 6, + backgroundColor: p.track, + valueColor: AlwaysStoppedAnimation(p.ink), + ), + ), + const SizedBox(height: S.x2), + Text( + isHousehold + ? (l?.stepCalElapsed ?? 'Keep going — this one is timed.') + : (l?.stepCalCountYourself(s.targetSteps) ?? + 'Count your own steps. Stop when you reach ${s.targetSteps}.'), + style: F.cap.copyWith(color: p.ink2), + ), + const SizedBox(height: S.x6), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : _cancel, + child: Text(l?.stepCalCancel ?? 'Cancel'), + ), + ), + const SizedBox(width: S.x3), + Expanded( + child: FilledButton( + onPressed: (_busy || !s.canFinish) ? null : _finish, + child: Text(l?.stepCalFinish ?? 'Done'), + ), + ), + ], + ), + if (!s.canFinish) ...[ + const SizedBox(height: S.x2), + Text( + isHousehold + ? (l?.stepCalKeepMoving ?? + 'Nothing recorded yet — keep the phone on you and keep moving.') + : (l?.stepCalKeepWalking ?? + 'Nothing recorded yet — start walking with the strap snug.'), + style: F.cap.copyWith(color: p.ink2), + ), + ], + ]; + } + + Widget _feedback(BuildContext c, String text) { + final p = P.of(c); + return Container( + padding: const EdgeInsets.all(S.x4), + decoration: BoxDecoration( + color: p.ink3.withValues(alpha: 0.15), + borderRadius: R.rMd, + ), + child: Text(text, style: F.cap.copyWith(color: p.ink, height: 1.5)), + ); + } + + IconData _iconFor(StepCategory category) => switch (category) { + StepCategory.slow => LucideIcons.footprints, + StepCategory.brisk => LucideIcons.personStanding, + StepCategory.jog => LucideIcons.activity, + StepCategory.everyday => LucideIcons.house, + }; + + String _titleFor(StepCategory category, AppLocalizations? l) => + switch (category) { + StepCategory.slow => l?.stepCalSlowTitle ?? 'Slow walk', + StepCategory.brisk => l?.stepCalBriskTitle ?? 'Brisk walk', + StepCategory.jog => l?.stepCalJogTitle ?? 'Jog', + StepCategory.everyday => l?.stepCalEverydayTitle ?? 'Around the house', + }; + + String _subtitleFor(StepCategory category, AppLocalizations? l) { + final profile = _profiles[category]; + final state = profile?.isCalibrated ?? false + ? (l?.stepCalCalibrated(profile!.nRuns) ?? + 'Calibrated — counted ${profile!.nRuns} times') + : (l?.stepCalNotCalibrated ?? 'Not calibrated yet'); + return '$state · ${_guideShort(category, l)}'; + } + + String _guideShort(StepCategory category, AppLocalizations? l) => + switch (category) { + StepCategory.slow => + l?.stepCalSlowSub ?? 'walk slowly, phone in your pocket', + StepCategory.brisk => l?.stepCalBriskSub ?? 'walk at your usual pace', + StepCategory.jog => l?.stepCalJogSub ?? 'jog steadily', + StepCategory.everyday => + l?.stepCalEverydaySub ?? 'tidy up for three minutes, phone in pocket', + }; + + String _guideFor(StepCategory category, AppLocalizations? l) => + switch (category) { + StepCategory.slow => + l?.stepCalSlowGuide ?? + 'Walk slowly and evenly, strap snug. Count 100 steps yourself.', + StepCategory.brisk => + l?.stepCalBriskGuide ?? + 'Walk at your normal pace. Count 100 steps yourself.', + StepCategory.jog => + l?.stepCalJogGuide ?? 'Jog steadily. Count 100 steps yourself.', + StepCategory.everyday => + l?.stepCalEverydayGuide ?? + 'Do ordinary things around the house — carry, lift, tidy — with ' + 'your phone in your pocket. Three minutes. Do not pace ' + 'deliberately.', + }; +} + +/// Why a calibration routine did not start. +enum RoutineStartRefusal { noPhoneAccess, phoneNotReady } + +/// Start a routine in the order that keeps its two references aligned: +/// phone access first, then (on Android, for a household routine) a starting +/// phone total from a COMPLETED flush, and only then the band baseline and the +/// routine's clock. Taking the band baseline first would let the phone's +/// starting total settle after the routine had begun, so the two counts would +/// cover different spans. +@visibleForTesting +Future<({CalibrationSession? session, int? startTotal, RoutineStartRefusal? refusal})> + beginCalibrationRoutine({ + required StepCategory category, + required Future Function() ensurePhoneAccess, + required bool runningTotalPlatform, + required Future Function() startTotal, + required int Function() bandTotalNow, + required int Function() nowMs, +}) async { + int? total; + if (category.referenceIsPhone) { + if (!await ensurePhoneAccess()) { + return (session: null, startTotal: null, refusal: RoutineStartRefusal.noPhoneAccess); + } + if (runningTotalPlatform) { + total = await startTotal(); + if (total == null) { + return (session: null, startTotal: null, refusal: RoutineStartRefusal.phoneNotReady); + } + } + } + final session = CalibrationSession(category: category) + ..start(bandBaseline: bandTotalNow(), nowMs: nowMs()); + return (session: session, startTotal: total, refusal: null); +} + diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart new file mode 100644 index 000000000..93ce1bcbc --- /dev/null +++ b/test/phone_pedometer_raw_count_test.dart @@ -0,0 +1,194 @@ +// The calibration's household reference on Android is the step sensor's own +// running total, read at the start and end of a routine after a flush. These +// pin the Dart side of that: the value passes through, a platform without it +// (iOS) answers null, and the settle wait only happens when a flush was made. +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/health/phone_pedometer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + tearDown(() => messenger.setMockMethodCallHandler(phoneStepsChannel, null)); + + test('rawCount passes the sensor total through', () async { + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + if (c.method == 'rawCount') return 12345; + return null; + }); + expect(await PhonePedometer().rawCount(), 12345); + }); + + test('a platform without a running total answers null', () async { + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + throw MissingPluginException(); + }); + expect(await PhonePedometer().rawCount(), isNull); + // And flushing there reports nothing to flush rather than throwing. + expect(await PhonePedometer().flushPending(), isNull); + }); + + test('flush is asked of the platform', () async { + final calls = []; + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + calls.add(c.method); + return false; + }); + await PhonePedometer().flushPending(); + expect(calls, ['flush']); + }); + + group('the routine reference', () { + final t = DateTime(2026, 10, 31, 12); + Future neverRead() async => fail('must not be read'); + + test('android uses the difference of two flushed totals', () async { + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + flush: () async => true, + total: () async => 1240, + interval: (_) => neverRead(), + now: () => t, + markEnd: (_) {}, + ), + 240); + }); + + test('android without a starting total refuses, never prorates', () async { + var prorated = false; + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: null, + flush: () async => true, + total: () async => 1240, + interval: (_) async { + prorated = true; + return 99; + }, + now: () => t, + markEnd: (_) {}, + ), + isNull); + expect(prorated, isFalse); + }); + + test('android without an ending total or a completed flush refuses', + () async { + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + flush: () async => true, + total: () async => null, + interval: (_) => neverRead(), + now: () => t, + markEnd: (_) {}, + ), + isNull); + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + flush: () async => false, + total: () async => 1240, + interval: (_) => neverRead(), + now: () => t, + markEnd: (_) {}, + ), + isNull); + }); + + test('android marks the end after the flush and before the total', + () async { + final order = []; + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + flush: () async { + order.add('flushed'); + return true; + }, + total: () async { + order.add('total'); + return 1240; + }, + interval: (_) => neverRead(), + now: () => t, + markEnd: (_) => order.add('end'), + ); + expect(order, ['flushed', 'end', 'total']); + }); + + test('elsewhere the query and the band end at the same instant', () async { + DateTime? marked, queried; + expect( + await routinePhoneReference( + runningTotalPlatform: false, + startTotal: null, + flush: () async => null, + total: neverRead, + interval: (end) async { + queried = end; + return 57; + }, + now: () => t, + markEnd: (end) => marked = end, + ), + 57); + expect(marked, t); + expect(queried, t); + }); + }); + + test('a refused flush gives no total', () async { + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + if (c.method == 'flush') return false; + if (c.method == 'rawCount') return 5000; + return null; + }); + expect(await PhonePedometer().flushPending(), isFalse); + expect(await PhonePedometer().flushedTotal(), isNull); + }); + + test('a flush that completes late is still accepted', () async { + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + if (c.method == 'flush') { + await Future.delayed(const Duration(milliseconds: 1800)); + return true; + } + if (c.method == 'rawCount') return 5000; + return null; + }); + expect(await PhonePedometer().flushedTotal(), 5000); + }); + + test('a flush that never completes is refused', () async { + final never = Completer(); + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + if (c.method == 'flush') return never.future; + if (c.method == 'rawCount') return 5000; + return null; + }); + final p = PhonePedometer(flushTimeout: const Duration(milliseconds: 200)); + expect(await p.flushPending(), isFalse); + expect(await p.flushedTotal(), isNull); + }); + + test('access is requested even when the status reads as allowed', () async { + // iOS reports notDetermined as allowed; only a request raises the prompt. + final calls = []; + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + calls.add(c.method); + return true; + }); + expect(await PhonePedometer().ensureAccess(), isTrue); + expect(calls, contains('requestPermission')); + }); +} diff --git a/test/step_calibration_band_pin_test.dart b/test/step_calibration_band_pin_test.dart new file mode 100644 index 000000000..6694408ff --- /dev/null +++ b/test/step_calibration_band_pin_test.dart @@ -0,0 +1,51 @@ +// Pins the cadence band the calibration fits its gait categories against to the +// band the SESSION logic already uses. +// +// `lib/compute/step_calibration_fit.dart` redeclares this band rather than +// importing it — it is deliberately dependency-free (pure, no analytics, no +// I/O). That makes a silent drift possible, and a silent drift here is the bad +// kind: the calibration would build category bands the session logic disagrees +// with, so a minute could be "walking" to one and "not walking" to the other, +// and the correction would be applied to windows the rest of the app would not +// call gait. +// +// So the pair is pinned in both directions, the same DERIVED-OR-PINNED +// convention `live_step_runs.dart` uses for `kRouteTypeKeys`: a deliberate +// change to either constant fails this test instead of quietly splitting the +// definition of gait. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/ble/live_cadence.dart' + show kMinCadenceSpm, kMaxCadenceSpm; +import 'package:openstrap_edge/compute/step_calibration_fit.dart'; + +void main() { + test('calibration gait band equals the session gait band', () { + expect(kGaitCadenceMinSpm, kMinCadenceSpm.toDouble()); + expect(kGaitCadenceMaxSpm, kMaxCadenceSpm.toDouble()); + }); + + test('the band identifies gait, not AN-2554 interval bounds', () { + // AN-2554's 0.5-5 steps/s (30-300 spm) is the widest interval a human gait + // interval may have — what the COUNTER accepts. The band that identifies a + // minute as ambulation is narrower, and a 40 spm "minute" is a few steps to + // the kettle. If this test fails because someone widened the band to the + // AN-2554 range, the everyday family will start absorbing slow walks. + expect(kGaitCadenceMinSpm, greaterThan(30)); + expect(kGaitCadenceMaxSpm, lessThan(300)); + }); + + test('an everyday run has no gait band at all', () { + final run = StepCalibrationRun( + category: StepCategory.everyday, + reference: StepReference.phonePedometer, + expectedSteps: 55, + rawSteps: 400, + cadenceSpm: 95, + peakToPeakG: 1.4, + confidence: 0.35, + ); + expect(categoryBand(StepCategory.everyday, [run]), isNull); + }); +} diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart new file mode 100644 index 000000000..872840b29 --- /dev/null +++ b/test/step_calibration_fit_test.dart @@ -0,0 +1,524 @@ +// Tests for the per-category calibration of the live 100 Hz pedometer. +// +// The load-bearing cases are the EVERYDAY ones. A household routine is not zero +// steps — the wearer really does walk to the next room — so the correction must +// come out LOWER without ever reaching zero, and a driving-like window must be +// damped rather than credited at the full wrist rate. +// +// These mirror the Python-mirror harness one-for-one. The mirror is what was +// actually RUN (no Dart/Flutter toolchain was available where this was +// authored); this file is the same cases in the repo's own form, and it has not +// been executed. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/step_calibration_fit.dart'; + +StepCalibrationRun _gait( + StepCategory category, + int expected, + int raw, + double cadence, +) => + StepCalibrationRun( + category: category, + reference: StepReference.wearerCount, + expectedSteps: expected, + rawSteps: raw, + cadenceSpm: cadence, + peakToPeakG: 1.0, + confidence: 0.8, + ); + +StepCalibrationRun _everyday(int expected, int raw, {double confidence = 0.35}) => + StepCalibrationRun( + category: StepCategory.everyday, + reference: StepReference.phonePedometer, + expectedSteps: expected, + rawSteps: raw, + cadenceSpm: 95, + peakToPeakG: 1.4, + confidence: confidence, + ); + +void main() { + group('admissibility — gait', () { + test('a walk too short to carry a ratio is refused', () { + expect(stepRunRejection(_gait(StepCategory.brisk, 30, 32, 110)), + StepRunRejection.expectedTooSmall); + }); + + test('a detector that saw almost nothing is refused', () { + expect(stepRunRejection(_gait(StepCategory.brisk, 100, 5, 110)), + StepRunRejection.rawTooSmall); + }); + + test('a count beyond the clamp is a broken run, not a big gain', () { + expect(stepRunRejection(_gait(StepCategory.brisk, 100, 250, 110)), + StepRunRejection.rawExceedsExpectedByTooMuch); + }); + + test('a non-finite feature is refused rather than propagated', () { + expect(stepRunRejection(_gait(StepCategory.brisk, 100, 110, double.nan)), + StepRunRejection.nonFiniteFeature); + }); + + test('a gait run referenced to the phone is refused', () { + final run = StepCalibrationRun( + category: StepCategory.brisk, + reference: StepReference.phonePedometer, + expectedSteps: 100, + rawSteps: 110, + cadenceSpm: 110, + peakToPeakG: 1.0, + confidence: 0.8, + ); + expect(stepRunRejection(run), StepRunRejection.referenceWrongSource); + }); + + test('a supervised walk is admitted', () { + expect(stepRunRejection(_gait(StepCategory.brisk, 100, 110, 110)), isNull); + }); + }); + + group('admissibility — everyday', () { + test('a reference that counted nothing is refused, not fitted as 0', () { + // The phone was on a table. That is the ABSENCE of a reference, and a 0.0 + // gain fitted from it would delete every real step in every later + // household window. + expect(stepRunRejection(_everyday(0, 400)), + StepRunRejection.noReferenceSteps); + }); + + test('a reference above the band count is refused', () { + expect(stepRunRejection(_everyday(500, 400)), + StepRunRejection.rawExceedsExpectedByTooMuch); + }); + + test('the 50-step floor does not apply to household', () { + // A household routine is not a gait cadence, so a 30-step reference is a + // legitimate measurement rather than a run too short to mean anything. + expect(stepRunRejection(_everyday(30, 400)), isNull); + }); + + test('over-counting household movement is admitted', () { + expect(stepRunRejection(_everyday(55, 400)), isNull); + }); + }); + + group('the fit', () { + test('a gait gain takes effect on the first run', () { + final fit = fittedGain(StepCategory.brisk, [_gait(StepCategory.brisk, 100, 110, 110)]); + expect(fit!.gain, closeTo(100 / 110, 1e-9)); + }); + + test('a household gain comes out low and positive', () { + final fit = fittedGain(StepCategory.everyday, [_everyday(55, 400)]); + expect(fit!.gain, closeTo(55 / 400, 1e-9)); + expect(fit.gain, greaterThan(0)); + }); + + test('a ratio under the floor is REFUSED, not clamped to it', () { + // Clamping a single run would turn "this routine did not measure what it + // claims" into a value at the edge of the legal band — a fabricated + // metric with a plausible shape. The floor is enforced by refusing. + expect(stepRunRejection(_everyday(15, 400)), + StepRunRejection.ratioOutsideGainRange); + expect(fittedGain(StepCategory.everyday, [_everyday(15, 400)]), isNull); + }); + + test('a third-done walk is refused rather than clamped to 2.0', () { + // 100 claimed against 30 seen implies 3.3, above kGaitGainMax. Clamped, it + // would DOUBLE every later step the wearer takes. + final half = _gait(StepCategory.brisk, 100, 30, 110); + expect(stepRunRejection(half), StepRunRejection.ratioOutsideGainRange); + expect(fittedGain(StepCategory.brisk, [half]), isNull); + }); + + test('the household gain can never inflate the count', () { + final fit = fittedGain(StepCategory.everyday, [_everyday(100, 100)]); + expect(fit!.gain, lessThanOrEqualTo(kEverydayGainMax)); + }); + + test('repeat runs weight by the evidence they carry', () { + final fit = fittedGain(StepCategory.everyday, [ + _everyday(500, 1400), + _everyday(55, 400), + ]); + expect(fit!.gain, closeTo(555 / 1800, 1e-9)); + }); + + test('a run of another family is not evidence for this one', () { + expect(fittedGain(StepCategory.brisk, [_gait(StepCategory.jog, 100, 110, 170)]), + isNull); + }); + + test('an uncalibrated family has no fit', () { + expect(fittedGain(StepCategory.jog, const []), isNull); + }); + }); + + group('cadence bands', () { + test('the band opens around the median cadence', () { + final band = categoryBand(StepCategory.brisk, [_gait(StepCategory.brisk, 100, 105, 110)]); + expect(band!.minSpm, lessThan(110)); + expect(band.maxSpm, greaterThan(110)); + }); + + test('the band never leaves the gait window', () { + final band = categoryBand(StepCategory.slow, [_gait(StepCategory.slow, 100, 100, 75)]); + expect(band!.minSpm, greaterThanOrEqualTo(kGaitCadenceMinSpm)); + expect(band.maxSpm, lessThanOrEqualTo(kGaitCadenceMaxSpm)); + }); + + test('a run that did not walk cannot define a walking band', () { + expect(categoryBand(StepCategory.slow, [_gait(StepCategory.slow, 100, 100, 55)]), + isNull); + }); + + test('an outlier run does not drag the band', () { + final band = categoryBand(StepCategory.brisk, [ + _gait(StepCategory.brisk, 100, 100, 110), + _gait(StepCategory.brisk, 100, 100, 110), + _gait(StepCategory.brisk, 100, 100, 190), + ]); + expect(band!.maxSpm, lessThan(180)); + }); + }); + + group('credit', () { + final calibrated = [_gait(StepCategory.brisk, 100, 110, 110), _everyday(55, 400)]; + + test('a gait window uses the gait gain', () { + final c = creditLiveSteps(rawSteps: 110, cadenceSpm: 110, runs: calibrated); + expect(c.category, StepCategory.brisk); + expect(c.gainApplied, closeTo(100 / 110, 1e-9)); + expect(c.creditedSteps, 100); + expect(c.calibrated, isTrue); + }); + + test('a household window comes out lower, never zero', () { + final c = creditLiveSteps(rawSteps: 140, cadenceSpm: 25, runs: calibrated); + expect(c.category, StepCategory.everyday); + expect(c.creditedSteps, greaterThan(0)); + expect(c.creditedSteps, lessThan(140)); + }); + + test('a driving-like window is damped, not credited at wrist rate', () { + // 12 spm of road vibration, 25 raw steps. The everyday gain is the + // backstop BEHIND live_coverage_policy's 40 spm still-hour rule. + final c = creditLiveSteps(rawSteps: 25, cadenceSpm: 12, runs: calibrated); + expect(c.creditedSteps, lessThanOrEqualTo(4)); + }); + + test('a WALKING cadence with no calibrated family is left alone', () { + // The bug this replaces: a wearer who calibrated only the household + // routine had every real walk scaled to 5-14% of what the band counted, + // because a gait cadence matching no calibrated band fell through to the + // household gain. "A household gain can only damp" is not reassurance — + // damping a walk by 90% is a far worse error than the over-count the + // calibration exists to fix. + final c = creditLiveSteps( + rawSteps: 140, + cadenceSpm: 140, // squarely inside the 60-200 gait band + runs: [_everyday(55, 400)], + ); + expect(c.creditedSteps, 140); + expect(c.calibrated, isFalse); + }); + + test('an uncalibrated family is credited exactly as today', () { + final c = creditLiveSteps(rawSteps: 140, cadenceSpm: 25, runs: const []); + expect(c.creditedSteps, 140); + expect(c.gainApplied, 1.0); + expect(c.calibrated, isFalse); + }); + + test('a gain below 1 can never manufacture steps', () { + final c = creditLiveSteps( + rawSteps: 100, + cadenceSpm: 110, + runs: [_gait(StepCategory.brisk, 100, 200, 110)], + ); + expect(c.creditedSteps, lessThanOrEqualTo(100)); + }); + + test('a zero or negative raw count stays zero', () { + expect(creditLiveSteps(rawSteps: 0, cadenceSpm: 110, runs: calibrated).creditedSteps, 0); + expect(creditLiveSteps(rawSteps: -5, cadenceSpm: 110, runs: calibrated).creditedSteps, 0); + }); + }); + + group('stored profiles', () { + test('a valid row round-trips', () { + final p = LiveStepGainProfile( + version: kLiveStepGainVersion, + category: StepCategory.brisk, + gain: 0.977, + nRuns: 3, + updatedAt: DateTime.utc(2026, 10, 26), + ); + final back = LiveStepGainProfile.tryParse(p.toJson()); + expect(back!.gain, closeTo(0.977, 1e-12)); + expect(back.category, StepCategory.brisk); + expect(back.nRuns, 3); + }); + + test('an everyday gain below the gait floor is legal and round-trips', () { + final p = LiveStepGainProfile( + version: kLiveStepGainVersion, + category: StepCategory.everyday, + gain: 0.14, + nRuns: 2, + updatedAt: DateTime.utc(2026, 10, 26), + ); + expect(LiveStepGainProfile.tryParse(p.toJson())!.gain, closeTo(0.14, 1e-12)); + }); + + test('a gain outside the category band is refused, not loaded', () { + final json = { + 'v': kLiveStepGainVersion, + 'cat': 'brisk', + 'gain': 0.14, // legal for everyday, not for a gait family + 'n': 1, + 'ts': '2026-10-26T10:00:00Z', + }; + expect(LiveStepGainProfile.tryParse(json), isNull); + }); + + test('malformed stored data yields null rather than throwing', () { + expect(LiveStepGainProfile.tryParse('kaputt'), isNull); + expect( + LiveStepGainProfile.tryParse({ + 'v': kLiveStepGainVersion, + 'cat': 'nope', + 'gain': 1.0, + 'n': 1, + 'ts': '2026-10-26T10:00:00Z', + }), + isNull, + ); + expect( + LiveStepGainProfile.tryParse({ + 'v': 1, // a version this build does not read + 'cat': 'brisk', + 'gain': 1.0, + 'n': 1, + 'ts': '2026-10-26T10:00:00Z', + }), + isNull, + ); + expect( + LiveStepGainProfile.tryParse({ + 'v': kLiveStepGainVersion, + 'cat': 'brisk', + 'gain': 1.0, + 'n': 1, + 'ts': null, + }), + isNull, + ); + }); + }); + + group('the calibration session', () { + // The session's inputs are what a SCREEN can observe: the live session's + // cumulative raw counter and a clock. Not finished chunks — the screen never + // sees one. + const t0 = 1000000; + + test('the LIVE BAND COUNT DOES NOT TERMINATE A GAIT RUN', () { + // The trap: ending the run when the counter reaches the target would make + // rawSteps == 100 by construction, so every wearer would fit a gain of + // exactly 1.0 forever. The wearer counts; the band's live count is only + // feedback. + final s = CalibrationSession(category: StepCategory.brisk, targetSteps: 100) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 112, nowMs: t0 + 60000); + final out = s.finish(); + expect(out.run!.expectedSteps, 100); // the wearer's count, NOT 112 + expect(out.run!.reference, StepReference.wearerCount); + final fit = fittedGain(StepCategory.brisk, [out.run!]); + expect(fit!.gain, closeTo(100 / 112, 1e-9)); + expect(fit.gain, isNot(closeTo(1.0, 0.001))); + }); + + test('a wearer who stops early is fitted on what they counted', () { + // Done is live from the first band step. Stopping at 60 must not be + // fitted as if the wearer walked the full 100 (a 1.67 gain). + final s = CalibrationSession(category: StepCategory.brisk, targetSteps: 100) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 60, nowMs: t0 + 35000); + final out = s.finish(countedSteps: 60); + expect(out.run!.expectedSteps, 60); + final fit = fittedGain(StepCategory.brisk, [out.run!]); + expect(fit!.gain, closeTo(1.0, 1e-9)); + }); + + test('time spent answering the count question is not part of the walk', () { + // 100 steps in 92 s is 65 spm, a valid slow walk. Ten seconds with the + // dialog open would make it 58.8 spm and refuse it. + final s = CalibrationSession(category: StepCategory.slow) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 100, nowMs: t0 + 92000) + ..hold(bandTotal: 100, nowMs: t0 + 92000) + ..observe(bandTotal: 100, nowMs: t0 + 102000); + final out = s.finish(countedSteps: 100); + expect(out.refusal, isNull); + expect(out.run!.cadenceSpm, closeTo(100 * 60 / 92, 1e-9)); + }); + + test('a cancelled question resumes the routine clock', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..hold(bandTotal: 40, nowMs: t0 + 20000) + ..resume() + ..observe(bandTotal: 100, nowMs: t0 + 55000); + expect(s.bandRawSteps, 100); + expect(s.bandSeconds, 55); + }); + + test('only the ROUTINE is measured, not the whole session', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 500, nowMs: t0) + ..observe(bandTotal: 612, nowMs: t0 + 60000); + expect(s.bandRawSteps, 112); // the delta, not 612 + expect(s.finish().run!.rawSteps, 112); + }); + + test('a counter reset cannot produce a negative correction', () { + // The counter restarts when a session ends. A routine that spans that + // must not be credited with a negative count. + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 500, nowMs: t0) + ..observe(bandTotal: 400, nowMs: t0 + 30000); + expect(s.bandRawSteps, 0); + }); + + test('the band reports its own cadence for the run', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 112, nowMs: t0 + 60000); + expect(s.finish().run!.cadenceSpm, closeTo(112, 1e-9)); + }); + + test('everyday refuses without a phone count instead of fitting a 0.0 gain', () { + final s = CalibrationSession(category: StepCategory.everyday) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 400, nowMs: t0 + 180000); + final out = s.finish(); + expect(out.refusal, StepRunRejection.noReferenceSteps); + expect(out.run, isNotNull); // the run exists; the REFERENCE does not + }); + + test('a failed final phone read does not leave an earlier partial count', + () { + // A read part-way through banked 20; the read at the end failed. The + // routine must be refused, not fitted on 20 phone steps against the + // band's whole three minutes. + final s = CalibrationSession(category: StepCategory.everyday) + ..start(bandBaseline: 0, nowMs: t0) + ..setPhoneSteps(20) + ..observe(bandTotal: 400, nowMs: t0 + 180000) + ..clearPhoneSteps(); + expect(s.finish().refusal, StepRunRejection.noReferenceSteps); + }); + + test('everyday with a phone count fits low and positive', () { + final s = CalibrationSession(category: StepCategory.everyday) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 400, nowMs: t0 + 180000) + ..setPhoneSteps(55); + final out = s.finish(); + expect(out.refusal, isNull); + expect(fittedGain(StepCategory.everyday, [out.run!])!.gain, + closeTo(55 / 400, 1e-9)); + }); + + test('everyday progress measures time, gait progress measures the band', () { + final ed = CalibrationSession(category: StepCategory.everyday, targetSeconds: 180) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 0, nowMs: t0 + 90000); + expect(ed.progress, closeTo(0.5, 1e-9)); + final gait = CalibrationSession(category: StepCategory.brisk, targetSteps: 100) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 50, nowMs: t0 + 30000); + expect(gait.progress, closeTo(0.5, 1e-9)); + }); + + test('a timed routine that stopped early is refused', () { + final s = CalibrationSession(category: StepCategory.everyday) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 200, nowMs: t0 + 60000); // 60 s of 180 + expect(s.canFinish, isFalse); + final out = s.finish(); + expect(out.refusal, StepRunRejection.routineTooShort); + // Raised by the session, not by stepRunRejection: a run does not know how + // long its routine was asked to last, so that function could not judge it. + }); + + test('a gait routine has no duration gate', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 112, nowMs: t0 + 60000); + expect(s.canFinish, isTrue); + expect(s.finish().refusal, isNull); + }); + + test('a silent band is its own outcome, not a refusal', () { + final out = CalibrationSession(category: StepCategory.jog).finish(); + expect(out.run, isNull); + expect(out.refusal, isNull); + expect(out.bandSilent, isTrue); + }); + + test('nothing is accepted after the run is finished', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 110, nowMs: t0 + 60000) + ..finish() + ..observe(bandTotal: 200, nowMs: t0 + 120000); + expect(s.bandRawSteps, 110); + }); + + test('unmeasured features stay absent, never a substituted zero', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 110, nowMs: t0 + 60000); + final bare = s.finish().run!; + expect(bare.peakToPeakG, isNull); + expect(bare.confidence, isNull); + + final withFeatures = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 110, nowMs: t0 + 60000) + ..setBandFeatures(peakToPeakG: 1.2, confidence: 0.86); + final full = withFeatures.finish().run!; + expect(full.peakToPeakG, 1.2); + expect(full.confidence, 0.86); + }); + + test('a NaN feature is refused rather than stored', () { + final s = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 110, nowMs: t0 + 60000) + ..setBandFeatures(confidence: double.nan); + expect(s.finish().refusal, StepRunRejection.nonFiniteFeature); + }); + + test('a run that did not walk is refused, not saved without a band', () { + final fast = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 200, nowMs: t0 + 50000); // 240 spm + final out = fast.finish(countedSteps: 200); + expect(out.refusal, StepRunRejection.cadenceOutsideGaitRange); + expect(categoryBand(StepCategory.brisk, [out.run!]), isNull); + // A slow start that drags the cadence under the walking range too. + final slow = CalibrationSession(category: StepCategory.slow) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 60, nowMs: t0 + 90000); // 40 spm + expect(slow.finish(countedSteps: 60).refusal, + StepRunRejection.cadenceOutsideGaitRange); + }); + }); +} diff --git a/test/step_calibration_ru_plural_test.dart b/test/step_calibration_ru_plural_test.dart new file mode 100644 index 000000000..3c69ce39a --- /dev/null +++ b/test/step_calibration_ru_plural_test.dart @@ -0,0 +1,30 @@ +// Russian inflects the noun by count; the calibration strings carry the count +// so 1, 2 and 5 each read correctly. +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; + +void main() { + final ru = lookupAppLocalizations(const Locale('ru')); + + test('the band counter label agrees with its number', () { + expect(ru.stepCalBandSeen(1), 'шаг по данным браслета'); + expect(ru.stepCalBandSeen(2), 'шага по данным браслета'); + expect(ru.stepCalBandSeen(5), 'шагов по данным браслета'); + }); + + test('the gait result inflects both counts and names no single gait', () { + final one = ru.stepCalDoneGait(1, 2, 10); + expect(one, contains('1 шаг,')); + expect(one, contains('2 шага')); + expect(ru.stepCalDoneGait(100, 112, -11), contains('100 шагов')); + expect(one, isNot(contains('ходьба'))); + }); + + test('the calibrated row inflects the run count', () { + expect(ru.stepCalCalibrated(1), endsWith('1 замер')); + expect(ru.stepCalCalibrated(3), endsWith('3 замера')); + expect(ru.stepCalCalibrated(5), endsWith('5 замеров')); + }); +} diff --git a/test/step_calibration_start_order_test.dart b/test/step_calibration_start_order_test.dart new file mode 100644 index 000000000..a91e8f3aa --- /dev/null +++ b/test/step_calibration_start_order_test.dart @@ -0,0 +1,89 @@ +// A household routine starts in an order that keeps its two references on the +// same span: phone access, then a starting phone total from a completed flush, +// and only then the band baseline and the clock. +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/step_calibration_fit.dart'; +import 'package:openstrap_edge/ui2/profile/step_calibration.dart'; + +void main() { + test('the band baseline is taken only after the phone total resolves', + () async { + final order = []; + final total = Completer(); + final started = beginCalibrationRoutine( + category: StepCategory.everyday, + ensurePhoneAccess: () async { + order.add('access'); + return true; + }, + runningTotalPlatform: true, + startTotal: () { + order.add('total asked'); + return total.future; + }, + bandTotalNow: () { + order.add('band'); + return 300; + }, + nowMs: () => 1000, + ); + await Future.delayed(Duration.zero); + expect(order, ['access', 'total asked']); + total.complete(5000); + final out = await started; + expect(order, ['access', 'total asked', 'band']); + expect(out.startTotal, 5000); + expect(out.session!.bandRawSteps, 0); + }); + + test('no starting total refuses to start', () async { + var band = false; + final out = await beginCalibrationRoutine( + category: StepCategory.everyday, + ensurePhoneAccess: () async => true, + runningTotalPlatform: true, + startTotal: () async => null, + bandTotalNow: () { + band = true; + return 0; + }, + nowMs: () => 0, + ); + expect(out.session, isNull); + expect(out.refusal, RoutineStartRefusal.phoneNotReady); + expect(band, isFalse); + }); + + test('no phone access refuses before asking for a total', () async { + var asked = false; + final out = await beginCalibrationRoutine( + category: StepCategory.everyday, + ensurePhoneAccess: () async => false, + runningTotalPlatform: true, + startTotal: () async { + asked = true; + return 1; + }, + bandTotalNow: () => 0, + nowMs: () => 0, + ); + expect(out.refusal, RoutineStartRefusal.noPhoneAccess); + expect(asked, isFalse); + }); + + test('a walk routine needs nothing from the phone', () async { + final out = await beginCalibrationRoutine( + category: StepCategory.brisk, + ensurePhoneAccess: () async => fail('not asked for a walk'), + runningTotalPlatform: true, + startTotal: () async => fail('not asked for a walk'), + bandTotalNow: () => 10, + nowMs: () => 0, + ); + expect(out.session, isNotNull); + expect(out.refusal, isNull); + }); +} diff --git a/test/step_calibration_store_test.dart b/test/step_calibration_store_test.dart new file mode 100644 index 000000000..f3f70afec --- /dev/null +++ b/test/step_calibration_store_test.dart @@ -0,0 +1,79 @@ +// The calibration store: a saved run and the profile it implies are one write, +// and a restore leaves every family's profile describing the runs it holds. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/compute/step_calibration_fit.dart'; +import 'package:openstrap_edge/data/db.dart' show LocalDb; + +StepCalibrationRun _brisk(int expected, int raw) => StepCalibrationRun( + category: StepCategory.brisk, + reference: StepReference.wearerCount, + expectedSteps: expected, + rawSteps: raw, + cadenceSpm: 110, + peakToPeakG: null, + confidence: null, + ); + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + await LocalDb.close(); + LocalDb.dbName = 'step_calibration_store_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDown(() async => LocalDb.close()); + + test('a save whose profile write fails leaves no run behind', () async { + final db = await LocalDb.instance; + // Make the profile write the step that fails, after the run insert. + await db.execute('DROP TABLE live_step_calibration'); + + await expectLater( + LocalDb.bankLiveStepCalibrationRun(_brisk(100, 120), runTs: 1000), + throwsA(anything), + ); + final runs = await db.query('live_step_calibration_run'); + expect(runs, isEmpty); + }); + + test('a save banks the run and its profile together', () async { + final profile = + await LocalDb.bankLiveStepCalibrationRun(_brisk(100, 120), runTs: 1000); + expect(profile.nRuns, 1); + expect(await LocalDb.liveStepCalibrationRuns(), hasLength(1)); + final stored = await LocalDb.liveStepGainProfile(StepCategory.brisk); + expect(stored?.gain, closeTo(100 / 120, 1e-9)); + }); + + test('refitting after a merge replaces a profile that no longer matches', + () async { + // What a restore leaves: two runs on record, and a profile row carried in + // from the backup that was fitted from only one of them. + await LocalDb.putLiveStepCalibrationRun(_brisk(100, 120), runTs: 1000); + await LocalDb.putLiveStepCalibrationRun(_brisk(100, 80), runTs: 2000); + await LocalDb.putLiveStepGainProfile(LiveStepGainProfile( + version: kLiveStepGainVersion, + category: StepCategory.brisk, + gain: 100 / 120, + nRuns: 1, + updatedAt: DateTime.fromMillisecondsSinceEpoch(0), + )); + + await LocalDb.refitAllLiveStepGains(); + + final stored = await LocalDb.liveStepGainProfile(StepCategory.brisk); + expect(stored?.nRuns, 2); + expect(stored?.gain, closeTo(200 / 200, 1e-9)); + // A family with no runs is left alone rather than written as uncalibrated. + expect(await LocalDb.liveStepGainProfile(StepCategory.jog), isNull); + }); +} diff --git a/test/ui2_gallery_test.dart b/test/ui2_gallery_test.dart index edf40335b..d9682ff2f 100644 --- a/test/ui2_gallery_test.dart +++ b/test/ui2_gallery_test.dart @@ -57,6 +57,8 @@ void main() { onGallery: () => opened = true, ))); expect(find.text('Developer'), findsOneWidget); + await tester.ensureVisible(find.text('Component gallery')); + await tester.pumpAndSettle(); await tester.tap(find.text('Component gallery')); expect(opened, isTrue); }); diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index e294130ae..db67c887f 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -211,6 +211,9 @@ const _notComponents = { 'NotificationSettings', 'NotificationSettingsView', 'EditProfile', 'EditProfileView', 'DataScreen', 'AlarmScreen', 'AlarmScreenView', 'ReportIssueScreen', + // Owns a live calibration routine: a ticker over the band's step stream and + // phone pedometer reads, and it writes calibration rows when it finishes. + 'StepCalibrationScreen', 'MyDevices', 'MyDevicesView', 'DeviceDetail', 'DeviceDetailView', 'RePair', // The strap-buzz relay picker: a Scaffold route over a live // NotificationRelay, whose list is whatever the OS notification stream has