From 6b939e51b55b244679c9955d08005ed6792f7436 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 29 Sep 2026 20:06:25 +0000 Subject: [PATCH 01/11] health: surface imported WHOOP blood oxygen as a suppressed metric row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WHOOP importer has stored spo2 scalars/series since the export import landed, but nothing could see them: no spec, no catalogue row. The band's own red/IR ADCs stay refused as a saturation source (kSpo2Refusal is a property of the bytes, not a firmware gap) — this only exposes the vendor's own derived value from the CSV, suppressed rather than charted so band-derived and vendor-imported points are never mixed under one line with no provenance axis. DerivationEngine's series map still does not list the key, so a re-derive can never fabricate or blank one. Co-authored-by: BucciMobile --- lib/l10n/app_de.arb | 6 ++- lib/l10n/app_en.arb | 6 ++- lib/l10n/app_es.arb | 6 ++- lib/l10n/app_fr.arb | 6 ++- lib/l10n/app_hi.arb | 6 ++- lib/l10n/app_zh.arb | 6 ++- lib/ui2/screens/health_screen.dart | 18 ++++++- lib/ui2/screens/metric_detail.dart | 28 +++++++++++ test/spo2_import_surface_test.dart | 76 ++++++++++++++++++++++++++++++ 9 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 test/spo2_import_surface_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e1c2e6125..318bf5d3b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1315,7 +1315,11 @@ "healthBlurbNapMin": "Schlaf, der außerhalb der Hauptnacht erkannt wurde", "healthBlurbRespRate": "Atemzüge pro Minute, aus der Herzschlagzeit ermittelt", "healthBlurbBrv": "Wie stark diese Rate über die Nacht hinweg schwankt", - "healthBlurbSteps": "Von einem Schrittzähler gezählt, niemals modelliert", + "healthBlurbSpo2": "WHOOPs eigener Wert, unverändert aus einem Import übernommen", + "@healthBlurbSpo2": { + "description": "Explore-Katalog: importierter Blutsauerstoff" + }, +"healthBlurbSteps": "Von einem Schrittzähler gezählt, niemals modelliert", "healthBlurbActiveMin": "Minuten an Bewegungsvolumen, nicht an Fortbewegung", "healthBlurbCalories": "Aktive Energie aus Herzfrequenz und deinem Profil", "healthBlurbStrain": "Kardiovaskuläre Belastung über den Tag, auf einer Skala von 0–21", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae97e419e..063ce9f76 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6744,7 +6744,11 @@ "@healthBlurbBrv": { "description": "Explore catalogue one-line description for breathing rate variability" }, - "healthBlurbSteps": "Counted by a pedometer, never modelled", + "healthBlurbSpo2": "WHOOP's own derived value, carried from an import", + "@healthBlurbSpo2": { + "description": "Explore catalogue one-line description for imported blood oxygen" + }, +"healthBlurbSteps": "Counted by a pedometer, never modelled", "@healthBlurbSteps": { "description": "Explore catalogue one-line description for steps" }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8612c4529..38c71bd4a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1315,7 +1315,11 @@ "healthBlurbNapMin": "Sueño detectado fuera de la noche principal", "healthBlurbRespRate": "Respiraciones por minuto, obtenidas del ritmo cardíaco", "healthBlurbBrv": "Cuánto varía ese ritmo a lo largo de la noche", - "healthBlurbSteps": "Contados por un podómetro, nunca estimados", + "healthBlurbSpo2": "Valor propio de WHOOP, tomado de una importación", + "@healthBlurbSpo2": { + "description": "Descripción del catálogo Explore: oxígeno en sangre importado" + }, +"healthBlurbSteps": "Contados por un podómetro, nunca estimados", "healthBlurbActiveMin": "Minutos de volumen de movimiento, no de desplazamiento", "healthBlurbCalories": "Energía activa calculada a partir de la frecuencia cardíaca y tu perfil", "healthBlurbStrain": "Carga cardiovascular a lo largo del día, en una escala de 0 a 21", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c760566d0..2166c72e6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1315,7 +1315,11 @@ "healthBlurbNapMin": "Sommeil détecté en dehors de la nuit principale", "healthBlurbRespRate": "Respirations par minute, déduites du rythme cardiaque", "healthBlurbBrv": "À quel point ce rythme varie au cours de la nuit", - "healthBlurbSteps": "Comptés par un podomètre, jamais modélisés", + "healthBlurbSpo2": "Valeur propre de WHOOP, reprise d'une importation", + "@healthBlurbSpo2": { + "description": "Description du catalogue Explore : oxygène sanguin importé" + }, +"healthBlurbSteps": "Comptés par un podomètre, jamais modélisés", "healthBlurbActiveMin": "Minutes de volume de mouvement, pas de déplacement", "healthBlurbCalories": "Énergie active calculée à partir de la fréquence cardiaque et de votre profil", "healthBlurbStrain": "Charge cardiovasculaire sur la journée, sur une échelle de 0 à 21", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a41b9a60b..858c21e13 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1315,7 +1315,11 @@ "healthBlurbNapMin": "मुख्य रात के अलावा मिली नींद", "healthBlurbRespRate": "प्रति मिनट साँसें, धड़कन-समय से निकाली गई", "healthBlurbBrv": "यह दर पूरी रात में कितनी बदलती है", - "healthBlurbSteps": "पेडोमीटर से गिने गए, कभी अनुमानित नहीं", + "healthBlurbSpo2": "WHOOP का अपना मान, आयात से लिया गया", + "@healthBlurbSpo2": { + "description": "Explore सूची: आयातित रक्त ऑक्सीजन" + }, +"healthBlurbSteps": "पेडोमीटर से गिने गए, कभी अनुमानित नहीं", "healthBlurbActiveMin": "गति की मात्रा के मिनट, चलना-फिरना नहीं", "healthBlurbCalories": "हृदय गति और आपकी प्रोफ़ाइल से निकाली गई सक्रिय ऊर्जा", "healthBlurbStrain": "पूरे दिन का हृदय-संवहनी भार, 0–21 के पैमाने पर", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f31d7ab86..a2f8cb6a2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1315,7 +1315,11 @@ "healthBlurbNapMin": "在主要夜间睡眠之外检测到的睡眠", "healthBlurbRespRate": "每分钟呼吸次数,由心跳节律推算得出", "healthBlurbBrv": "该频率在整夜中的波动程度", - "healthBlurbSteps": "由计步器实际计数,从不依赖模型估算", + "healthBlurbSpo2": "WHOOP 自己的数值,来自导入数据", + "@healthBlurbSpo2": { + "description": "Explore 目录:导入的血氧" + }, +"healthBlurbSteps": "由计步器实际计数,从不依赖模型估算", "healthBlurbActiveMin": "运动量的分钟数,而非移动距离", "healthBlurbCalories": "根据心率和你的个人资料计算出的活动能量", "healthBlurbStrain": "全天的心血管负荷,0至21分制", diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index e69d07590..6983d34df 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -334,10 +334,12 @@ class _Cat { /// The families, in the order a person looks for them. /// /// What is deliberately NOT here: -/// - SpO2, ODI and anything apnea-shaped. Refused outright — a capability this +/// - ODI and anything apnea-shaped. Refused outright — a capability this /// app does not produce has no entry, no card and no key, and an index that /// listed them to explain their absence would be the exact thing the -/// absent-forever rule forbids. +/// absent-forever rule forbids. (`spo2` is NOT in this bucket: it is a real +/// `metric_series` key the WHOOP importer writes, so it has a row — with a +/// spec that says whose number it is and why it is suppressed.) /// - Cycle. It is a Wellness tab with its own door and its own on/off switch; /// a second entrance from Health would be a duplicate route, not a feature. /// - `rmssd_whole`, `stress_si`, `brv_slope`. Real numbers, but single-night @@ -346,6 +348,16 @@ class _Cat { /// `stress` and `brv` below are the charted forms of two of the three. /// - Body clock, zones, Nerd stats. Each already has a door at the same depth /// as this one; adding a second is navigation debt. +/// Every metric key the Explore catalogue lists, in display order. +/// +/// Public so a test can pin the index without importing a private const — +/// and so anything that needs "the keys this screen considers metrics" +/// reads the SAME list the screen renders, not a second copy of it. +final Set catalogueKeys = { + for (final f in _catalogue) + for (final r in f.rows) r.key, +}; + const _catalogue = <_Cat>[ _Cat('Heart & rhythm', [ _CatRow('resting_hr', 'rhr', 'The lowest sustained rate of the night'), @@ -365,6 +377,7 @@ const _catalogue = <_Cat>[ _Cat('Breathing', [ _CatRow('resp_rate', 'resp_rate', 'Breaths per minute, recovered from beat timing'), _CatRow('brv', 'brv_cv', 'How much that rate varies across the night'), + _CatRow('spo2', 'spo2', "WHOOP's own derived value, carried from an import"), ]), _Cat('Movement & load', [ _CatRow('steps', 'steps', 'Counted by a pedometer, never modelled'), @@ -407,6 +420,7 @@ String _rowBlurb(AppLocalizations? l, String key, String blurb) => 'nap_min' => l?.healthBlurbNapMin ?? blurb, 'resp_rate' => l?.healthBlurbRespRate ?? blurb, 'brv' => l?.healthBlurbBrv ?? blurb, + 'spo2' => l?.healthBlurbSpo2 ?? blurb, 'steps' => l?.healthBlurbSteps ?? blurb, 'active_min' => l?.healthBlurbActiveMin ?? blurb, 'calories' => l?.healthBlurbCalories ?? blurb, diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index ef0f619ac..a9cd2c67e 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -310,6 +310,34 @@ const _specs = { citation: 'Within-user dispersion', requires: {InputSignal.rrIntervals}, ), + // Blood oxygen, as WHOOP'S OWN DERIVED VALUE from a CSV export — never as + // a measurement this app made. The band's raw red/IR ADCs are refused as an + // SpO2 source PERMANENTLY (see `kSpo2Refusal` in onehz_pipeline.dart: within + // a capture session `ir - red` is a fixed offset, so every ratio built from + // them measures baseline drift, not oxygenation). What survives is the + // vendor's own number, and only on days the WHOOP importer wrote + // (`series: {'spo2': ...}` in whoop_import.dart — DerivationEngine's own + // series map does NOT list the key, so a band re-derive can never fabricate + // one). SUPPRESSED rather than charted for the same reason skin_temp is: + // a chart mixes band-derived and vendor-imported points under one line with + // no provenance axis, and WHOOP's calibration is not this app's to audit. + 'spo2': MetricSpec( + chartKey: 'spo2', + title: 'Blood oxygen', + unit: '%', + color: C.pink, + icon: LucideIcons.droplet, + higherBetter: true, + suppress: 'Not measured by this app. WHOOP\'s own derived value, carried ' + 'verbatim from an imported export — never computed from the band\'s ' + 'raw signals, whose red/IR pair cannot yield a saturation.', + method: 'Nothing is computed here. The nightly mean saturation is ' + 'WHOOP\'s own derived number, read from the `blood oxygen %` column ' + 'of an imported export and stored as-is with ' + '`inputs_used: [whoop_export]`.', + citation: 'Vendor-derived, imported', + requires: {}, + ), // Both of these were written to `metric_series` on every derive since v55 and // had no spec, so nothing could open them — `specOf` fell through to a // generic entry titled "nap min". They are 17/17 on real data. diff --git a/test/spo2_import_surface_test.dart b/test/spo2_import_surface_test.dart new file mode 100644 index 000000000..ec2a43205 --- /dev/null +++ b/test/spo2_import_surface_test.dart @@ -0,0 +1,76 @@ +// The imported SpO2 surface — what it is, whose number it is, and what can +// never happen to it. +// +// Three claims are pinned here, and each one exists because its violation +// shipped before: +// +// * the metric is SUPPRESSED, not charted. A chart draws vendor-imported +// points under the same line as band-derived ones with no provenance +// axis, which is how a WHOOP night and a measured night end up compared +// as if the same maths produced both. +// * the band's own derivation NEVER writes the key. `putDayResult` replaces +// `metric_series` wholesale, so one careless `'spo2': null` in the +// engine's series map would blank every imported value on re-derive. +// The import path is the only writer. +// * the catalogue carries the row. An imported number nobody can see is +// indistinguishable from a number the app dropped. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/substrate.dart' show localDateLabel; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/import/whoop_import.dart'; +import 'package:openstrap_edge/ui2/screens/health_screen.dart' show catalogueKeys; +import 'package:openstrap_edge/ui2/screens/metric_detail.dart' show specOf; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'dart:io'; +import 'dart:convert'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + test('the spo2 spec exists, is suppressed, and claims no input signal', () { + final s = specOf('spo2'); + expect(s.suppress, isNotNull, + reason: 'imported vendor values must not be charted alongside ' + 'band-derived points'); + expect(s.requires, isEmpty, + reason: 'an imported scalar requires no device signal; a non-empty ' + 'set would gate the row behind devices that can never produce it'); + }); + + test('the Explore catalogue lists the imported blood-oxygen row', () { + expect(catalogueKeys, contains('spo2')); + }); + + test('the WHOOP importer writes the spo2 series row from the export column', + () async { + final dir = await Directory.systemTemp.createTemp('spo2_import'); + addTearDown(() => dir.delete(recursive: true)); + const wake = '2026-03-09 07:15:00'; + final day = localDateLabel( + DateTime.parse(wake).millisecondsSinceEpoch ~/ 1000); + final f = File('${dir.path}/day.csv'); + f.writeAsStringSync( + 'Cycle start time,Wake onset,Sleep onset,Blood oxygen %\n' + '$wake,$wake,2026-03-08 23:10:00,96.4\n', + ); + final res = await WhoopImporter.importFiles([f.path]); + expect(res.days, 1); + final db = await LocalDb.instance; + final rows = await db.query('metric_series', + where: 'date = ? AND key = ?', whereArgs: [day, 'spo2']); + expect(rows, hasLength(1)); + expect((rows.first['value'] as num?)?.toDouble(), 96.4); + // Provenance is the vendor's, never the band's. + final payload = jsonDecode((await db.query('day_result', + where: 'day_id = ?', whereArgs: [day])) + .first['payload_json'] as String) as Map; + expect(payload['source'], 'whoop_export'); + final scalars = payload['scalars'] as Map; + expect(scalars['spo2'], 96.4); + expect( + (payload['flags'] as List).contains('IMPORTED_WHOOP_BETA'), isTrue); + }); +} From e793ba684179a4748b4aef96387210660156fae4 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 29 Sep 2026 20:29:05 +0000 Subject: [PATCH 02/11] gen5: capture the band's own SpO2 candidate byte, stored unread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WHOOP 5.0/MG v18 record carries an SpO2 estimate/status byte at inner[74] every second; the app decoded it and threw it away. This banks the byte in decoded_onehz.spo2_candidate_raw so the capture exists from now on — the encoding is NOT pinned (nonzero only during band-declared sleep, clusters 95-99, bit 7 a suspected flag, no validity bit), so nothing may read it as a percentage or build a metric on it. Same contract as ts_subsec/band_sleep_state: off-ladder column via _ensureDecodedOneHzBandFields, omitted-when-null on the write, raw 0 (the band's own no-sample) preserved verbatim on gen5, null on gen4. The kSpo2Refusal on the gen4 red/IR pair is untouched. When the encoding is pinned against a real capture, the history is already there. Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 6 +++ lib/data/db.dart | 11 +++++ lib/data/models.dart | 29 +++++++++++ test/gen5_sample_mapping_test.dart | 61 +++++++++++++++++++++++ test/spo2_candidate_capture_test.dart | 70 +++++++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 test/spo2_candidate_capture_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c974bc1a0..eb0d78a1c 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -301,6 +301,12 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, + // The band's SpO2 estimate/status byte, RAW and STORED-UNREAD — see + // Sample.spo2CandidateRaw for why the byte is carried but nothing may + // interpret it. Same shape as bandSleepState: a raw byte, never an enum + // or a percent, because a stored number freezes a claim this app has + // no evidence to make. + spo2CandidateRaw: g.spo2CandidateRaw, ); } diff --git a/lib/data/db.dart b/lib/data/db.dart index f4ee1ecfe..d0851a3c4 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1350,6 +1350,12 @@ class LocalDb { // See Sample.bandSleepState for the evidence that it varies and means // what it says. 'band_sleep_state': 'INTEGER', + // The gen5 v18 SpO2 estimate/status byte, RAW — see + // Sample.spo2CandidateRaw for the full reasoning. Stored so the bytes + // EXIST: the encoding is not pinned, so nothing may read this as a + // percentage, a validity or a metric while it sits here unread. Not a + // ladder rung for the same reason as its neighbours. + 'spo2_candidate_raw': 'INTEGER', }; final have = await _columnsOf(db, 'decoded_onehz'); if (have.isEmpty) return; // table not created yet — the DDL carries them @@ -5710,6 +5716,7 @@ class LocalDb { dynAccelG: g.dynamicAccelerationG, tsSubsec: g.tsSubsec, bandSleepState: g.sleepStateRawNibble, + spo2CandidateRaw: g.spo2CandidateRaw, ); } } catch (_) {} @@ -5881,6 +5888,10 @@ class LocalDb { // everything the mid-ladder backfill can replay — but omitted-when-null // regardless, for the same reason. 'band_sleep_state': ?decoded.bandSleepState, + // The band's own SpO2 estimate/status byte, raw. gen5/MG only and + // omitted-when-null for the same mid-ladder reason. STORED-UNREAD — + // see Sample.spo2CandidateRaw. + 'spo2_candidate_raw': ?decoded.spo2CandidateRaw, // 0 IS THE ABSENT SENTINEL, NOT A READING. records.dart:501 emits // `ambientRaw: optical ? u16@70 : 0`, so every unconfirmed record version // reports 0 — writing that through would turn "we did not read the diff --git a/lib/data/models.dart b/lib/data/models.dart index ed619c12d..7f2288f99 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -150,6 +150,32 @@ class Sample { /// [tempCh2C] and [dynAccelG]. final int? bandSleepState; + /// The gen5 v18 SpO2 estimate/status byte, RAW — GEN5/MG ONLY, null on gen4. + /// + /// STORED, NEVER INTERPRETED. This is Option A of the SpO2 capture plan: the + /// band emits this byte every second whether or not anyone listens, and the + /// app threw it away. It is zero in ~99% of records, nonzero only while the + /// band's own sleep state reads `sleep`, and clusters at 95-99 — consistent + /// with a scheduled overnight sampling cadence. But the ENCODING is not + /// pinned (protocol refuses to publish a percentage from it; values above + /// 128 decompose as `128 + `, so bit 7 looks like + /// a flag), there is no validity flag to say which nonzero values are real, + /// and wrist reflectance oximetry is not cleared as a pulse oximeter for any + /// vendor. Persisting the raw byte claims none of that — it makes the data + /// exist, so the day the encoding is pinned against a real capture the + /// history is already there instead of starting then. + /// + /// The band-raw REFUSAL (`kSpo2Refusal`) is untouched: that governs gen4's + /// red/IR ADC pair (one signal — `ir − red` constant within a session) and + /// any DERIVED metric built from it. This field is the band's own computed + /// byte, stored unread. Nothing downstream consumes it; no metric, tier or + /// card may be built on it until the encoding is proven. + /// + /// 0 IS THE BAND'S "NO SAMPLE THIS SECOND", NOT A READING — and since the + /// byte is `required int` on the protocol record (never null), null here + /// means "not a gen5 v18 record". The raw 0 is preserved verbatim on gen5. + final int? spo2CandidateRaw; + Sample({ required this.tsEpoch, required this.counter, @@ -175,6 +201,7 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, + this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -206,6 +233,7 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, + spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => @@ -251,6 +279,7 @@ class Sample { dynAccelG: (m['dyn_accel_g'] as num?)?.toDouble(), tsSubsec: (m['ts_subsec'] as num?)?.toInt(), bandSleepState: (m['band_sleep_state'] as num?)?.toInt(), + spo2CandidateRaw: (m['spo2_candidate_raw'] as num?)?.toInt(), ); } } diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart index c73a31bf8..28e425352 100644 --- a/test/gen5_sample_mapping_test.dart +++ b/test/gen5_sample_mapping_test.dart @@ -33,6 +33,7 @@ Uint8List v18Inner({ required int skinTempRaw, int hrQualityFlags = 0, int sleepStateByte = 0, + int spo2CandidateRaw = 0, }) { final inner = Uint8List(kGen5V18InnerLen); final v = inner.buffer.asByteData(); @@ -50,6 +51,7 @@ Uint8List v18Inner({ v.setFloat32(45, 1.0, Endian.little); // gravity z — magSq 1.0 v.setInt16(65, skinTempRaw, Endian.little); // AS6221 skin temp, °C = raw/100 inner[73] = sleepStateByte; // body 60 — bits 0-1 are the disproven "on wrist" + inner[74] = spo2CandidateRaw; // body 61 - the SpO2 estimate/status byte return inner; } @@ -269,4 +271,63 @@ void main() { expect(sampleFromGen5Historical(decoded), isNull); }); }); + + // The band's own SpO2 estimate/status byte (inner[74]) — STORED, NEVER + // INTERPRETED. See Sample.spo2CandidateRaw: the encoding is not pinned + // (nonzero only during band-declared sleep, clusters 95-99, bit 7 a + // suspected flag), so the mapper carries the RAW byte through to + // `decoded_onehz.spo2_candidate_raw` and nothing may read it as a + // percentage. These tests pin the carry itself — the value, the verbatim + // zero, and the gen4 absence — so the column cannot silently go unwired + // the way the MT-12 channels once did. + group('sampleFromGen5Historical — the SpO2 candidate byte, raw', () { + test('a nonzero candidate comes through as the raw byte, unfiltered', () { + // 97 is inside the plausible-looking 95-99 cluster; the mapper must + // not range-check it, name it or convert it — carrying it raw is the + // entire contract. + final s = sampleFromGen5Historical( + parseGen5Historical(v18Inner(skinTempRaw: 3000, spo2CandidateRaw: 97)), + )!; + expect(s.spo2CandidateRaw, 97); + // Carrying the byte never costs the rest of the second. + expect(s.hr, 64); + expect(s.skinTempC, closeTo(30.0, 1e-9)); + }); + test('the band\'s own zero is preserved verbatim, not nulled', () { + // 0 is the band's "no sample this second" for 99% of records — a + // stored 0 says exactly that, and nulled-when-zero would erase the + // distinction between "sampled, nothing" and "not a gen5 record". + final s = sampleFromGen5Historical( + parseGen5Historical(v18Inner(skinTempRaw: 3000)), + )!; + expect(s.spo2CandidateRaw, 0); + }); + test('a >128 value rides through un-decomposed', () { + // bit 7 looks like a flag rather than part of the number; the mapper + // must not "helpfully" mask it off — the decomposition is unproven. + final s = sampleFromGen5Historical( + parseGen5Historical( + v18Inner(skinTempRaw: 3000, spo2CandidateRaw: 0xE1)), + )!; + expect(s.spo2CandidateRaw, 0xE1); + }); + test('the real fixtures carry their true zeros', () { + // Both CRC-verified real captures read inner[74] == 0 — consistent + // with the ~99%-zero finding, and the honest expectation for any + // daytime/worn second. + final s = sampleFromGen5Historical( + parseGen5Historical(hex( + '2f12804ffc41015e96716a5c4f00470000000000000000000031cd4f824f0000' + '4ca007723eaee72a3e148ec4bd3d7ad13e1702760000000100000000' + '0057015901950d400b010c020c2100000000000000000000000000' + '00000000000000000000000100a0658080000000bbe75cc0000000', + )), + )!; + expect(s.spo2CandidateRaw, 0); + }); + test('gen4 carries no such field', () { + // Null on gen4 — 0 would claim the band sampled and found nothing. + expect(Sample(tsEpoch: 1, counter: 1, hr: 60).spo2CandidateRaw, isNull); + }); + }); } diff --git a/test/spo2_candidate_capture_test.dart b/test/spo2_candidate_capture_test.dart new file mode 100644 index 000000000..eda1ee654 --- /dev/null +++ b/test/spo2_candidate_capture_test.dart @@ -0,0 +1,70 @@ +// The stored-unread SpO2 candidate column — persistence contract. +// +// `spo2_candidate_raw` exists so the gen5 band's own SpO2 estimate byte is +// CAPTURED from now on even though nothing may interpret it yet (the encoding +// is not pinned; see Sample.spo2CandidateRaw). These tests pin that the +// column really exists on a fresh open (the off-ladder `_repairOpenSchema` +// path), that a Sample written through the seam reads back byte-identical, +// and that the omitted-when-null write leaves absence — never a zero that +// would claim the band sampled and found nothing. +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/models.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + test('the column exists on a fresh open (off-ladder repair path)', () async { + final db = await LocalDb.instance; + final cols = await db.rawQuery('PRAGMA table_info(decoded_onehz)'); + expect( + cols.where((c) => c['name'] == 'spo2_candidate_raw'), + isNotEmpty, + reason: '_ensureDecodedOneHzBandFields must hand the column out on ' + 'every open, exactly like ts_subsec/band_sleep_state', + ); + }); + + test('a written candidate reads back verbatim through the Sample seam', + () async { + final db = await LocalDb.instance; + // The FFI database is shared across the whole run, so clear the slot + // first — the test is idempotent against whatever ran before it. + await db.delete('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924242]); + await db.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': 924242 * 1000, + 'rec_ts': 924242, + 'counter': 1, + 'hr': 64, + 'spo2_candidate_raw': 97, + }); + final rows = + await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924242]); + final s = Sample.fromDecodedRow(rows.first); + expect(s.spo2CandidateRaw, 97, reason: 'the raw byte, unfiltered'); + expect(s.hr, 64); + }); + + test('absence stays NULL — never a zero', () async { + final db = await LocalDb.instance; + await db.delete('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924243]); + await db.insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': 924243 * 1000, + 'rec_ts': 924243, + 'counter': 2, + 'hr': 60, + }); + final rows = + await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924243]); + final s = Sample.fromDecodedRow(rows.first); + expect(s.spo2CandidateRaw, isNull, + reason: 'no column value means not-a-gen5-record, not "sampled, ' + 'nothing" — the distinction IS the contract'); + }); +} From 25daf8bc8e8f0a3d11d26287c0bcc9b96a1c77d5 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 29 Sep 2026 21:19:06 +0000 Subject: [PATCH 03/11] health: show the imported spo2 values, dated, without charting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A suppressed spec loaded no series at all, so the catalogue row could not show what the importer had written. An importedValues flag lets the spo2 spec load its stored series and render it as a dated MonoTable with WHOOP provenance — on the detail screen and in Investigate — while the trend stays suppressed. Other suppressed specs (skin_temp) keep loading nothing. Co-authored-by: BucciMobile --- lib/l10n/app_de.arb | 6 ++++- lib/l10n/app_en.arb | 6 ++++- lib/l10n/app_es.arb | 6 ++++- lib/l10n/app_fr.arb | 6 ++++- lib/l10n/app_hi.arb | 6 ++++- lib/l10n/app_zh.arb | 6 ++++- lib/ui2/screens/investigate.dart | 25 +++++++++++++++++-- lib/ui2/screens/metric_detail.dart | 27 +++++++++++++++++++- test/spo2_import_surface_test.dart | 40 +++++++++++++++++++++++++++++- 9 files changed, 118 insertions(+), 10 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 318bf5d3b..1a987bbeb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1082,7 +1082,11 @@ "metricDetailRange6Months": "6 Monate", "metricDetailRangeYear": "Jahr", "metricDetailLockedNote": "{label} benötigt {needed} Tage Verlauf. Du hast {have}.", - "metricDetailNotShownTitle": "Nicht als Trend angezeigt", + "metricDetailImportedTableTitle": "Importierte Werte", + "@metricDetailImportedTableTitle": { + "description": "Datierte importierte Werte einer unterdrückten, hersteller-importierten Metrik (Blutsauerstoff)." + }, +"metricDetailNotShownTitle": "Nicht als Trend angezeigt", "metricDetailNothingRecordedToday": "Heute nichts aufgezeichnet", "metricDetailNoHistoryYet": "Noch kein Verlauf für {metric}", "metricDetailNoValueYet": "Für heute liegt noch kein Wert vor.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 063ce9f76..ede8e09de 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5433,7 +5433,11 @@ } } }, - "metricDetailNotShownTitle": "Not shown as a trend", + "metricDetailImportedTableTitle": "Imported values", + "@metricDetailImportedTableTitle": { + "description": "Dated imported values for a suppressed, vendor-imported metric (blood oxygen)." + }, +"metricDetailNotShownTitle": "Not shown as a trend", "@metricDetailNotShownTitle": { "description": "StatusCard title for a metric that cannot be charted as a trend (e.g. skin temperature)." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 38c71bd4a..d149663ff 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1082,7 +1082,11 @@ "metricDetailRange6Months": "6 meses", "metricDetailRangeYear": "Año", "metricDetailLockedNote": "{label} necesita {needed} días de historial. Tienes {have}.", - "metricDetailNotShownTitle": "No se muestra como tendencia", + "metricDetailImportedTableTitle": "Valores importados", + "@metricDetailImportedTableTitle": { + "description": "Valores importados con fecha para una métrica suprimida e importada del proveedor (oxígeno en sangre)." + }, +"metricDetailNotShownTitle": "No se muestra como tendencia", "metricDetailNothingRecordedToday": "Nada registrado hoy", "metricDetailNoHistoryYet": "Aún no hay historial de {metric}", "metricDetailNoValueYet": "Hoy aún no ha producido un valor.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2166c72e6..cfdb9abda 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1082,7 +1082,11 @@ "metricDetailRange6Months": "6 mois", "metricDetailRangeYear": "Année", "metricDetailLockedNote": "{label} nécessite {needed} jours d’historique. Vous en avez {have}.", - "metricDetailNotShownTitle": "Non affiché sous forme de tendance", + "metricDetailImportedTableTitle": "Valeurs importées", + "@metricDetailImportedTableTitle": { + "description": "Valeurs importées datées pour une métrique supprimée et importée du fournisseur (oxygène sanguin)." + }, +"metricDetailNotShownTitle": "Non affiché sous forme de tendance", "metricDetailNothingRecordedToday": "Rien d’enregistré aujourd’hui", "metricDetailNoHistoryYet": "Pas encore d’historique pour {metric}", "metricDetailNoValueYet": "Aujourd’hui n’a pas encore produit de valeur.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 858c21e13..7d5de42ff 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1082,7 +1082,11 @@ "metricDetailRange6Months": "6 महीने", "metricDetailRangeYear": "साल", "metricDetailLockedNote": "{label} के लिए {needed} दिनों का इतिहास चाहिए। आपके पास {have} है।", - "metricDetailNotShownTitle": "ट्रेंड के रूप में नहीं दिखाया गया", + "metricDetailImportedTableTitle": "आ—ूंत मान", + "@metricDetailImportedTableTitle": { + "description": "सप्रेड की गई, वेंडर-आ’fातत मेट्रिक (रक्त ऑक्सीजन) के दिनांकित आयातत मान।" + }, +"metricDetailNotShownTitle": "ट्रेंड के रूप में नहीं दिखाया गया", "metricDetailNothingRecordedToday": "आज कुछ भी दर्ज नहीं हुआ", "metricDetailNoHistoryYet": "{metric} के लिए अभी तक कोई इतिहास नहीं", "metricDetailNoValueYet": "आज अभी तक कोई मान नहीं मिला है।", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a2f8cb6a2..fe81ec8af 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1082,7 +1082,11 @@ "metricDetailRange6Months": "6 个月", "metricDetailRangeYear": "一年", "metricDetailLockedNote": "{label}需要 {needed} 天的历史记录。你有 {have} 天。", - "metricDetailNotShownTitle": "不作为趋势显示", + "metricDetailImportedTableTitle": "导入值", + "@metricDetailImportedTableTitle": { + "description": "被压制、供应商导入指标(血氧)的带日期导入值。" + }, +"metricDetailNotShownTitle": "不作为趋势显示", "metricDetailNothingRecordedToday": "今天没有记录", "metricDetailNoHistoryYet": "{metric}暂无历史记录", "metricDetailNoValueYet": "今天还没有产生数值。", diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index c645c0b1b..0efae1186 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -85,6 +85,10 @@ class InvestigateData { final int? coveragePct; final int? windowStart, windowEnd; final List series; + /// `series` WITH its timestamps, for the one reader that labels values + /// with days (the imported-values table). Suppressed metrics use this + /// instead of being reduced to bare doubles. + final List seriesPoints; const InvestigateData({ this.day, @@ -104,6 +108,7 @@ class InvestigateData { this.windowStart, this.windowEnd, this.series = const [], + this.seriesPoints = const [], }); static Future load(LocalRepository repo, String key, @@ -121,8 +126,10 @@ class InvestigateData { final lungs = await repo.getDayLungs(day); final row = await LocalDb.dayResult(day); final win = lungs['sleep_window']; - final series = - spec.suppress != null ? const [] : seriesOf(await repo.getChart(spec.chartKey)); + final seriesPoints = spec.suppress != null && !spec.importedValues + ? const [] + : pointsOf(await repo.getChart(spec.chartKey)); + final series = seriesOf({'points': seriesPoints}); final hrvish = key == 'hrv'; final dc = hrvish ? pointsOf(await repo.getChart('prsa_dc')) @@ -169,6 +176,7 @@ class InvestigateData { windowStart: win is Map ? (win['start'] as num?)?.toInt() : null, windowEnd: win is Map ? (win['end'] as num?)?.toInt() : null, series: series, + seriesPoints: seriesPoints, ); } @@ -1079,6 +1087,19 @@ class _InvestigateState extends State { l?.investigateNothingComputedForKey ?? 'Nothing computed for this key', spec.suppress!, icon: spec.icon), + // Same display path as the detail screen: a suppressed spec refuses + // the TREND, but a vendor-imported series is still the data the user + // came here to see. Dated values with provenance, never a line. + if (spec.importedValues && d.series.isNotEmpty) ...[ + const SizedBox(height: S.x3), + MonoTable( + l?.metricDetailImportedTableTitle ?? 'Imported values', + [ + for (final p in d.seriesPoints.reversed.take(30)) + (axisDay(p.t), '${p.v.toStringAsFixed(1)} ${spec.unit}'), + ], + ), + ], ]; } final s = d.series; diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index a9cd2c67e..ca792cb86 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -55,6 +55,12 @@ class MetricSpec { /// reason, shown as a `StatusCard` in place of the chart. final String? suppress; final String? suppressFix; + /// True when the suppressed metric still has imported values worth + /// SHOWING. A suppressed spec loads no series at all — the chart is the + /// thing suppression refuses — but a vendor-imported scalar that IS the + /// metric deserves its values on screen, as a dated table with provenance + /// instead of a trend line. Data-loading flag, not a rendering one. + final bool importedValues; /// How it is computed, and who published the method. Rendered by Nerd stats. final String method; @@ -83,6 +89,7 @@ class MetricSpec { this.higherBetter = true, this.suppress, this.suppressFix, + this.importedValues = false, this.method = '', this.citation = '', this.requires = const {}, @@ -336,6 +343,7 @@ const _specs = { 'of an imported export and stored as-is with ' '`inputs_used: [whoop_export]`.', citation: 'Vendor-derived, imported', + importedValues: true, requires: {}, ), // Both of these were written to `metric_series` on every derive since v55 and @@ -516,7 +524,9 @@ class MetricData { List candidates = const [], }) async { final spec = specOf(key); - if (spec.suppress != null) return const MetricData(); + if (spec.suppress != null && !spec.importedValues) { + return const MetricData(); + } final chart = await repo.getChart( spec.chartKey, signals: {for (final s in spec.requires) s.name}, @@ -880,6 +890,21 @@ class _MetricDetailState extends State { icon: spec.icon, ), const SizedBox(height: S.x5), + // The imported values themselves, as a dated table rather than a + // trend: suppression refuses the LINE (a chart would sit these + // vendor-derived points under the same maths as the band's own), not + // the numbers, which are the metric's only content. Newest first, so + // the latest import reads without scrolling. + if (d.series.isNotEmpty) ...[ + MonoTable( + l?.metricDetailImportedTableTitle ?? 'Imported values', + [ + for (final p in d.series.reversed.take(30)) + (axisDay(p.t), '${p.v.toStringAsFixed(1)} ${spec.unit}'), + ], + ), + const SizedBox(height: S.x5), + ], investigateRow(c, () => go(c, Investigate(widget.metricKey))), ] else if (vals.isEmpty) ...[ _ranges(c, d, spec.color), diff --git a/test/spo2_import_surface_test.dart b/test/spo2_import_surface_test.dart index ec2a43205..686879517 100644 --- a/test/spo2_import_surface_test.dart +++ b/test/spo2_import_surface_test.dart @@ -19,7 +19,9 @@ import 'package:openstrap_edge/compute/substrate.dart' show localDateLabel; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/import/whoop_import.dart'; import 'package:openstrap_edge/ui2/screens/health_screen.dart' show catalogueKeys; -import 'package:openstrap_edge/ui2/screens/metric_detail.dart' show specOf; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/ui2/screens/metric_detail.dart' + show MetricData, specOf; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'dart:io'; import 'dart:convert'; @@ -73,4 +75,40 @@ void main() { expect( (payload['flags'] as List).contains('IMPORTED_WHOOP_BETA'), isTrue); }); + + // The point of the imported-values surface (CodeRabbit, OpenStrap/edge#472): + // a suppressed spec used to load NO series at all, so the catalogue row was + // a door that opened onto a bare wall — the imported numbers were stored + // and nobody could see them. `importedValues` is the flag that lets the + // suppressed spec load its series without ever charting it. + test('a suppressed imported spec still loads its stored series', () async { + final spec = specOf('spo2'); + expect(spec.importedValues, isTrue, + reason: 'the spec must load its imported series despite suppression'); + final dir = await Directory.systemTemp.createTemp('spo2_visible'); + addTearDown(() => dir.delete(recursive: true)); + const wake = '2026-03-10 07:15:00'; + final f = File('${dir.path}/day.csv'); + f.writeAsStringSync( + 'Cycle start time,Wake onset,Sleep onset,Blood oxygen %\n' + '$wake,$wake,2026-03-09 23:10:00,95.1\n', + ); + final res = await WhoopImporter.importFiles([f.path]); + expect(res.days, 1); + final repo = LocalRepositoryImpl( + getProfileMap: () => const {}, + ); + final d = await MetricData.load(repo, 'spo2'); + expect(d.series, isNotEmpty, + reason: 'the imported values must be loadable for the dated table, ' + 'else the catalogue row cannot show what the importer wrote'); + expect(d.series.last.v, 95.1); + // skin_temp is suppressed WITHOUT imported values — its load must stay + // empty; the flag must not leak suppression's refusal of the chart into + // other suppressed specs' data. + final skin = await MetricData.load(repo, 'skin_temp'); + expect(skin.series, isEmpty, + reason: 'plain suppressed specs load nothing; only the imported ' + 'scalar spec has values worth loading'); + }); } From 2d90c4af4ebc0d8b96cef83ec184243d3f5760ea Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 29 Sep 2026 21:29:50 +0000 Subject: [PATCH 04/11] health: fix the Hindi imported-values strings and the investigate series build Two review findings: the new Hindi ARB strings carried stray control characters, and the investigate screen rebuilt its series by passing ChartPoint records through seriesOf, which accepts only map entries and dropped every point. The values now come straight off the records. Co-authored-by: BucciMobile --- lib/l10n/app_hi.arb | 4 ++-- lib/ui2/screens/investigate.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7d5de42ff..675995d91 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1082,9 +1082,9 @@ "metricDetailRange6Months": "6 महीने", "metricDetailRangeYear": "साल", "metricDetailLockedNote": "{label} के लिए {needed} दिनों का इतिहास चाहिए। आपके पास {have} है।", - "metricDetailImportedTableTitle": "आ—ूंत मान", + "metricDetailImportedTableTitle": "आयातित मान", "@metricDetailImportedTableTitle": { - "description": "सप्रेड की गई, वेंडर-आ’fातत मेट्रिक (रक्त ऑक्सीजन) के दिनांकित आयातत मान।" + "description": "दबाई गई, विक्रेता-आयातित मीट्रिक (रक्त ऑक्सीजन) के दिनांकित आयातित मान।" }, "metricDetailNotShownTitle": "ट्रेंड के रूप में नहीं दिखाया गया", "metricDetailNothingRecordedToday": "आज कुछ भी दर्ज नहीं हुआ", diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index 0efae1186..f0e0921bf 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -129,7 +129,7 @@ class InvestigateData { final seriesPoints = spec.suppress != null && !spec.importedValues ? const [] : pointsOf(await repo.getChart(spec.chartKey)); - final series = seriesOf({'points': seriesPoints}); + final series = [for (final p in seriesPoints) p.v]; final hrvish = key == 'hrv'; final dc = hrvish ? pointsOf(await repo.getChart('prsa_dc')) From e79e2f17fcbf0f127d05235d4080652ab1cf9cc3 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:16:08 +0530 Subject: [PATCH 05/11] drop the gen5 spo2 candidate byte column, nothing reads it yet --- lib/ble/ble_engine.dart | 6 --- lib/data/db.dart | 11 ----- lib/data/models.dart | 29 ----------- test/gen5_sample_mapping_test.dart | 61 ----------------------- test/spo2_candidate_capture_test.dart | 70 --------------------------- 5 files changed, 177 deletions(-) delete mode 100644 test/spo2_candidate_capture_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index eb0d78a1c..c974bc1a0 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -301,12 +301,6 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, - // The band's SpO2 estimate/status byte, RAW and STORED-UNREAD — see - // Sample.spo2CandidateRaw for why the byte is carried but nothing may - // interpret it. Same shape as bandSleepState: a raw byte, never an enum - // or a percent, because a stored number freezes a claim this app has - // no evidence to make. - spo2CandidateRaw: g.spo2CandidateRaw, ); } diff --git a/lib/data/db.dart b/lib/data/db.dart index d0851a3c4..f4ee1ecfe 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1350,12 +1350,6 @@ class LocalDb { // See Sample.bandSleepState for the evidence that it varies and means // what it says. 'band_sleep_state': 'INTEGER', - // The gen5 v18 SpO2 estimate/status byte, RAW — see - // Sample.spo2CandidateRaw for the full reasoning. Stored so the bytes - // EXIST: the encoding is not pinned, so nothing may read this as a - // percentage, a validity or a metric while it sits here unread. Not a - // ladder rung for the same reason as its neighbours. - 'spo2_candidate_raw': 'INTEGER', }; final have = await _columnsOf(db, 'decoded_onehz'); if (have.isEmpty) return; // table not created yet — the DDL carries them @@ -5716,7 +5710,6 @@ class LocalDb { dynAccelG: g.dynamicAccelerationG, tsSubsec: g.tsSubsec, bandSleepState: g.sleepStateRawNibble, - spo2CandidateRaw: g.spo2CandidateRaw, ); } } catch (_) {} @@ -5888,10 +5881,6 @@ class LocalDb { // everything the mid-ladder backfill can replay — but omitted-when-null // regardless, for the same reason. 'band_sleep_state': ?decoded.bandSleepState, - // The band's own SpO2 estimate/status byte, raw. gen5/MG only and - // omitted-when-null for the same mid-ladder reason. STORED-UNREAD — - // see Sample.spo2CandidateRaw. - 'spo2_candidate_raw': ?decoded.spo2CandidateRaw, // 0 IS THE ABSENT SENTINEL, NOT A READING. records.dart:501 emits // `ambientRaw: optical ? u16@70 : 0`, so every unconfirmed record version // reports 0 — writing that through would turn "we did not read the diff --git a/lib/data/models.dart b/lib/data/models.dart index 7f2288f99..ed619c12d 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -150,32 +150,6 @@ class Sample { /// [tempCh2C] and [dynAccelG]. final int? bandSleepState; - /// The gen5 v18 SpO2 estimate/status byte, RAW — GEN5/MG ONLY, null on gen4. - /// - /// STORED, NEVER INTERPRETED. This is Option A of the SpO2 capture plan: the - /// band emits this byte every second whether or not anyone listens, and the - /// app threw it away. It is zero in ~99% of records, nonzero only while the - /// band's own sleep state reads `sleep`, and clusters at 95-99 — consistent - /// with a scheduled overnight sampling cadence. But the ENCODING is not - /// pinned (protocol refuses to publish a percentage from it; values above - /// 128 decompose as `128 + `, so bit 7 looks like - /// a flag), there is no validity flag to say which nonzero values are real, - /// and wrist reflectance oximetry is not cleared as a pulse oximeter for any - /// vendor. Persisting the raw byte claims none of that — it makes the data - /// exist, so the day the encoding is pinned against a real capture the - /// history is already there instead of starting then. - /// - /// The band-raw REFUSAL (`kSpo2Refusal`) is untouched: that governs gen4's - /// red/IR ADC pair (one signal — `ir − red` constant within a session) and - /// any DERIVED metric built from it. This field is the band's own computed - /// byte, stored unread. Nothing downstream consumes it; no metric, tier or - /// card may be built on it until the encoding is proven. - /// - /// 0 IS THE BAND'S "NO SAMPLE THIS SECOND", NOT A READING — and since the - /// byte is `required int` on the protocol record (never null), null here - /// means "not a gen5 v18 record". The raw 0 is preserved verbatim on gen5. - final int? spo2CandidateRaw; - Sample({ required this.tsEpoch, required this.counter, @@ -201,7 +175,6 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, - this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -233,7 +206,6 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, - spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => @@ -279,7 +251,6 @@ class Sample { dynAccelG: (m['dyn_accel_g'] as num?)?.toDouble(), tsSubsec: (m['ts_subsec'] as num?)?.toInt(), bandSleepState: (m['band_sleep_state'] as num?)?.toInt(), - spo2CandidateRaw: (m['spo2_candidate_raw'] as num?)?.toInt(), ); } } diff --git a/test/gen5_sample_mapping_test.dart b/test/gen5_sample_mapping_test.dart index 28e425352..c73a31bf8 100644 --- a/test/gen5_sample_mapping_test.dart +++ b/test/gen5_sample_mapping_test.dart @@ -33,7 +33,6 @@ Uint8List v18Inner({ required int skinTempRaw, int hrQualityFlags = 0, int sleepStateByte = 0, - int spo2CandidateRaw = 0, }) { final inner = Uint8List(kGen5V18InnerLen); final v = inner.buffer.asByteData(); @@ -51,7 +50,6 @@ Uint8List v18Inner({ v.setFloat32(45, 1.0, Endian.little); // gravity z — magSq 1.0 v.setInt16(65, skinTempRaw, Endian.little); // AS6221 skin temp, °C = raw/100 inner[73] = sleepStateByte; // body 60 — bits 0-1 are the disproven "on wrist" - inner[74] = spo2CandidateRaw; // body 61 - the SpO2 estimate/status byte return inner; } @@ -271,63 +269,4 @@ void main() { expect(sampleFromGen5Historical(decoded), isNull); }); }); - - // The band's own SpO2 estimate/status byte (inner[74]) — STORED, NEVER - // INTERPRETED. See Sample.spo2CandidateRaw: the encoding is not pinned - // (nonzero only during band-declared sleep, clusters 95-99, bit 7 a - // suspected flag), so the mapper carries the RAW byte through to - // `decoded_onehz.spo2_candidate_raw` and nothing may read it as a - // percentage. These tests pin the carry itself — the value, the verbatim - // zero, and the gen4 absence — so the column cannot silently go unwired - // the way the MT-12 channels once did. - group('sampleFromGen5Historical — the SpO2 candidate byte, raw', () { - test('a nonzero candidate comes through as the raw byte, unfiltered', () { - // 97 is inside the plausible-looking 95-99 cluster; the mapper must - // not range-check it, name it or convert it — carrying it raw is the - // entire contract. - final s = sampleFromGen5Historical( - parseGen5Historical(v18Inner(skinTempRaw: 3000, spo2CandidateRaw: 97)), - )!; - expect(s.spo2CandidateRaw, 97); - // Carrying the byte never costs the rest of the second. - expect(s.hr, 64); - expect(s.skinTempC, closeTo(30.0, 1e-9)); - }); - test('the band\'s own zero is preserved verbatim, not nulled', () { - // 0 is the band's "no sample this second" for 99% of records — a - // stored 0 says exactly that, and nulled-when-zero would erase the - // distinction between "sampled, nothing" and "not a gen5 record". - final s = sampleFromGen5Historical( - parseGen5Historical(v18Inner(skinTempRaw: 3000)), - )!; - expect(s.spo2CandidateRaw, 0); - }); - test('a >128 value rides through un-decomposed', () { - // bit 7 looks like a flag rather than part of the number; the mapper - // must not "helpfully" mask it off — the decomposition is unproven. - final s = sampleFromGen5Historical( - parseGen5Historical( - v18Inner(skinTempRaw: 3000, spo2CandidateRaw: 0xE1)), - )!; - expect(s.spo2CandidateRaw, 0xE1); - }); - test('the real fixtures carry their true zeros', () { - // Both CRC-verified real captures read inner[74] == 0 — consistent - // with the ~99%-zero finding, and the honest expectation for any - // daytime/worn second. - final s = sampleFromGen5Historical( - parseGen5Historical(hex( - '2f12804ffc41015e96716a5c4f00470000000000000000000031cd4f824f0000' - '4ca007723eaee72a3e148ec4bd3d7ad13e1702760000000100000000' - '0057015901950d400b010c020c2100000000000000000000000000' - '00000000000000000000000100a0658080000000bbe75cc0000000', - )), - )!; - expect(s.spo2CandidateRaw, 0); - }); - test('gen4 carries no such field', () { - // Null on gen4 — 0 would claim the band sampled and found nothing. - expect(Sample(tsEpoch: 1, counter: 1, hr: 60).spo2CandidateRaw, isNull); - }); - }); } diff --git a/test/spo2_candidate_capture_test.dart b/test/spo2_candidate_capture_test.dart deleted file mode 100644 index eda1ee654..000000000 --- a/test/spo2_candidate_capture_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -// The stored-unread SpO2 candidate column — persistence contract. -// -// `spo2_candidate_raw` exists so the gen5 band's own SpO2 estimate byte is -// CAPTURED from now on even though nothing may interpret it yet (the encoding -// is not pinned; see Sample.spo2CandidateRaw). These tests pin that the -// column really exists on a fresh open (the off-ladder `_repairOpenSchema` -// path), that a Sample written through the seam reads back byte-identical, -// and that the omitted-when-null write leaves absence — never a zero that -// would claim the band sampled and found nothing. -import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:openstrap_edge/data/db.dart'; -import 'package:openstrap_edge/data/models.dart'; - -void main() { - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - test('the column exists on a fresh open (off-ladder repair path)', () async { - final db = await LocalDb.instance; - final cols = await db.rawQuery('PRAGMA table_info(decoded_onehz)'); - expect( - cols.where((c) => c['name'] == 'spo2_candidate_raw'), - isNotEmpty, - reason: '_ensureDecodedOneHzBandFields must hand the column out on ' - 'every open, exactly like ts_subsec/band_sleep_state', - ); - }); - - test('a written candidate reads back verbatim through the Sample seam', - () async { - final db = await LocalDb.instance; - // The FFI database is shared across the whole run, so clear the slot - // first — the test is idempotent against whatever ran before it. - await db.delete('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924242]); - await db.insert('decoded_onehz', { - 'device_id': '', - 'ts_ms': 924242 * 1000, - 'rec_ts': 924242, - 'counter': 1, - 'hr': 64, - 'spo2_candidate_raw': 97, - }); - final rows = - await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924242]); - final s = Sample.fromDecodedRow(rows.first); - expect(s.spo2CandidateRaw, 97, reason: 'the raw byte, unfiltered'); - expect(s.hr, 64); - }); - - test('absence stays NULL — never a zero', () async { - final db = await LocalDb.instance; - await db.delete('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924243]); - await db.insert('decoded_onehz', { - 'device_id': '', - 'ts_ms': 924243 * 1000, - 'rec_ts': 924243, - 'counter': 2, - 'hr': 60, - }); - final rows = - await db.query('decoded_onehz', where: 'rec_ts = ?', whereArgs: [924243]); - final s = Sample.fromDecodedRow(rows.first); - expect(s.spo2CandidateRaw, isNull, - reason: 'no column value means not-a-gen5-record, not "sampled, ' - 'nothing" — the distinction IS the contract'); - }); -} From 2522f2c94e5ec977d1ba847eac20ee7671b95ee0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:19:47 +0530 Subject: [PATCH 06/11] health: chart imported spo2 like the other imported metrics, drop the suppressed table --- lib/l10n/app_de.arb | 10 ++---- lib/l10n/app_en.arb | 10 ++---- lib/l10n/app_es.arb | 10 ++---- lib/l10n/app_fr.arb | 10 ++---- lib/l10n/app_hi.arb | 10 ++---- lib/l10n/app_zh.arb | 10 ++---- lib/ui2/screens/health_screen.dart | 20 +++-------- lib/ui2/screens/investigate.dart | 25 ++------------ lib/ui2/screens/metric_detail.dart | 53 ++++------------------------ test/spo2_import_surface_test.dart | 55 +++++------------------------- 10 files changed, 39 insertions(+), 174 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1a987bbeb..b8623af08 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1082,11 +1082,7 @@ "metricDetailRange6Months": "6 Monate", "metricDetailRangeYear": "Jahr", "metricDetailLockedNote": "{label} benötigt {needed} Tage Verlauf. Du hast {have}.", - "metricDetailImportedTableTitle": "Importierte Werte", - "@metricDetailImportedTableTitle": { - "description": "Datierte importierte Werte einer unterdrückten, hersteller-importierten Metrik (Blutsauerstoff)." - }, -"metricDetailNotShownTitle": "Nicht als Trend angezeigt", + "metricDetailNotShownTitle": "Nicht als Trend angezeigt", "metricDetailNothingRecordedToday": "Heute nichts aufgezeichnet", "metricDetailNoHistoryYet": "Noch kein Verlauf für {metric}", "metricDetailNoValueYet": "Für heute liegt noch kein Wert vor.", @@ -1319,11 +1315,11 @@ "healthBlurbNapMin": "Schlaf, der außerhalb der Hauptnacht erkannt wurde", "healthBlurbRespRate": "Atemzüge pro Minute, aus der Herzschlagzeit ermittelt", "healthBlurbBrv": "Wie stark diese Rate über die Nacht hinweg schwankt", - "healthBlurbSpo2": "WHOOPs eigener Wert, unverändert aus einem Import übernommen", + "healthBlurbSpo2": "WHOOPs eigener Wert, unverändert aus einem Import übernommen", "@healthBlurbSpo2": { "description": "Explore-Katalog: importierter Blutsauerstoff" }, -"healthBlurbSteps": "Von einem Schrittzähler gezählt, niemals modelliert", + "healthBlurbSteps": "Von einem Schrittzähler gezählt, niemals modelliert", "healthBlurbActiveMin": "Minuten an Bewegungsvolumen, nicht an Fortbewegung", "healthBlurbCalories": "Aktive Energie aus Herzfrequenz und deinem Profil", "healthBlurbStrain": "Kardiovaskuläre Belastung über den Tag, auf einer Skala von 0–21", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ede8e09de..d32768df1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5433,11 +5433,7 @@ } } }, - "metricDetailImportedTableTitle": "Imported values", - "@metricDetailImportedTableTitle": { - "description": "Dated imported values for a suppressed, vendor-imported metric (blood oxygen)." - }, -"metricDetailNotShownTitle": "Not shown as a trend", + "metricDetailNotShownTitle": "Not shown as a trend", "@metricDetailNotShownTitle": { "description": "StatusCard title for a metric that cannot be charted as a trend (e.g. skin temperature)." }, @@ -6748,11 +6744,11 @@ "@healthBlurbBrv": { "description": "Explore catalogue one-line description for breathing rate variability" }, - "healthBlurbSpo2": "WHOOP's own derived value, carried from an import", + "healthBlurbSpo2": "WHOOP's own derived value, carried from an import", "@healthBlurbSpo2": { "description": "Explore catalogue one-line description for imported blood oxygen" }, -"healthBlurbSteps": "Counted by a pedometer, never modelled", + "healthBlurbSteps": "Counted by a pedometer, never modelled", "@healthBlurbSteps": { "description": "Explore catalogue one-line description for steps" }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d149663ff..6d62d85da 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1082,11 +1082,7 @@ "metricDetailRange6Months": "6 meses", "metricDetailRangeYear": "Año", "metricDetailLockedNote": "{label} necesita {needed} días de historial. Tienes {have}.", - "metricDetailImportedTableTitle": "Valores importados", - "@metricDetailImportedTableTitle": { - "description": "Valores importados con fecha para una métrica suprimida e importada del proveedor (oxígeno en sangre)." - }, -"metricDetailNotShownTitle": "No se muestra como tendencia", + "metricDetailNotShownTitle": "No se muestra como tendencia", "metricDetailNothingRecordedToday": "Nada registrado hoy", "metricDetailNoHistoryYet": "Aún no hay historial de {metric}", "metricDetailNoValueYet": "Hoy aún no ha producido un valor.", @@ -1319,11 +1315,11 @@ "healthBlurbNapMin": "Sueño detectado fuera de la noche principal", "healthBlurbRespRate": "Respiraciones por minuto, obtenidas del ritmo cardíaco", "healthBlurbBrv": "Cuánto varía ese ritmo a lo largo de la noche", - "healthBlurbSpo2": "Valor propio de WHOOP, tomado de una importación", + "healthBlurbSpo2": "Valor propio de WHOOP, tomado de una importación", "@healthBlurbSpo2": { "description": "Descripción del catálogo Explore: oxígeno en sangre importado" }, -"healthBlurbSteps": "Contados por un podómetro, nunca estimados", + "healthBlurbSteps": "Contados por un podómetro, nunca estimados", "healthBlurbActiveMin": "Minutos de volumen de movimiento, no de desplazamiento", "healthBlurbCalories": "Energía activa calculada a partir de la frecuencia cardíaca y tu perfil", "healthBlurbStrain": "Carga cardiovascular a lo largo del día, en una escala de 0 a 21", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cfdb9abda..48786b420 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1082,11 +1082,7 @@ "metricDetailRange6Months": "6 mois", "metricDetailRangeYear": "Année", "metricDetailLockedNote": "{label} nécessite {needed} jours d’historique. Vous en avez {have}.", - "metricDetailImportedTableTitle": "Valeurs importées", - "@metricDetailImportedTableTitle": { - "description": "Valeurs importées datées pour une métrique supprimée et importée du fournisseur (oxygène sanguin)." - }, -"metricDetailNotShownTitle": "Non affiché sous forme de tendance", + "metricDetailNotShownTitle": "Non affiché sous forme de tendance", "metricDetailNothingRecordedToday": "Rien d’enregistré aujourd’hui", "metricDetailNoHistoryYet": "Pas encore d’historique pour {metric}", "metricDetailNoValueYet": "Aujourd’hui n’a pas encore produit de valeur.", @@ -1319,11 +1315,11 @@ "healthBlurbNapMin": "Sommeil détecté en dehors de la nuit principale", "healthBlurbRespRate": "Respirations par minute, déduites du rythme cardiaque", "healthBlurbBrv": "À quel point ce rythme varie au cours de la nuit", - "healthBlurbSpo2": "Valeur propre de WHOOP, reprise d'une importation", + "healthBlurbSpo2": "Valeur propre de WHOOP, reprise d'une importation", "@healthBlurbSpo2": { "description": "Description du catalogue Explore : oxygène sanguin importé" }, -"healthBlurbSteps": "Comptés par un podomètre, jamais modélisés", + "healthBlurbSteps": "Comptés par un podomètre, jamais modélisés", "healthBlurbActiveMin": "Minutes de volume de mouvement, pas de déplacement", "healthBlurbCalories": "Énergie active calculée à partir de la fréquence cardiaque et de votre profil", "healthBlurbStrain": "Charge cardiovasculaire sur la journée, sur une échelle de 0 à 21", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 675995d91..042de5c54 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1082,11 +1082,7 @@ "metricDetailRange6Months": "6 महीने", "metricDetailRangeYear": "साल", "metricDetailLockedNote": "{label} के लिए {needed} दिनों का इतिहास चाहिए। आपके पास {have} है।", - "metricDetailImportedTableTitle": "आयातित मान", - "@metricDetailImportedTableTitle": { - "description": "दबाई गई, विक्रेता-आयातित मीट्रिक (रक्त ऑक्सीजन) के दिनांकित आयातित मान।" - }, -"metricDetailNotShownTitle": "ट्रेंड के रूप में नहीं दिखाया गया", + "metricDetailNotShownTitle": "ट्रेंड के रूप में नहीं दिखाया गया", "metricDetailNothingRecordedToday": "आज कुछ भी दर्ज नहीं हुआ", "metricDetailNoHistoryYet": "{metric} के लिए अभी तक कोई इतिहास नहीं", "metricDetailNoValueYet": "आज अभी तक कोई मान नहीं मिला है।", @@ -1319,11 +1315,11 @@ "healthBlurbNapMin": "मुख्य रात के अलावा मिली नींद", "healthBlurbRespRate": "प्रति मिनट साँसें, धड़कन-समय से निकाली गई", "healthBlurbBrv": "यह दर पूरी रात में कितनी बदलती है", - "healthBlurbSpo2": "WHOOP का अपना मान, आयात से लिया गया", + "healthBlurbSpo2": "WHOOP का अपना मान, आयात से लिया गया", "@healthBlurbSpo2": { "description": "Explore सूची: आयातित रक्त ऑक्सीजन" }, -"healthBlurbSteps": "पेडोमीटर से गिने गए, कभी अनुमानित नहीं", + "healthBlurbSteps": "पेडोमीटर से गिने गए, कभी अनुमानित नहीं", "healthBlurbActiveMin": "गति की मात्रा के मिनट, चलना-फिरना नहीं", "healthBlurbCalories": "हृदय गति और आपकी प्रोफ़ाइल से निकाली गई सक्रिय ऊर्जा", "healthBlurbStrain": "पूरे दिन का हृदय-संवहनी भार, 0–21 के पैमाने पर", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fe81ec8af..9a9fbe216 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1082,11 +1082,7 @@ "metricDetailRange6Months": "6 个月", "metricDetailRangeYear": "一年", "metricDetailLockedNote": "{label}需要 {needed} 天的历史记录。你有 {have} 天。", - "metricDetailImportedTableTitle": "导入值", - "@metricDetailImportedTableTitle": { - "description": "被压制、供应商导入指标(血氧)的带日期导入值。" - }, -"metricDetailNotShownTitle": "不作为趋势显示", + "metricDetailNotShownTitle": "不作为趋势显示", "metricDetailNothingRecordedToday": "今天没有记录", "metricDetailNoHistoryYet": "{metric}暂无历史记录", "metricDetailNoValueYet": "今天还没有产生数值。", @@ -1319,11 +1315,11 @@ "healthBlurbNapMin": "在主要夜间睡眠之外检测到的睡眠", "healthBlurbRespRate": "每分钟呼吸次数,由心跳节律推算得出", "healthBlurbBrv": "该频率在整夜中的波动程度", - "healthBlurbSpo2": "WHOOP 自己的数值,来自导入数据", + "healthBlurbSpo2": "WHOOP 自己的数值,来自导入数据", "@healthBlurbSpo2": { "description": "Explore 目录:导入的血氧" }, -"healthBlurbSteps": "由计步器实际计数,从不依赖模型估算", + "healthBlurbSteps": "由计步器实际计数,从不依赖模型估算", "healthBlurbActiveMin": "运动量的分钟数,而非移动距离", "healthBlurbCalories": "根据心率和你的个人资料计算出的活动能量", "healthBlurbStrain": "全天的心血管负荷,0至21分制", diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 6983d34df..0a5496941 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -334,12 +334,10 @@ class _Cat { /// The families, in the order a person looks for them. /// /// What is deliberately NOT here: -/// - ODI and anything apnea-shaped. Refused outright — a capability this -/// app does not produce has no entry, no card and no key, and an index that -/// listed them to explain their absence would be the exact thing the -/// absent-forever rule forbids. (`spo2` is NOT in this bucket: it is a real -/// `metric_series` key the WHOOP importer writes, so it has a row — with a -/// spec that says whose number it is and why it is suppressed.) +/// - ODI and anything apnea-shaped. A capability this app does not produce +/// has no entry, no card and no key, and an index that listed them to +/// explain their absence would be the exact thing the absent-forever rule +/// forbids. /// - Cycle. It is a Wellness tab with its own door and its own on/off switch; /// a second entrance from Health would be a duplicate route, not a feature. /// - `rmssd_whole`, `stress_si`, `brv_slope`. Real numbers, but single-night @@ -348,16 +346,6 @@ class _Cat { /// `stress` and `brv` below are the charted forms of two of the three. /// - Body clock, zones, Nerd stats. Each already has a door at the same depth /// as this one; adding a second is navigation debt. -/// Every metric key the Explore catalogue lists, in display order. -/// -/// Public so a test can pin the index without importing a private const — -/// and so anything that needs "the keys this screen considers metrics" -/// reads the SAME list the screen renders, not a second copy of it. -final Set catalogueKeys = { - for (final f in _catalogue) - for (final r in f.rows) r.key, -}; - const _catalogue = <_Cat>[ _Cat('Heart & rhythm', [ _CatRow('resting_hr', 'rhr', 'The lowest sustained rate of the night'), diff --git a/lib/ui2/screens/investigate.dart b/lib/ui2/screens/investigate.dart index f0e0921bf..c645c0b1b 100644 --- a/lib/ui2/screens/investigate.dart +++ b/lib/ui2/screens/investigate.dart @@ -85,10 +85,6 @@ class InvestigateData { final int? coveragePct; final int? windowStart, windowEnd; final List series; - /// `series` WITH its timestamps, for the one reader that labels values - /// with days (the imported-values table). Suppressed metrics use this - /// instead of being reduced to bare doubles. - final List seriesPoints; const InvestigateData({ this.day, @@ -108,7 +104,6 @@ class InvestigateData { this.windowStart, this.windowEnd, this.series = const [], - this.seriesPoints = const [], }); static Future load(LocalRepository repo, String key, @@ -126,10 +121,8 @@ class InvestigateData { final lungs = await repo.getDayLungs(day); final row = await LocalDb.dayResult(day); final win = lungs['sleep_window']; - final seriesPoints = spec.suppress != null && !spec.importedValues - ? const [] - : pointsOf(await repo.getChart(spec.chartKey)); - final series = [for (final p in seriesPoints) p.v]; + final series = + spec.suppress != null ? const [] : seriesOf(await repo.getChart(spec.chartKey)); final hrvish = key == 'hrv'; final dc = hrvish ? pointsOf(await repo.getChart('prsa_dc')) @@ -176,7 +169,6 @@ class InvestigateData { windowStart: win is Map ? (win['start'] as num?)?.toInt() : null, windowEnd: win is Map ? (win['end'] as num?)?.toInt() : null, series: series, - seriesPoints: seriesPoints, ); } @@ -1087,19 +1079,6 @@ class _InvestigateState extends State { l?.investigateNothingComputedForKey ?? 'Nothing computed for this key', spec.suppress!, icon: spec.icon), - // Same display path as the detail screen: a suppressed spec refuses - // the TREND, but a vendor-imported series is still the data the user - // came here to see. Dated values with provenance, never a line. - if (spec.importedValues && d.series.isNotEmpty) ...[ - const SizedBox(height: S.x3), - MonoTable( - l?.metricDetailImportedTableTitle ?? 'Imported values', - [ - for (final p in d.seriesPoints.reversed.take(30)) - (axisDay(p.t), '${p.v.toStringAsFixed(1)} ${spec.unit}'), - ], - ), - ], ]; } final s = d.series; diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index ca792cb86..e1daf824b 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -55,12 +55,6 @@ class MetricSpec { /// reason, shown as a `StatusCard` in place of the chart. final String? suppress; final String? suppressFix; - /// True when the suppressed metric still has imported values worth - /// SHOWING. A suppressed spec loads no series at all — the chart is the - /// thing suppression refuses — but a vendor-imported scalar that IS the - /// metric deserves its values on screen, as a dated table with provenance - /// instead of a trend line. Data-loading flag, not a rendering one. - final bool importedValues; /// How it is computed, and who published the method. Rendered by Nerd stats. final String method; @@ -89,7 +83,6 @@ class MetricSpec { this.higherBetter = true, this.suppress, this.suppressFix, - this.importedValues = false, this.method = '', this.citation = '', this.requires = const {}, @@ -317,17 +310,7 @@ const _specs = { citation: 'Within-user dispersion', requires: {InputSignal.rrIntervals}, ), - // Blood oxygen, as WHOOP'S OWN DERIVED VALUE from a CSV export — never as - // a measurement this app made. The band's raw red/IR ADCs are refused as an - // SpO2 source PERMANENTLY (see `kSpo2Refusal` in onehz_pipeline.dart: within - // a capture session `ir - red` is a fixed offset, so every ratio built from - // them measures baseline drift, not oxygenation). What survives is the - // vendor's own number, and only on days the WHOOP importer wrote - // (`series: {'spo2': ...}` in whoop_import.dart — DerivationEngine's own - // series map does NOT list the key, so a band re-derive can never fabricate - // one). SUPPRESSED rather than charted for the same reason skin_temp is: - // a chart mixes band-derived and vendor-imported points under one line with - // no provenance axis, and WHOOP's calibration is not this app's to audit. + // Only the WHOOP importer writes this key today; the band derive does not. 'spo2': MetricSpec( chartKey: 'spo2', title: 'Blood oxygen', @@ -335,16 +318,9 @@ const _specs = { color: C.pink, icon: LucideIcons.droplet, higherBetter: true, - suppress: 'Not measured by this app. WHOOP\'s own derived value, carried ' - 'verbatim from an imported export — never computed from the band\'s ' - 'raw signals, whose red/IR pair cannot yield a saturation.', - method: 'Nothing is computed here. The nightly mean saturation is ' - 'WHOOP\'s own derived number, read from the `blood oxygen %` column ' - 'of an imported export and stored as-is with ' - '`inputs_used: [whoop_export]`.', - citation: 'Vendor-derived, imported', - importedValues: true, - requires: {}, + method: 'WHOOP\'s own nightly value, read from the blood oxygen column of ' + 'an imported export and stored as-is.', + citation: 'Imported from WHOOP', ), // Both of these were written to `metric_series` on every derive since v55 and // had no spec, so nothing could open them — `specOf` fell through to a @@ -402,7 +378,7 @@ const _specs = { citation: 'Relative only — uncalibrated ADC', requires: {InputSignal.skinTempRaw}, ), - // `spo2`, `odi_per_hour` and `strain_effort` used to live here as cards that + // `odi_per_hour` and `strain_effort` used to live here as cards that // existed only to explain that they were empty. A metric this app does not // produce has no entry, no card and no key. See docs/internal/UI_ROADMAP.md. // @@ -524,9 +500,7 @@ class MetricData { List candidates = const [], }) async { final spec = specOf(key); - if (spec.suppress != null && !spec.importedValues) { - return const MetricData(); - } + if (spec.suppress != null) return const MetricData(); final chart = await repo.getChart( spec.chartKey, signals: {for (final s in spec.requires) s.name}, @@ -890,21 +864,6 @@ class _MetricDetailState extends State { icon: spec.icon, ), const SizedBox(height: S.x5), - // The imported values themselves, as a dated table rather than a - // trend: suppression refuses the LINE (a chart would sit these - // vendor-derived points under the same maths as the band's own), not - // the numbers, which are the metric's only content. Newest first, so - // the latest import reads without scrolling. - if (d.series.isNotEmpty) ...[ - MonoTable( - l?.metricDetailImportedTableTitle ?? 'Imported values', - [ - for (final p in d.series.reversed.take(30)) - (axisDay(p.t), '${p.v.toStringAsFixed(1)} ${spec.unit}'), - ], - ), - const SizedBox(height: S.x5), - ], investigateRow(c, () => go(c, Investigate(widget.metricKey))), ] else if (vals.isEmpty) ...[ _ranges(c, d, spec.color), diff --git a/test/spo2_import_surface_test.dart b/test/spo2_import_surface_test.dart index 686879517..1d127ceb6 100644 --- a/test/spo2_import_surface_test.dart +++ b/test/spo2_import_surface_test.dart @@ -1,24 +1,9 @@ -// The imported SpO2 surface — what it is, whose number it is, and what can -// never happen to it. -// -// Three claims are pinned here, and each one exists because its violation -// shipped before: -// -// * the metric is SUPPRESSED, not charted. A chart draws vendor-imported -// points under the same line as band-derived ones with no provenance -// axis, which is how a WHOOP night and a measured night end up compared -// as if the same maths produced both. -// * the band's own derivation NEVER writes the key. `putDayResult` replaces -// `metric_series` wholesale, so one careless `'spo2': null` in the -// engine's series map would blank every imported value on re-derive. -// The import path is the only writer. -// * the catalogue carries the row. An imported number nobody can see is -// indistinguishable from a number the app dropped. +// Imported WHOOP blood oxygen: the importer writes the `spo2` series with +// WHOOP provenance, and the metric screen loads and charts it. import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/substrate.dart' show localDateLabel; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/import/whoop_import.dart'; -import 'package:openstrap_edge/ui2/screens/health_screen.dart' show catalogueKeys; import 'package:openstrap_edge/data/local_repository_impl.dart'; import 'package:openstrap_edge/ui2/screens/metric_detail.dart' show MetricData, specOf; @@ -32,18 +17,13 @@ void main() { databaseFactory = databaseFactoryFfi; }); - test('the spo2 spec exists, is suppressed, and claims no input signal', () { + test('the spo2 spec is charted in percent and claims no device signal', () { final s = specOf('spo2'); - expect(s.suppress, isNotNull, - reason: 'imported vendor values must not be charted alongside ' - 'band-derived points'); + expect(s.suppress, isNull); + expect(s.unit, '%'); expect(s.requires, isEmpty, - reason: 'an imported scalar requires no device signal; a non-empty ' - 'set would gate the row behind devices that can never produce it'); - }); - - test('the Explore catalogue lists the imported blood-oxygen row', () { - expect(catalogueKeys, contains('spo2')); + reason: 'an imported scalar needs no device signal; a non-empty set ' + 'would gate the row behind devices that never produce it'); }); test('the WHOOP importer writes the spo2 series row from the export column', @@ -76,15 +56,7 @@ void main() { (payload['flags'] as List).contains('IMPORTED_WHOOP_BETA'), isTrue); }); - // The point of the imported-values surface (CodeRabbit, OpenStrap/edge#472): - // a suppressed spec used to load NO series at all, so the catalogue row was - // a door that opened onto a bare wall — the imported numbers were stored - // and nobody could see them. `importedValues` is the flag that lets the - // suppressed spec load its series without ever charting it. - test('a suppressed imported spec still loads its stored series', () async { - final spec = specOf('spo2'); - expect(spec.importedValues, isTrue, - reason: 'the spec must load its imported series despite suppression'); + test('the metric screen loads the imported series', () async { final dir = await Directory.systemTemp.createTemp('spo2_visible'); addTearDown(() => dir.delete(recursive: true)); const wake = '2026-03-10 07:15:00'; @@ -99,16 +71,7 @@ void main() { getProfileMap: () => const {}, ); final d = await MetricData.load(repo, 'spo2'); - expect(d.series, isNotEmpty, - reason: 'the imported values must be loadable for the dated table, ' - 'else the catalogue row cannot show what the importer wrote'); + expect(d.series, isNotEmpty); expect(d.series.last.v, 95.1); - // skin_temp is suppressed WITHOUT imported values — its load must stay - // empty; the flag must not leak suppression's refusal of the chart into - // other suppressed specs' data. - final skin = await MetricData.load(repo, 'skin_temp'); - expect(skin.series, isEmpty, - reason: 'plain suppressed specs load nothing; only the imported ' - 'scalar spec has values worth loading'); }); } From 6328216eff36aafdba1da0fb397029c8e166a598 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:19:47 +0530 Subject: [PATCH 07/11] docs: spo2 is supported now, drop the old permanent-refusal wording --- lib/coach/coach_engine.dart | 4 ++-- lib/compute/onehz_pipeline.dart | 21 +++++++++---------- lib/models/payloads.dart | 36 +++++++++++---------------------- lib/ui2/README.md | 8 +++++--- 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 27063ae1f..4cb8e089e 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -957,8 +957,8 @@ class CoachEngine { 'v_daily(date,resting_hr,hrv,sdnn,readiness,strain,resp_rate,stress,' 'sleep_efficiency,sleep_min,deep_min,rem_min,light_min,nap_min,steps,' // `odi_per_hour` is NOT listed: the view still has the column but the - // pipeline stopped writing the key when SpO2 was refused edge-side, so - // it is permanently NULL. Advertising a column that can never hold a + // pipeline stopped writing the key when band SpO2 was dropped, so it + // is always NULL. Advertising a column that can never hold a // value makes the model query it, get nothing, and reason about the // hole. A column that can never have data is a lie to the model. 'active_calories,total_calories,skin_temp_z,lf_hf,hrv_cv,dip_pct,' diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index beae36e8b..7861c9c7c 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -508,14 +508,12 @@ Map deriveDayBundle(Map inputJson) { ), ); - // SpO2 is refused PERMANENTLY, not parked. `spo2RedRaw` and `spo2IrRaw` are - // one signal: `ir - red` is a fixed integer within a capture session (see - // protocol/records.dart — constant across 178 of 300 hours of a real export) - // while both drift together. Any ratio, or ratio-of-ratios, built from them - // is a function of one channel's baseline drift and measures that drift, not - // oxygenation. No firmware capture and no packet work changes that; it is a - // property of the bytes. The raw channels stay in the substrate because they - // ARE the bytes at those offsets. + // No SpO2 is derived from the band's red/IR channels. `spo2RedRaw` and + // `spo2IrRaw` are one signal: `ir - red` is a fixed integer within a capture + // session (see protocol/records.dart) while both drift together, so any + // ratio built from them measures that drift, not oxygenation. Imported WHOOP + // blood oxygen is a separate `spo2` metric_series key and does not pass + // through here. const kSpo2Refusal = 'refused: the red and IR channels are one signal — ' 'ir − red is a fixed offset within a session, so any ratio built from ' 'them measures baseline drift, not oxygenation'; @@ -1128,10 +1126,9 @@ Map deriveDayBundle(Map inputJson) { 'note': 'Baevsky Stress Index → 0–100; resting autonomic tension (PRV).', }; - // ── SpO₂ — REFUSED, permanently. See kSpo2Refusal above: the red and IR - // ADCs are one signal, so there is no oxygen metric to publish from them - // at any tier, relative or otherwise. The block stays so old bundles keep - // a shape, and it says why. + // ── SpO₂ from the band: none. See kSpo2Refusal above: the red and IR ADCs + // are one signal. The block stays so old bundles keep a shape, and it + // says why. final rejectCounts = odi.present ? odi.value!.rejectCounts : null; final severityCounts = odi.present ? odi.value!.severityCounts : null; final spo2Block = { diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index e1fa5fdaa..099c82027 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -101,9 +101,8 @@ class TodayData { /// Skin-temp deviation vs your baseline (relative, raw units), or null. double? get skinTempIdx => (_skinTemp?['value'] as num?)?.toDouble(); - /// ALWAYS NULL. SpO2 is permanently refused edge-side — `odi_per_hour` is - /// hard-null in onehz_pipeline.dart with the reason attached. see [Spo2Data] - /// before you try to make this return something. + /// Always null for now: `odi_per_hour` is hard-null in onehz_pipeline.dart + /// with the reason attached. see [Spo2Data]. Spo2Data? get spo2 => (_spo2 == null || _spo2['odi_per_hour'] == null) ? null : Spo2Data(_spo2); @@ -307,30 +306,19 @@ class NocturnalData { bool get elevated => _n['elevated'] == true; } -/// ── SpO2 — REFUSED, permanently. do not add a reader. ──────────────────────── +/// ── SpO2 from the band: not derived yet. ──────────────────────────────────── /// -/// this class only still exists so old bundles keep a shape. `odi_per_hour` is -/// hard-null in the pipeline, so the `spo2` getter above returns null for every -/// day and every getter below is dead. that is the intended state. +/// `odi_per_hour` is hard-null in the pipeline, so the `spo2` getter above +/// returns null for every day. imported WHOOP blood oxygen does not come +/// through here; it is the `spo2` metric_series key. /// -/// gen4: `spo2RedRaw` and `spo2IrRaw` are ONE signal. `ir − red` is a fixed -/// integer within a capture session across 61% of a million real rows while -/// both channels drift together, so any ratio — or ratio-of-ratios — built from -/// them is a function of one channel's baseline drift and measures the drift, -/// not oxygenation. no firmware capture and no packet work changes that; it is -/// a property of the bytes. protocol/records.dart carries the finding verbatim. +/// gen4: `spo2RedRaw` and `spo2IrRaw` are one signal. `ir − red` is a fixed +/// integer within a session while both drift together, so a red/IR ratio +/// measures the drift, not oxygenation. /// -/// gen5: `spo2CandidateRaw` @inner[74] is the most dangerous field on the whole -/// decode surface and it is deliberately unread here. it is zero in 99% of -/// records, nonzero only inside band-declared sleep, and clusters at 95-99 — -/// exactly where a real SpO2 sits, with no validity flag to say which are real. -/// it validates against intuition and is wrong, and it is one line from a tile. -/// -/// nothing survives at a lower tier either: not a %, not a relative trend, not a -/// desaturation count, not an overnight minimum, not in Investigate. wrist -/// reflectance oximetry is not cleared as a pulse oximeter by any vendor, and -/// the FDA's pigmentation accuracy work means the error is not uniform across -/// users — a number here would be worst for the people it is already worst for. +/// gen5: the v18 byte at inner[74] has no pinned encoding (zero in ~99% of +/// records, nonzero only in band-declared sleep, no validity flag), so it is +/// not read. class Spo2Data { final Map _s; Spo2Data(this._s); diff --git a/lib/ui2/README.md b/lib/ui2/README.md index 2ebdd7fb9..6cfa913ad 100644 --- a/lib/ui2/README.md +++ b/lib/ui2/README.md @@ -434,9 +434,11 @@ Three rules the tab keeps: - A metric with zero stored days is listed as not measured yet, **with no cause** — this screen reads a row count, and a count of zero never says why. No `fix:` either; nothing here can make a locked day derive. -- A capability this app does not produce gets **no row and no spec**. SpO2, ODI - and anything apnea-shaped are refused; an index entry that existed to explain - an absence is the thing the absent-forever rule forbids. +- A capability this app does not produce gets **no row and no spec**. ODI and + anything apnea-shaped are not produced; an index entry that existed to explain + an absence is the thing the absent-forever rule forbids. SpO2 has a row + because the WHOOP importer writes a `spo2` series; it is charted like any + other imported metric and labelled as WHOOP's value. --- From 658790ab3f03eafc4020b1d6c9e28e86a61f782e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:20:08 +0530 Subject: [PATCH 08/11] whoop import: drop blood oxygen cells outside 70-100, a 0 is no reading --- lib/import/whoop_import.dart | 5 ++++- test/spo2_import_surface_test.dart | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/import/whoop_import.dart b/lib/import/whoop_import.dart index ccb5f52d8..b98f9e4ae 100644 --- a/lib/import/whoop_import.dart +++ b/lib/import/whoop_import.dart @@ -211,6 +211,9 @@ class WhoopImporter { /// accumulate these across every file in the import before writing, so a /// later file's row (missing the earlier file's columns) can't null out /// what the earlier file already contributed for the same date. + // a blank/0/garbage cell is no reading, not a 0% night. + static num? _pct(num? v, num lo) => v != null && v >= lo && v <= 100 ? v : null; + static (String, Map)? _extractDayFields(_Row row) { String get(List names) => row.get(names); final wakeTs = _parseTs(get(['wake onset', 'sleep onset', 'cycle start time'])); @@ -227,7 +230,7 @@ class WhoopImporter { 'strain': n(['day strain', 'strain']), 'calories': _kcal(get(_energyCols), row.header(_energyCols)), 'resp': n(['respiratory rate (rpm)', 'respiratory rate']), - 'spo2': n(['blood oxygen %', 'blood oxygen']), + 'spo2': _pct(n(['blood oxygen %', 'blood oxygen']), 70), 'skinTempC': n(['skin temp (celsius)', 'skin temperature (celsius)']), 'asleepMin': n(['asleep duration (min)', 'asleep duration (minutes)']), 'inBedMin': n(['in bed duration (min)', 'in bed duration (minutes)']), diff --git a/test/spo2_import_surface_test.dart b/test/spo2_import_surface_test.dart index 1d127ceb6..e66408f0a 100644 --- a/test/spo2_import_surface_test.dart +++ b/test/spo2_import_surface_test.dart @@ -56,6 +56,24 @@ void main() { (payload['flags'] as List).contains('IMPORTED_WHOOP_BETA'), isTrue); }); + test('an out-of-range blood oxygen cell is dropped, not charted', () async { + final dir = await Directory.systemTemp.createTemp('spo2_range'); + addTearDown(() => dir.delete(recursive: true)); + const wake = '2026-03-11 07:15:00'; + final day = localDateLabel( + DateTime.parse(wake).millisecondsSinceEpoch ~/ 1000); + final f = File('${dir.path}/day.csv'); + f.writeAsStringSync( + 'Cycle start time,Wake onset,Sleep onset,Blood oxygen %,Resting heart rate (bpm)\n' + '$wake,$wake,2026-03-10 23:10:00,0,52\n', + ); + await WhoopImporter.importFiles([f.path]); + final db = await LocalDb.instance; + final rows = await db.query('metric_series', + where: 'date = ? AND key = ?', whereArgs: [day, 'spo2']); + expect(rows.where((r) => r['value'] != null), isEmpty); + }); + test('the metric screen loads the imported series', () async { final dir = await Directory.systemTemp.createTemp('spo2_visible'); addTearDown(() => dir.delete(recursive: true)); From f9948170f53213ccd32562c5a5a1d2828fe27772 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:45:01 +0530 Subject: [PATCH 09/11] health: no blood oxygen row in explore unless an import wrote one --- lib/ui2/screens/health_screen.dart | 15 ++++++-- test/spo2_import_surface_test.dart | 57 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 0a5496941..226baad5a 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -322,7 +322,14 @@ class LabsData { /// is how two screens end up disagreeing about what a metric is called. class _CatRow { final String key, series, blurb; - const _CatRow(this.key, this.series, this.blurb); + + /// Only an import writes this series, so with no history the row is left + /// out entirely instead of being listed as not measured yet. + final bool importOnly; + const _CatRow(this.key, this.series, this.blurb, {this.importOnly = false}); + + bool shown(Map counts) => + !importOnly || (counts[series] ?? 0) > 0; } class _Cat { @@ -365,7 +372,8 @@ const _catalogue = <_Cat>[ _Cat('Breathing', [ _CatRow('resp_rate', 'resp_rate', 'Breaths per minute, recovered from beat timing'), _CatRow('brv', 'brv_cv', 'How much that rate varies across the night'), - _CatRow('spo2', 'spo2', "WHOOP's own derived value, carried from an import"), + _CatRow('spo2', 'spo2', "WHOOP's own derived value, carried from an import", + importOnly: true), ]), _Cat('Movement & load', [ _CatRow('steps', 'steps', 'Counted by a pedometer, never modelled'), @@ -1291,6 +1299,7 @@ class _HealthScreenState extends State with RevisionReload { var have = 0, total = 0; for (final f in _catalogue) { for (final r in f.rows) { + if (!r.shown(e.counts)) continue; total++; if ((e.counts[r.series] ?? 0) > 0) have++; } @@ -1324,7 +1333,7 @@ class _HealthScreenState extends State with RevisionReload { ]; final none = [ for (final r in f.rows) - if ((counts[r.series] ?? 0) == 0) r, + if ((counts[r.series] ?? 0) == 0 && r.shown(counts)) r, ]; return Section( diff --git a/test/spo2_import_surface_test.dart b/test/spo2_import_surface_test.dart index e66408f0a..89749d071 100644 --- a/test/spo2_import_surface_test.dart +++ b/test/spo2_import_surface_test.dart @@ -1,5 +1,6 @@ // Imported WHOOP blood oxygen: the importer writes the `spo2` series with // WHOOP provenance, and the metric screen loads and charts it. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/substrate.dart' show localDateLabel; import 'package:openstrap_edge/data/db.dart'; @@ -7,6 +8,9 @@ import 'package:openstrap_edge/import/whoop_import.dart'; import 'package:openstrap_edge/data/local_repository_impl.dart'; import 'package:openstrap_edge/ui2/screens/metric_detail.dart' show MetricData, specOf; +import 'package:openstrap_edge/ui2/screens/screens.dart' + show ExploreData, HealthData, HealthScreen; +import 'package:openstrap_edge/ui2/ui2.dart' show buildTheme; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'dart:io'; import 'dart:convert'; @@ -92,4 +96,57 @@ void main() { expect(d.series, isNotEmpty); expect(d.series.last.v, 95.1); }); + + test('stored spo2 rows that are not a percentage are not read back', + () async { + final db = await LocalDb.instance; + // banked before the import-time 70-100 bound + for (final (date, v) in [('2026-02-01', 0.0), ('2026-02-02', 101.0)]) { + await db.insert('metric_series', {'date': date, 'key': 'spo2', 'value': v}, + conflictAlgorithm: ConflictAlgorithm.replace); + } + // the old cloud_v2 importer's relative index under the same key + await LocalDb.putDayResult( + dayId: '2026-02-03', + algoVersion: 1, + payloadJson: jsonEncode({'imported': true, 'source': 'cloud_v2'}), + windowJson: '{}', + finalized: true, + source: 'cloud_v2', + series: {'spo2': 88}, + ); + final dates = [ + for (final r in await LocalDb.metricSeries('spo2')) r['date'], + ]; + expect(dates, isNot(contains('2026-02-01'))); + expect(dates, isNot(contains('2026-02-02'))); + expect(dates, isNot(contains('2026-02-03'))); + expect((await LocalDb.metricSeriesCounts(['spo2']))['spo2'], dates.length); + }); + + testWidgets('a device with no imported spo2 lists no blood oxygen row', + (tester) async { + // tall enough that the lazy list builds the Breathing family + tester.view.physicalSize = const Size(390 * 3, 4000 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + Future pump(Map counts) async { + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: HealthScreen( + key: UniqueKey(), + data: const HealthData(daysWithData: 2), + explore: ExploreData(counts: counts), + tab: 1), + ), + )); + await tester.pumpAndSettle(); + } + + await pump({'resp_rate': 3}); + expect(find.textContaining('Blood oxygen'), findsNothing); + await pump({'resp_rate': 3, 'spo2': 2}); + expect(find.textContaining('Blood oxygen'), findsOneWidget); + }); } From 185c14db0dcfb78c34258b9736268f5c541daaa2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:45:09 +0530 Subject: [PATCH 10/11] spo2: keep stored rows outside 70-100 and the old cloud index off the chart --- lib/data/db.dart | 14 ++++++++++++-- lib/ui2/screens/metric_detail.dart | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index f4ee1ecfe..9ac3086ee 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9293,7 +9293,8 @@ class LocalDb { out[key] = Sqflite.firstIntValue( await db.rawQuery( - 'SELECT COUNT(*) FROM metric_series WHERE key = ? AND value IS NOT NULL', + 'SELECT COUNT(*) FROM metric_series WHERE key = ? AND value IS NOT NULL' + '${_validSql(key)}', [key], ), ) ?? @@ -9396,7 +9397,7 @@ class LocalDb { final db = await instance; return db.query( 'metric_series', - where: 'key = ? AND value IS NOT NULL' + where: 'key = ? AND value IS NOT NULL${_validSql(key)}' '${measuredOnly ? ' AND date NOT IN ($_importedDatesSql)' : ''}', whereArgs: [key], orderBy: 'date ASC', @@ -9404,6 +9405,15 @@ class LocalDb { ); } + /// Drops stored `spo2` rows that are not a blood oxygen percentage: WHOOP export + /// cells outside 70-100 banked before the importer dropped them, and the old + /// cloud_v2 importer's relative index, written under the same key. + static String _validSql(String key) => key != 'spo2' + ? '' + : ' AND value BETWEEN 70 AND 100 AND date NOT IN (' + 'SELECT r.day_id FROM day_result r $_servedDayJoin ' + "WHERE r.day_id IS NOT NULL AND r.payload_json LIKE '%\"source\":\"cloud_v2\"%')"; + /// The TRAILING [n] non-null values for [key] — the newest n days, returned /// oldest→newest. Unlike [metricSeries] (which is `date ASC LIMIT n`, i.e. the /// OLDEST n days), this is the right window for a rolling baseline. Because diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index e1daf824b..d57e0ea1f 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -311,6 +311,8 @@ const _specs = { requires: {InputSignal.rrIntervals}, ), // Only the WHOOP importer writes this key today; the band derive does not. + // The old cloud_v2 importer's relative index under the same key is kept + // out on read (LocalDb.metricSeries). 'spo2': MetricSpec( chartKey: 'spo2', title: 'Blood oxygen', From c30fd709599a6ce14d2e95134c7f01b9049bb509 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:45:10 +0530 Subject: [PATCH 11/11] whoop import: put _pct above the extract doc comment --- lib/import/whoop_import.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/import/whoop_import.dart b/lib/import/whoop_import.dart index b98f9e4ae..2b4961147 100644 --- a/lib/import/whoop_import.dart +++ b/lib/import/whoop_import.dart @@ -206,14 +206,14 @@ class WhoopImporter { // ── per-row writers ────────────────────────────────────────────────────────── + // a blank/0/garbage cell is no reading, not a 0% night. + static num? _pct(num? v, num lo) => v != null && v >= lo && v <= 100 ? v : null; + /// Pure extraction: (date, raw field map) from one CSV row, or null when /// the row has no parseable anchor timestamp. No DB access — callers /// accumulate these across every file in the import before writing, so a /// later file's row (missing the earlier file's columns) can't null out /// what the earlier file already contributed for the same date. - // a blank/0/garbage cell is no reading, not a 0% night. - static num? _pct(num? v, num lo) => v != null && v >= lo && v <= 100 ? v : null; - static (String, Map)? _extractDayFields(_Row row) { String get(List names) => row.get(names); final wakeTs = _parseTs(get(['wake onset', 'sleep onset', 'cycle start time']));