From 8ee059d39010212bb26bcfb5479a3e09b975c69a Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 19:46:58 +0100 Subject: [PATCH 01/33] feat(compute): per-category calibration policy for the live 100 Hz pedometer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calibrates the counter that already exists — pedometer()/livePedometer() in analytics, fed from the live IMU stream and banked as live_coverage source 'strap_100hz'. Nothing here touches BLE or the strap: every value stays on the phone, so there is nothing a factory reset has to undo. Two kinds of calibration, and the second is the one that pays: * POSITIVE (slow/brisk/jog) — the walk produces N real steps and the detector reports some other number; the correction is a SCALE, gain = truth/raw. * NEGATIVE (everyday) — carrying, lifting, rearranging: the detector reports a count and the true step count is ~0. No scale fixes that: 0/raw is 0, and a gain of 0 would credit nothing everywhere. What the run teaches is a BOUNDARY (the highest confidence any non-gait run reached), so windows no more rhythmic than the wearer's own household movement are credited nothing. The negative run is what addresses OVER-count, the expensive error — live_step_runs.dart records the owner's own measurement of rhythmic arm work scored as gait: +199.5%, 217 true steps read as 650. No prior weight, deliberately deviating from lib/data/step_calibration.dart: that prior guards an INFERRED reference (a phone alongside the band), while this one is SUPERVISED (the wearer counts their own steps). Admissibility and the clamp already bound a bad run, and blending 3 pseudo-runs of 1.0 meant a first calibration resolved to 0.977 instead of 0.909 — a user who walks their calibration walk and sees nothing change has been told the feature does not work. Repeat runs still converge: the estimator is a ratio of SUMS, so later runs outweigh earlier ones by the steps they carry. Credit order is the whole policy: the negative probe refuses first (a fabricated step cannot be recovered, a missed one can), then cadence picks a calibrated category, then anything uncalibrated is credited EXACTLY as today — the feature is invisible until the wearer calibrates. Verified against a Python mirror: 42/42 cases. --- lib/compute/step_calibration_fit.dart | 580 ++++++++++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 lib/compute/step_calibration_fit.dart diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart new file mode 100644 index 000000000..8131f1051 --- /dev/null +++ b/lib/compute/step_calibration_fit.dart @@ -0,0 +1,580 @@ +// 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 that `edge` already ingests (`frameAccel`: gen5 0x2B, gen4 0x33 / +// R10) and banked as `live_coverage.source = 'strap_100hz'`. This file only +// turns a supervised calibration walk into numbers that counter is configured +// with — nothing here touches BLE, a clock or the disk, and NOTHING is written +// to the strap. Everything below is a value the phone keeps. +// +// WHY A CALIBRATION, AND WHAT IT MAY CLAIM. Analytics' own `StepParams.gain` +// docstring asks for exactly this: "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 the gain corrects the RESIDUAL a real wrist adds — +// placement, tightness, arm-swing style — not a defect in the detector. Expect +// values near 1.0, and never a population constant. +// +// TWO KINDS OF CALIBRATION, AND THE SECOND IS THE ONE THAT PAYS. +// +// * POSITIVE (slow / brisk / jog): the walk produces N real steps and the +// detector reports some other number over the same span. The correction is +// a SCALE — `gain = truth / raw` — because the count is real, only its size +// is wrong. +// +// * NEGATIVE (everyday: carrying, lifting, moving things around a room): the +// detector reports a count and the TRUE step count is ~0, because the peaks +// it counted were arm work, not gait. There is no scale that fixes this: +// `0 / raw` is 0, and a gain of 0 would credit nothing everywhere. What the +// run teaches instead is a BOUNDARY — what this wearer's non-gait movement +// looks like to this sensor — so windows matching it are credited nothing. +// +// The negative run is the one that addresses OVER-counting, which is the +// expensive error: `live_step_runs.dart` records the owner's own measurement of +// rhythmic arm work scored as gait (+199.5%, 217 true steps read as 650). A +// miss costs a walk; a fabricated step corrupts the day. +// +// HONEST LIMIT. Analytics states its own ceiling plainly: "Every independent +// free-living wrist evaluation puts peak detection between 60% and 230% error." +// Nothing here lifts that. It trims a known bias per wearer and refuses one +// measured class of false movement. It does not turn a wrist into a pedometer, +// and no copy built on this may imply it does. +// +// PURE Dart — no I/O, no clock, no protocol import, plain values in and out. + +/// The four calibration categories. `everyday` is the negative one; see the +/// file header for why it cannot be a scale factor. +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, + }; + + bool get isPositive => this != StepCategory.everyday; +} + +/// One supervised calibration run: the wearer counts their own steps, the +/// detector counts what it saw, and the features are what the run looked like. +/// +/// [expectedSteps] is the ONLY ground truth in this feature and it comes from +/// the user, which is why a single admitted run is enough to mark a category +/// calibrated — unlike `lib/data/step_calibration.dart`, whose days are fitted +/// against a phone and therefore need three of them plus a prior. +class StepCalibrationRun { + const StepCalibrationRun({ + required this.category, + required this.expectedSteps, + required this.rawSteps, + required this.cadenceSpm, + required this.peakToPeakG, + required this.confidence, + }); + + final StepCategory category; + final int expectedSteps; + final int rawSteps; + + /// From `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. + final double peakToPeakG; + + /// `PedometerResult.confidence` — amplitude AND rhythm regularity, 0..1. + /// This is the discriminator the negative run leans on: rhythmic arm work + /// scores high, lifting and carrying score low. + 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 walk is a signal the +/// run failed (band not worn, walk done off-wrist, stream dropped), not a +/// measurement of a 20x gain. +const int kStepCalMinRawSteps = 20; + +/// A learned gain outside this band means the RUN went wrong, not that the +/// counter is that far off — same reasoning as `kStepFactorMin/Max`. +const double kStepGainMin = 0.5; +const double kStepGainMax = 2.0; + +// NO PRIOR WEIGHT HERE, AND THAT IS A DELIBERATE DEVIATION from +// `lib/data/step_calibration.dart`, which blends three pseudo-days of factor 1.0 +// into its fit. +// +// That prior exists because ITS reference is INFERRED: a day is admitted when +// the phone counted alongside the band, and a phone in a drawer is a bad day +// dressed as a measurement. Shrinking toward 1.0 stops one bad day from moving +// the factor. +// +// This fit's reference is SUPERVISED: the wearer counts their own 100 steps, on +// purpose, having been told to walk evenly. That is a different class of +// evidence, and the guards that remain — admissibility, and the clamp below — +// already bound the damage a single bad run can do. A prior on top of them cost +// more than it bought: blending 3 pseudo-runs of 1.0 meant a first calibration +// of 100-said/110-seen resolved to 0.977 and corrected barely a fifth of the +// bias the wearer had just measured. A user who walks their calibration walk +// and then sees no change has been told the feature does not work. +// +// Repeated runs still converge: the estimator is a ratio of SUMS, so later runs +// outweigh earlier ones by the steps they carry. That is evidence weighting, not +// forgetting, and it needs no invented constant. + +/// 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 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; + +/// Physiological bounds from AN-2554, in spm: 0.5–5 steps/s. Bands are clamped +/// here so a mis-entered run cannot produce a band outside human gait. +const double kGaitCadenceMinSpm = 30.0; +const double kGaitCadenceMaxSpm = 300.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 walk the user just did. +enum StepRunRejection { + expectedTooSmall, + rawTooSmall, + nonFiniteFeature, + rawExceedsExpectedByTooMuch, +} + +/// Null when the run may be used to fit; otherwise why it may not. +StepRunRejection? stepRunRejection(StepCalibrationRun r) { + if (!r.cadenceSpm.isFinite || !r.peakToPeakG.isFinite || !r.confidence.isFinite) { + return StepRunRejection.nonFiniteFeature; + } + if (r.expectedSteps < kStepCalMinExpectedSteps) { + return StepRunRejection.expectedTooSmall; + } + if (r.rawSteps < kStepCalMinRawSteps) { + return StepRunRejection.rawTooSmall; + } + // `raw / expected` is clamped to kStepGainMax, so a run beyond the clamp is + // not a measurement of the gain — it is a measurement of something else. + if (r.rawSteps > (r.expectedSteps * kStepGainMax).ceil()) { + return StepRunRejection.rawExceedsExpectedByTooMuch; + } + 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 [kStepGainMin, kStepGainMax]; exactly 1.0 when nothing was + /// learned. 1.0 is not "unset" — it is the shipped behaviour, which is why an + /// uncalibrated category and a fitted 1.0 behave identically and correctly. + 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 supervised run is admitted: the + /// ground truth is the wearer's own count, not an inferred reference. + bool get isCalibrated => nRuns > 0; + + /// The profile every uncalibrated category resolves to. `gain` 1.0 is the + /// value 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; + final version = raw['v']; + if (version is! int || version != kLiveStepGainVersion) return null; + final gain = raw['gain']; + if (gain is! num || !gain.isFinite) return null; + if (gain < kStepGainMin || gain > kStepGainMax) 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: version, + category: category, + gain: gain.toDouble(), + nRuns: nRuns, + updatedAt: updatedAt, + ); + } +} + +/// Fit the positive-category gain over every admitted run. +/// +/// 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 +/// — the same construction as `estimateStepCalibration`. +/// +/// `everyday` returns the uncalibrated profile on purpose. It has no scale, and +/// producing one would be a fabricated number with a legitimate-looking shape; +/// its measurement is [EverydayProbe] instead. +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, + ); +} + +/// The fitted gain and its evidence, or null when the category has no admitted +/// run. Split out so [creditLiveSteps] can apply the correction without +/// inventing a timestamp to wrap it in a profile. +({double gain, int nRuns})? fittedGain( + StepCategory category, + List runs, +) { + if (!category.isPositive) return null; + 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 — the sum ratio is + // dominated by whichever runs carried the most steps. + final ratio = expected / raw; + return ( + gain: ratio.clamp(kStepGainMin, kStepGainMax), + nRuns: admitted.length, + ); +} + +/// A cadence window a calibrated 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 positive 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 when the category has no admitted +/// run — an uncalibrated category owns no window and therefore never claims a +/// minute. +StepCategoryBand? categoryBand( + StepCategory category, + List runs, +) { + if (!category.isPositive) return null; + final cadences = [ + for (final r in runs) + if (r.category == category && stepRunRejection(r) == null) 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; + var half = (median * kCategoryBandSpread).clamp( + kCategoryBandMinWidthSpm / 2, + kGaitCadenceMaxSpm, + ); + 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 category owns a live window's cadence. +/// +/// Nearest band centre among the calibrated positive categories, but only when +/// the cadence actually lands inside that band. A window between two bands, or +/// outside every calibrated band, belongs to NO category — it falls back to the +/// shipped behaviour rather than being forced into the nearest one, because +/// forcing it would apply a correction measured on a different gait. +StepCategory? categoryForCadence( + double spm, + List runs, +) { + if (!spm.isFinite || spm <= 0) return null; + StepCategoryBand? 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 = band; + } + } + return best?.category; +} + +/// The negative calibration: what this wearer's non-gait movement looks like. +/// +/// A walk has a rhythm. Carrying, lifting and rearranging do not, and analytics +/// already scores exactly that as `PedometerResult.confidence` (amplitude plus +/// rhythm regularity). So the everyday run yields one number — the HIGHEST +/// confidence any of its runs reached — and a live window at or below it is +/// credited nothing. +/// +/// The maximum, not the mean, and deliberately so: the boundary must reject +/// every non-gait run the user actually performed, including their most +/// walk-like one. Using a mean would let the worst run through, which is the +/// false positive this whole category exists to stop. +class EverydayProbe { + const EverydayProbe({ + required this.maxConfidence, + required this.nRuns, + required this.updatedAt, + }); + + final double maxConfidence; + final int nRuns; + final DateTime updatedAt; + + bool get isCalibrated => nRuns > 0; + + /// A window is refused when it is no more rhythmic than the wearer's own + /// non-gait movement. Equal counts as refused: the boundary is inclusive on + /// the rejecting side, because a false step is the error that cannot be + /// recovered. + bool refuses(double confidence) => isCalibrated && confidence <= maxConfidence; + + Map toJson() => { + 'v': kLiveStepGainVersion, + 'cat': StepCategory.everyday.key, + 'conf': maxConfidence, + 'n': nRuns, + 'ts': updatedAt.toIso8601String(), + }; + + static EverydayProbe? tryParse(Object? raw) { + if (raw is! Map) return null; + if (StepCategory.parse(raw['cat']) != StepCategory.everyday) return null; + if (raw['v'] != kLiveStepGainVersion) return null; + final conf = raw['conf']; + if (conf is! num || !conf.isFinite || conf < 0 || conf > 1) 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 EverydayProbe( + maxConfidence: conf.toDouble(), + nRuns: nRuns, + updatedAt: updatedAt, + ); + } +} + +/// Build the negative probe from every admitted `everyday` run, or null when +/// the wearer has not performed one. Null is the shipped behaviour: with no +/// probe, nothing is refused and the counter behaves exactly as it does today. +EverydayProbe? estimateEverydayProbe( + List runs, { + required DateTime now, +}) { + final admitted = [ + for (final r in runs) + if (r.category == StepCategory.everyday && stepRunRejection(r) == null) r, + ]; + if (admitted.isEmpty) return null; + var maxConfidence = 0.0; + for (final r in admitted) { + if (r.confidence > maxConfidence) maxConfidence = r.confidence; + } + return EverydayProbe( + maxConfidence: maxConfidence, + nRuns: admitted.length, + updatedAt: now, + ); +} + +/// 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.refusedByEverydayProbe, + }); + + final int creditedSteps; + final int rawSteps; + + /// Null when no calibrated band owned this window's cadence. + final StepCategory? category; + + /// The gain actually used — 1.0 for an uncalibrated category, so the caller + /// can log "no correction applied" without a second lookup. + final double gainApplied; + + /// True when the negative probe refused the window, so the UI can say WHY a + /// count of 40 became 0 instead of showing a silent zero. + final bool refusedByEverydayProbe; +} + +/// Apply the calibration to one live window. +/// +/// Order matters and is the whole policy: +/// 1. the negative probe refuses non-gait movement, because a fabricated step +/// costs more than a missed one; +/// 2. otherwise the window's cadence picks a calibrated category, whose gain +/// scales the raw count; +/// 3. anything uncalibrated is credited EXACTLY as it is today — raw, gain +/// 1.0. The feature must be invisible until the wearer has calibrated. +StepCredit creditLiveSteps({ + required int rawSteps, + required double cadenceSpm, + required double confidence, + required List runs, + EverydayProbe? everydayProbe, +}) { + if (rawSteps <= 0) { + return StepCredit( + creditedSteps: 0, + rawSteps: rawSteps < 0 ? 0 : rawSteps, + category: null, + gainApplied: 1.0, + refusedByEverydayProbe: false, + ); + } + if (everydayProbe?.refuses(confidence) ?? false) { + return StepCredit( + creditedSteps: 0, + rawSteps: rawSteps, + category: null, + gainApplied: 1.0, + refusedByEverydayProbe: true, + ); + } + final category = categoryForCadence(cadenceSpm, runs); + if (category == null) { + return StepCredit( + creditedSteps: rawSteps, + rawSteps: rawSteps, + category: null, + gainApplied: 1.0, + refusedByEverydayProbe: false, + ); + } + final fit = fittedGain(category, runs); + if (fit == null) { + return StepCredit( + creditedSteps: rawSteps, + rawSteps: rawSteps, + category: category, + gainApplied: 1.0, + refusedByEverydayProbe: 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 * kStepGainMax).ceil()), + rawSteps: rawSteps, + category: category, + gainApplied: fit.gain, + refusedByEverydayProbe: false, + ); +} From 347c48a881a408326fcc5f3d9095918a57661deb Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 19:53:44 +0100 Subject: [PATCH 02/33] fix(compute): everyday is a low positive scale against the PHONE, not a zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the design in the previous commit. Household movement is not zero steps — the wearer really does walk to the next room — so rejecting the window outright would delete real steps exactly where a number was asked for. Both families are now the same maths (gain = truth / raw) and differ only in their REFERENCE and RANGE: * gait (slow/brisk/jog): reference is the wearer's own count; range 0.5..2.0. * everyday: reference is the PHONE's pedometer over the same span, because nobody counts steps while doing chores; range 0.05..1.0. The everyday floor is 0.05 precisely so the correction can be large without ever reaching zero, and an everyday run whose reference counted nothing is REFUSED (noReferenceSteps) rather than fitted as a 0.0 gain — a phone on a table is the absence of a reference, not a measured zero. The everyday reference is the phone because the phone is the better sensor for this one quantity, which lib/health/phone_pedometer.dart already argues: at the wrist, arm work out-accelerates walking (stirring ~104 mg, chopping ~139 mg vs walking ~66 mg ENMO), while a pocket-carried device observes trunk motion. Driving is NOT this file's job. lib/data/live_coverage_policy.dart already drops band credit overlapping a phone-confirmed motionless hour unless the span's own density looks like gait (kConfirmedStillMinSpm = 40, above the measured 22-27 spm false-positive ceiling and below real walking). The everyday gain is a backstop on what survives that rule. Measured on the mirror: a household window of 140 raw steps with a fitted 0.1375 gain credits 19, not 0; a driving-like window at 12 spm credits 3 of 25; an uncalibrated family credits raw exactly as today. Verified against the Python mirror: 43/43 cases. --- lib/compute/step_calibration_fit.dart | 523 ++++++++++++-------------- 1 file changed, 244 insertions(+), 279 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index 8131f1051..2778f777d 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -3,50 +3,68 @@ // // WHERE THE COUNTER LIVES. Not here. The counter is `pedometer()` / // `livePedometer()` in analytics (`onehz/motion/steps.dart`), fed from the live -// IMU stream that `edge` already ingests (`frameAccel`: gen5 0x2B, gen4 0x33 / -// R10) and banked as `live_coverage.source = 'strap_100hz'`. This file only -// turns a supervised calibration walk into numbers that counter is configured -// with — nothing here touches BLE, a clock or the disk, and NOTHING is written -// to the strap. Everything below is a value the phone keeps. +// 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, AND WHAT IT MAY CLAIM. Analytics' own `StepParams.gain` -// docstring asks for exactly this: "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 the gain corrects the RESIDUAL a real wrist adds — -// placement, tightness, arm-swing style — not a defect in the detector. Expect -// values near 1.0, and never a population constant. +// 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 KINDS OF CALIBRATION, AND THE SECOND IS THE ONE THAT PAYS. +// ── TWO CATEGORY FAMILIES, ONE MECHANISM ──────────────────────────────────── // -// * POSITIVE (slow / brisk / jog): the walk produces N real steps and the -// detector reports some other number over the same span. The correction is -// a SCALE — `gain = truth / raw` — because the count is real, only its size -// is wrong. +// 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: // -// * NEGATIVE (everyday: carrying, lifting, moving things around a room): the -// detector reports a count and the TRUE step count is ~0, because the peaks -// it counted were arm work, not gait. There is no scale that fixes this: -// `0 / raw` is 0, and a gain of 0 would credit nothing everywhere. What the -// run teaches instead is a BOUNDARY — what this wearer's non-gait movement -// looks like to this sensor — so windows matching it are credited nothing. +// * 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. // -// The negative run is the one that addresses OVER-counting, which is the -// expensive error: `live_step_runs.dart` records the owner's own measurement of -// rhythmic arm work scored as gait (+199.5%, 217 true steps read as 650). A -// miss costs a walk; a fabricated step corrupts the day. +// * 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. // -// HONEST LIMIT. Analytics states its own ceiling plainly: "Every independent -// free-living wrist evaluation puts peak detection between 60% and 230% error." -// Nothing here lifts that. It trims a known bias per wearer and refuses one -// measured class of false movement. It does not turn a wrist into a pedometer, -// and no copy built on this may imply it does. +// 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. `everyday` is the negative one; see the -/// file header for why it cannot be a scale factor. +/// 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, @@ -71,19 +89,32 @@ enum StepCategory { _ => null, }; - bool get isPositive => this != StepCategory.everyday; + /// 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; } -/// One supervised calibration run: the wearer counts their own steps, the -/// detector counts what it saw, and the features are what the run looked like. -/// -/// [expectedSteps] is the ONLY ground truth in this feature and it comes from -/// the user, which is why a single admitted run is enough to mark a category -/// calibrated — unlike `lib/data/step_calibration.dart`, whose days are fitted -/// against a phone and therefore need three of them plus a prior. +/// 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, @@ -92,10 +123,16 @@ class StepCalibrationRun { }); 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; - /// From `PedometerResult.cadenceSpm` over the run's span. + /// `PedometerResult.cadenceSpm` over the run's span. final double cadenceSpm; /// `PedometerResult.peakToPeakG` — median stride amplitude, the motion @@ -103,8 +140,6 @@ class StepCalibrationRun { final double peakToPeakG; /// `PedometerResult.confidence` — amplitude AND rhythm regularity, 0..1. - /// This is the discriminator the negative run leans on: rhythmic arm work - /// scores high, lifting and carrying score low. final double confidence; } @@ -112,37 +147,25 @@ class StepCalibrationRun { /// count is larger than the bias it is meant to measure. const int kStepCalMinExpectedSteps = 50; -/// The detector claiming almost nothing over a supervised walk is a signal the -/// run failed (band not worn, walk done off-wrist, stream dropped), not a -/// measurement of a 20x gain. +/// 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; -/// A learned gain outside this band means the RUN went wrong, not that the -/// counter is that far off — same reasoning as `kStepFactorMin/Max`. -const double kStepGainMin = 0.5; -const double kStepGainMax = 2.0; +/// 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; -// NO PRIOR WEIGHT HERE, AND THAT IS A DELIBERATE DEVIATION from -// `lib/data/step_calibration.dart`, which blends three pseudo-days of factor 1.0 -// into its fit. -// -// That prior exists because ITS reference is INFERRED: a day is admitted when -// the phone counted alongside the band, and a phone in a drawer is a bad day -// dressed as a measurement. Shrinking toward 1.0 stops one bad day from moving -// the factor. -// -// This fit's reference is SUPERVISED: the wearer counts their own 100 steps, on -// purpose, having been told to walk evenly. That is a different class of -// evidence, and the guards that remain — admissibility, and the clamp below — -// already bound the damage a single bad run can do. A prior on top of them cost -// more than it bought: blending 3 pseudo-runs of 1.0 meant a first calibration -// of 100-said/110-seen resolved to 0.977 and corrected barely a fifth of the -// bias the wearer had just measured. A user who walks their calibration walk -// and then sees no change has been told the feature does not work. -// -// Repeated runs still converge: the estimator is a ratio of SUMS, so later runs -// outweigh earlier ones by the steps they carry. That is evidence weighting, not -// forgetting, and it needs no invented constant. +/// 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 @@ -150,44 +173,68 @@ const double kStepGainMax = 2.0; /// models must never be able to overwrite each other's stored value. const int kLiveStepGainVersion = 100; -/// Cadence search window for a category band, as a fraction of the fitted +/// 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 +/// 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; -/// Physiological bounds from AN-2554, in spm: 0.5–5 steps/s. Bands are clamped +/// Physiological bounds from AN-2554, in spm: 0.5-5 steps/s. Bands are clamped /// here so a mis-entered run cannot produce a band outside human gait. const double kGaitCadenceMinSpm = 30.0; const double kGaitCadenceMaxSpm = 300.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 walk the user just did. +/// 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, } /// Null when the run may be used to fit; otherwise why it may not. StepRunRejection? stepRunRejection(StepCalibrationRun r) { - if (!r.cadenceSpm.isFinite || !r.peakToPeakG.isFinite || !r.confidence.isFinite) { + if (!r.cadenceSpm.isFinite || + !r.peakToPeakG.isFinite || + !r.confidence.isFinite) { return StepRunRejection.nonFiniteFeature; } - if (r.expectedSteps < kStepCalMinExpectedSteps) { - return StepRunRejection.expectedTooSmall; + if (r.category.referenceIsPhone != (r.reference == StepReference.phonePedometer)) { + return StepRunRejection.referenceWrongSource; } - if (r.rawSteps < kStepCalMinRawSteps) { - return StepRunRejection.rawTooSmall; + 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; + } + return null; } - // `raw / expected` is clamped to kStepGainMax, so a run beyond the clamp is - // not a measurement of the gain — it is a measurement of something else. - if (r.rawSteps > (r.expectedSteps * kStepGainMax).ceil()) { + + // 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 null; @@ -206,25 +253,23 @@ class LiveStepGainProfile { final int version; final StepCategory category; - /// Clamped to [kStepGainMin, kStepGainMax]; exactly 1.0 when nothing was - /// learned. 1.0 is not "unset" — it is the shipped behaviour, which is why an - /// uncalibrated category and a fitted 1.0 behave identically and correctly. + /// 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 supervised run is admitted: the - /// ground truth is the wearer's own count, not an inferred reference. + /// 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 the - /// value analytics ships as its default. - static LiveStepGainProfile uncalibrated( - StepCategory category, - DateTime? now, - ) => + /// 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, @@ -248,18 +293,19 @@ class LiveStepGainProfile { if (raw is! Map) return null; final category = StepCategory.parse(raw['cat']); if (category == null) return null; - final version = raw['v']; - if (version is! int || version != kLiveStepGainVersion) return null; + if (raw['v'] != kLiveStepGainVersion) return null; final gain = raw['gain']; if (gain is! num || !gain.isFinite) return null; - if (gain < kStepGainMin || gain > kStepGainMax) 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: version, + version: kLiveStepGainVersion, category: category, gain: gain.toDouble(), nRuns: nRuns, @@ -268,39 +314,23 @@ class LiveStepGainProfile { } } -/// Fit the positive-category gain over every admitted run. -/// -/// 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 -/// — the same construction as `estimateStepCalibration`. -/// -/// `everyday` returns the uncalibrated profile on purpose. It has no scale, and -/// producing one would be a fabricated number with a legitimate-looking shape; -/// its measurement is [EverydayProbe] instead. -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, - ); -} +/// 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 the correction without -/// inventing a timestamp to wrap it in a profile. +/// 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, ) { - if (!category.isPositive) return null; final admitted = [ for (final r in runs) if (r.category == category && stepRunRejection(r) == null) r, @@ -313,16 +343,44 @@ LiveStepGainProfile estimateLiveStepGain( // 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 — the sum ratio is - // dominated by whichever runs carried the most steps. + // 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 ( - gain: ratio.clamp(kStepGainMin, kStepGainMax), + gain: ratio.clamp(gainFloor(category), gainCeiling(category)), nRuns: admitted.length, ); } -/// A cadence window a calibrated category owns. +/// 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, @@ -337,18 +395,17 @@ class StepCategoryBand { bool contains(double spm) => spm >= minSpm && spm <= maxSpm; } -/// Band for one positive category from its admitted runs: median cadence, opened -/// by [kCategoryBandSpread] and floored at [kCategoryBandMinWidthSpm]. +/// 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 when the category has no admitted -/// run — an uncalibrated category owns no window and therefore never claims a -/// minute. +/// 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.isPositive) return null; + if (!category.isGait) return null; final cadences = [ for (final r in runs) if (r.category == category && stepRunRejection(r) == null) r.cadenceSpm, @@ -356,13 +413,10 @@ StepCategoryBand? categoryBand( if (cadences.isEmpty) return null; final n = cadences.length; - final median = n.isOdd - ? cadences[n ~/ 2] - : (cadences[n ~/ 2 - 1] + cadences[n ~/ 2]) / 2; - var half = (median * kCategoryBandSpread).clamp( - kCategoryBandMinWidthSpm / 2, - kGaitCadenceMaxSpm, - ); + final median = + n.isOdd ? cadences[n ~/ 2] : (cadences[n ~/ 2 - 1] + cadences[n ~/ 2]) / 2; + final half = (median * kCategoryBandSpread) + .clamp(kCategoryBandMinWidthSpm / 2, kGaitCadenceMaxSpm); var lo = median - half; var hi = median + half; // Clamp to human gait without letting the clamp collapse the band. @@ -381,19 +435,20 @@ StepCategoryBand? categoryBand( ); } -/// Which calibrated category owns a live window's cadence. +/// Which calibrated GAIT category owns a live window's cadence. /// -/// Nearest band centre among the calibrated positive categories, but only when -/// the cadence actually lands inside that band. A window between two bands, or -/// outside every calibrated band, belongs to NO category — it falls back to the -/// shipped behaviour rather than being forced into the nearest one, because -/// forcing it would apply a correction measured on a different gait. -StepCategory? categoryForCadence( +/// 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; - StepCategoryBand? best; + StepCategory? best; var bestDistance = double.infinity; for (final c in StepCategory.values) { final band = categoryBand(c, runs); @@ -401,91 +456,10 @@ StepCategory? categoryForCadence( final distance = (spm - (band.minSpm + band.maxSpm) / 2).abs(); if (distance < bestDistance) { bestDistance = distance; - best = band; + best = c; } } - return best?.category; -} - -/// The negative calibration: what this wearer's non-gait movement looks like. -/// -/// A walk has a rhythm. Carrying, lifting and rearranging do not, and analytics -/// already scores exactly that as `PedometerResult.confidence` (amplitude plus -/// rhythm regularity). So the everyday run yields one number — the HIGHEST -/// confidence any of its runs reached — and a live window at or below it is -/// credited nothing. -/// -/// The maximum, not the mean, and deliberately so: the boundary must reject -/// every non-gait run the user actually performed, including their most -/// walk-like one. Using a mean would let the worst run through, which is the -/// false positive this whole category exists to stop. -class EverydayProbe { - const EverydayProbe({ - required this.maxConfidence, - required this.nRuns, - required this.updatedAt, - }); - - final double maxConfidence; - final int nRuns; - final DateTime updatedAt; - - bool get isCalibrated => nRuns > 0; - - /// A window is refused when it is no more rhythmic than the wearer's own - /// non-gait movement. Equal counts as refused: the boundary is inclusive on - /// the rejecting side, because a false step is the error that cannot be - /// recovered. - bool refuses(double confidence) => isCalibrated && confidence <= maxConfidence; - - Map toJson() => { - 'v': kLiveStepGainVersion, - 'cat': StepCategory.everyday.key, - 'conf': maxConfidence, - 'n': nRuns, - 'ts': updatedAt.toIso8601String(), - }; - - static EverydayProbe? tryParse(Object? raw) { - if (raw is! Map) return null; - if (StepCategory.parse(raw['cat']) != StepCategory.everyday) return null; - if (raw['v'] != kLiveStepGainVersion) return null; - final conf = raw['conf']; - if (conf is! num || !conf.isFinite || conf < 0 || conf > 1) 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 EverydayProbe( - maxConfidence: conf.toDouble(), - nRuns: nRuns, - updatedAt: updatedAt, - ); - } -} - -/// Build the negative probe from every admitted `everyday` run, or null when -/// the wearer has not performed one. Null is the shipped behaviour: with no -/// probe, nothing is refused and the counter behaves exactly as it does today. -EverydayProbe? estimateEverydayProbe( - List runs, { - required DateTime now, -}) { - final admitted = [ - for (final r in runs) - if (r.category == StepCategory.everyday && stepRunRejection(r) == null) r, - ]; - if (admitted.isEmpty) return null; - var maxConfidence = 0.0; - for (final r in admitted) { - if (r.confidence > maxConfidence) maxConfidence = r.confidence; - } - return EverydayProbe( - maxConfidence: maxConfidence, - nRuns: admitted.length, - updatedAt: now, - ); + return best; } /// What a live window's raw count is finally credited as. @@ -495,39 +469,42 @@ class StepCredit { required this.rawSteps, required this.category, required this.gainApplied, - required this.refusedByEverydayProbe, + required this.calibrated, }); final int creditedSteps; final int rawSteps; - /// Null when no calibrated band owned this window's cadence. + /// The family the correction came from. Null only when nothing was applied. final StepCategory? category; - /// The gain actually used — 1.0 for an uncalibrated category, so the caller - /// can log "no correction applied" without a second lookup. + /// 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; - /// True when the negative probe refused the window, so the UI can say WHY a - /// count of 40 became 0 instead of showing a silent zero. - final bool refusedByEverydayProbe; + /// 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. /// -/// Order matters and is the whole policy: -/// 1. the negative probe refuses non-gait movement, because a fabricated step -/// costs more than a missed one; -/// 2. otherwise the window's cadence picks a calibrated category, whose gain -/// scales the raw count; -/// 3. anything uncalibrated is credited EXACTLY as it is today — raw, gain -/// 1.0. The feature must be invisible until the wearer has calibrated. +/// 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 double confidence, required List runs, - EverydayProbe? everydayProbe, }) { if (rawSteps <= 0) { return StepCredit( @@ -535,46 +512,34 @@ StepCredit creditLiveSteps({ rawSteps: rawSteps < 0 ? 0 : rawSteps, category: null, gainApplied: 1.0, - refusedByEverydayProbe: false, - ); - } - if (everydayProbe?.refuses(confidence) ?? false) { - return StepCredit( - creditedSteps: 0, - rawSteps: rawSteps, - category: null, - gainApplied: 1.0, - refusedByEverydayProbe: true, - ); - } - final category = categoryForCadence(cadenceSpm, runs); - if (category == null) { - return StepCredit( - creditedSteps: rawSteps, - rawSteps: rawSteps, - category: null, - gainApplied: 1.0, - refusedByEverydayProbe: false, + calibrated: false, ); } - final fit = fittedGain(category, runs); + + final gait = gaitCategoryForCadence(cadenceSpm, runs); + // A gait window with an uncalibrated band falls through to everyday, whose + // gain is capped at 1.0 — so it can never inflate, only damp. + final family = gait ?? StepCategory.everyday; + + final fit = fittedGain(family, runs); if (fit == null) { return StepCredit( creditedSteps: rawSteps, rawSteps: rawSteps, - category: category, + category: null, gainApplied: 1.0, - refusedByEverydayProbe: false, + 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 * kStepGainMax).ceil()), + creditedSteps: credited.clamp(0, (rawSteps * kGaitGainMax).ceil()), rawSteps: rawSteps, - category: category, + category: family, gainApplied: fit.gain, - refusedByEverydayProbe: false, + calibrated: true, ); } From 40a7801322e77172e83e79846b8dd9c738ea4fd7 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 19:58:57 +0100 Subject: [PATCH 03/33] feat(compute,data): persist the live-pedometer calibration; align its gait band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistence (lib/data/db.dart): * live_step_calibration — the fitted gain per activity family * live_step_calibration_run — the individual runs behind it Separate tables from step_calibration/step_calibration_day on purpose: that pair calibrates the gen5 ON-CHIP counter (a factor on raw ticks, per device family and wearing), these calibrate the LIVE 100 Hz pedometer (a scale per activity family, different reference, different range, different version namespace). Sharing a table would let one model overwrite the other's stored value. Both tables are registered in the export AND import lists — a calibration cannot be rebuilt once raw is pruned, so a backup that dropped it would silently lose the wearer's own work. Stored rows are read back defensively: an unknown category or reference is skipped rather than guessed (attaching a run to the wrong family would corrupt that family's fit with another one's evidence), and a gain is re-checked against its category's clamp, so a hand-edited or restored row cannot present a number the fit would never have produced. BAND FIX. The gait band was 30-300 spm — AN-2554's step-INTERVAL bound, which is what the counter accepts, not what identifies a minute as walking. lib/ble/live_cadence.dart already defines that: 60-200 spm, the same band livePedometer treats as confident gait. Two definitions of gait in one codebase would let the calibration build bands the session logic disagrees with, so the constant now matches and a run whose own cadence sits outside the band no longer defines one — a 55 spm calibration run is not a walking band, and a 40 spm minute is, in live_cadence.dart's words, a few steps to the kettle. The pair is redeclared (this file stays dependency-free) and PINNED by test/step_calibration_band_pin_test.dart in both directions, the same DERIVED-OR-PINNED convention live_step_runs.dart uses for kRouteTypeKeys. test/step_calibration_fit_test.dart carries the mirror's cases in flutter_test form. Verified: 46/46 against the Python mirror. NOT verified: Dart syntax — neither test file has been run (no Flutter toolchain was available where this was authored). --- lib/compute/step_calibration_fit.dart | 35 ++- lib/data/db.dart | 156 ++++++++++++ test/step_calibration_band_pin_test.dart | 51 ++++ test/step_calibration_fit_test.dart | 296 +++++++++++++++++++++++ 4 files changed, 533 insertions(+), 5 deletions(-) create mode 100644 test/step_calibration_band_pin_test.dart create mode 100644 test/step_calibration_fit_test.dart diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index 2778f777d..720bf6923 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -183,10 +183,28 @@ const double kCategoryBandSpread = 0.20; /// variation would reject their real walking. const double kCategoryBandMinWidthSpm = 20.0; -/// Physiological bounds from AN-2554, in spm: 0.5-5 steps/s. Bands are clamped -/// here so a mis-entered run cannot produce a band outside human gait. -const double kGaitCadenceMinSpm = 30.0; -const double kGaitCadenceMaxSpm = 300.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 @@ -408,7 +426,14 @@ StepCategoryBand? categoryBand( if (!category.isGait) return null; final cadences = [ for (final r in runs) - if (r.category == category && stepRunRejection(r) == null) r.cadenceSpm, + 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; diff --git a/lib/data/db.dart b/lib/data/db.dart index ed954c51b..d2ab77d5b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -52,6 +52,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 'med_store.dart'; import 'models.dart'; @@ -312,6 +313,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 @@ -2053,6 +2056,44 @@ 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, + p2p_g REAL NOT NULL, + confidence REAL NOT NULL, + PRIMARY KEY (category, run_ts) + ) + '''); } /// Idempotent by key. NULL reference_steps = the phone did not cover the day. @@ -2183,6 +2224,119 @@ 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() async { + final db = 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, + peakToPeakG: (r['p2p_g'] as num?)?.toDouble() ?? 0, + confidence: (r['confidence'] as num?)?.toDouble() ?? 0, + )); + } + 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, + }) async { + final db = 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, + ); + } + + /// 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) async { + final db = 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 @@ -10024,6 +10178,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/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..58d98b948 --- /dev/null +++ b/test/step_calibration_fit_test.dart @@ -0,0 +1,296 @@ +// 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('the household gain is floored, never zero', () { + final fit = fittedGain(StepCategory.everyday, [_everyday(15, 400)]); + expect(fit!.gain, kEverydayGainMin); + expect(fit.gain, greaterThan(0)); + }); + + 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('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, + ); + }); + }); +} From cd52c7d260cc56c8b969378433b6c6991e662f03 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 20:03:08 +0100 Subject: [PATCH 04/33] feat(state): bank live runs through the wearer's calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the per-category fit into the ONE place the persistence path applies the step trim — _bankGaitRuns — plus the session checkpoint, which must use the same function because recovery banks its numbers verbatim. The invariant is one FACTOR per run, and it is now expressed as one FUNCTION per path. When a family is calibrated, the calibrated gain REPLACES ana.StepParams.gain rather than multiplying into it: this file's own history records that applying the gain at both ends 'shipped a silent x1.23'. When nothing is calibrated the call falls back to ana.StepParams.gain, so an uncalibrated app banks exactly the numbers it banks today, to the digit. The run's cadence comes from the run itself — its raw count over its own sampled span — which is how lib/ble/live_cadence.dart already defines one, and the RAW count is what picks the family. Gain last, per that file's 'gate first, gain last' rule: a gain applied before the cadence test stretches the band by whatever the gain is. The calibration is cached per session (the fit reads the whole run history; a session banks a handful of runs), with invalidateLiveStepCalibration() for the calibration screen to call after a save. Verified against the Python mirror: 54/54, including the regression guard that a calibrated run yields 100 from raw 110 and NOT 122 (double-applied) or 111 (the fallback multiplied in). --- lib/compute/step_calibration_fit.dart | 54 ++++++++++++++++++++++++++ lib/state/app_state.dart | 56 ++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index 720bf6923..ca87b0fcb 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -568,3 +568,57 @@ StepCredit creditLiveSteps({ 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; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2dbac2d31..f9688c5d3 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -48,6 +48,7 @@ import '../compute/derive_scheduler.dart'; import '../compute/inputs/canonical.dart' show flagOnOrPrimary, onWearableDaysChanged, refreshSessionSensorSources; 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 @@ -3218,6 +3219,23 @@ 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; + + Future> _liveStepCalibrationRuns() async => + _liveStepCalibration ??= await LocalDb.liveStepCalibrationRuns(); + + /// Drop the cached fit so the next banked run uses a newly saved calibration. + void invalidateLiveStepCalibration() => _liveStepCalibration = null; + /// Map a phone-clock instant onto the BAND's record-time base, the base /// `live_coverage` rows live in. /// @@ -3516,13 +3534,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. @@ -3552,6 +3583,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 @@ -3561,7 +3593,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, + ), + ], ], }), ); From 26a415536743872ff09d8fc36865bd20266313df Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 20:15:04 +0100 Subject: [PATCH 05/33] =?UTF-8?q?feat(compute):=20calibration=20session=20?= =?UTF-8?q?=E2=80=94=20the=20wearer=20counts,=20the=20band=20does=20not=20?= =?UTF-8?q?terminate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CalibrationSession: one routine's state (start, live band count, phone reference for everyday, finish -> run-or-refusal). THE TRAP THIS AVOIDS. The obvious design is 'count the steps as the wearer walks and stop at 100'. That calibrates nothing: the run would end when the COUNTER reached 100, rawSteps would be 100 by construction, and every wearer would fit a gain of exactly 1.0 forever — a number shaped like a calibration and worth nothing. A reference must be independent of the instrument it calibrates. So the WEARER counts for the gait families, the PHONE is the reference for everyday, and the band's live count is feedback on the screen rather than the terminator. The gap between the two IS the measurement: 100 walked, 112 seen, gain 0.893. Also corrects a fabrication in the previous commit: peakToPeakG and confidence were being written as 0.0 when the caller had never measured them. A real still reading is 0, so a made-up zero is indistinguishable from a measurement at exactly the end of the scale where the discriminator lives. Both fields are now nullable (column and model), absent by default, and populated only from a real PedometerResult; a present-but-NaN value is refused rather than stored. A silent band is its own outcome (bandSilent), not dressed up as a StepRunRejection — that enum judges a run that exists, and this one never did. Relevant context: app_state.dart's v56 note records that a previous GUIDED STEP CALIBRATION was removed because its only reader was the deleted 1 Hz estimator 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 go through creditedStepsForRun — and no screen may claim otherwise until that path is built. Verified against the Python mirror: 71/71. --- lib/compute/step_calibration_fit.dart | 194 +++++++++++++++++++++++++- lib/data/db.dart | 7 +- test/step_calibration_fit_test.dart | 88 ++++++++++++ 3 files changed, 282 insertions(+), 7 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index ca87b0fcb..e2aeae218 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -137,10 +137,18 @@ class StepCalibrationRun { /// `PedometerResult.peakToPeakG` — median stride amplitude, the motion /// strength. Carrying and lifting push this above a walk's. - final double peakToPeakG; + /// + /// 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. - final double confidence; + /// 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 @@ -225,9 +233,16 @@ enum StepRunRejection { /// Null when the run may be used to fit; otherwise why it may not. StepRunRejection? stepRunRejection(StepCalibrationRun r) { - if (!r.cadenceSpm.isFinite || - !r.peakToPeakG.isFinite || - !r.confidence.isFinite) { + 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)) { @@ -622,3 +637,172 @@ int creditedStepsForRun({ 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 _bandRaw = 0; + int _bandSeconds = 0; + int? _phoneSteps; + double? _peakToPeakG; + double? _confidence; + int _elapsedMs = 0; + bool _finished = false; + + /// The strap's RAW count so far — pre-gain, exactly as `pedometer()` returns + /// it, and the value that becomes the run's `rawSteps`. + int get bandRawSteps => _bandRaw; + + /// Sampled seconds the strap's chunks covered. Sampled, never wall time: a + /// chunk covers the signal it holds however long it took to arrive. + int get bandSeconds => _bandSeconds; + + /// 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 : _bandRaw * 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 = _bandRaw / targetSteps; + return p < 0 ? 0 : (p > 1 ? 1 : p); + } + if (targetSeconds <= 0) return 0; + final p = (_elapsedMs / 1000) / targetSeconds; + return p < 0 ? 0 : (p > 1 ? 1 : p); + } + + /// Fold one completed pedometer chunk: [rawSteps] counted over [seconds] of + /// signal. A chunk with no steps or no span cannot move the routine. + void addBandChunk({required int rawSteps, required int seconds}) { + if (_finished || rawSteps <= 0 || seconds <= 0) return; + _bandRaw += rawSteps; + _bandSeconds += seconds; + } + + /// 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; + } + + /// 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; + } + + void advance({required int elapsedMs}) { + if (_finished || elapsedMs < 0) return; + _elapsedMs = elapsedMs; + } + + /// 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 => _bandSeconds > 0 && _bandRaw > 0; + + /// 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. + ({StepCalibrationRun? run, StepRunRejection? refusal, bool bandSilent}) + finish() { + _finished = true; + if (_bandSeconds <= 0 || _bandRaw <= 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); + } + 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) : targetSteps, + rawSteps: _bandRaw, + 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 d2ab77d5b..3ecc27550 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2089,8 +2089,11 @@ class LocalDb { expected_steps INTEGER NOT NULL, raw_steps INTEGER NOT NULL, cadence_spm REAL NOT NULL, - p2p_g REAL NOT NULL, - confidence 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) ) '''); diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index 58d98b948..c61bd64b0 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -293,4 +293,92 @@ void main() { ); }); }); + + group('the calibration session', () { + 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); + s.addBandChunk(rawSteps: 56, seconds: 30); + s.addBandChunk(rawSteps: 56, seconds: 30); + expect(s.bandRawSteps, 112); + 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('the band reports its own cadence for the run', () { + final s = CalibrationSession(category: StepCategory.brisk); + s.addBandChunk(rawSteps: 112, seconds: 60); + final out = s.finish(); + expect(out.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); + s.addBandChunk(rawSteps: 400, seconds: 180); + final out = s.finish(); + expect(out.refusal, StepRunRejection.noReferenceSteps); + expect(out.run, isNotNull); // the run exists; it is the REFERENCE that does not + }); + + test('everyday with a phone count fits low and positive', () { + final s = CalibrationSession(category: StepCategory.everyday); + s.addBandChunk(rawSteps: 400, seconds: 180); + s.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); + ed.advance(elapsedMs: 90000); + expect(ed.progress, closeTo(0.5, 1e-9)); + final gait = CalibrationSession(category: StepCategory.brisk, targetSteps: 100); + gait.addBandChunk(rawSteps: 50, seconds: 30); + expect(gait.progress, closeTo(0.5, 1e-9)); + }); + + test('a silent band is its own outcome, not a refusal', () { + final s = CalibrationSession(category: StepCategory.jog); + final out = s.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)..finish(); + s.addBandChunk(rawSteps: 50, seconds: 60); + expect(s.bandRawSteps, 0); + }); + + test('unmeasured features stay absent, never a substituted zero', () { + final s = CalibrationSession(category: StepCategory.brisk); + s.addBandChunk(rawSteps: 110, seconds: 60); + final bare = s.finish().run!; + expect(bare.peakToPeakG, isNull); + expect(bare.confidence, isNull); + + final withFeatures = CalibrationSession(category: StepCategory.brisk); + withFeatures.addBandChunk(rawSteps: 110, seconds: 60); + withFeatures.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); + s.addBandChunk(rawSteps: 110, seconds: 60); + s.setBandFeatures(confidence: double.nan); + expect(s.finish().refusal, StepRunRejection.nonFiniteFeature); + }); + }); } From 3348aa3cca5c30f40bcd6a9d2c0236d29b7a9e07 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 20:19:50 +0100 Subject: [PATCH 06/33] refactor(compute): session inputs are what a screen can observe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites CalibrationSession around a cumulative counter and a clock, because that is all a screen can read: the live session's raw step total (AppState.liveStrapRawSteps) and wall time. The previous API took completed chunks, which no caller has — the screen never sees one, so it could not have been wired up without inventing them. * start(bandBaseline, nowMs) — everything the routine is credited with is measured from the counter's value at that moment, so a walk that happened before the wearer pressed start cannot land in their calibration. * observe(bandTotal, nowMs) — the counter is never allowed to go backwards: it resets when a session ends, and a routine spanning a reconnect must not be credited with a negative correction. * bandSeconds is the routine's own span. Documented as wall time, not sampled time, with why that is tolerable: the live path refuses to commit a window measuring under kMinLiveSampleRateHz, so a stall shows up as the band count standing still, and a cadence that still lands outside 60-200 spm merely produces no gait band. Also adds AppState.liveStrapRawSteps (public) and LocalDb.bankLiveStepCalibrationRun, which stores a run and its refitted profile in one call — the two writes must not be done by hand in two places, or the app ends up correcting with a gain that does not match the evidence behind it. Notes a doc bug it had to read past: _liveRaw's docstring calls its value 'gain-applied', but _committedRaw holds pre-gain counts and ana.pedometer returns a raw count. The new public getter is named for what the value is. Verified against the Python mirror: 71/71. --- lib/compute/step_calibration_fit.dart | 77 ++++++++++++------- lib/data/db.dart | 28 +++++++ lib/state/app_state.dart | 15 ++++ test/step_calibration_fit_test.dart | 104 ++++++++++++++++++-------- 4 files changed, 167 insertions(+), 57 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index e2aeae218..f3b3d26d5 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -688,21 +688,36 @@ class CalibrationSession { /// How long to keep a household routine going. Everyday only. final int targetSeconds; - int _bandRaw = 0; - int _bandSeconds = 0; + int _bandBaseline = 0; + int _bandTotal = 0; + int _startMs = 0; + int _nowMs = 0; + bool _started = false; int? _phoneSteps; double? _peakToPeakG; double? _confidence; - int _elapsedMs = 0; bool _finished = false; - /// The strap's RAW count so far — pre-gain, exactly as `pedometer()` returns - /// it, and the value that becomes the run's `rawSteps`. - int get bandRawSteps => _bandRaw; + /// 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; - /// Sampled seconds the strap's chunks covered. Sampled, never wall time: a - /// chunk covers the signal it holds however long it took to arrive. - int get bandSeconds => _bandSeconds; + /// 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). @@ -714,7 +729,7 @@ class CalibrationSession { /// 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 : _bandRaw * 60.0 / _bandSeconds; + bandSeconds <= 0 ? 0.0 : bandRawSteps * 60.0 / bandSeconds; /// 0..1 for the progress affordance. /// @@ -724,20 +739,35 @@ class CalibrationSession { double get progress { if (category.isGait) { if (targetSteps <= 0) return 0; - final p = _bandRaw / targetSteps; + final p = bandRawSteps / targetSteps; return p < 0 ? 0 : (p > 1 ? 1 : p); } if (targetSeconds <= 0) return 0; - final p = (_elapsedMs / 1000) / targetSeconds; + final p = bandSeconds / targetSeconds; return p < 0 ? 0 : (p > 1 ? 1 : p); } - /// Fold one completed pedometer chunk: [rawSteps] counted over [seconds] of - /// signal. A chunk with no steps or no span cannot move the routine. - void addBandChunk({required int rawSteps, required int seconds}) { - if (_finished || rawSteps <= 0 || seconds <= 0) return; - _bandRaw += rawSteps; - _bandSeconds += seconds; + /// 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) return; + _bandTotal = bandTotal < _bandBaseline ? _bandBaseline : bandTotal; + if (nowMs > _nowMs) _nowMs = nowMs; } /// The phone's count for this routine's window. Replaceable, because the @@ -760,17 +790,14 @@ class CalibrationSession { _confidence = confidence; } - void advance({required int elapsedMs}) { - if (_finished || elapsedMs < 0) return; - _elapsedMs = elapsedMs; - } + /// 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 => _bandSeconds > 0 && _bandRaw > 0; + bool get canFinish => _started && bandSeconds > 0 && bandRawSteps > 0; /// Finish the routine and produce the run, or the reason there is none. /// @@ -781,7 +808,7 @@ class CalibrationSession { ({StepCalibrationRun? run, StepRunRejection? refusal, bool bandSilent}) finish() { _finished = true; - if (_bandSeconds <= 0 || _bandRaw <= 0) { + 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); @@ -796,7 +823,7 @@ class CalibrationSession { // [stepRunRejection] turns into `noReferenceSteps` rather than a fitted // 0.0 gain. expectedSteps: category.referenceIsPhone ? (_phoneSteps ?? 0) : targetSteps, - rawSteps: _bandRaw, + 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. diff --git a/lib/data/db.dart b/lib/data/db.dart index 3ecc27550..8d575176c 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2293,6 +2293,34 @@ class LocalDb { ); } + /// 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, + }) async { + await putLiveStepCalibrationRun(run, runTs: runTs); + final rows = await liveStepCalibrationRuns(); + final profile = estimateLiveStepGain( + run.category, + rows, + now: now ?? DateTime.now(), + ); + await putLiveStepGainProfile(profile); + return profile; + } + /// 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. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index f9688c5d3..483fff4b1 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3281,6 +3281,21 @@ class AppState extends ChangeNotifier { /// first measurement lands. It is never banked (the commit path measures its /// own chunk, below). Measure the partial too only if a rate that low is ever /// seen on real hardware. + /// 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. + /// + /// [_liveRaw]'s own docstring calls its value "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". + /// The value is exposed under a name that matches what it actually is. + int get liveStrapRawSteps => _liveRaw; + int get _liveRaw { if (_magMin.isEmpty) return _committedRaw; final hz = _liveHz; diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index c61bd64b0..d4a254e3d 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -295,15 +295,19 @@ void main() { }); 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); - s.addBandChunk(rawSteps: 56, seconds: 30); - s.addBandChunk(rawSteps: 56, seconds: 30); - expect(s.bandRawSteps, 112); + 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); @@ -312,73 +316,109 @@ void main() { expect(fit.gain, isNot(closeTo(1.0, 0.001))); }); + 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); - s.addBandChunk(rawSteps: 112, seconds: 60); - final out = s.finish(); - expect(out.run!.cadenceSpm, closeTo(112, 1e-9)); + 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); - s.addBandChunk(rawSteps: 400, seconds: 180); + 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; it is the REFERENCE that does not + expect(out.run, isNotNull); // the run exists; the REFERENCE does not }); test('everyday with a phone count fits low and positive', () { - final s = CalibrationSession(category: StepCategory.everyday); - s.addBandChunk(rawSteps: 400, seconds: 180); - s.setPhoneSteps(55); + 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)); + 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); - ed.advance(elapsedMs: 90000); + 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); - gait.addBandChunk(rawSteps: 50, seconds: 30); + 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 silent band is its own outcome, not a refusal', () { - final s = CalibrationSession(category: StepCategory.jog); - final out = s.finish(); + 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)..finish(); - s.addBandChunk(rawSteps: 50, seconds: 60); - expect(s.bandRawSteps, 0); + 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); - s.addBandChunk(rawSteps: 110, seconds: 60); + 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); - withFeatures.addBandChunk(rawSteps: 110, seconds: 60); - withFeatures.setBandFeatures(peakToPeakG: 1.2, confidence: 0.86); + 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); - s.addBandChunk(rawSteps: 110, seconds: 60); - s.setBandFeatures(confidence: double.nan); + 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 defines no gait band', () { + final fast = CalibrationSession(category: StepCategory.brisk) + ..start(bandBaseline: 0, nowMs: t0) + ..observe(bandTotal: 200, nowMs: t0 + 50000); // 240 spm + final r = fast.finish().run!; + expect(stepRunRejection(r), isNull); + expect(categoryBand(StepCategory.brisk, [r]), isNull); + }); }); } From da357c4a023c3da7e676d62e438b6e5258169aed Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 20:23:26 +0100 Subject: [PATCH 07/33] feat(ui): step calibration screen, settings row, and the phone reference read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the screen the calibration needs, wired to the reader that already exists. THE WEARER COUNTS. A gait routine cannot end when the band reaches the target: rawSteps would be the target by construction and every wearer would fit exactly 1.0 forever. So the screen asks the wearer to count their own steps and shows the band's live count as FEEDBACK — labelled as the band's count, never as the wearer's progress. The gap between the two is the number they came for. The household routine is timed instead, and its reference is the phone. Adds PhonePedometer.stepsBetween(from, to), because the reference has to be the steps inside exactly those three minutes, not a day. Null and intervalNotCovered both mean 'no reference', and finish() refuses the run on either — a phone on a table measured nothing, which must not be fitted as a measured zero. The screen claims nothing before the save returns: LocalDb.bankLiveStepCalibrationRun stores the run and its refitted profile in one call, AppState.invalidateLiveStepCalibration clears the per-session cache so a walk in progress banks with the new gain, and only then does the wearer see a message. app_state.dart records that the previous guided calibration 'told the user their walk had taught the app something when nothing read the result'; this one must not repeat that, and the reader (_bankGaitRuns, the session checkpoint) is already wired. Every refusal names the routine rather than leaving a silent no-op: no phone reference, band too quiet, routine too short, band counted far more than the wearer, unreadable sensor data. A silent band is its own message, not a refusal. English strings are in app_en.arb. The other six locales fall back to English through the repo's idiom; they are NOT translated yet — adding six unreviewed translations of 30 user-facing strings would be worse than showing English until someone does it properly. NOT COMPILED. This is the largest unverified change in the series: a new Flutter widget against theme tokens, Material buttons and a provider lookup, none of which any check here can execute. Verified: JSON validity of the ARB, bracket balance of every touched file, and the fit/session logic behind it (71/71 against the Python mirror). --- lib/health/phone_pedometer.dart | 19 ++ lib/l10n/app_en.arb | 36 ++- lib/ui2/profile/settings.dart | 11 + lib/ui2/profile/step_calibration.dart | 419 ++++++++++++++++++++++++++ 4 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 lib/ui2/profile/step_calibration.dart diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index c7198e59c..5a80ce488 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -92,6 +92,25 @@ class PhonePedometer { /// ACTIVITY_RECOGNITION request and it also arms the sensor listener. If this /// method stops being reached, the feature looks broken with nothing for the /// user to fix; `arming reaches the platform` in the tests pins that. + /// 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; + } + } + Future requestPermission() async { try { return await phoneStepsChannel.invokeMethod('requestPermission') ?? diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 85cbcceaa..6bcd63fdc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15755,5 +15755,39 @@ "type": "String" } } - } + }, + "settingsStepCalibration": "Step calibration", + "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 \u2014 carry, lift, tidy \u2014 with your phone in your pocket. Three minutes. Do not pace deliberately.", + "stepCalCalibrated": "Calibrated \u2014 counted {n} times", + "stepCalNotCalibrated": "Not calibrated yet", + "stepCalBandSeen": "steps the band has seen", + "stepCalCountYourself": "Count your own steps. Stop when you reach {target}.", + "stepCalElapsed": "Keep going \u2014 this one is timed.", + "stepCalFinish": "Done", + "stepCalCancel": "Cancel", + "stepCalKeepWalking": "Nothing recorded yet \u2014 start walking with the strap snug.", + "stepCalKeepMoving": "Nothing recorded yet \u2014 keep the phone on you and keep moving.", + "stepCalNoSignal": "The band recorded nothing during that routine.", + "stepCalNoPhone": "Your phone counted no steps while you did that. Keep it in your pocket next time \u2014 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.", + "stepCalSaveFailed": "Could not save that calibration.", + "stepCalDoneGait": "Saved. You counted {count} steps and the band saw {band}. From now on this kind of walking is corrected by {pct}%.", + "stepCalDoneHousehold": "Saved. Your phone counted {count} steps of the {band} the band saw. Household movement is now scaled to {gain}." } diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index df0ddb248..1d8a82c01 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: @@ -746,6 +747,16 @@ 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. + _SettingsTile( + icon: LucideIcons.footprints, + label: l?.settingsStepCalibration ?? 'Step calibration', + value: '', + 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..966bcea14 --- /dev/null +++ b/lib/ui2/profile/step_calibration.dart @@ -0,0 +1,419 @@ +// 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/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 '../ui2.dart'; + +class StepCalibrationScreen extends StatefulWidget { + const StepCalibrationScreen({super.key}); + + @override + State createState() => _StepCalibrationScreenState(); +} + +class _StepCalibrationScreenState extends State { + final Map _profiles = {}; + CalibrationSession? _session; + Timer? _ticker; + DateTime? _routineStart; + + /// 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(() {}); + } + + void _start(StepCategory category) { + final app = context.read(); + _ticker?.cancel(); + _routineStart = DateTime.now(); + setState(() { + _outcome = null; + _session = CalibrationSession(category: category) + ..start( + // 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. + bandBaseline: app.liveStrapRawSteps, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + }); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _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(() {}); + } + + Future _readPhone(CalibrationSession s) async { + final start = _routineStart; + if (start == null) return; + try { + final steps = await PhonePedometer().stepsBetween(start, DateTime.now()); + // 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. + } + } + + void _cancel() { + _ticker?.cancel(); + setState(() { + _session = null; + _routineStart = null; + _outcome = null; + }); + } + + Future _finish() async { + final s = _session; + if (s == null) return; + _ticker?.cancel(); + final out = s.finish(); + final l = AppLocalizations.of(context); + + if (out.bandSilent) { + setState(() { + _session = null; + _outcome = l?.stepCalNoSignal ?? 'The band recorded nothing during that routine.'; + }); + return; + } + if (out.refusal != null) { + setState(() { + _session = null; + _outcome = _describeRefusal(out.refusal!, l); + }); + return; + } + + final app = context.read(); + setState(() => _busy = true); + 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(); + await _loadProfiles(); + 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.', + }; + + String _describeOutcome( + StepCalibrationRun run, + LiveStepGainProfile profile, + AppLocalizations? l, + ) { + final pct = ((profile.gain - 1) * 100).round(); + final sign = pct >= 0 ? '+' : ''; + if (run.category.isGait) { + return (l?.stepCalDoneGait ?? + 'Saved. You counted {count} steps and the band saw {band}. From ' + 'now on this kind of walking is corrected by {pct}%.') + .replaceAll('{count}', '${run.expectedSteps}') + .replaceAll('{band}', '${run.rawSteps}') + .replaceAll('{pct}', '$sign$pct'); + } + return (l?.stepCalDoneHousehold ?? + 'Saved. Your phone counted {count} steps of the {band} the band ' + 'saw. Household movement is now scaled to {gain}.') + .replaceAll('{count}', '${run.expectedSteps}') + .replaceAll('{band}', '${run.rawSteps}') + .replaceAll('{gain}', 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( + (l?.stepCalBandSeen ?? 'steps the band has seen') + .replaceAll('{target}', + isHousehold ? '' : '${s.targetSteps}'), + style: F.cap.copyWith(color: p.ink2), + ), + ), + const SizedBox(height: S.x4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + 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 ?? + 'Count your own steps. Stop when you reach {target}.') + .replaceAll('{target}', '${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: BorderRadius.circular(12), + ), + 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 ?? 'Calibrated — counted {n} times') + .replaceAll('{n}', '${profile!.nRuns}') + : (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.', + }; +} From 03f416db90487c35ebf5a37afaf9253ada1cec25 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 20:57:42 +0100 Subject: [PATCH 08/33] fix(ui): settings row and screen imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three guesses I had to correct by reading rather than assuming: * _SettingsTile does not exist. The row widget is SetRow(icon, color, title, {sub, value, onTap}) — a positional signature, declared in lib/ui2/profile/profile.dart. * detailScaffold / detailLinkRow are in screens/metric_detail.dart and are NOT re-exported by ui2.dart (that barrel carries the widgets, not the screens), so the screen imports them explicitly the way advanced.dart does. * F.h1 and p.accent do not exist either; corrected earlier to F.display and the palette's ink/track. Icon names were checked against the set the repo already uses rather than against the package (which is not vendored here): footprints, personStanding, activity and house are all already in use elsewhere in lib/. --- lib/l10n/app_en.arb | 1 + lib/ui2/profile/settings.dart | 13 ++++++++----- lib/ui2/profile/step_calibration.dart | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6bcd63fdc..386b9c5e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15757,6 +15757,7 @@ } }, "settingsStepCalibration": "Step calibration", + "settingsStepCalibrationSub": "Teaches the strap your own walking, so its step count fits you. 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", diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 1d8a82c01..a26af1da3 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -752,11 +752,14 @@ class MoreSettingsView extends StatelessWidget { // 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. - _SettingsTile( - icon: LucideIcons.footprints, - label: l?.settingsStepCalibration ?? 'Step calibration', - value: '', - onTap: () => goto(c, const StepCalibrationScreen())), + 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 index 966bcea14..057b45ab6 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -39,6 +39,7 @@ 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 { From 922df66ef1337af90d916bfabe1b05595b4eff30 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 22:10:08 +0100 Subject: [PATCH 09/33] fix(steps): address review findings, and four the review did not find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOURCERY'S FINDINGS * #2 incomplete routines: a run whose implied ratio falls outside its category's gain range is now REFUSED rather than clamped into range. Clamping turned 'this routine did not measure what it claims' into a value at the edge of the legal band — and a third-done walk (100 claimed, 30 seen) clamped to 2.0 would DOUBLE every later step. * #3 walking steps get the household gain: a cadence inside the 60-200 gait band that no calibrated family owns is now credited EXACTLY as today. The old fall-through protected nothing — 'a household gain can only damp' is not reassurance, because damping a real walk to 5-14% is a worse error than the over-count the calibration exists to fix. * #4/#8 atomicity and concurrent overwrites: saves are serialised on one lock and the refit is split out as refitLiveStepGain, so a torn state — a run durable without the profile it implies — is repairable by re-deriving from the rows. Both sides are idempotent by key. * #6 stale load repopulates the cache: loads carry a generation and will not assign after an invalidation landed while they were in flight. * #7/#9 phone permission and staleness: the permission is requested BEFORE the routine (a refusal that blames the wearer's pocket for a grant never asked for is a wrong reason, which is worse than none), one PhonePedometer is reused instead of constructing 180 of them, and a final interval read is awaited before finishing. * #5 the prorated Android reference is NOT addressed here — it needs a different read path, and it is a real limitation of fitting a household gain from a 5-minute bucket. NOT REPORTED, FOUND HERE * kAlgoVersion was not bumped (AGENTS.md §3.4: 'without a bump nothing recomputes'). Now 114, with a changelog entry that states plainly what the bump can and cannot do: the gain is applied when a run is banked, so it changes the future and CANNOT repair windows already in live_coverage, because those rows keep no raw per-run count. * §4.7 verbatim — the capability was wired into one call path and I never enumerated the others. addLiveCoverage has three callers; all three are now listed at the banking site with the decision for each. * ARB placeholders were undeclared: 516 of 520 existing keys with a placeholder carry an @-block, and the screen hand-rolled .replaceAll instead of using the generated getter. Now declared and called properly. * The screen's refusal copy blamed the wearer's pocket for a missing permission. Verified against the Python mirror: 76/76. NOT verified: compilation — no Dart or Flutter toolchain was available, so flutter analyze has still never run, and Sourcery's 'does not compile' claim remains open. --- lib/compute/derivation_engine.dart | 22 ++++++- lib/compute/step_calibration_fit.dart | 84 +++++++++++++++++++++++++-- lib/data/db.dart | 33 ++++++++++- lib/l10n/app_en.arb | 26 ++++++++- lib/state/app_state.dart | 34 ++++++++++- lib/ui2/profile/step_calibration.dart | 73 ++++++++++++++++------- test/step_calibration_fit_test.dart | 54 +++++++++++++++-- 7 files changed, 289 insertions(+), 37 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index fba1cc9e6..0074ea712 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1810,7 +1810,27 @@ import 'vendor_sleep.dart'; // store `skin_temp_ambient_r` and the illness watch leaves out WHOOP 4 nights // whose skin temp tracks ambient (|r| >= 0.8, or unmeasured). One-shot, // raw-free: days derived before v106 drop naps overlapping the next night. -const int kAlgoVersion = 113; +// 113 → 114 (per-wearer step calibration, edge-only): the strap's live 100 Hz +// pedometer banks through the wearer's own calibrated gain, per activity family +// (slow / brisk / jog against the wearer's own counted walk; household against +// the phone). NO CALIBRATION ⇒ EVERY NUMBER IS UNCHANGED TO THE DIGIT, since an +// uncalibrated family resolves to `StepParams.gain` exactly as before. +// +// What this bump does and does NOT do — the honest half: +// * The gain is applied when a run is BANKED (`_bankGaitRuns`), so it changes +// walks banked from here on. It CANNOT retroactively alter windows already +// in `live_coverage`: those rows store the post-gain count and their raw +// per-run counts are not retained, so a later recalibration cannot repair +// them. Recalibrating changes the future, not the past. +// * The bump is still required, because the OUTPUT of any day that banks +// afterwards differs and a `day_result` is immutable per version. +// * It follows that a user who calibrates today sees today's and later days +// move; earlier days keep the steps they were derived with. Fixing that +// properly means storing the RAW count per coverage window and applying the +// gain on READ (the daily-sum layer `calcSteps` documents as the gain's +// home), which is a change to `live_coverage` and its readers, not to this +// feature. +const int kAlgoVersion = 114; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index f3b3d26d5..d3e2c8e84 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -229,6 +229,23 @@ enum StepRunRejection { /// 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, } /// Null when the run may be used to fit; otherwise why it may not. @@ -259,7 +276,7 @@ StepRunRejection? stepRunRejection(StepCalibrationRun r) { if (r.rawSteps > (r.expectedSteps * kGaitGainMax).ceil()) { return StepRunRejection.rawExceedsExpectedByTooMuch; } - return null; + return _ratioAdmissible(r); } // EVERYDAY. The band is EXPECTED to see far more than the phone — that is the @@ -270,6 +287,23 @@ StepRunRejection? stepRunRejection(StepCalibrationRun r) { 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; } @@ -557,8 +591,34 @@ StepCredit creditLiveSteps({ } final gait = gaitCategoryForCadence(cadenceSpm, runs); - // A gait window with an uncalibrated band falls through to everyday, whose - // gain is capped at 1.0 — so it can never inflate, only damp. + + // 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); @@ -797,7 +857,14 @@ class CalibrationSession { /// 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; + 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. /// @@ -813,6 +880,15 @@ class CalibrationSession { // 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 diff --git a/lib/data/db.dart b/lib/data/db.dart index 8d575176c..f3e525f61 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2309,11 +2309,40 @@ class LocalDb { 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. + final chained = _liveStepGainLock.then((_) async { + await putLiveStepCalibrationRun(run, runTs: runTs); + return refitLiveStepGain(run.category, now: now); + }); + // 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, }) async { - await putLiveStepCalibrationRun(run, runTs: runTs); final rows = await liveStepCalibrationRuns(); final profile = estimateLiveStepGain( - run.category, + category, rows, now: now ?? DateTime.now(), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 386b9c5e4..b8088d90a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15790,5 +15790,29 @@ "stepCalBadData": "That routine produced unreadable sensor data.", "stepCalSaveFailed": "Could not save that calibration.", "stepCalDoneGait": "Saved. You counted {count} steps and the band saw {band}. From now on this kind of walking is corrected by {pct}%.", - "stepCalDoneHousehold": "Saved. Your phone counted {count} steps of the {band} the band saw. Household movement is now scaled to {gain}." + "stepCalDoneHousehold": "Saved. Your phone counted {count} steps of the {band} 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/state/app_state.dart b/lib/state/app_state.dart index 483fff4b1..ccec0c11e 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3230,11 +3230,28 @@ class AppState extends ChangeNotifier { /// saved, or a walk that started before the save would bank with the old gain. List? _liveStepCalibration; - Future> _liveStepCalibrationRuns() async => - _liveStepCalibration ??= await LocalDb.liveStepCalibrationRuns(); + /// 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 { + final cached = _liveStepCalibration; + if (cached != null) return cached; + final generation = _liveStepCalibrationGeneration; + final rows = await LocalDb.liveStepCalibrationRuns(); + if (generation != _liveStepCalibrationGeneration) return rows; + return _liveStepCalibration = rows; + } /// Drop the cached fit so the next banked run uses a newly saved calibration. - void invalidateLiveStepCalibration() => _liveStepCalibration = null; + /// 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. @@ -3582,6 +3599,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. } } diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 057b45ab6..554f7de87 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -51,6 +51,11 @@ class StepCalibrationScreen extends StatefulWidget { 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; @@ -85,6 +90,14 @@ class _StepCalibrationScreenState extends State { final app = context.read(); _ticker?.cancel(); _routineStart = DateTime.now(); + if (category.referenceIsPhone) { + // Ask BEFORE the routine, not after. A three-minute household routine that + // is then refused because the phone was never granted step access has + // wasted the wearer's time, and its refusal message would blame their + // pocket for a permission they were never asked for — a wrong reason, + // which is worse than none. + unawaited(_ensurePhonePermission()); + } setState(() { _outcome = null; _session = CalibrationSession(category: category) @@ -114,11 +127,21 @@ class _StepCalibrationScreenState extends State { setState(() {}); } + Future _ensurePhonePermission() async { + try { + if (!await _pedometer.hasPermission()) { + await _pedometer.requestPermission(); + } + } catch (_) { + // No permission means no reference, which `finish()` reports as such. + } + } + Future _readPhone(CalibrationSession s) async { final start = _routineStart; if (start == null) return; try { - final steps = await PhonePedometer().stepsBetween(start, DateTime.now()); + final steps = await _pedometer.stepsBetween(start, DateTime.now()); // 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. @@ -141,6 +164,13 @@ class _StepCalibrationScreenState extends State { final s = _session; if (s == null) return; _ticker?.cancel(); + // 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) await _readPhone(s); + // §4.5: `context` may not be used after an await without this. + if (!mounted) return; final out = s.finish(); final l = AppLocalizations.of(context); @@ -221,22 +251,22 @@ class _StepCalibrationScreenState extends State { 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(); - final sign = pct >= 0 ? '+' : ''; if (run.category.isGait) { - return (l?.stepCalDoneGait ?? - 'Saved. You counted {count} steps and the band saw {band}. From ' - 'now on this kind of walking is corrected by {pct}%.') - .replaceAll('{count}', '${run.expectedSteps}') - .replaceAll('{band}', '${run.rawSteps}') - .replaceAll('{pct}', '$sign$pct'); + 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 ?? - 'Saved. Your phone counted {count} steps of the {band} the band ' - 'saw. Household movement is now scaled to {gain}.') - .replaceAll('{count}', '${run.expectedSteps}') - .replaceAll('{band}', '${run.rawSteps}') - .replaceAll('{gain}', profile.gain.toStringAsFixed(2)); + 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 @@ -298,9 +328,9 @@ class _StepCalibrationScreenState extends State { const SizedBox(height: S.x2), Center( child: Text( - (l?.stepCalBandSeen ?? 'steps the band has seen') - .replaceAll('{target}', - isHousehold ? '' : '${s.targetSteps}'), + // No placeholder: this label names the counter, it does not read the + // target. It used to substitute a `{target}` the string never had. + l?.stepCalBandSeen ?? 'steps the band has seen', style: F.cap.copyWith(color: p.ink2), ), ), @@ -318,9 +348,8 @@ class _StepCalibrationScreenState extends State { Text( isHousehold ? (l?.stepCalElapsed ?? 'Keep going — this one is timed.') - : (l?.stepCalCountYourself ?? - 'Count your own steps. Stop when you reach {target}.') - .replaceAll('{target}', '${s.targetSteps}'), + : (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), @@ -385,8 +414,8 @@ class _StepCalibrationScreenState extends State { String _subtitleFor(StepCategory category, AppLocalizations? l) { final profile = _profiles[category]; final state = profile?.isCalibrated ?? false - ? (l?.stepCalCalibrated ?? 'Calibrated — counted {n} times') - .replaceAll('{n}', '${profile!.nRuns}') + ? (l?.stepCalCalibrated(profile!.nRuns) ?? + 'Calibrated — counted ${profile!.nRuns} times') : (l?.stepCalNotCalibrated ?? 'Not calibrated yet'); return '$state · ${_guideShort(category, l)}'; } diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index d4a254e3d..cf43733f9 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -118,10 +118,21 @@ void main() { expect(fit.gain, greaterThan(0)); }); - test('the household gain is floored, never zero', () { - final fit = fittedGain(StepCategory.everyday, [_everyday(15, 400)]); - expect(fit!.gain, kEverydayGainMin); - 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', () { @@ -200,6 +211,22 @@ void main() { 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); @@ -371,6 +398,25 @@ void main() { 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); From 0388d2bdae78616b7c3c191480a73833312ed10a Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 22:10:45 +0100 Subject: [PATCH 10/33] chore(compute): make the three clamp result types explicit Sourcery flagged `ratio.clamp(...)` and `credited.clamp(...)` as not compiling, because `num.clamp` returns `num`. I believe that is a false positive: `lib/data/step_calibration.dart:122` returns `corrected.clamp(0, (rawTicks * kStepFactorMax).ceil())` straight out of an `int` function with no cast, in shipped, CI-green code. That only compiles if Dart applies its static special case for `clamp` when the receiver and both bounds are the same numeric type. But I cannot run the analyzer here to settle it, and the two possible readings have very different costs: if the special case applies, `.toDouble()` /`.toInt()` is a no-op; if it does not, this is a build failure in someone else's repo. The conversion is therefore free insurance rather than a claim about whose reading is right, and the comment beside it says exactly that. A third site (categoryBand's `half`) was not flagged and had the same shape. --- lib/compute/step_calibration_fit.dart | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index d3e2c8e84..5db283cad 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -424,7 +424,15 @@ double gainCeiling(StepCategory category) => // feature does not work. final ratio = expected / raw; return ( - gain: ratio.clamp(gainFloor(category), gainCeiling(category)), + // `.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, ); } @@ -490,7 +498,8 @@ StepCategoryBand? categoryBand( final median = n.isOdd ? cadences[n ~/ 2] : (cadences[n ~/ 2 - 1] + cadences[n ~/ 2]) / 2; final half = (median * kCategoryBandSpread) - .clamp(kCategoryBandMinWidthSpm / 2, kGaitCadenceMaxSpm); + .clamp(kCategoryBandMinWidthSpm / 2, kGaitCadenceMaxSpm) + .toDouble(); var lo = median - half; var hi = median + half; // Clamp to human gait without letting the clamp collapse the band. @@ -636,7 +645,8 @@ StepCredit creditLiveSteps({ // 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()), + creditedSteps: + credited.clamp(0, (rawSteps * kGaitGainMax).ceil()).toInt(), rawSteps: rawSteps, category: family, gainApplied: fit.gain, From 906f016d02b91992b92ea7d657d04e179f612b38 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 22:15:39 +0100 Subject: [PATCH 11/33] fix(steps): the two review findings that were only half done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOURCERY #9 — imported calibration runs stay invisible. I had DESCRIBED this in a docstring ('also called after a database import') and never wired it: the only caller of invalidateLiveStepCalibration was the calibration screen itself. A comment claiming a call that nothing makes is the exact failure this whole round was about. LocalDb.importFromDbFile has one caller, so the fix is one line in app_state.dart — and it matters most in the case the feature exists for: a new phone restoring the backup that holds the wearer's calibration. SOURCERY #7 — phone reads overwrite newer counts. The previous commit only added a final awaited read and a reused PhonePedometer; the out-of-order race itself was untouched. On Android a query can outlive the next tick, so an older interval landed last and replaced the newer count. Reads now carry a sequence and only the most recently STARTED one may write. Still open, and named as such on the PR: * #5, the prorated Android bucket — the household reference is estimated, which violates §3.3 on a value used as ground truth. Needs a different read path. * #4, atomicity — mitigated (one lock, repairable refit), not atomic. * #1, whether the clamp conversions were ever needed — still unverified. Verified against the Python mirror: 76/76. Still not compiled. --- lib/state/app_state.dart | 8 ++++++++ lib/ui2/profile/step_calibration.dart | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ccec0c11e..c1bec6f29 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -697,6 +697,14 @@ class AppState extends ChangeNotifier { // truncated backup fails loudly; `gzip.decoder` returns partial output // without raising and would restore short while reporting success. final counts = await LocalDb.importFromDbFile(path); + // 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; + // this line is what makes that true. + invalidateLiveStepCalibration(); lastImportArchiveSkipped = counts['_substrate_archive_skipped'] ?? 0; lastImportArchiveRestored = counts['substrate_archive'] ?? 0; if (lastImportArchiveSkipped > 0) { diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 554f7de87..5dcfc787e 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -137,11 +137,19 @@ class _StepCalibrationScreenState extends State { } } + /// 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. From e51cd1a5cfc928f69f6e15d5a52b0a3c3c402d02 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 22:28:37 +0100 Subject: [PATCH 12/33] =?UTF-8?q?fix(steps):=20CodeRabbit's=20real=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20a=20NULL=20turned=20back=20into=20a=20reading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finished its review after the Sourcery round and raised 14 findings, not 9. Ten are duplicates of things already addressed here; two are not. **NULL was being turned back into a reading.** live_step_calibration_run's p2p_g and confidence are nullable precisely so a routine whose caller never measured them stores an ABSENCE. The reader then did `as num?)?.toDouble() ?? 0` and converted that absence into 0.0 — a real still reading at the bottom of the scale, which is exactly the fabrication the nullable column exists to prevent. I made the WRITE side honest and left the READ side fabricating, in the same commit series that claimed 'unmeasured features stay absent, never a substituted zero'. cadence_spm keeps its ?? 0: it is NOT NULL, and 0 there means 'no cadence measurable', which is a different claim. **The doc comment was on the wrong getter.** liveStrapRawSteps was inserted between _liveRaw's doc block and _liveRaw's declaration, so _liveRaw's doc — and with it the sentence '(real, gain-applied)' — described the new getter, while _liveRaw had none. Both getters own their own doc now, and the false '(real, gain-applied)' is corrected in place rather than left standing, because the calibration reads that value and depends on it being raw. Also: the settings row said the calibration 'teaches the strap your own walking'. Nothing is ever written to the strap — the correction lives on the phone — so the copy said the opposite of the feature's most important property. Verified against the Python mirror: 76/76. Still not compiled. --- lib/data/db.dart | 10 ++++++++-- lib/l10n/app_en.arb | 2 +- lib/state/app_state.dart | 24 +++++++++++++++--------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index f3e525f61..9f435fece 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2256,8 +2256,14 @@ class LocalDb { expectedSteps: (r['expected_steps'] as num?)?.toInt() ?? 0, rawSteps: (r['raw_steps'] as num?)?.toInt() ?? 0, cadenceSpm: (r['cadence_spm'] as num?)?.toDouble() ?? 0, - peakToPeakG: (r['p2p_g'] as num?)?.toDouble() ?? 0, - confidence: (r['confidence'] 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; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8088d90a..42506931a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15757,7 +15757,7 @@ } }, "settingsStepCalibration": "Step calibration", - "settingsStepCalibrationSub": "Teaches the strap your own walking, so its step count fits you. Nothing leaves the device", + "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", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index c1bec6f29..06d76cdf4 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3294,8 +3294,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 @@ -3306,6 +3312,13 @@ class AppState extends ChangeNotifier { /// first measurement lands. It is never banked (the commit path measures its /// own chunk, below). Measure the partial too only if a rate that low is ever /// seen on real hardware. + int get _liveRaw { + if (_magMin.isEmpty) return _committedRaw; + final hz = _liveHz; + if (hz != null && hz < kMinLiveSampleRateHz) return _committedRaw; + 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. /// @@ -3321,13 +3334,6 @@ class AppState extends ChangeNotifier { /// The value is exposed under a name that matches what it actually is. int get liveStrapRawSteps => _liveRaw; - int get _liveRaw { - if (_magMin.isEmpty) return _committedRaw; - final hz = _liveHz; - if (hz != null && hz < kMinLiveSampleRateHz) return _committedRaw; - return _committedRaw + ana.pedometer(_magMin); - } - // ── 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 From 8555b50de5d6575d07c15ff8cfb47a607808a1d8 Mon Sep 17 00:00:00 2001 From: Minis Agent Date: Mon, 26 Oct 2026 22:31:39 +0100 Subject: [PATCH 13/33] fix(steps): close the remaining CodeRabbit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * resetAllData calls LocalDb.wipeAll, which empties live_step_calibration — and left the cached fit holding the rows it just deleted. A walk banked after a data reset would still be corrected by the pre-reset gain. Same omission as the import path, same one line. * The household routine now STOPS when phone step access is denied, instead of running for three minutes towards a refusal it cannot avoid. The wearer finds out while standing there, and the message names the real cause rather than their pocket. * _finish locks the screen BEFORE the awaited final phone read. The read leaves the buttons live, and a second tap would finish an already-finished session. _busy is released again on both early returns. * Copy: 'the band saw {band}' now says 'the band saw {band} steps'. Left as is, deliberately: the counter-reset case. observe() floors a reading below the baseline to the baseline, which makes the routine void rather than crediting a negative count — the conservative outcome. Detecting the reset and splitting the routine around it would mean deciding which side of the break the steps belong to, and the pre-start side is a walk the wearer did not calibrate. Voiding is the answer that cannot produce a wrong gain. Verified against the Python mirror: 76/76. Still not compiled. --- lib/l10n/app_en.arb | 5 +++-- lib/state/app_state.dart | 4 ++++ lib/ui2/profile/step_calibration.dart | 31 +++++++++++++++++++++------ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 42506931a..06de91de4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15782,6 +15782,7 @@ "stepCalKeepWalking": "Nothing recorded yet \u2014 start walking with the strap snug.", "stepCalKeepMoving": "Nothing recorded yet \u2014 keep the phone on you and keep moving.", "stepCalNoSignal": "The band recorded nothing during that routine.", + "stepCalNoPhoneAccess": "This routine needs your phone\u2019s step counter, and access was not granted. That counter is the reference for household movement.", "stepCalNoPhone": "Your phone counted no steps while you did that. Keep it in your pocket next time \u2014 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.", @@ -15789,8 +15790,8 @@ "stepCalWrongSource": "That routine used the wrong reference.", "stepCalBadData": "That routine produced unreadable sensor data.", "stepCalSaveFailed": "Could not save that calibration.", - "stepCalDoneGait": "Saved. You counted {count} steps and the band saw {band}. From now on this kind of walking is corrected by {pct}%.", - "stepCalDoneHousehold": "Saved. Your phone counted {count} steps of the {band} the band saw. Household movement is now scaled to {gain}.", + "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"}} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 06d76cdf4..c45d32dcb 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1304,6 +1304,10 @@ class AppState extends ChangeNotifier { // 2 · every row in every table (see LocalDb.wipeAll for why it is not a // hand-written table list, and for the sync_cursor decision). 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 — the + // same omission as the import path, and the same one line. + invalidateLiveStepCalibration(); // Surfaces outside the database that were still showing it. await NotificationService.instance.cancelAll(); diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 5dcfc787e..ea436decd 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -128,13 +128,27 @@ class _StepCalibrationScreenState extends State { } Future _ensurePhonePermission() async { + var granted = false; try { - if (!await _pedometer.hasPermission()) { - await _pedometer.requestPermission(); - } + granted = await _pedometer.hasPermission() || + await _pedometer.requestPermission(); } catch (_) { - // No permission means no reference, which `finish()` reports as such. + granted = false; } + if (!mounted || granted) return; + // STOP the routine rather than let it run to its refusal. Without access the + // phone reference can never arrive, so three minutes of tidying could only + // end in a refusal — and the wearer finds out now, standing there, instead of + // afterwards. + _ticker?.cancel(); + final l = AppLocalizations.of(context); + setState(() { + _session = null; + _routineStart = null; + _outcome = l?.stepCalNoPhoneAccess ?? + 'This routine needs your phone\u2019s step counter, and access was ' + 'not granted. It is the reference for household movement.'; + }); } /// Sequence of phone reads started. Only the MOST RECENTLY STARTED read may @@ -170,8 +184,12 @@ class _StepCalibrationScreenState extends State { Future _finish() async { final s = _session; - if (s == null) return; + // `_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; _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 @@ -184,6 +202,7 @@ class _StepCalibrationScreenState extends State { if (out.bandSilent) { setState(() { + _busy = false; _session = null; _outcome = l?.stepCalNoSignal ?? 'The band recorded nothing during that routine.'; }); @@ -191,6 +210,7 @@ class _StepCalibrationScreenState extends State { } if (out.refusal != null) { setState(() { + _busy = false; _session = null; _outcome = _describeRefusal(out.refusal!, l); }); @@ -198,7 +218,6 @@ class _StepCalibrationScreenState extends State { } final app = context.read(); - setState(() => _busy = true); try { final profile = await LocalDb.bankLiveStepCalibrationRun( out.run!, From 63a2dbf15fdf9c95aa7d74c22c5a06200a1fde48 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 01:19:45 +0530 Subject: [PATCH 14/33] make the calibration screen compile and pass the ui checks the refusal switch did not cover the two refusals added later, which did not compile. both now have strings, every new string has a russian translation, the screen uses the radius and tick tokens, and the gallery test scrolls to its row now that settings is longer. --- lib/l10n/app_en.arb | 2 + lib/l10n/app_ru.arb | 78 +++++++++++++++++++++++++++ lib/ui2/profile/step_calibration.dart | 18 +++++-- test/ui2_gallery_test.dart | 2 + test/ui2_tokens_test.dart | 3 ++ 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 06de91de4..e1016ab83 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15789,6 +15789,8 @@ "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\u2019s are too far apart to use. Repeat the routine and walk the full count.", + "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}.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7cee376be..dc1998cfc 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7515,5 +7515,83 @@ "type": "String" } } + }, + "settingsStepCalibration": "Калибровка шагов", + "settingsStepCalibrationSub": "Узнаёт, насколько браслет завышает или занижает ваши шаги, и исправляет это на телефоне. На браслет ничего не записывается, и ничего не покидает устройство", + "stepCalTitle": "Калибровка шагов", + "stepCalIntro": "Браслет считает шаги по собственному датчику движения, и его погрешность зависит от вас. Эти упражнения её измеряют. Ничего не покидает ваш телефон.", + "stepCalSlowTitle": "Медленная ходьба", + "stepCalSlowSub": "идите медленно, телефон в кармане", + "stepCalSlowGuide": "Идите медленно и ровно, браслет плотно на руке. Сами отсчитайте 100 шагов.", + "stepCalBriskTitle": "Быстрая ходьба", + "stepCalBriskSub": "идите в обычном темпе", + "stepCalBriskGuide": "Идите в обычном темпе. Сами отсчитайте 100 шагов.", + "stepCalJogTitle": "Бег трусцой", + "stepCalJogSub": "бегите ровно", + "stepCalJogGuide": "Бегите ровно. Сами отсчитайте 100 шагов.", + "stepCalEverydayTitle": "Дома", + "stepCalEverydaySub": "три минуты наводите порядок, телефон в кармане", + "stepCalEverydayGuide": "Занимайтесь обычными домашними делами — носите, поднимайте, убирайте — с телефоном в кармане. Три минуты. Не ходите туда-сюда специально.", + "stepCalCalibrated": "Откалибровано — замеров: {n}", + "@stepCalCalibrated": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "stepCalNotCalibrated": "Ещё не откалибровано", + "stepCalBandSeen": "шагов зафиксировал браслет", + "stepCalCountYourself": "Считайте шаги сами. Остановитесь на {target}.", + "@stepCalCountYourself": { + "placeholders": { + "target": { + "type": "int" + } + } + }, + "stepCalElapsed": "Продолжайте — это упражнение на время.", + "stepCalFinish": "Готово", + "stepCalCancel": "Отмена", + "stepCalKeepWalking": "Пока ничего не записано — начните идти, браслет плотно на руке.", + "stepCalKeepMoving": "Пока ничего не записано — держите телефон при себе и продолжайте двигаться.", + "stepCalNoSignal": "За это упражнение браслет ничего не записал.", + "stepCalNoPhoneAccess": "Для этого упражнения нужен шагомер телефона, а доступ к нему не предоставлен. Он служит эталоном для домашней активности.", + "stepCalNoPhone": "Телефон не насчитал ни одного шага. В следующий раз держите его в кармане — он служит эталоном для домашней активности.", + "stepCalBandQuiet": "Браслет почти не заметил движения. Проверьте, что он плотно сидит на руке и передаёт данные.", + "stepCalTooShort": "Упражнение было слишком коротким для сравнения. Пройдите весь счёт.", + "stepCalMismatch": "Браслет насчитал намного больше, чем вы. Он улавливал что-то кроме ходьбы.", + "stepCalWrongSource": "В этом упражнении использован не тот эталон.", + "stepCalBadData": "Упражнение дало нечитаемые данные датчиков.", + "stepCalRatioOutOfRange": "Ваш счёт и счёт браслета слишком расходятся, чтобы их использовать. Повторите упражнение и пройдите весь счёт.", + "stepCalRoutineTooShort": "Упражнение закончилось раньше таймера. Продолжайте, пока он не истечёт.", + "stepCalSaveFailed": "Не удалось сохранить калибровку.", + "stepCalDoneGait": "Сохранено. Вы насчитали {count} шагов, браслет — {band}. Теперь такая ходьба корректируется на {pct}%.", + "@stepCalDoneGait": { + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "pct": { + "type": "int" + } + } + }, + "stepCalDoneHousehold": "Сохранено. Телефон насчитал {count} шагов из {band}, которые зафиксировал браслет. Домашняя активность теперь масштабируется с коэффициентом {gain}.", + "@stepCalDoneHousehold": { + "placeholders": { + "count": { + "type": "int" + }, + "band": { + "type": "int" + }, + "gain": { + "type": "String" + } + } } } diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index ea436decd..b68fde5b9 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -109,7 +109,7 @@ class _StepCalibrationScreenState extends State { nowMs: DateTime.now().millisecondsSinceEpoch, ); }); - _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); + _ticker = Timer.periodic(Motion.tick, (_) => _tick()); } void _tick() { @@ -240,8 +240,8 @@ class _StepCalibrationScreenState extends State { _busy = false; _session = null; _routineStart = null; - _outcome = (l?.stepCalSaveFailed ?? 'Could not save that calibration.') + - ' ($e)'; + _outcome = + '${l?.stepCalSaveFailed ?? 'Could not save that calibration.'} ($e)'; }); } } @@ -271,6 +271,14 @@ class _StepCalibrationScreenState extends State { 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.routineTooShort => + l?.stepCalRoutineTooShort ?? + 'That routine ended before the timer finished. Keep going until ' + 'it completes.', }; String _describeOutcome( @@ -363,7 +371,7 @@ class _StepCalibrationScreenState extends State { ), const SizedBox(height: S.x4), ClipRRect( - borderRadius: BorderRadius.circular(4), + borderRadius: R.rSm, child: LinearProgressIndicator( value: s.progress, minHeight: 6, @@ -417,7 +425,7 @@ class _StepCalibrationScreenState extends State { padding: const EdgeInsets.all(S.x4), decoration: BoxDecoration( color: p.ink3.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), + borderRadius: R.rMd, ), child: Text(text, style: F.cap.copyWith(color: p.ink, height: 1.5)), ); 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 From 04750c8327091282410ff63ff11de00841b3888e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 01:19:45 +0530 Subject: [PATCH 15/33] save calibration atomically and refit it after a restore a run could be saved while the refit or the profile write after it failed, so the screen reported a failure that later fits quietly used. a restore merged the backup's runs and profiles without refitting, so the stored gain no longer matched the runs. a calibration read that was invalidated while in flight now reads again instead of returning the old rows, and the new tables are classified for issue reports. --- lib/data/db.dart | 52 +++++++++++++++--- lib/data/recent_slice.dart | 3 + lib/state/app_state.dart | 23 ++++---- test/step_calibration_store_test.dart | 79 +++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 test/step_calibration_store_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 9f435fece..49777d33a 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2231,8 +2231,10 @@ class LocalDb { /// 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() async { - final db = await instance; + static Future> liveStepCalibrationRuns({ + DatabaseExecutor? exec, + }) async { + final db = exec ?? await instance; final rows = await db.query( 'live_step_calibration_run', orderBy: 'run_ts DESC', @@ -2278,8 +2280,9 @@ class LocalDb { static Future putLiveStepCalibrationRun( StepCalibrationRun r, { required int runTs, + DatabaseExecutor? exec, }) async { - final db = await instance; + final db = exec ?? await instance; await db.insert( 'live_step_calibration_run', { @@ -2322,9 +2325,15 @@ class LocalDb { // 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 { - await putLiveStepCalibrationRun(run, runTs: runTs); - return refitLiveStepGain(run.category, now: now); + 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. @@ -2345,17 +2354,39 @@ class LocalDb { static Future refitLiveStepGain( StepCategory category, { DateTime? now, + DatabaseExecutor? exec, }) async { - final rows = await liveStepCalibrationRuns(); + final rows = await liveStepCalibrationRuns(exec: exec); final profile = estimateLiveStepGain( category, rows, now: now ?? DateTime.now(), ); - await putLiveStepGainProfile(profile); + 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. @@ -2388,8 +2419,11 @@ class LocalDb { ); } - static Future putLiveStepGainProfile(LiveStepGainProfile p) async { - final db = await instance; + static Future putLiveStepGainProfile( + LiveStepGainProfile p, { + DatabaseExecutor? exec, + }) async { + final db = exec ?? await instance; await db.insert( 'live_step_calibration', { diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index 2b4ab7bf1..219b9b22d 100644 --- a/lib/data/recent_slice.dart +++ b/lib/data/recent_slice.dart @@ -113,6 +113,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/state/app_state.dart b/lib/state/app_state.dart index c45d32dcb..b1a9e827e 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -697,6 +697,7 @@ class AppState extends ChangeNotifier { // truncated backup fails loudly; `gzip.decoder` returns partial output // without raising and would restore short while reporting success. final counts = await LocalDb.importFromDbFile(path); + await LocalDb.refitAllLiveStepGains(); // 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 @@ -3249,12 +3250,17 @@ class AppState extends ChangeNotifier { int _liveStepCalibrationGeneration = 0; Future> _liveStepCalibrationRuns() async { - final cached = _liveStepCalibration; - if (cached != null) return cached; - final generation = _liveStepCalibrationGeneration; - final rows = await LocalDb.liveStepCalibrationRuns(); - if (generation != _liveStepCalibrationGeneration) return rows; - return _liveStepCalibration = rows; + 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. @@ -3331,11 +3337,6 @@ class AppState extends ChangeNotifier { /// 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. - /// - /// [_liveRaw]'s own docstring calls its value "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". - /// The value is exposed under a name that matches what it actually is. int get liveStrapRawSteps => _liveRaw; // ── the two safety gates on this tier — see live_step_runs.dart ──────────── 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); + }); +} From ecc45aa61eeecb5bb34c8de5774bd583525810f4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 01:19:46 +0530 Subject: [PATCH 16/33] put the permission docs back on requestPermission stepsBetween had been inserted between requestPermission and its doc comment. --- lib/health/phone_pedometer.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index 5a80ce488..e747bfbd8 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -83,15 +83,6 @@ class PhonePedometer { } } - /// 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 - /// permission the app never asks for is not listed in Settings at all — - /// CoreMotion has no explicit request API, so the native side raises the - /// prompt by issuing a one-minute query. On Android this is the runtime - /// ACTIVITY_RECOGNITION request and it also arms the sensor listener. If this - /// method stops being reached, the feature looks broken with nothing for the - /// user to fix; `arming reaches the platform` in the tests pins that. /// The motion coprocessor's own step count between two instants, or null when /// the interval is not covered. /// @@ -111,6 +102,15 @@ class PhonePedometer { } } + /// 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 + /// permission the app never asks for is not listed in Settings at all — + /// CoreMotion has no explicit request API, so the native side raises the + /// prompt by issuing a one-minute query. On Android this is the runtime + /// ACTIVITY_RECOGNITION request and it also arms the sensor listener. If this + /// method stops being reached, the feature looks broken with nothing for the + /// user to fix; `arming reaches the platform` in the tests pins that. Future requestPermission() async { try { return await phoneStepsChannel.invokeMethod('requestPermission') ?? From 5065865b64c19c594f2d382fa40b82645d29e6b8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 01:19:46 +0530 Subject: [PATCH 17/33] take kAlgoVersion 118 for step calibration 114 to 117 are claimed by other open changes. the freeze goldens differ only in algo_version. --- lib/compute/derivation_engine.dart | 7 ++++--- test/fixtures/whoop_freeze/gen4_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_utc.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_utc.json | 18 +++++++++--------- 7 files changed, 58 insertions(+), 57 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 0074ea712..17aad106c 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1810,8 +1810,9 @@ import 'vendor_sleep.dart'; // store `skin_temp_ambient_r` and the illness watch leaves out WHOOP 4 nights // whose skin temp tracks ambient (|r| >= 0.8, or unmeasured). One-shot, // raw-free: days derived before v106 drop naps overlapping the next night. -// 113 → 114 (per-wearer step calibration, edge-only): the strap's live 100 Hz -// pedometer banks through the wearer's own calibrated gain, per activity family +// → 118 (per-wearer step calibration, edge-only; 114-117 are claimed by other +// open changes, and this has to sort above all of them): the strap's live +// 100 Hz pedometer banks through the wearer's own calibrated gain, per activity family // (slow / brisk / jog against the wearer's own counted walk; household against // the phone). NO CALIBRATION ⇒ EVERY NUMBER IS UNCHANGED TO THE DIGIT, since an // uncalibrated family resolves to `StepParams.gain` exactly as before. @@ -1830,7 +1831,7 @@ import 'vendor_sleep.dart'; // gain on READ (the daily-sum layer `calcSteps` documents as the gain's // home), which is a change to `live_coverage` and its readers, not to this // feature. -const int kAlgoVersion = 114; +const int kAlgoVersion = 118; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 82c4b8122..739da00b1 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 071bd2e28..70b2617fc 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 985e1bd8f..4892e0904 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index 6c4a2008d..8fdc58248 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index 3109bf0c1..a5b7b28d4 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index 781e9ee03..c74e8bb93 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 118, "payload_json": { "day_id": "2026-07-15", "active_min": 40, From ae91fcdbf931951c693305776ee5136e4e34051a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 18:53:31 +0530 Subject: [PATCH 18/33] ask what the wearer counted, and flush android's step batch around a routine done is live from the first band step, so a wearer who stopped at 60 was fitted as if they had walked 100. android delivers step counts up to a minute late, so the household reference missed the end of a three-minute routine. --- .../openstrap_edge/PhoneStepCounter.kt | 6 +++ lib/compute/step_calibration_fit.dart | 11 +++- lib/health/phone_pedometer.dart | 9 ++++ lib/l10n/app_en.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/ui2/profile/step_calibration.dart | 54 ++++++++++++++++++- 6 files changed, 78 insertions(+), 4 deletions(-) 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..acc28377f 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 @@ -111,6 +111,12 @@ 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. + "flush" -> { + val sm = app.getSystemService(Context.SENSOR_SERVICE) as SensorManager + result.success(registered && sm.flush(this)) + } "stepsInInterval" -> { val from = call.argument("fromMs")?.toLong() val to = call.argument("toMs")?.toLong() diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index 5db283cad..ea8eed4a5 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -882,8 +882,13 @@ class CalibrationSession { /// "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() { + finish({int? countedSteps}) { _finished = true; if (!_started || bandSeconds <= 0 || bandRawSteps <= 0) { // No usable band signal at all. Not a [StepRunRejection] — that enum @@ -908,7 +913,9 @@ class CalibrationSession { // 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) : targetSteps, + expectedSteps: category.referenceIsPhone + ? (_phoneSteps ?? 0) + : (countedSteps ?? targetSteps), rawSteps: bandRawSteps, cadenceSpm: bandCadenceSpm, // Passed THROUGH, never defaulted: null here means the caller did not diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index e747bfbd8..29cd15cb3 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -102,6 +102,15 @@ class PhonePedometer { } } + /// Asks the step sensor to deliver what it has batched (Android holds counts + /// for up to a minute before delivering them). A no-op where the platform + /// has nothing to flush. Callers wait a moment afterwards before reading. + Future flushPending() async { + try { + await phoneStepsChannel.invokeMethod('flush'); + } catch (_) {} + } + /// 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 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e1016ab83..dddf47345 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15777,6 +15777,7 @@ "stepCalBandSeen": "steps the band has seen", "stepCalCountYourself": "Count your own steps. Stop when you reach {target}.", "stepCalElapsed": "Keep going \u2014 this one is timed.", + "stepCalHowManyCounted": "How many steps did you count?", "stepCalFinish": "Done", "stepCalCancel": "Cancel", "stepCalKeepWalking": "Nothing recorded yet \u2014 start walking with the strap snug.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index dc1998cfc..df82d15f6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7551,6 +7551,7 @@ } }, "stepCalElapsed": "Продолжайте — это упражнение на время.", + "stepCalHowManyCounted": "Сколько шагов вы насчитали?", "stepCalFinish": "Готово", "stepCalCancel": "Отмена", "stepCalKeepWalking": "Пока ничего не записано — начните идти, браслет плотно на руке.", diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index b68fde5b9..31efe7f17 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -30,6 +30,7 @@ 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'; @@ -97,6 +98,9 @@ class _StepCalibrationScreenState extends State { // pocket for a permission they were never asked for — a wrong reason, // which is worse than none. unawaited(_ensurePhonePermission()); + // Deliver whatever the step sensor has batched, so steps taken before + // the routine land before its window opens. + unawaited(_pedometer.flushPending()); } setState(() { _outcome = null; @@ -182,11 +186,49 @@ class _StepCalibrationScreenState extends State { }); } + 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) { + counted = await _askCounted(s.targetSteps); + if (counted == null || !mounted) return; + } _ticker?.cancel(); // Locked BEFORE the read, not after it. setState(() => _busy = true); @@ -194,10 +236,18 @@ class _StepCalibrationScreenState extends State { // 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) await _readPhone(s); + 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. + await _pedometer.flushPending(); + if (defaultTargetPlatform == TargetPlatform.android) { + await Future.delayed(const Duration(milliseconds: 1500)); + } + await _readPhone(s); + } // §4.5: `context` may not be used after an await without this. if (!mounted) return; - final out = s.finish(); + final out = s.finish(countedSteps: counted); final l = AppLocalizations.of(context); if (out.bandSilent) { From ffe8e3bfff29c5e507e2d3bcb0cb51b695b43304 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 19:20:57 +0530 Subject: [PATCH 19/33] wait for the flushed step batch in the pedometer, and pin the early-stop fit --- lib/health/phone_pedometer.dart | 11 ++++++++--- lib/ui2/profile/step_calibration.dart | 6 +----- test/step_calibration_fit_test.dart | 12 ++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index 38e5c0ee5..bbdb8d711 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -104,11 +104,16 @@ class PhonePedometer { /// Asks the step sensor to deliver what it has batched (Android holds counts /// for up to a minute before delivering them). A no-op where the platform - /// has nothing to flush. Callers wait a moment afterwards before reading. - Future flushPending() async { + /// has nothing to flush. With [settle], waits for the flushed batch to be + /// delivered and banked before returning, so a read right after sees it. + Future flushPending({bool settle = false}) async { + bool? flushed; try { - await phoneStepsChannel.invokeMethod('flush'); + flushed = await phoneStepsChannel.invokeMethod('flush'); } catch (_) {} + if (settle && flushed == true) { + await Future.delayed(const Duration(milliseconds: 1500)); + } } /// Ask for access to the phone's step sensor. Safe to call repeatedly. diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 31efe7f17..cfe0a6168 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -30,7 +30,6 @@ 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'; @@ -239,10 +238,7 @@ class _StepCalibrationScreenState extends State { 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. - await _pedometer.flushPending(); - if (defaultTargetPlatform == TargetPlatform.android) { - await Future.delayed(const Duration(milliseconds: 1500)); - } + await _pedometer.flushPending(settle: true); await _readPhone(s); } // §4.5: `context` may not be used after an await without this. diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index cf43733f9..02d3fa767 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -343,6 +343,18 @@ void main() { 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('only the ROUTINE is measured, not the whole session', () { final s = CalibrationSession(category: StepCategory.brisk) ..start(bandBaseline: 500, nowMs: t0) From 93459e0c298eead1b635b08b25d2b6981dbfa046 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 19:36:42 +0530 Subject: [PATCH 20/33] drop the cached calibration after an import even if the refit fails --- lib/state/app_state.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index b8a69566b..4ce0a1180 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -736,15 +736,20 @@ class AppState extends ChangeNotifier { // truncated backup fails loudly; `gzip.decoder` returns partial output // without raising and would restore short while reporting success. final counts = await LocalDb.importFromDbFile(path); - await LocalDb.refitAllLiveStepGains(); + // Invalidated whether or not the refit below succeeds: the merged runs are + // already in the table, and the cache holds runs, not fitted profiles. + try { + 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; - // this line is what makes that true. - invalidateLiveStepCalibration(); + // the `finally` above is what makes that true. lastImportArchiveSkipped = counts['_substrate_archive_skipped'] ?? 0; lastImportArchiveRestored = counts['substrate_archive'] ?? 0; if (lastImportArchiveSkipped > 0) { From 0488d8a9ac9863c02b5bb4e53bcb0f53c530feb9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 19:37:48 +0530 Subject: [PATCH 21/33] refuse a gait routine whose cadence is outside the walking range such a run was saved and counted toward the fit but could never give the family a band to own its walks. --- lib/compute/step_calibration_fit.dart | 9 +++++++++ lib/l10n/app_en.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/ui2/profile/step_calibration.dart | 4 ++++ test/step_calibration_fit_test.dart | 14 ++++++++++---- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index ea8eed4a5..369df0400 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -246,6 +246,12 @@ enum StepRunRejection { /// 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. @@ -276,6 +282,9 @@ StepRunRejection? stepRunRejection(StepCalibrationRun r) { if (r.rawSteps > (r.expectedSteps * kGaitGainMax).ceil()) { return StepRunRejection.rawExceedsExpectedByTooMuch; } + if (r.cadenceSpm < kGaitCadenceMinSpm || r.cadenceSpm > kGaitCadenceMaxSpm) { + return StepRunRejection.cadenceOutsideGaitRange; + } return _ratioAdmissible(r); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fce1d8645..454bf1bef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16147,6 +16147,7 @@ "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}%.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 238ca55a5..840ebf8c9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7795,6 +7795,7 @@ "stepCalWrongSource": "В этом упражнении использован не тот эталон.", "stepCalBadData": "Упражнение дало нечитаемые данные датчиков.", "stepCalRatioOutOfRange": "Ваш счёт и счёт браслета слишком расходятся, чтобы их использовать. Повторите упражнение и пройдите весь счёт.", + "stepCalCadenceOutOfRange": "Браслет не распознал в этом упражнении ритм ходьбы. Начинайте идти сразу после старта и держите ровный темп.", "stepCalRoutineTooShort": "Упражнение закончилось раньше таймера. Продолжайте, пока он не истечёт.", "stepCalSaveFailed": "Не удалось сохранить калибровку.", "stepCalDoneGait": "Сохранено. Вы насчитали {count} шагов, браслет — {band}. Теперь такая ходьба корректируется на {pct}%.", diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index cfe0a6168..9c20de9df 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -321,6 +321,10 @@ class _StepCalibrationScreenState extends State { 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 ' diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index 02d3fa767..07a1894ad 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -470,13 +470,19 @@ void main() { expect(s.finish().refusal, StepRunRejection.nonFiniteFeature); }); - test('a run that did not walk defines no gait band', () { + 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 r = fast.finish().run!; - expect(stepRunRejection(r), isNull); - expect(categoryBand(StepCategory.brisk, [r]), isNull); + 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); }); }); } From 65f953e0b3dccf1ae03722a601d9729e541e825a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 19:39:13 +0530 Subject: [PATCH 22/33] take the household reference from the step sensor's own total on android a range query prorates five-minute bins, so a three-minute routine could pick up steps from before it or miss its own. the reference is now the running total at the end minus at the start, each after a flush. a failed final read refuses the routine instead of keeping an earlier partial count. --- .../openstrap_edge/PhoneStepCounter.kt | 7 +++ lib/compute/step_calibration_fit.dart | 7 +++ lib/health/phone_pedometer.dart | 11 +++++ lib/ui2/profile/step_calibration.dart | 43 ++++++++++++++++-- test/phone_pedometer_raw_count_test.dart | 44 +++++++++++++++++++ test/step_calibration_fit_test.dart | 13 ++++++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 test/phone_pedometer_raw_count_test.dart 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 acc28377f..fd593e99d 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 @@ -117,6 +117,13 @@ object PhoneStepCounter : SensorEventListener { val sm = app.getSystemService(Context.SENSOR_SERVICE) as SensorManager result.success(registered && sm.flush(this)) } + // 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() diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index 369df0400..f8162b4b3 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -856,6 +856,13 @@ class CalibrationSession { _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 diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index bbdb8d711..289bd881c 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -116,6 +116,17 @@ class PhonePedometer { } } + /// 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 diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 9c20de9df..34414d1f0 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -60,6 +60,10 @@ class _StepCalibrationScreenState extends State { 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. @@ -90,6 +94,7 @@ class _StepCalibrationScreenState extends State { final app = context.read(); _ticker?.cancel(); _routineStart = DateTime.now(); + _startRaw = null; if (category.referenceIsPhone) { // Ask BEFORE the routine, not after. A three-minute household routine that // is then refused because the phone was never granted step access has @@ -98,8 +103,10 @@ class _StepCalibrationScreenState extends State { // which is worse than none. unawaited(_ensurePhonePermission()); // Deliver whatever the step sensor has batched, so steps taken before - // the routine land before its window opens. - unawaited(_pedometer.flushPending()); + // the routine land before its window opens, then note the running total. + _startRaw = _pedometer + .flushPending(settle: true) + .then((_) => _pedometer.rawCount()); } setState(() { _outcome = null; @@ -176,6 +183,35 @@ class _StepCalibrationScreenState extends State { } } + /// 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 startRaw = await _startRaw; + await _pedometer.flushPending(settle: true); + if (startRaw != null) { + final endRaw = await _pedometer.rawCount(); + if (endRaw == null || endRaw < startRaw) return false; + s.setPhoneSteps(endRaw - startRaw); + return true; + } + final start = _routineStart; + if (start == null) return false; + try { + final steps = await _pedometer.stepsBetween(start, DateTime.now()); + if (steps == null || steps < 0) return false; + s.setPhoneSteps(steps); + return true; + } catch (_) { + return false; + } + } + void _cancel() { _ticker?.cancel(); setState(() { @@ -238,8 +274,7 @@ class _StepCalibrationScreenState extends State { 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. - await _pedometer.flushPending(settle: true); - await _readPhone(s); + if (!await _finalPhoneRead(s)) s.clearPhoneSteps(); } // §4.5: `context` may not be used after an await without this. if (!mounted) return; diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart new file mode 100644 index 000000000..22510b28b --- /dev/null +++ b/test/phone_pedometer_raw_count_test.dart @@ -0,0 +1,44 @@ +// 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 '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 neither throws nor waits. + final sw = Stopwatch()..start(); + await PhonePedometer().flushPending(settle: true); + expect(sw.elapsedMilliseconds, lessThan(1000)); + }); + + 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']); + }); +} diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index 07a1894ad..791a47ae7 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -388,6 +388,19 @@ void main() { 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) From 25cda128d4b7776ca5e93024ab535abe78be91f2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 19:40:08 +0530 Subject: [PATCH 23/33] inflect the calibration counts in russian and keep the gait result neutral --- lib/l10n/app_en.arb | 9 ++++++- lib/l10n/app_ru.arb | 15 +++++++++--- lib/ui2/profile/step_calibration.dart | 6 ++--- test/step_calibration_ru_plural_test.dart | 30 +++++++++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 test/step_calibration_ru_plural_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 454bf1bef..d97c8b7d6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16130,7 +16130,14 @@ "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": "steps the band has seen", + "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?", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 840ebf8c9..a4482bfd2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7762,7 +7762,7 @@ "stepCalEverydayTitle": "Дома", "stepCalEverydaySub": "три минуты наводите порядок, телефон в кармане", "stepCalEverydayGuide": "Занимайтесь обычными домашними делами — носите, поднимайте, убирайте — с телефоном в кармане. Три минуты. Не ходите туда-сюда специально.", - "stepCalCalibrated": "Откалибровано — замеров: {n}", + "stepCalCalibrated": "Откалибровано — {n, plural, one{{n} замер} few{{n} замера} many{{n} замеров} other{{n} замера}}", "@stepCalCalibrated": { "placeholders": { "n": { @@ -7771,7 +7771,14 @@ } }, "stepCalNotCalibrated": "Ещё не откалибровано", - "stepCalBandSeen": "шагов зафиксировал браслет", + "stepCalBandSeen": "{count, plural, one{шаг зафиксировал браслет} few{шага зафиксировал браслет} many{шагов зафиксировал браслет} other{шага зафиксировал браслет}}", + "@stepCalBandSeen": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "stepCalCountYourself": "Считайте шаги сами. Остановитесь на {target}.", "@stepCalCountYourself": { "placeholders": { @@ -7798,7 +7805,7 @@ "stepCalCadenceOutOfRange": "Браслет не распознал в этом упражнении ритм ходьбы. Начинайте идти сразу после старта и держите ровный темп.", "stepCalRoutineTooShort": "Упражнение закончилось раньше таймера. Продолжайте, пока он не истечёт.", "stepCalSaveFailed": "Не удалось сохранить калибровку.", - "stepCalDoneGait": "Сохранено. Вы насчитали {count} шагов, браслет — {band}. Теперь такая ходьба корректируется на {pct}%.", + "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": { @@ -7812,7 +7819,7 @@ } } }, - "stepCalDoneHousehold": "Сохранено. Телефон насчитал {count} шагов из {band}, которые зафиксировал браслет. Домашняя активность теперь масштабируется с коэффициентом {gain}.", + "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": { diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 34414d1f0..205c5b2e5 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -448,9 +448,9 @@ class _StepCalibrationScreenState extends State { const SizedBox(height: S.x2), Center( child: Text( - // No placeholder: this label names the counter, it does not read the - // target. It used to substitute a `{target}` the string never had. - l?.stepCalBandSeen ?? 'steps the band has seen', + // 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), ), ), diff --git a/test/step_calibration_ru_plural_test.dart b/test/step_calibration_ru_plural_test.dart new file mode 100644 index 000000000..7673fd2af --- /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), startsWith('шаг ')); + expect(ru.stepCalBandSeen(2), startsWith('шага ')); + expect(ru.stepCalBandSeen(5), startsWith('шагов ')); + }); + + 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 замеров')); + }); +} From 6de1d1a112081c1125d0ea265fb682af76a88c65 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 20:34:12 +0530 Subject: [PATCH 24/33] end a walk routine at the done tap, not when the count is answered the clock kept running while the count question was open, so a slow but valid walk could fall below the gait cadence range. --- lib/compute/step_calibration_fit.dart | 16 +++++++++++++++- lib/ui2/profile/step_calibration.dart | 11 ++++++++++- test/step_calibration_fit_test.dart | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/compute/step_calibration_fit.dart b/lib/compute/step_calibration_fit.dart index f8162b4b3..d3d7ea12e 100644 --- a/lib/compute/step_calibration_fit.dart +++ b/lib/compute/step_calibration_fit.dart @@ -844,11 +844,25 @@ class CalibrationSession { /// routine that spans a reconnect must not be credited with a negative /// correction. void observe({required int bandTotal, required int nowMs}) { - if (_finished || !_started) return; + 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) { diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 205c5b2e5..6323df882 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -261,8 +261,17 @@ class _StepCalibrationScreenState extends State { // 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 (counted == null || !mounted) return; + if (!mounted) return; + if (counted == null) { + s.resume(); + return; + } } _ticker?.cancel(); // Locked BEFORE the read, not after it. diff --git a/test/step_calibration_fit_test.dart b/test/step_calibration_fit_test.dart index 791a47ae7..872840b29 100644 --- a/test/step_calibration_fit_test.dart +++ b/test/step_calibration_fit_test.dart @@ -355,6 +355,29 @@ void main() { 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) From 56c32567e47822bbff92db3b4585f7844e8534d6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 20:35:40 +0530 Subject: [PATCH 25/33] refuse a household routine on android without two flushed totals the final read fell back to the prorated range query when the starting total was missing, and a refused flush was waited out and read anyway. on android the reference is now only a difference of two totals taken after accepted flushes; anything less refuses the routine. --- lib/health/phone_pedometer.dart | 48 ++++++++++++++++- lib/ui2/profile/step_calibration.dart | 30 ++++------- test/phone_pedometer_raw_count_test.dart | 65 ++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index 289bd881c..878d9238b 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -106,14 +106,30 @@ class PhonePedometer { /// for up to a minute before delivering them). A no-op where the platform /// has nothing to flush. With [settle], waits for the flushed batch to be /// delivered and banked before returning, so a read right after sees it. - Future flushPending({bool settle = false}) async { + /// + /// True when the platform accepted the flush, false when it refused it, and + /// null on a platform with nothing to flush. Android cannot report when a + /// flush has COMPLETED without a listener callback, so the settle wait + /// stands in for that; a refused flush is reported, never waited out. + Future flushPending({bool settle = false}) async { bool? flushed; try { flushed = await phoneStepsChannel.invokeMethod('flush'); - } catch (_) {} + } on MissingPluginException { + return null; + } catch (_) { + return false; + } if (settle && flushed == true) { await Future.delayed(const Duration(milliseconds: 1500)); } + return flushed; + } + + /// The running total after a flush the platform accepted, or null. + Future flushedTotal() async { + if (await flushPending(settle: true) != true) return null; + return rawCount(); } /// The step sensor's own running total, where the platform exposes one @@ -415,3 +431,31 @@ 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. +Future routinePhoneReference({ + required bool runningTotalPlatform, + required int? startTotal, + required Future Function() endTotal, + required Future Function() interval, +}) async { + if (runningTotalPlatform) { + if (startTotal == null) return null; + final end = await endTotal(); + if (end == null || end < startTotal) return null; + return end - startTotal; + } + try { + final n = await interval(); + return n == null || n < 0 ? null : n; + } catch (_) { + return null; + } +} diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 6323df882..be28061fd 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -30,6 +30,7 @@ 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'; @@ -104,9 +105,7 @@ class _StepCalibrationScreenState extends State { unawaited(_ensurePhonePermission()); // Deliver whatever the step sensor has batched, so steps taken before // the routine land before its window opens, then note the running total. - _startRaw = _pedometer - .flushPending(settle: true) - .then((_) => _pedometer.rawCount()); + _startRaw = _pedometer.flushedTotal(); } setState(() { _outcome = null; @@ -192,24 +191,17 @@ class _StepCalibrationScreenState extends State { Future _finalPhoneRead(CalibrationSession s) async { // A periodic read still in flight must not land on top of this one. ++_phoneReadSeq; - final startRaw = await _startRaw; - await _pedometer.flushPending(settle: true); - if (startRaw != null) { - final endRaw = await _pedometer.rawCount(); - if (endRaw == null || endRaw < startRaw) return false; - s.setPhoneSteps(endRaw - startRaw); - return true; - } final start = _routineStart; if (start == null) return false; - try { - final steps = await _pedometer.stepsBetween(start, DateTime.now()); - if (steps == null || steps < 0) return false; - s.setPhoneSteps(steps); - return true; - } catch (_) { - return false; - } + final steps = await routinePhoneReference( + runningTotalPlatform: defaultTargetPlatform == TargetPlatform.android, + startTotal: await _startRaw, + endTotal: _pedometer.flushedTotal, + interval: () => _pedometer.stepsBetween(start, DateTime.now()), + ); + if (steps == null) return false; + s.setPhoneSteps(steps); + return true; } void _cancel() { diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart index 22510b28b..389417dd2 100644 --- a/test/phone_pedometer_raw_count_test.dart +++ b/test/phone_pedometer_raw_count_test.dart @@ -41,4 +41,69 @@ void main() { await PhonePedometer().flushPending(); expect(calls, ['flush']); }); + + group('the routine reference', () { + Future never() async => fail('must not be read'); + + test('android uses the difference of two flushed totals', () async { + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + endTotal: () async => 1240, + interval: never, + ), + 240); + }); + + test('android without a starting total refuses, never prorates', () async { + var prorated = false; + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: null, + endTotal: () async => 1240, + interval: () async { + prorated = true; + return 99; + }, + ), + isNull); + expect(prorated, isFalse); + }); + + test('android without an ending total refuses', () async { + expect( + await routinePhoneReference( + runningTotalPlatform: true, + startTotal: 1000, + endTotal: () async => null, + interval: never, + ), + isNull); + }); + + test('elsewhere the exact interval query is used', () async { + expect( + await routinePhoneReference( + runningTotalPlatform: false, + startTotal: null, + endTotal: never, + interval: () async => 57, + ), + 57); + }); + }); + + test('a refused flush is reported and not waited out', () async { + messenger.setMockMethodCallHandler(phoneStepsChannel, (c) async { + if (c.method == 'flush') return false; + if (c.method == 'rawCount') return 5000; + return null; + }); + final sw = Stopwatch()..start(); + expect(await PhonePedometer().flushPending(settle: true), isFalse); + expect(sw.elapsedMilliseconds, lessThan(1000)); + expect(await PhonePedometer().flushedTotal(), isNull); + }); } From 58f3a651d7ae2d0af4250735b993dea042227774 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 20:36:03 +0530 Subject: [PATCH 26/33] make the russian band counter label unambiguous --- lib/l10n/app_ru.arb | 2 +- test/step_calibration_ru_plural_test.dart | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a4482bfd2..7095d22a6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7771,7 +7771,7 @@ } }, "stepCalNotCalibrated": "Ещё не откалибровано", - "stepCalBandSeen": "{count, plural, one{шаг зафиксировал браслет} few{шага зафиксировал браслет} many{шагов зафиксировал браслет} other{шага зафиксировал браслет}}", + "stepCalBandSeen": "{count, plural, one{шаг по данным браслета} few{шага по данным браслета} many{шагов по данным браслета} other{шага по данным браслета}}", "@stepCalBandSeen": { "placeholders": { "count": { diff --git a/test/step_calibration_ru_plural_test.dart b/test/step_calibration_ru_plural_test.dart index 7673fd2af..3c69ce39a 100644 --- a/test/step_calibration_ru_plural_test.dart +++ b/test/step_calibration_ru_plural_test.dart @@ -9,9 +9,9 @@ void main() { final ru = lookupAppLocalizations(const Locale('ru')); test('the band counter label agrees with its number', () { - expect(ru.stepCalBandSeen(1), startsWith('шаг ')); - expect(ru.stepCalBandSeen(2), startsWith('шага ')); - expect(ru.stepCalBandSeen(5), startsWith('шагов ')); + expect(ru.stepCalBandSeen(1), 'шаг по данным браслета'); + expect(ru.stepCalBandSeen(2), 'шага по данным браслета'); + expect(ru.stepCalBandSeen(5), 'шагов по данным браслета'); }); test('the gait result inflects both counts and names no single gait', () { From a0fcd3efeb4477655bde0a8e4fe8accd10ea5e9c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 21:05:51 +0530 Subject: [PATCH 27/33] wait for a step-sensor flush to complete instead of a fixed pause the flush call now answers from onFlushCompleted, after the batched counts are delivered, and a flush that is refused or does not complete in time reads as failed. --- .../openstrap_edge/PhoneStepCounter.kt | 45 ++++++++++++++++--- lib/health/phone_pedometer.dart | 37 ++++++++------- test/phone_pedometer_raw_count_test.dart | 38 ++++++++++++---- 3 files changed, 89 insertions(+), 31 deletions(-) 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 fd593e99d..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" @@ -113,10 +115,10 @@ object PhoneStepCounter : SensorEventListener { } // The listener batches for up to 60 s. A three-minute calibration // routine read without a flush misses up to a third of itself. - "flush" -> { - val sm = app.getSystemService(Context.SENSOR_SERVICE) as SensorManager - result.success(registered && sm.flush(this)) - } + // 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. @@ -230,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/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index 878d9238b..bc03bbb26 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; @@ -103,32 +110,28 @@ class PhonePedometer { } /// Asks the step sensor to deliver what it has batched (Android holds counts - /// for up to a minute before delivering them). A no-op where the platform - /// has nothing to flush. With [settle], waits for the flushed batch to be - /// delivered and banked before returning, so a read right after sees it. + /// for up to a minute before delivering them) and waits for it to finish. /// - /// True when the platform accepted the flush, false when it refused it, and - /// null on a platform with nothing to flush. Android cannot report when a - /// flush has COMPLETED without a listener callback, so the settle wait - /// stands in for that; a refused flush is reported, never waited out. - Future flushPending({bool settle = false}) async { - bool? flushed; + /// 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 { - flushed = await phoneStepsChannel.invokeMethod('flush'); + final done = await phoneStepsChannel + .invokeMethod('flush') + .timeout(flushTimeout, onTimeout: () => false); + return done == true; } on MissingPluginException { return null; } catch (_) { return false; } - if (settle && flushed == true) { - await Future.delayed(const Duration(milliseconds: 1500)); - } - return flushed; } - /// The running total after a flush the platform accepted, or null. + /// The running total after a completed flush, or null. Future flushedTotal() async { - if (await flushPending(settle: true) != true) return null; + if (await flushPending() != true) return null; return rawCount(); } diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart index 389417dd2..02a15d906 100644 --- a/test/phone_pedometer_raw_count_test.dart +++ b/test/phone_pedometer_raw_count_test.dart @@ -2,6 +2,8 @@ // 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'; @@ -26,10 +28,8 @@ void main() { throw MissingPluginException(); }); expect(await PhonePedometer().rawCount(), isNull); - // And flushing there neither throws nor waits. - final sw = Stopwatch()..start(); - await PhonePedometer().flushPending(settle: true); - expect(sw.elapsedMilliseconds, lessThan(1000)); + // And flushing there reports nothing to flush rather than throwing. + expect(await PhonePedometer().flushPending(), isNull); }); test('flush is asked of the platform', () async { @@ -95,15 +95,37 @@ void main() { }); }); - test('a refused flush is reported and not waited out', () async { + 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; }); - final sw = Stopwatch()..start(); - expect(await PhonePedometer().flushPending(settle: true), isFalse); - expect(sw.elapsedMilliseconds, lessThan(1000)); + 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); + }); } From 024ceaf6c1d713aa9c7f60246bfc4c5e9eba6f07 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 21:05:51 +0530 Subject: [PATCH 28/33] start a household routine only once the phone's starting total is in the band baseline and the clock were taken before the phone's starting total settled, so the two counts covered different spans. access, then the phone total, then the band; no starting total means the routine does not start. --- lib/l10n/app_en.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/ui2/profile/step_calibration.dart | 119 +++++++++++++------- test/step_calibration_start_order_test.dart | 89 +++++++++++++++ 4 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 test/step_calibration_start_order_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d97c8b7d6..44dec25da 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16147,6 +16147,7 @@ "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.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7095d22a6..0bb1922e0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7795,6 +7795,7 @@ "stepCalKeepMoving": "Пока ничего не записано — держите телефон при себе и продолжайте двигаться.", "stepCalNoSignal": "За это упражнение браслет ничего не записал.", "stepCalNoPhoneAccess": "Для этого упражнения нужен шагомер телефона, а доступ к нему не предоставлен. Он служит эталоном для домашней активности.", + "stepCalPhoneNotReady": "Шагомер телефона не ответил. Попробуйте ещё раз через минуту.", "stepCalNoPhone": "Телефон не насчитал ни одного шага. В следующий раз держите его в кармане — он служит эталоном для домашней активности.", "stepCalBandQuiet": "Браслет почти не заметил движения. Проверьте, что он плотно сидит на руке и передаёт данные.", "stepCalTooShort": "Упражнение было слишком коротким для сравнения. Пройдите весь счёт.", diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index be28061fd..3a81a2ea5 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -91,34 +91,49 @@ class _StepCalibrationScreenState extends State { if (mounted) setState(() {}); } - void _start(StepCategory category) { + /// 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(); - _routineStart = DateTime.now(); - _startRaw = null; - if (category.referenceIsPhone) { - // Ask BEFORE the routine, not after. A three-minute household routine that - // is then refused because the phone was never granted step access has - // wasted the wearer's time, and its refusal message would blame their - // pocket for a permission they were never asked for — a wrong reason, - // which is worse than none. - unawaited(_ensurePhonePermission()); - // Deliver whatever the step sensor has batched, so steps taken before - // the routine land before its window opens, then note the running total. - _startRaw = _pedometer.flushedTotal(); - } setState(() { + _starting = true; _outcome = null; - _session = CalibrationSession(category: category) - ..start( - // 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. - bandBaseline: app.liveStrapRawSteps, - nowMs: DateTime.now().millisecondsSinceEpoch, - ); }); - _ticker = Timer.periodic(Motion.tick, (_) => _tick()); + 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: _ensurePhonePermission, + 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() { @@ -136,28 +151,13 @@ class _StepCalibrationScreenState extends State { setState(() {}); } - Future _ensurePhonePermission() async { - var granted = false; + Future _ensurePhonePermission() async { try { - granted = await _pedometer.hasPermission() || + return await _pedometer.hasPermission() || await _pedometer.requestPermission(); } catch (_) { - granted = false; + return false; } - if (!mounted || granted) return; - // STOP the routine rather than let it run to its refusal. Without access the - // phone reference can never arrive, so three minutes of tidying could only - // end in a refusal — and the wearer finds out now, standing there, instead of - // afterwards. - _ticker?.cancel(); - final l = AppLocalizations.of(context); - setState(() { - _session = null; - _routineStart = null; - _outcome = l?.stepCalNoPhoneAccess ?? - 'This routine needs your phone\u2019s step counter, and access was ' - 'not granted. It is the reference for household movement.'; - }); } /// Sequence of phone reads started. Only the MOST RECENTLY STARTED read may @@ -568,3 +568,40 @@ class _StepCalibrationScreenState extends State { '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/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); + }); +} From 829daa3533518f03c446345baebf9fa30f945ea4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 21:58:30 +0530 Subject: [PATCH 29/33] ask for phone step access before a household routine every time ios reports an undecided permission as allowed, so the request was skipped on a first-use phone and the prompt came up mid-routine. --- lib/health/phone_pedometer.dart | 8 ++++++++ lib/ui2/profile/step_calibration.dart | 10 +--------- test/phone_pedometer_raw_count_test.dart | 11 +++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index bc03bbb26..d43bfa929 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -165,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 diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 3a81a2ea5..2120aa74e 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -108,7 +108,7 @@ class _StepCalibrationScreenState extends State { // 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: _ensurePhonePermission, + ensurePhoneAccess: _pedometer.ensureAccess, runningTotalPlatform: defaultTargetPlatform == TargetPlatform.android, startTotal: _pedometer.flushedTotal, bandTotalNow: () => app.liveStrapRawSteps, @@ -151,14 +151,6 @@ class _StepCalibrationScreenState extends State { setState(() {}); } - Future _ensurePhonePermission() async { - try { - return await _pedometer.hasPermission() || - await _pedometer.requestPermission(); - } catch (_) { - return false; - } - } /// 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 diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart index 02a15d906..613c597da 100644 --- a/test/phone_pedometer_raw_count_test.dart +++ b/test/phone_pedometer_raw_count_test.dart @@ -128,4 +128,15 @@ void main() { 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')); + }); } From 361f7f36d53f530f1fdcdd8c6444144005817bc9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 22:00:15 +0530 Subject: [PATCH 30/33] stop the band count where the household phone reference stops the band's count stopped at the last tick before done while the phone's reference ran on through the final flush or query. both now end at one instant: after the completed flush on android, at the query's end time elsewhere. --- lib/health/phone_pedometer.dart | 19 ++++-- lib/ui2/profile/step_calibration.dart | 12 +++- test/phone_pedometer_raw_count_test.dart | 74 ++++++++++++++++++++---- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/lib/health/phone_pedometer.dart b/lib/health/phone_pedometer.dart index d43bfa929..8a8319a00 100644 --- a/lib/health/phone_pedometer.dart +++ b/lib/health/phone_pedometer.dart @@ -451,20 +451,31 @@ class PhonePedometer { /// 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() endTotal, - required Future Function() interval, + 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; - final end = await endTotal(); + 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(); + final n = await interval(end); return n == null || n < 0 ? null : n; } catch (_) { return null; diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 2120aa74e..44af78c61 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -185,11 +185,19 @@ class _StepCalibrationScreenState extends State { ++_phoneReadSeq; final start = _routineStart; if (start == null) return false; + final app = context.read(); final steps = await routinePhoneReference( runningTotalPlatform: defaultTargetPlatform == TargetPlatform.android, startTotal: await _startRaw, - endTotal: _pedometer.flushedTotal, - interval: () => _pedometer.stepsBetween(start, DateTime.now()), + 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); diff --git a/test/phone_pedometer_raw_count_test.dart b/test/phone_pedometer_raw_count_test.dart index 613c597da..93ce1bcbc 100644 --- a/test/phone_pedometer_raw_count_test.dart +++ b/test/phone_pedometer_raw_count_test.dart @@ -43,15 +43,19 @@ void main() { }); group('the routine reference', () { - Future never() async => fail('must not be read'); + 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, - endTotal: () async => 1240, - interval: never, + flush: () async => true, + total: () async => 1240, + interval: (_) => neverRead(), + now: () => t, + markEnd: (_) {}, ), 240); }); @@ -62,36 +66,84 @@ void main() { await routinePhoneReference( runningTotalPlatform: true, startTotal: null, - endTotal: () async => 1240, - interval: () async { + 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 refuses', () async { + test('android without an ending total or a completed flush refuses', + () async { expect( await routinePhoneReference( runningTotalPlatform: true, startTotal: 1000, - endTotal: () async => null, - interval: never, + 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 exact interval query is used', () async { + test('elsewhere the query and the band end at the same instant', () async { + DateTime? marked, queried; expect( await routinePhoneReference( runningTotalPlatform: false, startTotal: null, - endTotal: never, - interval: () async => 57, + 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); }); }); From 587a60b19e47e80b6237485ba0d44ee85b09adbe Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 22:00:15 +0530 Subject: [PATCH 31/33] report a stored calibration as saved even if the reload after it fails the save failure message invited a retry that stored the same routine twice. --- lib/ui2/profile/step_calibration.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index 44af78c61..bf5d354c6 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -308,7 +308,12 @@ class _StepCalibrationScreenState extends State { // Only after the save returned: a walk already in progress would otherwise // bank with the previous gain. app.invalidateLiveStepCalibration(); - await _loadProfiles(); + // 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. + try { + await _loadProfiles(); + } catch (_) {} if (!mounted) return; setState(() { _busy = false; From 66868b8535609db5ae7715a6ea6415b7f7bc0e01 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 23:20:19 +0530 Subject: [PATCH 32/33] drop the cached calibration even when an import fails part way the import commits page by page, so a page that throws can leave merged calibration runs behind; the invalidation now sits in a finally around the import itself. --- lib/state/app_state.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4ce0a1180..40b291cb9 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -735,10 +735,12 @@ 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 whether or not the refit below succeeds: the merged runs are - // already in the table, and the cache holds runs, not fitted profiles. + // 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(); From 60a72516814d94c3e53f22562d4e4f87cbf630a3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 23:20:47 +0530 Subject: [PATCH 33/33] show the saved category from the save itself when the reload fails --- lib/ui2/profile/step_calibration.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/ui2/profile/step_calibration.dart b/lib/ui2/profile/step_calibration.dart index bf5d354c6..701e4edb2 100644 --- a/lib/ui2/profile/step_calibration.dart +++ b/lib/ui2/profile/step_calibration.dart @@ -311,6 +311,9 @@ class _StepCalibrationScreenState extends State { // 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 (_) {}