Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/coach/coach_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,'
Expand Down
21 changes: 9 additions & 12 deletions lib/compute/onehz_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -508,14 +508,12 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> 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';
Expand Down Expand Up @@ -1128,10 +1126,9 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> 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 = <String, dynamic>{
Expand Down
14 changes: 12 additions & 2 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9331,7 +9331,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],
),
) ??
Expand Down Expand Up @@ -9434,14 +9435,23 @@ 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',
limit: limit,
);
}

/// 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
Expand Down
5 changes: 4 additions & 1 deletion lib/import/whoop_import.dart
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ 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
Expand All @@ -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)']),
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"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": {
"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",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -6744,6 +6744,10 @@
"@healthBlurbBrv": {
"description": "Explore catalogue one-line description for breathing rate variability"
},
"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"
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"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": {
"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",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"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": {
"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",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_hi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"healthBlurbNapMin": "मुख्य रात के अलावा मिली नींद",
"healthBlurbRespRate": "प्रति मिनट साँसें, धड़कन-समय से निकाली गई",
"healthBlurbBrv": "यह दर पूरी रात में कितनी बदलती है",
"healthBlurbSpo2": "WHOOP का अपना मान, आयात से लिया गया",
"@healthBlurbSpo2": {
"description": "Explore सूची: आयातित रक्त ऑक्सीजन"
},
"healthBlurbSteps": "पेडोमीटर से गिने गए, कभी अनुमानित नहीं",
"healthBlurbActiveMin": "गति की मात्रा के मिनट, चलना-फिरना नहीं",
"healthBlurbCalories": "हृदय गति और आपकी प्रोफ़ाइल से निकाली गई सक्रिय ऊर्जा",
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"healthBlurbNapMin": "在主要夜间睡眠之外检测到的睡眠",
"healthBlurbRespRate": "每分钟呼吸次数,由心跳节律推算得出",
"healthBlurbBrv": "该频率在整夜中的波动程度",
"healthBlurbSpo2": "WHOOP 自己的数值,来自导入数据",
"@healthBlurbSpo2": {
"description": "Explore 目录:导入的血氧"
},
"healthBlurbSteps": "由计步器实际计数,从不依赖模型估算",
"healthBlurbActiveMin": "运动量的分钟数,而非移动距离",
"healthBlurbCalories": "根据心率和你的个人资料计算出的活动能量",
Expand Down
36 changes: 12 additions & 24 deletions lib/models/payloads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<String, dynamic> _s;
Spo2Data(this._s);
Expand Down
8 changes: 5 additions & 3 deletions lib/ui2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
23 changes: 17 additions & 6 deletions lib/ui2/screens/health_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, int> counts) =>
!importOnly || (counts[series] ?? 0) > 0;
}

class _Cat {
Expand All @@ -334,10 +341,10 @@ 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
/// 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.
/// - 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
Expand Down Expand Up @@ -365,6 +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",
importOnly: true),
]),
_Cat('Movement & load', [
_CatRow('steps', 'steps', 'Counted by a pedometer, never modelled'),
Expand Down Expand Up @@ -407,6 +416,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,
Expand Down Expand Up @@ -1289,6 +1299,7 @@ class _HealthScreenState extends State<HealthScreen> 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++;
}
Expand Down Expand Up @@ -1322,7 +1333,7 @@ class _HealthScreenState extends State<HealthScreen> 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(
Expand Down
16 changes: 15 additions & 1 deletion lib/ui2/screens/metric_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,20 @@ const _specs = <String, MetricSpec>{
citation: 'Within-user dispersion',
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',
unit: '%',
color: C.pink,
icon: LucideIcons.droplet,
higherBetter: true,
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
// generic entry titled "nap min". They are 17/17 on real data.
Expand Down Expand Up @@ -366,7 +380,7 @@ const _specs = <String, MetricSpec>{
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.
//
Expand Down
Loading
Loading