diff --git a/lib/src/onehz/wellness/temp_circadian.dart b/lib/src/onehz/wellness/temp_circadian.dart index 8294d8d..5b5b91c 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); @@ -268,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', ); } @@ -294,11 +324,13 @@ 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}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..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'); @@ -977,6 +988,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)];