From dac7e99cc04cc4a7e6c0f7197f280bfccedc0fe7 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:46:41 +0530 Subject: [PATCH 1/2] skin temp: provisional settle band for the rings (no accelerometer gate, half confidence) Ultrahuman, Colmi and Oura send finger skin temperature in centi-C with no accelerometer beside it. Give them a settle band set on synthetic nights, mark the settled mean provisional and serve it at half confidence until real ring nights replace the band. tempCircadian skips the motion gate when the family has none. --- lib/src/onehz/wellness/temp_circadian.dart | 46 ++++++-- test/onehz/wellness_test.dart | 127 +++++++++++++++++++++ 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index 8294d8d..850510a 100644 --- a/lib/src/onehz/wellness/temp_circadian.dart +++ b/lib/src/onehz/wellness/temp_circadian.dart @@ -83,13 +83,21 @@ class TempCircadian { /// and still be skin. All three are sensor properties, not physiology. class _TempCal { final String unit; - final double motionGate; // g of |‖a‖ − 1| above which an epoch is masked + + /// g of |‖a‖ − 1| above which an epoch is masked. Null = the family sends + /// no accelerometer with its temperature (a ring), so nothing is masked. + final double? motionGate; /// One-sided settle band, in [unit]: a sample more than this far BELOW the /// night's median is not settled skin (see [nightlySkinTemp]). Null = this /// family has no measured band, so the settled mean REFUSES for it rather /// than borrow gen4's counts. final double? settleBandLow; - const _TempCal(this.unit, this.motionGate, this.settleBandLow); + + /// True when [settleBandLow] was set on synthetic nights, not real ones: + /// the settled mean is then served at half confidence and says so. + final bool provisional; + const _TempCal(this.unit, this.motionGate, this.settleBandLow, + {this.provisional = false}); } const Map _tempCal = { @@ -106,6 +114,18 @@ const Map _tempCal = { // exactly what device.dart's contract forbids. Fill this in from gen5 nights, // not from arithmetic. 'gen5': _TempCal('centi_c', 0.04, null), + // The rings (Ultrahuman, Colmi, Oura): finger skin temperature in centi-°C, + // one reading per 5-minute record (Colmi: per 30-minute slot; Oura: per + // temperature event), no accelerometer beside it. PROVISIONAL, set on synthetic nights because no real ring + // night exists yet (test/onehz/wellness_test.dart, "ring settle band"): + // a settled finger night sits within ~0.3 °C of its own median with a + // ~1.5 °C rise over the first half hour after onset, while a ring that is + // loose or just put on reads 2-5 °C low. 150 centi-°C keeps every clean + // synthetic night whole and trims every cold segment. Replace from real + // ring nights, the way gen4's 40 counts were measured. + 'ultrahuman': _TempCal('centi_c', null, 150.0, provisional: true), + 'colmi': _TempCal('centi_c', null, 150.0, provisional: true), + 'oura': _TempCal('centi_c', null, 150.0, provisional: true), }; /// A nightly skin-temp mean that knows how much of the night it is made of. @@ -119,11 +139,16 @@ class SettledSkinTemp { /// `adc_counts` (gen4) or `centi_c` (gen5). Never °C on screen. final String unit; - const SettledSkinTemp(this.mean, this.settledFraction, this.unit); + + /// The family's settle band was set on synthetic nights ([_TempCal]). + final bool provisional; + const SettledSkinTemp(this.mean, this.settledFraction, this.unit, + {this.provisional = false}); Map toJson() => { 'mean': round6(mean), 'settled_fraction': round6(settledFraction), 'unit': unit, + if (provisional) 'provisional': true, }; } @@ -199,14 +224,17 @@ Metric nightlySkinTemp( ); } return Metric( - value: SettledSkinTemp(mean(kept)!, frac, cal.unit), - confidence: frac.clamp(0.0, 1.0), + value: SettledSkinTemp(mean(kept)!, frac, cal.unit, + provisional: cal.provisional), + confidence: frac.clamp(0.0, 1.0) * (cal.provisional ? 0.5 : 1.0), tier: Tier.relative, inputs_used: inputs, note: 'RELATIVE nightly skin-temp mean over the SETTLED portion only ' '(${round6(frac)} of valid samples, band ${cal.settleBandLow} ' '${cal.unit} below the night median; one-sided so a fever passes). ' - 'Unit is ${cal.unit} — never °C, never compared across families.', + 'Unit is ${cal.unit} — never °C, never compared across families.' + '${cal.provisional ? ' PROVISIONAL band (synthetic nights, no real ' + 'night of this family yet): half confidence.' : ''}', ); } @@ -251,7 +279,7 @@ Metric tempCircadian( for (var i = 0; i < samples.length; i++) { final s = samples[i]; if (!s.valid) continue; - if (accel != null && i < accel.length) { + if (gate != null && accel != null && i < accel.length) { final a = accel[i]; if (a.valid) { final mag = math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z); @@ -297,8 +325,8 @@ Metric tempCircadian( inputs_used: accel == null ? inputs : [...inputs, 'accel'], note: 'RELATIVE skin-temp phase only (no °C/fever/core). Wrist temp is ' 'ANTIPHASE to core; activity-demasked epochs dropped=$deMasked ' - '(gate=${gate}g). Amplitude is in ${cal.unit} — never compare it ' - 'across device families. M10/L5/RA are WITHHELD: the series is ' + '(gate=${gate == null ? 'none, no accel' : '${gate}g'}). Amplitude ' + 'is in ${cal.unit} — never compare it across device families. M10/L5/RA are WITHHELD: the series is ' 'median-centred, so RA divides by a quantity that crosses zero.', ); } diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index 8887f74..f98ad2a 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -977,6 +977,133 @@ void main() { }); }); + // The rings' PROVISIONAL settle band (temp_circadian.dart `_tempCal`), set + // on synthetic nights since no real ring night exists yet. A night is 90 + // five-minute records in centi-°C: a plateau with a slow drift and ±0.2 °C + // jitter, entered through a half-hour vasodilation ramp from 1.5 °C below. + group('ring settle band (synthetic, provisional)', () { + List ringNight({ + double plateau = 3520, + int coldFrom = 0, + int coldRecords = 0, + double coldDrop = 300, + }) => + [ + for (var i = 0; i < 90; i++) + AdcSample( + i * 300000.0, + (i >= coldFrom && i < coldFrom + coldRecords) + ? plateau - coldDrop + : plateau - + (i < 6 ? 150.0 * (6 - i) / 6 : 0.0) + // onset ramp + 30.0 * math.sin(i / 90 * math.pi) + // slow drift + (i % 3 - 1) * 20.0, // jitter + ), + ]; + + test('every clean night stays whole, at half confidence', () { + for (final fam in ['ultrahuman', 'colmi']) { + for (final plateau in [3350.0, 3520.0, 3600.0]) { + final m = nightlySkinTemp(ringNight(plateau: plateau), + deviceFamily: fam, minSamples: 12); + expect(m.present, isTrue, reason: m.note); + expect(m.value!.settledFraction, greaterThanOrEqualTo(0.95)); + expect(m.value!.provisional, isTrue); + expect(m.confidence, lessThanOrEqualTo(0.5)); + expect(m.value!.toJson()['provisional'], isTrue); + } + } + }); + + test('a cold segment 2 °C or more low is trimmed, 25 % of one refuses', + () { + for (final drop in [200.0, 300.0, 500.0]) { + final m = nightlySkinTemp( + ringNight(coldFrom: 30, coldRecords: 12, coldDrop: drop), + deviceFamily: 'ultrahuman', + minSamples: 12); + expect(m.present, isTrue, reason: m.note); + // All 12 cold records go; the coolest onset record may go with them. + expect(m.value!.settledFraction, inInclusiveRange(77 / 90, 78 / 90)); + expect(m.value!.mean, greaterThan(3480)); + } + final cold = nightlySkinTemp( + ringNight(coldFrom: 30, coldRecords: 23, coldDrop: 300), + deviceFamily: 'colmi', + minSamples: 12); + expect(cold.present, isFalse); + expect(cold.note, startsWith('unsettled_skin_temp:')); + }); + + // A cold record has to clear the band by a margin, and a settled one sit + // inside it by a margin: neither side rests on a value placed on the edge. + test('the band has room on both sides of a clean night', () { + for (final fam in ['ultrahuman', 'colmi']) { + // A gentler 1 °C onset keeps every record. + final soft = [ + for (final s in ringNight()) + AdcSample( + s.tsMs, + s.adc + + (s.tsMs < 6 * 300000 + ? 50.0 * (6 - s.tsMs / 300000) / 6 + : 0.0)) + ]; + final m = nightlySkinTemp(soft, deviceFamily: fam, minSamples: 12); + expect(m.value!.settledFraction, 1.0, reason: fam); + // A ring 1.8 °C low for an hour (well past the band) is all trimmed. + final c = nightlySkinTemp( + ringNight(coldFrom: 40, coldRecords: 12, coldDrop: 180), + deviceFamily: fam, + minSamples: 12); + expect(c.value!.settledFraction, lessThanOrEqualTo(78 / 90)); + } + }); + + // Colmi's real shape: one reading per 30-minute slot, repeated on each of + // the slot's six 5-minute HR rows, so one cold slot is six identical lows. + List colmiNight({Set coldSlots = const {}}) => [ + for (var j = 0; j < 15; j++) + for (var k = 0; k < 6; k++) + AdcSample( + (j * 6 + k) * 300000.0, + coldSlots.contains(j) + ? 3520.0 - 300 + : 3520.0 - + (j == 0 ? 100.0 : 0.0) + // onset slot + 30.0 * math.sin(j / 15 * math.pi) + + (j % 3 - 1) * 20.0, + ), + ]; + + test('Colmi slots: one cold slot drops its six rows, four refuse', () { + expect( + nightlySkinTemp(colmiNight(), deviceFamily: 'colmi', minSamples: 12) + .value! + .settledFraction, + 1.0); + expect( + nightlySkinTemp(colmiNight(coldSlots: {7}), + deviceFamily: 'colmi', minSamples: 12) + .value! + .settledFraction, + 84 / 90); + final cold = nightlySkinTemp(colmiNight(coldSlots: {5, 6, 7, 8}), + deviceFamily: 'colmi', minSamples: 12); + expect(cold.present, isFalse); + expect(cold.note, startsWith('unsettled_skin_temp:')); + }); + + test('gen4 is not provisional and keeps its JSON', () { + final m = nightlySkinTemp( + [for (var i = 0; i < 100; i++) AdcSample(i * 1000.0, 805.0)], + deviceFamily: 'gen4'); + expect(m.value!.provisional, isFalse); + expect(m.confidence, 1.0); + expect(m.value!.toJson().containsKey('provisional'), isFalse); + }); + }); + group('tempInput settled gate (RD-05)', () { List around(double c) => [for (var i = 0; i < 14; i++) c + (i.isEven ? 1.0 : -1.0)]; From ad6384746d36a2e6b7b964658d308e40de403c8a Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:50:04 +0530 Subject: [PATCH 2/2] tempCircadian: name accel as an input only when it is read A ring family has no motion gate, so an accel list handed to it is never read; it no longer appears in inputs_used. --- lib/src/onehz/wellness/temp_circadian.dart | 8 ++++++-- test/onehz/wellness_test.dart | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index 850510a..5b5b91c 100644 --- a/lib/src/onehz/wellness/temp_circadian.dart +++ b/lib/src/onehz/wellness/temp_circadian.dart @@ -296,7 +296,9 @@ Metric tempCircadian( if (adc.length < 4) { return Metric.absent( tier: Tier.relative, - inputs_used: accel == null ? inputs : [...inputs, 'accel'], + inputs_used: gate == null || accel == null + ? inputs + : [...inputs, 'accel'], note: 'too few valid temp epochs for circadian analysis', ); } @@ -322,7 +324,9 @@ Metric tempCircadian( value: TempCircadian(cos.value, np, cal.unit), confidence: conf, tier: Tier.relative, - inputs_used: accel == null ? inputs : [...inputs, 'accel'], + inputs_used: gate == null || accel == null + ? inputs + : [...inputs, 'accel'], note: 'RELATIVE skin-temp phase only (no °C/fever/core). Wrist temp is ' 'ANTIPHASE to core; activity-demasked epochs dropped=$deMasked ' '(gate=${gate == null ? 'none, no accel' : '${gate}g'}). Amplitude ' diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index f98ad2a..d64843c 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -137,6 +137,17 @@ void main() { 'adc_counts'); expect( tempCircadian(samples, deviceFamily: 'gen5').value!.unit, 'centi_c'); + // A ring has no motion gate: an accel list it is handed is not read, + // so it is not named as an input. + final still = [for (final s in samples) AccelSample(s.tsMs, 0, 0, 1)]; + expect( + tempCircadian(samples, deviceFamily: 'oura', accel: still) + .inputs_used, + isNot(contains('accel'))); + expect( + tempCircadian(samples, deviceFamily: 'gen4', accel: still) + .inputs_used, + contains('accel')); for (final id in [null, '', 'imported']) { final m = tempCircadian(samples, deviceFamily: id); expect(m.present, isFalse, reason: 'id=$id');