diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index d1ccd8ea..f3ce5afb 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -50,6 +50,7 @@ import 'movement_floor_policy.dart' as mfp; import 'sleep_profile_policy.dart'; import 'derive_prepare.dart'; import 'onehz_pipeline.dart'; +import 'step_cadence.dart'; import 'profile.dart'; import 'substrate.dart'; @@ -1287,7 +1288,28 @@ import 'substrate.dart'; // imported this is a strict no-op: the set is empty and every read is unchanged. // Days already finalized keep the score they were derived with — raw is pruned, // so no bump can heal them. -const int kAlgoVersion = 76; +// v77 — WALKING FINALLY PRICES INTO ACTIVE ENERGY (issue: "Walking calories +// are not counted"). MOT-02's HR-flex gate deliberately bills nothing below +// the ACSM moderate floor — right for HR (Keytel has no fitted data there), +// but it left a one-hour walk at 95 bpm adding ZERO active kcal while its +// steps counted fine. MT-05 established the 1 Hz accel cannot fill that gap; +// measured CADENCE can. `Calories.dailyEnergy` (analytics, CADENCE-Adults: +// 100/110/120/130 spm ↔ 3/4/5/6 METs) now prices sub-gate minutes whose +// measured cadence clears the study's own moderate floor, at (MET−1) +// basal-minutes of surplus. Edge feeds it the day's resolved `live_coverage` +// spans through ONE mapping (`cadenceSpmForMinutes`) from BOTH energy passes +// — the coordinator's canonical `wakeDayEnergy` and the pipeline's early-read +// mirror — so the two bill a walk identically. A minute the HR gate accepts +// still bills by HR alone (never both), an unmeasured minute stays basal, and +// a day with no pedometer coverage is byte-identical to v76. The TDEE block +// discloses the term (`live_coverage_pedometer` in inputs_used + the walking +// kcal in the note) only on days it actually priced something. +// +// PIN GATE (invariant #5): this bump cites the analytics walking term +// (OpenStrap/analytics#52). Do not release with a pubspec pin that predates +// that merge — the bump would recompute every day against a sibling that +// cannot price walking, burning the version on nothing. +const int kAlgoVersion = 77; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. @@ -1334,7 +1356,13 @@ const int kAlgoVersion = 76; // COMMAND builders (alarmRev1Payload and friends) plus their exports and // test. Commands go TO the strap; no decoder line moves, so no stored number // can. -const String kAnalyticsPin = 'd9362a66fbeac326d5d7d7b1fe27b28e41169a79'; +// +// The analytics repin to a077a4a (the #52 merge) is the OPPOSITE case: it +// exists to move a number. #52 adds the cadence→MET walking term to +// `Calories.dailyEnergy` (CADENCE-Adults), which is exactly what v77 above +// prices in — the bump and the v77 migration cover the hop, and the hop is +// exactly #52 (diff the two pins: calories.dart + its tests, nothing else). +const String kAnalyticsPin = 'a077a4aeb2d2a86c52f86f7005654b3be27b03d9'; const String kProtocolPin = '4ce8f021568a4cd9a1d86c91004f91c6b21980da'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling @@ -3190,10 +3218,20 @@ class DerivationEngine { // (NREM split into Light/Deep via the LOW-CONFIDENCE HR-depth overlay); we // pass it through verbatim so the UI can render Light vs Deep. Fall back to // the 3-class enum (light = plain NREM) only if stages4 is unexpectedly empty. + // Resolved PER WINDOW, not per day: each span of the day goes to the best + // source that actually covered it. Read HERE — before the pipeline input + // is built — because BOTH energy passes price walking off these spans: + // the pipeline's early-read mirror below and the second-half + // `applyDayActivity`. One read, one span list, or the two figures drift. + final liveSteps = await LocalDb.resolvedStepsForDay(day.date); + final stepSpans = [ + for (final s in liveSteps.spans) [s.startTs, s.endTs, s.steps], + ]; final input = DayBundleInput( date: day.date, dayTsSec: daySub.tsSec, dayHr: daySub.hr, + stepSpans: stepSpans, dayRrTsMs: daySub.rrTsMs, dayRrMs: daySub.rrMs, sleepTsSec: sleepSub.tsSec, @@ -3357,10 +3395,9 @@ class DerivationEngine { try { final dayLo = daySub.length == 0 ? 0 : daySub.tsSec.first; final dayHi = daySub.length == 0 ? 0 : daySub.tsSec.last + 60; - // Resolved PER WINDOW, not per day: each span of the day goes to the best - // source that actually covered it. `.strap` is carried alongside the - // total so the bundle can name the sensor that counted. - final liveSteps = await LocalDb.resolvedStepsForDay(day.date); + // `liveSteps`/`stepSpans` were read above, before the pipeline input — + // one resolution serves both energy passes. `.strap` is carried + // alongside the total so the bundle can name the sensor that counted. final savedSessions = await LocalDb.sessionsInRange(dayLo, dayHi); // Off-wrist / charging spans over the NAP window (which runs past this @@ -3441,6 +3478,10 @@ class DerivationEngine { maxHrUsed: (bundle['max_hr_used'] as num?)?.round(), liveStepsReal: liveSteps.total, liveStepsFromStrap: liveSteps.strap, + // The same resolution's credited spans, so the walking-cadence term + // prices exactly the steps the day's total already counted — never a + // raw row the ladder took back. + stepSpans: stepSpans, dynFloorG: dynFloorG, dynHistoryDays: dynHistory.length, savedSessions: savedSessions, @@ -4613,7 +4654,10 @@ class DerivationEngine { } } - static List _perMinuteMeanWake( + /// The wake-minute series WITH its bucket keys (epoch-seconds ~/ 60), so a + /// per-minute companion series (the measured cadence) can be aligned to the + /// same minutes instead of guessed from array position. + static ({List keys, List hr}) _perMinuteMeanWake( Substrate s, int sleepOnsetSec, int sleepOffsetSec, @@ -4630,7 +4674,7 @@ class DerivationEngine { (buckets[t ~/ 60] ??= []).add(s.hr[i].toDouble()); } final keys = buckets.keys.toList()..sort(); - return [for (final k in keys) _meanWake(buckets[k]!)!]; + return (keys: keys, hr: [for (final k in keys) _meanWake(buckets[k]!)!]); } static Map _wakeZoneMinutes( @@ -4706,12 +4750,14 @@ class DerivationEngine { /// The 1 Hz pipeline's early-read `calories` gates on height for the same /// reason, so Today does not show a figure the derived day then withdraws. @visibleForTesting - static ({double active, double basal, double total})? wakeDayEnergy( + static ({double active, double basal, double total, double walking})? + wakeDayEnergy( List wakeHrPerMin, { required Profile profile, required double? restingHr, int? dayMinutes, String? deviceFamily, + List? cadenceSpmPerMin, }) { if (!profile.hasCalorieAnchors) return null; // The active gate is a %HRR flex point, so it needs BOTH ends of the @@ -4726,11 +4772,23 @@ class DerivationEngine { final heightCm = profile.heightCm; if (heightCm == null) return null; // Off-skin samples are the package's 0 sentinel; billing them would credit - // lost contact at the resting rate. - final hr = [ - for (final h in wakeHrPerMin) - if (h > 0) h, - ]; + // lost contact at the resting rate. The cadence series is filtered in the + // SAME pass: `dailyEnergy` aligns the two by index, so dropping an HR + // entry without dropping its cadence would price every later cadence + // against the wrong minute. + if (cadenceSpmPerMin != null && + cadenceSpmPerMin.length != wakeHrPerMin.length) { + // A caller bug, but a derive pass is not the place to throw: no cadence + // beats a misaligned one, and the HR half of the figure is still real. + cadenceSpmPerMin = null; + } + final hr = []; + final cadence = cadenceSpmPerMin == null ? null : []; + for (var i = 0; i < wakeHrPerMin.length; i++) { + if (wakeHrPerMin[i] <= 0) continue; + hr.add(wakeHrPerMin[i]); + cadence?.add(cadenceSpmPerMin?[i]); + } if (hr.isEmpty) return null; final e = ana.Calories.dailyEnergy( hr, @@ -4743,12 +4801,18 @@ class DerivationEngine { hrmax: hrmax, restingHr: restingHr, dayMinutes: dayMinutes ?? 1440, + cadenceSpmPerMin: cadence, ); // Anchors that cannot define an active gate are an ABSENT day's energy, // not a day billed entirely as active. `dailyEnergy` abstains; so does the // day, which is what every other caller of this method already expects. if (e == null) return null; - return (active: e.active, basal: e.basal, total: e.total); + return ( + active: e.active, + basal: e.basal, + total: e.total, + walking: e.walking, + ); } static double? _meanWake(List xs) { @@ -4792,6 +4856,7 @@ class DerivationEngine { int liveStepsReal = 0, int liveStepsFromStrap = 0, int dynHistoryDays = 0, + List> stepSpans = const [], }) { final wake = _buildWakeDayFeatures( daySub, @@ -4803,6 +4868,7 @@ class DerivationEngine { dataNowSec: dataNowSec, restingHr: restingHr, dynFloorG: dynFloorG, + stepSpans: stepSpans, ); _applyWakeDayFeatures(bundle, scalars, wake); _stepsAndEnergy( @@ -4868,15 +4934,25 @@ class DerivationEngine { // for nothing. final caloriesBasal = (wake['calories_basal'] as num?)?.toDouble(); if (caloriesTotal != null && calories != null && caloriesBasal != null) { + // Disclosed only when it actually priced something: an input listed on a + // day it contributed nothing to would claim pedometer coverage the day + // may not have. + final walking = (wake['calories_walking'] as num?)?.toDouble() ?? 0.0; bundle['calories_total'] = { 'value': caloriesTotal.round(), 'active': calories.round(), 'basal': caloriesBasal.round(), 'confidence': 0.5, 'tier': 'ESTIMATE', - 'inputs_used': const ['hr_1hz', 'profile'], + 'inputs_used': [ + 'hr_1hz', + 'profile', + if (walking > 0) 'live_coverage_pedometer', + ], 'note': 'total daily energy: Mifflin BMR floor over the covered day + ' - 'active Keytel surplus over the wake span (HR-flex)', + 'active Keytel surplus over the wake span (HR-flex)' + '${walking > 0 ? ' + measured-cadence walking term ' + '(CADENCE-Adults, ${walking.round()} kcal)' : ''}', }; } // WHY each of the above is absent, per figure. This recompute is the answer @@ -5272,6 +5348,7 @@ class DerivationEngine { required int dataNowSec, double? restingHr, double? dynFloorG, + List> stepSpans = const [], }) { final activeMin = _activeMinutes(daySub, sleepOnsetSec, sleepOffsetSec); final wear = _wearBlock( @@ -5280,7 +5357,16 @@ class DerivationEngine { dayCalendarEndSec: dayCalendarEndSec, dataNowSec: dataNowSec, ); - final perMin = _perMinuteMeanWake(daySub, sleepOnsetSec, sleepOffsetSec); + final wakeSeries = + _perMinuteMeanWake(daySub, sleepOnsetSec, sleepOffsetSec); + final perMin = wakeSeries.hr; + // The day's MEASURED walking cadence, minute-aligned to the same wake + // buckets — from the resolved `live_coverage` spans, so band/phone overlap + // is already settled and a step is never priced twice. Null minutes are + // exactly the minutes nobody's pedometer covered. + final wakeCadence = stepSpans.isEmpty + ? null + : cadenceSpmForMinutes(wakeSeries.keys, stepSpans); final motion = _motionMinutes(daySub); final dayHrValid = [ for (final h in daySub.hr) @@ -5358,6 +5444,7 @@ class DerivationEngine { double? steps; // stays null here — real counts only, see below double? movementMin; double? caloriesTotal; + double? caloriesWalking; double? caloriesBasal; Map zones = const {}; if (perMin.isNotEmpty && hrMax != null) { @@ -5449,11 +5536,13 @@ class DerivationEngine { restingHr: rhrForTrimp, dayMinutes: motion.length, deviceFamily: daySub.deviceFamily, + cadenceSpmPerMin: wakeCadence, ); if (energy != null) { calories = energy.active; caloriesTotal = energy.total; caloriesBasal = energy.basal; + caloriesWalking = energy.walking; } } // Same peak, same smoothing as the pipeline's copy and as every workout @@ -5500,6 +5589,11 @@ class DerivationEngine { // export derives basal as `calories_total - calories`, and this is here to // make sure that subtraction and this figure are the same number. 'calories_basal': caloriesBasal, + // How much of `calories` came from the measured-cadence walking term + // rather than HR (0 when no pedometer coverage priced anything). Carried + // for the TDEE block's disclosure — a figure whose provenance changed + // must say so in `inputs_used`. + 'calories_walking': caloriesWalking, 'wear_min': (wear['worn_min'] as num?)?.toDouble(), 'activity': { 'value': activeMin, @@ -6737,6 +6831,7 @@ class DerivationEngine { liveStepsReal: inp.liveStepsReal, liveStepsFromStrap: inp.liveStepsFromStrap, dynHistoryDays: inp.dynHistoryDays, + stepSpans: inp.stepSpans, ); bundlePatch['daytime_hrv'] = _daytimeHrv(daySub, onset, offset); @@ -7435,6 +7530,12 @@ class _DayBlocksInput { /// isolate, which has no handle. final int liveStepsFromStrap; + /// The SAME resolution's credited spans, as `[startSec, endSec, steps]` — + /// the walking-cadence energy term prices wake minutes off these (see + /// `cadenceSpmForMinutes`). Credited, never raw rows, so a step the ladder + /// took back off a span cannot be priced here either. + final List> stepSpans; + /// PERSONAL ambulatory floor (g, dynAmp units) from trailing days, or null /// when there isn't enough history yet — in which case the 1 Hz estimator /// abstains rather than falling back to a constant. Computed on the main @@ -7486,6 +7587,7 @@ class _DayBlocksInput { required this.maxHrUsed, required this.liveStepsReal, this.liveStepsFromStrap = 0, + this.stepSpans = const [], required this.dynFloorG, required this.dynHistoryDays, required this.savedSessions, diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 86522c78..26ad604d 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -33,6 +33,7 @@ import 'package:openstrap_analytics/onehz.dart'; import 'hr_max.dart' show estimatedMaxHr, smoothedMaxHr, smoothedMinHr, trainingZones; import 'profile.dart' show workoutSex; +import 'step_cadence.dart' show cadenceSpmForMinutes; // Same argument: a pure `DateTime` lookup, no DB / IO / Flutter binding. It is // the ONE definition of "the UTC offset in effect at this instant" in the tree, // and a second copy here would be the exact drift SLP-09's timezone guard is @@ -169,6 +170,14 @@ class DayBundleInput { final double dayConfidence; final List dayFlags; + /// The day's resolved `live_coverage` spans as `[startSec, endSec, steps]` + /// — credited, never raw rows (band/phone overlap already settled). The + /// energy mirror prices sub-flex-gate walking minutes off these, through + /// the SAME `cadenceSpmForMinutes` mapping the coordinator's canonical + /// `wakeDayEnergy` pass uses, so the early read and the derived day bill + /// the same walk the same way. Empty when nothing measured steps. + final List> stepSpans; + /// Which strap measured this day — `'gen4'`, `'gen5'`, or null for UNKNOWN /// (unstamped historical rows, imports, the raw-hex replay path). Null is its /// own case, never gen4: see [Substrate.deviceFamily] and analytics' @@ -207,10 +216,12 @@ class DayBundleInput { this.dayFlags = const [], this.deviceFamily, this.sleepSource = 'auto', + this.stepSpans = const [], }); Map toJson() => { 'date': date, + 'step_spans': stepSpans, 'day_ts': dayTsSec, 'day_hr': dayHr, 'day_rr_ts_ms': dayRrTsMs, @@ -283,6 +294,10 @@ class DayBundleInput { dayFlags: strs('day_flags'), deviceFamily: m['device_family'] as String?, sleepSource: m['sleep_source'] as String? ?? 'auto', + stepSpans: [ + for (final r in (m['step_spans'] as List? ?? const [])) + [for (final v in (r as List)) (v as num).toInt()], + ], ); } } @@ -747,10 +762,20 @@ Map deriveDayBundle(Map inputJson) { ), hrmax: hrMax, restingHr: rhrForTrimp, + // The SAME minute-aligned cadence the canonical pass uses — one + // mapping (`cadenceSpmForMinutes`) fed by the same credited spans, so + // this early read and the derived day bill a walk identically instead + // of the number growing when the coordinator's pass lands. + cadenceSpmPerMin: d.stepSpans.isEmpty + ? null + : cadenceSpmForMinutes( + [for (final p in wakeHr) p.tsSec ~/ 60], + d.stepSpans, + ), // `?.` — `dailyEnergy` abstains outright when the anchors cannot // define a gate, rather than billing every waking minute as active. // Absent stays absent here, same as every other input on this seam. - )?.active; // active-energy component (Keytel surplus over basal) + )?.active; // active-energy component (Keytel surplus + walking term) } } diff --git a/lib/compute/step_cadence.dart b/lib/compute/step_cadence.dart new file mode 100644 index 00000000..eaabed47 --- /dev/null +++ b/lib/compute/step_cadence.dart @@ -0,0 +1,43 @@ +// step_cadence.dart — the ONE windows→minutes cadence mapping. +// +// `Calories.dailyEnergy`'s walking term (analytics) prices a sub-flex-gate +// minute from its MEASURED cadence. The measurements live in `live_coverage` +// as resolved, credited step windows (see `resolveDaySteps` — overlap between +// band and phone already settled there, so summing here cannot double-count a +// step). This maps those windows onto the wake-minute series both energy call +// sites use — `DerivationEngine.wakeDayEnergy` and the pure pipeline's +// early-read mirror. ONE implementation, imported by both: two copies of this +// mapping is how the derived day and its early read would drift apart, the +// exact bug class the single `wakeDayEnergy` pass exists to prevent. +// +// Pure and isolate-safe: no I/O, no clock, plain lists in and out. + +/// Steps credited to each minute of [minuteKeys] (epoch-seconds ~/ 60, the +/// wake-series bucket key), from [spans] of `[startSec, endSec, steps]`. +/// +/// Each span's count is spread uniformly over its own duration, so a minute +/// reads the steps that landed IN IT: a span covering only half a minute +/// credits half its per-minute rate there, and a walk's boundary minute +/// under-bills rather than half a minute of walking pricing a full +/// MET-minute. Null for a minute no span touches — nobody measured it, which +/// is not the same claim as a measured zero. +List cadenceSpmForMinutes( + List minuteKeys, + List> spans, +) { + if (minuteKeys.isEmpty) return const []; + final steps = {}; + for (final s in spans) { + if (s.length < 3) continue; + final start = s[0], end = s[1], count = s[2]; + if (end <= start || count < 0) continue; + final rate = count / (end - start); // steps per second, uniform + for (var k = start ~/ 60; k * 60 < end; k++) { + final lo = k * 60 < start ? start : k * 60; + final hi = (k + 1) * 60 > end ? end : (k + 1) * 60; + if (hi <= lo) continue; + steps[k] = (steps[k] ?? 0.0) + rate * (hi - lo); + } + } + return [for (final k in minuteKeys) steps[k]]; +} diff --git a/pubspec.lock b/pubspec.lock index 712b1376..e82d403c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -924,8 +924,8 @@ packages: dependency: "direct main" description: path: "." - ref: d9362a66fbeac326d5d7d7b1fe27b28e41169a79 - resolved-ref: d9362a66fbeac326d5d7d7b1fe27b28e41169a79 + ref: a077a4aeb2d2a86c52f86f7005654b3be27b03d9 + resolved-ref: a077a4aeb2d2a86c52f86f7005654b3be27b03d9 url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0d745879..db13ae03 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -210,7 +210,13 @@ dependencies: # abstains rather than billing every waking minute as active when the # anchors are unusable — which is source-breaking here, not # number-changing (see `onehz_pipeline` and `_dailyEnergy`). - ref: d9362a66fbeac326d5d7d7b1fe27b28e41169a79 + # + # REPIN (this branch): analytics main @ a077a4a, the #52 merge commit — + # the cadence→MET walking term in `Calories.dailyEnergy` (CADENCE-Adults) + # this branch's v77 exists to consume. The hop is exactly #52 and + # nothing else; the number change it brings IS this branch's change and + # is covered by the kAlgoVersion 77 bump + v77 migration. + ref: a077a4aeb2d2a86c52f86f7005654b3be27b03d9 # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/daily_energy_consistency_test.dart b/test/daily_energy_consistency_test.dart index fbb9e0da..59d411a8 100644 --- a/test/daily_energy_consistency_test.dart +++ b/test/daily_energy_consistency_test.dart @@ -96,6 +96,62 @@ void main() { expect(e.total, closeTo(_bmrDay, 0.5)); }); + group('walking cadence term (the "Walking calories are not counted" bug)', + () { + // One hour of walking at 95 bpm — below the flex gate, so HR-flex bills + // nothing for it. With the hour's measured cadence passed alongside, the + // walk finally prices: 110 spm is 4 METs (CADENCE-Adults), a surplus of + // (4−1) basal-minutes per minute. + final walkHr = [ + for (var i = 0; i < 60; i++) 95.0, + for (var i = 0; i < 1380; i++) 55.0, + ]; + final walkCad = [ + for (var i = 0; i < 60; i++) 110.0, + for (var i = 0; i < 1380; i++) null, + ]; + + test('a below-gate walk with measured cadence bills into active', () { + final without = DerivationEngine.wakeDayEnergy(walkHr, + profile: _profile, restingHr: 55, deviceFamily: 'gen4')!; + expect(without.active, 0.0, + reason: 'the reported bug: the walk adds nothing'); + + final e = DerivationEngine.wakeDayEnergy(walkHr, + profile: _profile, + restingHr: 55, + deviceFamily: 'gen4', + cadenceSpmPerMin: walkCad)!; + expect(e.active, closeTo(60 * 3 * _basalPerMin, 0.5)); + expect(e.walking, closeTo(e.active, 1e-9)); + }); + + test('the Health-export invariant survives the walking term', () { + final e = DerivationEngine.wakeDayEnergy(walkHr, + profile: _profile, + restingHr: 55, + deviceFamily: 'gen4', + cadenceSpmPerMin: walkCad)!; + expect(e.total - e.active, closeTo(e.basal, 0.001)); + }); + + test('cadence stays aligned across the off-skin filter', () { + // wakeDayEnergy drops non-positive HR entries before handing the + // series to analytics. If the cadence list were not filtered in the + // same pass, every entry after a dropped one would price the WRONG + // minute. Here the walk sits after an off-skin entry: misalignment + // would move its cadence onto resting minutes and change the figure. + final hr = [0.0, ...walkHr]; + final cad = [null, ...walkCad]; + final e = DerivationEngine.wakeDayEnergy(hr, + profile: _profile, + restingHr: 55, + deviceFamily: 'gen4', + cadenceSpmPerMin: cad)!; + expect(e.walking, closeTo(60 * 3 * _basalPerMin, 0.5)); + }); + }); + test('abstains when the profile lacks a Keytel anchor', () { // Matches Profile.hasCalorieAnchors: age, body mass and sex. Absent beats // fabricated — the engine must not fall back to a stand-in body. diff --git a/test/step_cadence_test.dart b/test/step_cadence_test.dart new file mode 100644 index 00000000..50c2e755 --- /dev/null +++ b/test/step_cadence_test.dart @@ -0,0 +1,96 @@ +// The one windows→minutes cadence mapping (lib/compute/step_cadence.dart). +// +// Both energy call sites — DerivationEngine.wakeDayEnergy and the pure +// pipeline's mirror — feed `Calories.dailyEnergy` a per-wake-minute cadence +// built by this ONE function from the day's resolved `live_coverage` spans. +// Two implementations of this mapping is how the day and its early read would +// drift apart (the exact bug class the single wakeDayEnergy pass ended), so +// the contract is pinned here once, for both. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/step_cadence.dart'; + +void main() { + // Minute keys are epoch-seconds ~/ 60, same as the wake-series buckets. + const k = 1000; // an arbitrary minute key; second 60000..60059 + + test('a span covering whole minutes reads as its own steps-per-minute', () { + final cad = cadenceSpmForMinutes( + [k, k + 1], + [ + [k * 60, (k + 2) * 60, 220], // 2 min, 220 steps → 110 spm + ], + ); + expect(cad, hasLength(2)); + expect(cad[0], closeTo(110.0, 1e-9)); + expect(cad[1], closeTo(110.0, 1e-9)); + }); + + test('a minute no span touches is null — unmeasured, not zero', () { + final cad = cadenceSpmForMinutes( + [k, k + 5], + [ + [k * 60, (k + 1) * 60, 100], + ], + ); + expect(cad[0], closeTo(100.0, 1e-9)); + expect(cad[1], isNull, + reason: 'nobody measured minute k+5; null is what keeps the walking ' + 'term from pricing a cadence that was never observed'); + }); + + test('partial coverage pro-rates DOWN — steps that minute, not the pace', + () { + // A span at 120 spm covering only the last 30 s of the minute credits 60 + // steps to it. The minute reads 60 spm — below the moderate floor — so a + // walk's boundary minute under-bills rather than a half-minute of walking + // billing a full MET-minute. + final cad = cadenceSpmForMinutes( + [k], + [ + [k * 60 + 30, (k + 1) * 60, 60], + ], + ); + expect(cad[0], closeTo(60.0, 1e-9)); + }); + + test('overlapping credited spans sum into the same minute', () { + final cad = cadenceSpmForMinutes( + [k], + [ + [k * 60, k * 60 + 30, 30], // 60 spm for the first half + [k * 60 + 30, (k + 1) * 60, 60], // 120 spm for the second + ], + ); + expect(cad[0], closeTo(90.0, 1e-9)); + }); + + test('a measured stillness is 0.0, not null', () { + // The pedometer ran and counted nothing: that is a measurement of not + // walking, distinct from no pedometer at all. Both refuse to bill (0 is + // under the floor), but only one claims knowledge. + final cad = cadenceSpmForMinutes( + [k], + [ + [k * 60, (k + 1) * 60, 0], + ], + ); + expect(cad[0], 0.0); + }); + + test('degenerate and disjoint spans are skipped, not thrown on', () { + final cad = cadenceSpmForMinutes( + [k], + [ + [k * 60, k * 60, 50], // zero width + [(k + 9) * 60, (k + 10) * 60, 100], // elsewhere + ], + ); + expect(cad[0], isNull); + }); + + test('no spans at all → all null, same length as the keys', () { + expect(cadenceSpmForMinutes([k, k + 1], const []), + [null, null]); + }); +}