From be6cfc6c4a6789232c5a352325fd1d2620a83712 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:43:36 +0530 Subject: [PATCH] fix: IS divides by epochsPerDay not populated hour-bin count phase-locked missing data (charging at the same hour every day) leaves a hour-of-day bin permanently unsampled, so profile.length < epochsPerDay and the old profVar/profile.length divisor inflated interdailyStability by epochsPerDay/profile.length. matches circadian_np.dart's p=epochsPerDay. --- lib/src/onehz/wellness/temp_circadian.dart | 8 ++++++- test/onehz/wellness_test.dart | 28 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index 30fdbe0..89f33d1 100644 --- a/lib/src/onehz/wellness/temp_circadian.dart +++ b/lib/src/onehz/wellness/temp_circadian.dart @@ -385,7 +385,13 @@ CircadianNonparam? _nonparam( for (final v in profile) { profVar += (v - grand) * (v - grand); } - profVar /= profile.length; + // Divide by the FIXED epochsPerDay, not profile.length (the count of + // hour-of-day bins that happen to have >=1 sample). A phase-locked gap + // (e.g. charging at the same hour every day, see file header) leaves a + // bin permanently unpopulated, shrinking profile.length below + // epochsPerDay and inflating IS by epochsPerDay/profile.length. Matches + // circadian_np.dart's `p = epochsPerDay` normalization. + profVar /= epochsPerDay; } final double is_ = varTot > 0 ? (profVar / (varTot / p)).clamp(0, 1) : 0.0; diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index e79a1b9..1b10623 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -73,6 +73,34 @@ void main() { expect(np.interdailyStability, closeTo(0.5582, 0.0005)); }); + test( + 'IS divisor is epochsPerDay, not the count of populated hour-of-day ' + 'bins (phase-locked charging-gap regression)', () { + // Hour-of-day 3 is NEVER sampled across the whole window (e.g. the band + // charges at the same time every day) -> profile.length == 23 while + // epochsPerDay == 24. Dividing profVar by profile.length instead of + // epochsPerDay inflates IS by 24/23. + final samples = []; + for (var day = 0; day < 14; day++) { + for (var h = 0; h < 24; h++) { + if (h == 3) continue; + for (var k = 0; k < 6; k++) { + final tHours = day * 24.0 + h + k / 6.0; + final adc = + 2000 + 150 * math.cos(2 * math.pi / 24 * (tHours - 4)); + samples.add(AdcSample(tHours * 3.6e6, adc)); + } + } + } + final np = tempCircadian(samples, deviceFamily: 'gen4', epochMin: 60) + .value! + .nonparam!; + // Correct value with divisor = epochsPerDay; strictly less than the + // pre-fix value (which clamped to 1.0). + expect(np.interdailyStability, lessThan(0.999)); + expect(np.interdailyStability, closeTo(0.9583, 0.001)); + }); + test('M10/L5/RA are WITHHELD, not merely null (MT-09)', () { final samples = []; for (var i = 0; i < 3 * 24 * 6; i++) {