diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index e061cafe6..1597eb7f3 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -283,6 +283,70 @@ const kCsvExportSets = [ ORDER BY d.date ASC ''', ), + CsvExportSet( + name: 'bp_research', + title: 'BP research captures (EXPERIMENTAL)', + // Cuff readings plus the band window before each. Absent stats stay + // empty, never 0. + columns: [ + 'measured_at_ms', + 'measurement_started_at_ms', + 'measurement_finished_at_ms', + 'entered_at_ms', + 'device', + 'posture', + 'conditions', + 'systolic_mmhg', + 'diastolic_mmhg', + 'band_device_id', + 'measurement_session_id', + 'time_precision', + 'window_start_ms', + 'window_end_ms', + 'observed_start_ms', + 'observed_end_ms', + 'onehz_rows', + 'rr_beats', + 'hr_mean', + 'rr_ms_mean', + 'rr_ms_min', + 'rr_ms_max', + 'rmssd_ms', + 'valid_hr_seconds', + 'valid_interval_count', + 'valid_interval_pair_count', + 'coverage_fraction', + 'rejected_interval_fraction', + 'quality_status', + 'feature_version', + 'snapshot_revision', + 'meta_json', + ], + sql: ''' + SELECT r.measured_at_ms, r.measurement_started_at_ms, + r.measurement_finished_at_ms, + r.captured_at_ms AS entered_at_ms, + COALESCE(r.device, '') AS device, + COALESCE(r.posture, '') AS posture, + COALESCE(r.conditions, '') AS conditions, + r.systolic_mmhg, r.diastolic_mmhg, + COALESCE(r.band_device_id, '') AS band_device_id, + COALESCE(r.measurement_session_id, '') AS measurement_session_id, + COALESCE(r.time_precision, '') AS time_precision, + w.window_start_ms, w.window_end_ms, + w.observed_start_ms, w.observed_end_ms, + w.onehz_rows, w.rr_beats, + w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + w.valid_hr_seconds, w.valid_interval_count, + w.valid_interval_pair_count, w.coverage_fraction, + w.rejected_interval_fraction, w.quality_status, + w.feature_version, w.snapshot_revision, + COALESCE(w.meta_json, '') AS meta_json + FROM bp_research_reference r + LEFT JOIN bp_research_window w ON w.reference_id = r.id + ORDER BY r.measured_at_ms ASC + ''', + ), ]; /// What CSV deliberately does NOT carry, and why. Shown to the user on the diff --git a/lib/data/db.dart b/lib/data/db.dart index f4ee1ecfe..160d2106b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -33,6 +33,7 @@ import '../import/import_container.dart'; import 'coverage_resolver.dart' show CoverageInterval; import 'day_label.dart'; import 'journal_fields.dart'; +import '../health/bp_research_capture.dart'; import 'live_coverage_policy.dart'; import 'med_store.dart'; import 'models.dart'; @@ -166,6 +167,9 @@ class LocalDb { /// flash as we ACK, so in practice this is the only copy of those days too. static const _salvageTables = [ // Hand-entered. The only copy that exists anywhere. + 'bp_research_reference', + 'bp_research_window', + 'bp_research_snapshot', 'journal', 'journal_metric', 'journal_field_def', @@ -461,6 +465,7 @@ class LocalDb { await _createNotifFired(db); await _createNotifSlots(db); await _createAlarmSchedule(db); + await _createBpResearch(db); await _ensureCoachViews(db); }, onUpgrade: (db, oldV, newV) async { @@ -1154,6 +1159,8 @@ class LocalDb { await _createNotifFired(db); await _createNotifSlots(db); await _createAlarmSchedule(db); + // Dev-mode BP research capture tables. Additive, no rung. + await _createBpResearch(db); // CREATE TABLE IF NOT EXISTS on the every-open repair path, no schema // version bump needed — additive, no backfill (see _createImportedWorkout // just above for the same reasoning). @@ -1582,6 +1589,382 @@ class LocalDb { ); } + /// Dev-mode BP research store: cuff readings typed in next to the band's + /// own decoded window before them, for CSV export only. Nothing derived, + /// nothing in `compute/` and no health-store writer may read these; + /// `bp_research_isolation_test.dart` enforces it. + static Future _createBpResearch(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_reference ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + measured_at_ms INTEGER NOT NULL, + measurement_started_at_ms INTEGER, + measurement_finished_at_ms INTEGER, + device TEXT, + posture TEXT, + conditions TEXT, + systolic_mmhg REAL NOT NULL, + diastolic_mmhg REAL NOT NULL, + captured_at_ms INTEGER NOT NULL, + band_device_id TEXT, + measurement_session_id TEXT, + time_precision TEXT, + UNIQUE (measured_at_ms, device) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_window ( + reference_id INTEGER NOT NULL PRIMARY KEY + REFERENCES bp_research_reference(id) ON DELETE CASCADE, + window_start_ms INTEGER NOT NULL, + window_end_ms INTEGER NOT NULL, + observed_start_ms INTEGER, + observed_end_ms INTEGER, + onehz_rows INTEGER, + rr_beats INTEGER, + hr_mean REAL, + rr_ms_mean REAL, + rr_ms_min REAL, + rr_ms_max REAL, + rmssd_ms REAL, + valid_hr_seconds INTEGER, + valid_interval_count INTEGER, + valid_interval_pair_count INTEGER, + coverage_fraction REAL, + rejected_interval_fraction REAL, + quality_status TEXT, + feature_version INTEGER, + snapshot_revision INTEGER, + meta_json TEXT + ) + '''); + // The exact rows a window revision was computed from. Insert-only: a + // refresh writes revision n+1 and never rewrites an older one. + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_snapshot ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reference_id INTEGER NOT NULL, + revision INTEGER NOT NULL, + onehz_json TEXT NOT NULL, + rr_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + UNIQUE (reference_id, revision) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_bp_research_reference_at ' + 'ON bp_research_reference(measured_at_ms)', + ); + } + + /// The decoded rows of one band inside the half-open window + /// `[startMs, endMs)`, plus how far that band's decoded data reaches: the + /// earlier of the HR and RR watermarks, 0 for a series with no rows yet. + static Future< + ({ + List> onehz, + List> rr, + int dataThroughMs, + }) + > + bpResearchRows(String deviceId, int startMs, int endMs) async { + final db = await instance; + final onehz = await db.rawQuery( + 'SELECT rec_ts, hr FROM decoded_onehz ' + 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts < ? ' + 'ORDER BY rec_ts ASC', + [deviceId, (startMs + 999) ~/ 1000, (endMs + 999) ~/ 1000], + ); + // A beat sits a few seconds before its record at most; the rr_ts_ms + // bound is only there so the query can use its index. + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? AND rr_ts_ms >= ? AND rr_ts_ms < ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', + [deviceId, startMs - 60000, endMs + 60000, startMs, endMs], + ); + final onehzThrough = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(rec_ts) * 1000 FROM decoded_onehz WHERE device_id = ?', + [deviceId], + ), + ) ?? 0; + final rrThrough = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(rr_ts_ms) FROM decoded_rr WHERE device_id = ?', + [deviceId], + ), + ) ?? 0; + return ( + onehz: onehz, + rr: rr, + dataThroughMs: onehzThrough < rrThrough ? onehzThrough : rrThrough, + ); + } + + /// Re-read the current local data for a stored capture's original window + /// and write it as a new snapshot revision. The cuff reference is never + /// touched. + static Future reprocessBpResearchCapture(int referenceId) async { + final db = await instance; + final refs = await db.rawQuery( + 'SELECT r.measured_at_ms, r.band_device_id, ' + 'w.window_start_ms, w.window_end_ms, ' + '(SELECT MAX(revision) FROM bp_research_snapshot s ' + 'WHERE s.reference_id = r.id) AS max_rev ' + 'FROM bp_research_reference r ' + 'LEFT JOIN bp_research_window w ON w.reference_id = r.id ' + 'WHERE r.id = ?', + [referenceId], + ); + if (refs.isEmpty) return; + final r = refs.first; + final at = (r['measured_at_ms'] as num).toInt(); + final start = (r['window_start_ms'] as num?)?.toInt() ?? + at - kResearchRestPreMs; + final end = (r['window_end_ms'] as num?)?.toInt() ?? + at + kResearchWindowPostMs; + final rows = await bpResearchRows( + (r['band_device_id'] as String?) ?? kPrimaryDeviceId, + start, + end, + ); + // Nothing local any more but a snapshot exists: the decoded rows were + // pruned, and the frozen window is the only copy. Keep it. + // A partially pruned window still writes a smaller revision. + if (rows.onehz.isEmpty && rows.rr.isEmpty && r['max_rev'] != null) return; + final window = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows.onehz, + rrRows: rows.rr, + preMs: at - start, + postMs: end - at, + nowMs: DateTime.now().millisecondsSinceEpoch, + dataThroughMs: rows.dataThroughMs, + ); + await db.transaction( + (txn) => _putBpResearchWindow( + txn, + referenceId, + window, + rows.onehz, + rows.rr, + DateTime.now().millisecondsSinceEpoch, + ), + ); + } + + /// Insert or restate one cuff reading plus its band window and the + /// snapshot of the rows the window was computed from. Idempotent on + /// `(measured_at_ms, device)`: a retake keeps the reference id and every + /// older snapshot revision. Invalid pairs throw before anything is written. + static Future putBpResearchCapture( + BpResearchCapture c, { + List>? snapshotOnehzRows, + List>? snapshotRrRows, + }) async { + if (!c.systolicMmHg.isFinite || + !c.diastolicMmHg.isFinite || + c.systolicMmHg < kResearchSystolicBounds.$1 || + c.systolicMmHg > kResearchSystolicBounds.$2 || + c.diastolicMmHg < kResearchDiastolicBounds.$1 || + c.diastolicMmHg > kResearchDiastolicBounds.$2 || + c.diastolicMmHg >= c.systolicMmHg) { + throw ArgumentError( + 'Invalid BP research reference: systolic ' + '${c.systolicMmHg} / diastolic ${c.diastolicMmHg} mmHg is ' + 'outside the research bounds or dia >= sys.', + ); + } + final db = await instance; + await db.transaction((txn) async { + // NULL never equals NULL in a UNIQUE constraint, so no device is ''. + final device = c.device ?? ''; + final existing = await txn.rawQuery( + 'SELECT id FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?', + [c.measuredAtMs, device], + ); + final int id; + if (existing.isNotEmpty) { + id = (existing.first['id'] as num).toInt(); + await txn.rawUpdate( + 'UPDATE bp_research_reference SET ' + 'measurement_started_at_ms = ?, measurement_finished_at_ms = ?, ' + 'posture = ?, conditions = ?, systolic_mmhg = ?, ' + 'diastolic_mmhg = ?, captured_at_ms = ?, band_device_id = ?, ' + 'measurement_session_id = ?, time_precision = ? WHERE id = ?', + [ + c.measurementStartedAtMs, + c.measurementFinishedAtMs, + c.posture, + c.conditions, + c.systolicMmHg, + c.diastolicMmHg, + c.capturedAtMs, + c.bandDeviceId, + c.measurementSessionId, + c.timePrecision, + id, + ], + ); + } else { + id = await txn.rawInsert( + 'INSERT INTO bp_research_reference ' + '(measured_at_ms, measurement_started_at_ms, ' + 'measurement_finished_at_ms, device, posture, conditions, ' + 'systolic_mmhg, diastolic_mmhg, captured_at_ms, band_device_id, ' + 'measurement_session_id, time_precision) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + c.measuredAtMs, + c.measurementStartedAtMs, + c.measurementFinishedAtMs, + device, + c.posture, + c.conditions, + c.systolicMmHg, + c.diastolicMmHg, + c.capturedAtMs, + c.bandDeviceId, + c.measurementSessionId, + c.timePrecision, + ], + ); + } + await _putBpResearchWindow( + txn, + id, + c.window, + snapshotOnehzRows ?? const [], + snapshotRrRows ?? const [], + c.capturedAtMs, + ); + }); + } + + /// Restate the window row of [id]. No window deletes the row. Rows freeze + /// into the next snapshot revision and the window names it; no rows means + /// no revision is claimed (snapshot_revision NULL). + static Future _putBpResearchWindow( + Transaction txn, + int id, + BpResearchWindow? w, + List> onehz, + List> rr, + int createdAtMs, + ) async { + if (w == null) { + await txn.rawDelete( + 'DELETE FROM bp_research_window WHERE reference_id = ?', + [id], + ); + return; + } + int? rev; + if (onehz.isNotEmpty || rr.isNotEmpty) { + final maxRev = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT MAX(revision) FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ), + ); + rev = (maxRev ?? 0) + 1; + // Plain INSERT: UNIQUE (reference_id, revision) makes rewriting an + // existing revision an error, never a silent overwrite. + await txn.rawInsert( + 'INSERT INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' + 'VALUES (?, ?, ?, ?, ?)', + [id, rev, jsonEncode(onehz), jsonEncode(rr), createdAtMs], + ); + } + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' + 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' + 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' + 'valid_interval_count, valid_interval_pair_count, ' + 'coverage_fraction, rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + id, + w.windowStartMs, + w.windowEndMs, + w.observedStartMs, + w.observedEndMs, + w.onehzRows, + w.rrBeats, + w.hrMean, + w.rrMsMean, + w.rrMsMin, + w.rrMsMax, + w.rmssdMs, + w.validHrSeconds, + w.validIntervalCount, + w.validIntervalPairCount, + w.coverageFraction, + w.rejectedIntervalFraction, + w.qualityStatus, + w.featureVersion, + rev, + w.metaJson, + ], + ); + } + + /// All captures, newest first, for the dev screen and the CSV export. + static Future>> bpResearchCaptures() async { + final db = await instance; + return db.rawQuery(''' + SELECT r.id, r.measured_at_ms, + r.measurement_started_at_ms, r.measurement_finished_at_ms, + r.device, r.posture, r.conditions, + r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, + r.band_device_id, r.measurement_session_id, + r.time_precision, + w.window_start_ms, w.window_end_ms, + w.observed_start_ms, w.observed_end_ms, + w.onehz_rows, w.rr_beats, + w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + w.valid_hr_seconds, w.valid_interval_count, + w.valid_interval_pair_count, w.coverage_fraction, + w.rejected_interval_fraction, w.quality_status, + w.feature_version, w.snapshot_revision, + w.meta_json + FROM bp_research_reference r + LEFT JOIN bp_research_window w ON w.reference_id = r.id + ORDER BY r.measured_at_ms DESC + '''); + } + + /// Delete one capture with its window and snapshots. foreign_keys is off, + /// so ON DELETE CASCADE does nothing here. + static Future deleteBpResearchCapture(int id) async { + final db = await instance; + await db.transaction((txn) async { + await txn.delete( + 'bp_research_window', + where: 'reference_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'bp_research_snapshot', + where: 'reference_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'bp_research_reference', + where: 'id = ?', + whereArgs: [id], + ); + }); + } + /// Atomically claim [key] for a one-time OS notification fire. /// /// Returns true iff THIS caller won the claim (the row did not exist and we @@ -8590,6 +8973,9 @@ class LocalDb { // banked. They were also simply MISSING here until now — nutrition, // medication, strength sets, symptoms and routes did not survive a // backup/restore round trip at all, the same omission `wipeAll` documents. + 'bp_research_reference', + 'bp_research_window', + 'bp_research_snapshot', 'journal', 'journal_metric', 'journal_field_def', @@ -8687,7 +9073,24 @@ class LocalDb { return {for (final c in info) (c['name'] as String)}; } + Future srcHasTable(String t, Database s) async { + final rows = await s.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + [t], + ); + return rows.isNotEmpty; + } + final counts = {}; + // BP research rows name their reference by the source's AUTOINCREMENT id. + final bpIdMap = {}; + var skippedSnapshots = 0; + var skippedWindows = 0; + var skippedWindowsSnapshotConflict = 0; + var skippedWindowsMissingSnapshot = 0; + var skippedSnapshotsDanglingReference = 0; + var skippedWindowsDanglingReference = 0; + var skippedSnapshotsIdentical = 0; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". // @@ -8746,6 +9149,227 @@ class LocalDb { if (e.isNoSuchTableError()) continue; rethrow; } + // BP research captures merge by their natural key + // (measured_at_ms, device) in one transaction, never by source id. + // A snapshot revision is never overwritten; a window whose + // snapshot is missing or differs here is skipped and counted. + if (t == 'bp_research_reference' || + t == 'bp_research_window' || + t == 'bp_research_snapshot') { + if (t != 'bp_research_reference') { + continue; + } + try { + final refCols = await destCols('bp_research_reference'); + final winCols = await destCols('bp_research_window'); + final snapCols = await destCols('bp_research_snapshot'); + final srcRefs = await srcHasTable('bp_research_reference', src) + ? await src.rawQuery('SELECT * FROM bp_research_reference') + : const >[]; + final srcSnaps = await srcHasTable('bp_research_snapshot', src) + ? await src.rawQuery('SELECT * FROM bp_research_snapshot') + : const >[]; + final srcWins = await srcHasTable('bp_research_window', src) + ? await src.rawQuery('SELECT * FROM bp_research_window') + : const >[]; + await db.transaction((txn) async { + for (final r in srcRefs) { + final row = { + for (final e in r.entries) + if (refCols.contains(e.key)) e.key: e.value, + }; + final srcId = row.remove('id'); + final device = (row['device'] as String?) ?? ''; + final existing = await txn.rawQuery( + 'SELECT id FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?', + [row['measured_at_ms'], device], + ); + final int destId; + if (existing.isNotEmpty) { + destId = (existing.first['id'] as num).toInt(); + await txn.rawUpdate( + 'UPDATE bp_research_reference SET ' + 'measurement_started_at_ms = ?, ' + 'measurement_finished_at_ms = ?, posture = ?, ' + 'conditions = ?, systolic_mmhg = ?, diastolic_mmhg = ?, ' + 'captured_at_ms = ?, band_device_id = ?, ' + 'measurement_session_id = ?, time_precision = ? ' + 'WHERE id = ?', + [ + row['measurement_started_at_ms'], + row['measurement_finished_at_ms'], + row['posture'], + row['conditions'], + row['systolic_mmhg'], + row['diastolic_mmhg'], + row['captured_at_ms'], + row['band_device_id'], + row['measurement_session_id'], + row['time_precision'], + destId, + ], + ); + } else { + destId = await txn.rawInsert( + 'INSERT INTO bp_research_reference ' + '(measured_at_ms, measurement_started_at_ms, ' + 'measurement_finished_at_ms, device, posture, ' + 'conditions, systolic_mmhg, diastolic_mmhg, ' + 'captured_at_ms, band_device_id, ' + 'measurement_session_id, time_precision) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + row['measured_at_ms'], + row['measurement_started_at_ms'], + row['measurement_finished_at_ms'], + device, + row['posture'], + row['conditions'], + row['systolic_mmhg'], + row['diastolic_mmhg'], + row['captured_at_ms'], + row['band_device_id'], + row['measurement_session_id'], + row['time_precision'], + ], + ); + } + if (srcId is num) { + bpIdMap[srcId.toInt()] = destId; + } + } + final snapStatus = <(int, int), String>{}; + for (final sn in srcSnaps) { + final row = { + for (final e in sn.entries) + if (snapCols.contains(e.key)) e.key: e.value, + }; + final mapped = + bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; + final rev = (row['revision'] as num?)?.toInt(); + if (mapped == null || rev == null) { + skippedSnapshots++; + skippedSnapshotsDanglingReference++; + continue; + } + final clash = await txn.rawQuery( + 'SELECT onehz_json, rr_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = ?', + [mapped, rev], + ); + if (clash.isNotEmpty) { + final same = + clash.first['onehz_json'] == row['onehz_json'] && + clash.first['rr_json'] == row['rr_json']; + snapStatus[(mapped, rev)] = same ? 'identical' : 'conflict'; + if (same) { + skippedSnapshots++; + skippedSnapshotsIdentical++; + } else { + skippedSnapshots++; + } + } else { + await txn.rawInsert( + 'INSERT INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, ' + 'created_at_ms) VALUES (?, ?, ?, ?, ?)', + [ + mapped, + rev, + row['onehz_json'], + row['rr_json'], + row['created_at_ms'], + ], + ); + snapStatus[(mapped, rev)] = 'inserted'; + } + } + for (final w in srcWins) { + final row = { + for (final e in w.entries) + if (winCols.contains(e.key)) e.key: e.value, + }; + final mapped = + bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; + if (mapped == null) { + skippedWindows++; + skippedWindowsDanglingReference++; + continue; + } + final rev = (row['snapshot_revision'] as num?)?.toInt(); + if (rev != null) { + final status = snapStatus[(mapped, rev)]; + if (status == null) { + skippedWindows++; + skippedWindowsMissingSnapshot++; + continue; + } + if (status == 'conflict') { + skippedWindows++; + skippedWindowsSnapshotConflict++; + continue; + } + } + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, ' + 'observed_start_ms, observed_end_ms, onehz_rows, ' + 'rr_beats, hr_mean, rr_ms_mean, rr_ms_min, rr_ms_max, ' + 'rmssd_ms, valid_hr_seconds, valid_interval_count, ' + 'valid_interval_pair_count, coverage_fraction, ' + 'rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + mapped, + row['window_start_ms'], + row['window_end_ms'], + row['observed_start_ms'], + row['observed_end_ms'], + row['onehz_rows'], + row['rr_beats'], + row['hr_mean'], + row['rr_ms_mean'], + row['rr_ms_min'], + row['rr_ms_max'], + row['rmssd_ms'], + row['valid_hr_seconds'], + row['valid_interval_count'], + row['valid_interval_pair_count'], + row['coverage_fraction'], + row['rejected_interval_fraction'], + row['quality_status'], + row['feature_version'], + row['snapshot_revision'], + row['meta_json'], + ], + ); + } + }); + counts['bp_research_reference'] = srcRefs.length; + counts['bp_research_snapshot'] = + srcSnaps.length - skippedSnapshots; + counts['bp_research_window'] = srcWins.length - skippedWindows; + counts['bp_research_snapshot_conflicts'] = + skippedSnapshots - + skippedSnapshotsDanglingReference - + skippedSnapshotsIdentical; + counts['bp_research_window_snapshot_conflicts'] = + skippedWindowsSnapshotConflict; + counts['bp_research_window_missing_snapshot'] = + skippedWindowsMissingSnapshot; + counts['bp_research_snapshot_dangling_reference'] = + skippedSnapshotsDanglingReference; + counts['bp_research_window_dangling_reference'] = + skippedWindowsDanglingReference; + } catch (_) { + if (!tolerant) rethrow; + counts[t] = 0; + } + continue; + } + if (t == 'day_result') importedDays = {}; if (firstPage.isEmpty) { counts[t] = 0; diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart new file mode 100644 index 000000000..bdb12c5c6 --- /dev/null +++ b/lib/health/bp_research_capture.dart @@ -0,0 +1,373 @@ +// BP research capture: a cuff reading typed in next to the band's own +// decoded data from the minutes before it, kept for CSV export. Dev mode +// only. Nothing in compute/, nothing that feeds day_result / metric_series +// and no health-store writer may read these tables +// (bp_research_isolation_test.dart). Missing data stays NULL. + +import 'dart:math' show sqrt; + +/// Version of the window formulas, exported with every row. Bump when a +/// field's meaning changes. +const int kResearchFeatureVersion = 3; + +/// Rest window before the measurement: [at - 5 min, at). Ending at the +/// measurement keeps the cuff's own inflation out. +const int kResearchRestPreMs = 5 * 60 * 1000; + +/// No post-measurement window. +const int kResearchWindowPostMs = 0; + +/// Two successive beats further apart than this are not paired for RMSSD. +const int kResearchMaxBeatGapMs = 2500; + +/// The band window before one measurement. A stat that could not be +/// computed is null, never zero. +class BpResearchWindow { + const BpResearchWindow({ + required this.windowStartMs, + required this.windowEndMs, + this.observedStartMs, + this.observedEndMs, + this.onehzRows, + this.rrBeats, + this.hrMean, + this.rrMsMean, + this.rrMsMin, + this.rrMsMax, + this.rmssdMs, + this.validHrSeconds, + this.validIntervalCount, + this.validIntervalPairCount, + this.coverageFraction, + this.rejectedIntervalFraction, + this.qualityStatus, + required this.featureVersion, + this.snapshotRevision, + this.metaJson, + }); + + /// Requested bounds, half-open [start, end). + final int windowStartMs; + final int windowEndMs; + + /// First and last valid sample inside the window. + final int? observedStartMs; + final int? observedEndMs; + + /// In-window 1 Hz rows, deduplicated by rec_ts. + final int? onehzRows; + + /// In-window beats, deduplicated by beat identity. + final int? rrBeats; + + final double? hrMean; + final double? rrMsMean; + final double? rrMsMin; + final double? rrMsMax; + final double? rmssdMs; + + /// 1 Hz rows with a valid HR. + final int? validHrSeconds; + + /// Beats with a finite positive interval. + final int? validIntervalCount; + + /// Successive valid beats within [kResearchMaxBeatGapMs]; RMSSD uses these. + final int? validIntervalPairCount; + + /// validHrSeconds / window seconds; null without any valid HR. + final double? coverageFraction; + + /// Share of successive valid pairs dropped for a gap; null without pairs. + final double? rejectedIntervalFraction; + + /// 'pending' | 'ok' | 'gappy' | 'no_data', see [researchWindowFrom]. + final String? qualityStatus; + + final int featureVersion; + + /// Snapshot revision holding the rows these stats came from. + final int? snapshotRevision; + + final String? metaJson; +} + +/// One cuff reading plus its window, ready to store. +class BpResearchCapture { + const BpResearchCapture({ + required this.measuredAtMs, + required this.systolicMmHg, + required this.diastolicMmHg, + required this.capturedAtMs, + required this.device, + this.measurementStartedAtMs, + this.measurementFinishedAtMs, + this.timePrecision, + this.posture, + this.conditions, + this.bandDeviceId, + this.measurementSessionId, + this.window, + }); + + /// When the reading was taken (the entry moment when not back-dated). + /// Idempotency key together with [device]. + final int measuredAtMs; + + /// Cuff inflation start/finish when known; the UI leaves both null. + final int? measurementStartedAtMs; + + final int? measurementFinishedAtMs; + + /// Precision of the user-reported time ('minute' from the UI). + final String? timePrecision; + + /// When the pair was typed in. + final int capturedAtMs; + + final double systolicMmHg; + final double diastolicMmHg; + + /// The cuff, as the user named it. + final String? device; + + /// The band the window was read from. + final String? bandDeviceId; + + /// Free-form label grouping readings of one sitting. + final String? measurementSessionId; + + final String? posture; + final String? conditions; + + /// Null when the band had nothing decoded in the window. + final BpResearchWindow? window; +} + +/// Same bounds as `health_measurement_import.dart`; out of range is +/// rejected, never clamped. +const (double, double) kResearchSystolicBounds = (50, 300); +const (double, double) kResearchDiastolicBounds = (20, 200); + +/// `beat_ts_ms` when present, else the record second (`rr_ts_ms`). +int _beatTimeMs(Map r) { + final beatTs = r['beat_ts_ms']; + if (beatTs is num && beatTs > 0) return beatTs.toInt(); + final ts = r['rr_ts_ms']; + return ts is num ? ts.toInt() : 0; +} + +/// Beat identity: `beat_ts_ms` when present, else (rr_ts_ms, beat_index). +/// rr_ts_ms alone is shared by every beat of a record. +(String, int, int) _beatKey(Map r) { + final beatTs = r['beat_ts_ms']; + if (beatTs is num && beatTs > 0) return ('b', beatTs.toInt(), 0); + final ts = r['rr_ts_ms']; + final idx = r['beat_index']; + return ('r', ts is num ? ts.toInt() : 0, idx is num ? idx.toInt() : 0); +} + +/// Beat time, then record time, then beat index. +int _beatOrder(Map a, Map b) { + final at = _beatTimeMs(a); + final bt = _beatTimeMs(b); + if (at != bt) return at < bt ? -1 : 1; + final ar = (a['rr_ts_ms'] as num?)?.toInt() ?? 0; + final br = (b['rr_ts_ms'] as num?)?.toInt() ?? 0; + if (ar != br) return ar < br ? -1 : 1; + final ai = (a['beat_index'] as num?)?.toInt() ?? 0; + final bi = (b['beat_index'] as num?)?.toInt() ?? 0; + if (ai != bi) return ai < bi ? -1 : 1; + return 0; +} + +/// The window [measuredAtMs - preMs, measuredAtMs + postMs) from decoded +/// rows. Null when no row falls inside and the data is final. +/// +/// Status: 'pending' while the window end is in the future ([nowMs]) or the +/// band's decoded data does not reach it yet ([dataThroughMs]); otherwise +/// 'no_data', 'gappy' (under half covered, or over half the pairs dropped +/// for gaps) or 'ok'. The thresholds are research defaults, not validated. +BpResearchWindow? researchWindowFrom({ + required int measuredAtMs, + required List> onehzRows, + required List> rrRows, + int? preMs, + int? postMs, + int? maxGapMs, + int? nowMs, + int? dataThroughMs, + String? deviceId, + String? metaJson, +}) { + final pre = preMs ?? kResearchRestPreMs; + final post = postMs ?? kResearchWindowPostMs; + final gap = maxGapMs ?? kResearchMaxBeatGapMs; + final start = measuredAtMs - pre; + final end = measuredAtMs + post; + + // decoded_onehz.rec_ts is epoch seconds. + final onehz = + onehzRows.where((r) { + final ts = r['rec_ts']; + return ts is num && ts * 1000 >= start && ts * 1000 < end; + }).toList()..sort( + (a, b) => ((a['rec_ts'] as num).toDouble()).compareTo( + (b['rec_ts'] as num).toDouble(), + ), + ); + final onehzDedup = >[]; + { + int? lastTs; + for (final r in onehz) { + final ts = (r['rec_ts'] as num).toInt(); + if (lastTs == ts) continue; + lastTs = ts; + onehzDedup.add(r); + } + } + + final rrAll = rrRows.where((r) { + final t = _beatTimeMs(r); + return t >= start && t < end; + }).toList()..sort(_beatOrder); + final rrDedup = >[]; + { + (String, int, int)? lastKey; + for (final r in rrAll) { + final key = _beatKey(r); + if (lastKey == key) continue; + lastKey = key; + rrDedup.add(r); + } + } + + // Data not final yet: the window end is in the future, or this band's + // decoded data stops short of it. rec_ts is whole seconds, so the last + // second inside [start, end) is end - 1000. + final notFinal = + (nowMs != null && end > nowMs) || + (dataThroughMs != null && dataThroughMs < end - 1000); + if (onehzDedup.isEmpty && rrDedup.isEmpty) { + // Keep a pending window row so a refresh can fill it later. + if (notFinal) { + return BpResearchWindow( + windowStartMs: start, + windowEndMs: end, + qualityStatus: 'pending', + featureVersion: kResearchFeatureVersion, + metaJson: metaJson, + ); + } + return null; + } + + final validHrRows = onehzDedup + .where((r) { + final h = r['hr']; + return h is num && h.isFinite && h > 0; + }) + .toList(growable: false); + final hrMean = validHrRows.isEmpty + ? null + : validHrRows + .map((r) => (r['hr'] as num).toDouble()) + .reduce((a, b) => a + b) / + validHrRows.length; + final validHrSeconds = validHrRows.isEmpty ? null : validHrRows.length; + + // RMSSD pairs only beats that are adjacent in the series AND within + // [gap] of each other; an invalid beat breaks the chain. + final validIntervals = <(int, double)>[]; // (beat_time_ms, rr_ms) + var pairTotal = 0; + var validPairs = 0; + var sumSq = 0.0; + (int, double)? prev; + for (final r in rrDedup) { + final v = r['rr_ms']; + if (v is! num || !v.isFinite || v <= 0) { + prev = null; + continue; + } + final cur = (_beatTimeMs(r), v.toDouble()); + validIntervals.add(cur); + if (prev != null) { + pairTotal++; + if (cur.$1 - prev.$1 <= gap) { + final d = cur.$2 - prev.$2; + sumSq += d * d; + validPairs++; + } + } + prev = cur; + } + + double? rrMean, rrMin, rrMax; + if (validIntervals.isNotEmpty) { + final values = validIntervals.map((p) => p.$2).toList(growable: false); + rrMean = values.reduce((a, b) => a + b) / values.length; + rrMin = values.reduce((a, b) => a < b ? a : b); + rrMax = values.reduce((a, b) => a > b ? a : b); + } + final rmssd = validPairs > 0 ? sqrt(sumSq / validPairs) : null; + final rejectedPairFraction = pairTotal == 0 + ? null + : 1.0 - (validPairs / pairTotal); + + final windowSeconds = (end - start) / 1000.0; + final coverage = validHrSeconds == null || windowSeconds <= 0 + ? null + : validHrSeconds / windowSeconds; + + final String status; + if (notFinal) { + status = 'pending'; + } else if (validHrRows.isEmpty && validIntervals.isEmpty) { + status = 'no_data'; + } else if ((rejectedPairFraction != null && rejectedPairFraction > 0.5) || + (coverage != null && coverage < 0.5)) { + status = 'gappy'; + } else { + status = 'ok'; + } + + int? observedStart; + int? observedEnd; + final o1 = validHrRows.isNotEmpty + ? (validHrRows.first['rec_ts'] as num).toInt() * 1000 + : null; + final o2 = validIntervals.isNotEmpty ? validIntervals.first.$1 : null; + final e1 = validHrRows.isNotEmpty + ? (validHrRows.last['rec_ts'] as num).toInt() * 1000 + : null; + final e2 = validIntervals.isNotEmpty ? validIntervals.last.$1 : null; + if (o1 != null && o2 != null) { + observedStart = o1 < o2 ? o1 : o2; + observedEnd = (e1 ?? o1) > (e2 ?? o2) ? (e1 ?? o1) : (e2 ?? o2); + } else { + observedStart = o1 ?? o2; + observedEnd = e1 ?? e2; + } + + return BpResearchWindow( + windowStartMs: start, + windowEndMs: end, + observedStartMs: observedStart, + observedEndMs: observedEnd, + onehzRows: onehzDedup.isEmpty ? null : onehzDedup.length, + rrBeats: rrDedup.isEmpty ? null : rrDedup.length, + hrMean: hrMean, + rrMsMean: rrMean, + rrMsMin: rrMin, + rrMsMax: rrMax, + rmssdMs: rmssd, + validHrSeconds: validHrSeconds, + validIntervalCount: validIntervals.isEmpty ? null : validIntervals.length, + validIntervalPairCount: validPairs == 0 ? null : validPairs, + coverageFraction: coverage, + rejectedIntervalFraction: rejectedPairFraction, + qualityStatus: status, + featureVersion: kResearchFeatureVersion, + metaJson: metaJson, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae97e419e..9a414b8fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12441,5 +12441,115 @@ "type": "int" } } + }, + "settingsBpResearchRowTitle": "BP research capture", + "@settingsBpResearchRowTitle": { + "description": "Developer settings row title for the experimental blood-pressure research capture screen" + }, + "settingsBpResearchRowSub": "Experimental. Pair a cuff reading with the band data from the 5 minutes before it, for CSV export", + "@settingsBpResearchRowSub": { + "description": "Developer settings row subtitle for the BP research capture screen" + }, + "bpResearchTitle": "BP research capture", + "@bpResearchTitle": { + "description": "Title of the experimental BP research capture screen" + }, + "bpResearchIntro": "Experimental. Take a cuff reading, type it in and press capture. The 5 minutes of band data before the reading are saved next to it for CSV export.", + "@bpResearchIntro": { + "description": "Intro text of the BP research capture screen" + }, + "bpResearchSystolic": "Systolic (mmHg)", + "@bpResearchSystolic": { + "description": "Text field label for the systolic reference value" + }, + "bpResearchDiastolic": "Diastolic (mmHg)", + "@bpResearchDiastolic": { + "description": "Text field label for the diastolic reference value" + }, + "bpResearchDevice": "Cuff device (optional)", + "@bpResearchDevice": { + "description": "Text field label for the cuff device name" + }, + "bpResearchPosture": "Posture (optional)", + "@bpResearchPosture": { + "description": "Text field label for the posture during the reading" + }, + "bpResearchConditions": "Conditions (optional)", + "@bpResearchConditions": { + "description": "Text field label for measurement conditions" + }, + "bpResearchCapture": "Capture now", + "@bpResearchCapture": { + "description": "Button label to store one capture" + }, + "bpResearchMeasuredAt": "Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)", + "@bpResearchMeasuredAt": { + "description": "Optional back-dating field: when the cuff reading was actually taken" + }, + "bpResearchMeasuredAtHint": "Back-date to the actual cuff reading \u2014 the 5-minute band window covers the rest time BEFORE that reading, not the typing-in. Your entry is minute-precise; the stored timestamp is the technical pairing anchor.", + "@bpResearchMeasuredAtHint": { + "description": "Helper text under the measurement-time field" + }, + "bpResearchSessionId": "Session id (optional)", + "@bpResearchSessionId": { + "description": "Optional label grouping readings of one sitting" + }, + "bpResearchSessionIdHint": "Groups readings taken in one sitting.", + "@bpResearchSessionIdHint": { + "description": "Helper text under the session-id field" + }, + "bpResearchBadTime": "Could not read the measurement time — use HH:MM or YYYY-MM-DD HH:MM, or leave it empty for \"now\". Nothing was stored.", + "@bpResearchBadTime": { + "description": "Snackbar text for an unparseable measurement-time entry, which is rejected, never guessed" + }, + "bpResearchFutureTime": "The measurement time is in the future. Nothing was stored.", + "@bpResearchFutureTime": { + "description": "Snackbar text rejecting a future measurement time" + }, + "bpResearchRefreshTooltip": "Refresh band window", + "@bpResearchRefreshTooltip": { + "description": "Tooltip of the developer-mode button that re-reads the current local data for this capture's original window bounds" + }, + "bpResearchPendingSync": "Band data is still syncing — refresh this window after sync.", + "@bpResearchPendingSync": { + "description": "Window summary hint for a pending window: the local sync provably does not reach the window end yet, so the missing tail may still arrive; not a data-quality verdict" + }, + "bpResearchNoData": "No band data in the window — stored as-is.", + "@bpResearchNoData": { + "description": "Window summary text for a final window that provably holds no band data" + }, + "bpResearchHistory": "Captures", + "@bpResearchHistory": { + "description": "Section header for the stored capture list" + }, + "bpResearchBadValue": "That pair is outside the range this app supports (systolic 50–300, diastolic 20–200 mmHg, diastolic below systolic) — it was not saved. Check the numbers and try again.", + "@bpResearchBadValue": { + "description": "Snackbar text for an out-of-bounds reference pair, which is rejected, never clamped" + }, + "bpResearchExportHint": "Export the captures from Your data › Export CSV, set \"BP research captures\".", + "@bpResearchExportHint": { + "description": "Hint under the capture list explaining the CSV export path" + }, + "bpResearchSaveFailed": "Capture failed, nothing was stored. ({error})", + "@bpResearchSaveFailed": { + "description": "Snackbar when storing a BP research capture throws", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "bpResearchListFailed": "Capture saved, but the list did not refresh. ({error})", + "@bpResearchListFailed": { + "description": "Snackbar when a capture was stored but re-reading the list failed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "bpResearchDeleteTooltip": "Delete capture", + "@bpResearchDeleteTooltip": { + "description": "Tooltip of the button that deletes one BP research capture" } } diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart new file mode 100644 index 000000000..1ccb14cf5 --- /dev/null +++ b/lib/ui2/profile/bp_research.dart @@ -0,0 +1,396 @@ +// BP research capture (developer mode): type in a cuff reading and the band's +// decoded window before it is saved next to it. See bp_research_capture.dart. +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../data/db.dart'; +import '../../health/bp_research_capture.dart'; +import '../../l10n/app_localizations.dart'; +import '../ui2.dart'; +import 'devices.dart' show formatDayTime; + +class BpResearchScreen extends StatefulWidget { + const BpResearchScreen({super.key}); + + @visibleForTesting + static String windowSummary(Map r, [AppLocalizations? l]) => + _BpResearchScreenState._windowSummary(r, l); + + @override + State createState() => _BpResearchScreenState(); +} + +class _BpResearchScreenState extends State { + final _sys = TextEditingController(); + final _dia = TextEditingController(); + final _device = TextEditingController(); + final _posture = TextEditingController(); + final _conditions = TextEditingController(); + final _sessionId = TextEditingController(); + // Optional back-dating: 'HH:MM' today or 'YYYY-MM-DD HH:MM'. Empty means + // the measurement is being taken right now. + final _measuredAt = TextEditingController(); + List> _rows = const []; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + @override + void dispose() { + _sys.dispose(); + _dia.dispose(); + _device.dispose(); + _posture.dispose(); + _conditions.dispose(); + _sessionId.dispose(); + _measuredAt.dispose(); + super.dispose(); + } + + Future _refresh() async { + final rows = await LocalDb.bpResearchCaptures(); + if (mounted) setState(() => _rows = rows); + } + + Future _rowAction(Future Function() action) async { + try { + await action(); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + /// The measurement time field, minute precision. Empty is [now]; anything + /// unparseable (including a rolled-over date like 2024-02-31) is null. + DateTime? _parseMeasuredAt(DateTime now) { + final text = _measuredAt.text.trim(); + if (text.isEmpty) return now; + final twoPart = RegExp(r'^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}):(\d{2})$'); + final m = twoPart.firstMatch(text); + if (m != null) { + final y = int.tryParse(m.group(1)!.substring(0, 4)); + final mo = int.tryParse(m.group(1)!.substring(5, 7)); + final d = int.tryParse(m.group(1)!.substring(8, 10)); + final h = int.tryParse(m.group(2)!); + final min = int.tryParse(m.group(3)!); + if (y == null || mo == null || d == null || h == null || min == null) { + return null; + } + if (mo < 1 || mo > 12 || d < 1 || h > 23 || min > 59) return null; + final parsed = DateTime(y, mo, d, h, min); + // Overflow check: a rolled-over date no longer matches its parts. + if (parsed.year != y || parsed.month != mo || parsed.day != d) { + return null; + } + return parsed; + } + final hm = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(text); + if (hm != null) { + final h = int.tryParse(hm.group(1)!); + final min = int.tryParse(hm.group(2)!); + if (h == null || min == null || h > 23 || min > 59) return null; + return DateTime(now.year, now.month, now.day, h, min); + } + return null; + } + + Future _capture() async { + if (_busy) return; + final sys = double.tryParse(_sys.text); + final dia = double.tryParse(_dia.text); + final l = AppLocalizations.of(context); + if (sys == null || + dia == null || + sys < kResearchSystolicBounds.$1 || + sys > kResearchSystolicBounds.$2 || + dia < kResearchDiastolicBounds.$1 || + dia > kResearchDiastolicBounds.$2 || + dia >= sys) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l?.bpResearchBadValue ?? + 'That pair is outside the range this app supports \u2014 ' + 'check the numbers and try again. Nothing was stored.', + ), + ), + ); + } + return; + } + final enteredAt = DateTime.now(); + final measuredAt = _parseMeasuredAt(enteredAt); + if (measuredAt == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l?.bpResearchBadTime ?? + 'Could not read the measurement time \u2014 use HH:MM or ' + 'YYYY-MM-DD HH:MM, or leave it empty for "now". Nothing was ' + 'stored.', + ), + ), + ); + } + return; + } + final measuredAtMs = measuredAt.millisecondsSinceEpoch; + final enteredAtMs = enteredAt.millisecondsSinceEpoch; + if (measuredAtMs > enteredAtMs + 60 * 1000) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l?.bpResearchFutureTime ?? + 'The measurement time is in the future. Nothing was stored.', + ), + ), + ); + } + return; + } + setState(() => _busy = true); + var stored = false; + try { + final rows = await LocalDb.bpResearchRows( + LocalDb.kPrimaryDeviceId, + measuredAtMs - kResearchRestPreMs, + measuredAtMs + kResearchWindowPostMs, + ); + final window = researchWindowFrom( + measuredAtMs: measuredAtMs, + onehzRows: rows.onehz, + rrRows: rows.rr, + nowMs: enteredAtMs, + dataThroughMs: rows.dataThroughMs, + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: measuredAtMs, + measurementStartedAtMs: null, + measurementFinishedAtMs: null, + timePrecision: 'minute', + systolicMmHg: sys, + diastolicMmHg: dia, + capturedAtMs: enteredAtMs, + device: _device.text.trim().isEmpty ? null : _device.text.trim(), + posture: _posture.text.trim().isEmpty ? null : _posture.text.trim(), + conditions: _conditions.text.trim().isEmpty + ? null + : _conditions.text.trim(), + bandDeviceId: LocalDb.kPrimaryDeviceId, + measurementSessionId: _sessionId.text.trim().isEmpty + ? null + : _sessionId.text.trim(), + window: window, + ), + snapshotOnehzRows: rows.onehz, + snapshotRrRows: rows.rr, + ); + stored = true; + _sys.clear(); + _dia.clear(); + _measuredAt.clear(); + await _refresh(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + stored + ? (l?.bpResearchListFailed('$e') ?? + 'Capture saved, but the list did not refresh. ($e)') + : (l?.bpResearchSaveFailed('$e') ?? + 'Capture failed, nothing was stored. ($e)'), + ), + ), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final p = P.of(c); + return Scaffold( + appBar: AppBar(title: Text(l?.bpResearchTitle ?? 'BP research capture')), + body: ListView( + padding: const EdgeInsets.all(S.x4), + children: [ + Text( + l?.bpResearchIntro ?? + 'Experimental. Take a cuff reading, type it in and press ' + 'capture. The 5 minutes of band data before the reading are ' + 'saved next to it for CSV export.', + style: F.cap.copyWith(color: p.ink2, height: 1.5), + ), + const SizedBox(height: S.x4), + TextField( + controller: _sys, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + ], + decoration: InputDecoration( + labelText: l?.bpResearchSystolic ?? 'Systolic (mmHg)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _dia, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + ], + decoration: InputDecoration( + labelText: l?.bpResearchDiastolic ?? 'Diastolic (mmHg)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _measuredAt, + keyboardType: TextInputType.datetime, + decoration: InputDecoration( + labelText: + l?.bpResearchMeasuredAt ?? + 'Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)', + helperText: + l?.bpResearchMeasuredAtHint ?? + 'Back-date to the actual cuff reading \u2014 the 5-minute ' + 'band window covers the rest time BEFORE that reading, not ' + 'the typing-in. Minute precision; empty = taken just now.', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _device, + decoration: InputDecoration( + labelText: l?.bpResearchDevice ?? 'Cuff device (optional)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _sessionId, + decoration: InputDecoration( + labelText: l?.bpResearchSessionId ?? 'Session id (optional)', + helperText: + l?.bpResearchSessionIdHint ?? + 'Groups readings taken in one sitting.', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _posture, + decoration: InputDecoration( + labelText: l?.bpResearchPosture ?? 'Posture (optional)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _conditions, + decoration: InputDecoration( + labelText: l?.bpResearchConditions ?? 'Conditions (optional)', + ), + ), + const SizedBox(height: S.x4), + FilledButton.icon( + onPressed: _busy ? null : _capture, + icon: const Icon(LucideIcons.plus), + label: Text(l?.bpResearchCapture ?? 'Capture now'), + ), + const SizedBox(height: S.x6), + if (_rows.isNotEmpty) ...[ + Text(l?.bpResearchHistory ?? 'Captures', style: F.head), + const SizedBox(height: S.x2), + for (final r in _rows) + ListTile( + dense: true, + title: Text( + '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg \u2014 ' + '${formatDayTime(DateTime.fromMillisecondsSinceEpoch(r['measured_at_ms'] as int), l)}', + ), + subtitle: Text(_windowSummary(r, l)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(LucideIcons.refreshCw, size: 18), + tooltip: + l?.bpResearchRefreshTooltip ?? 'Refresh band window', + onPressed: () => _rowAction( + () => LocalDb.reprocessBpResearchCapture( + r['id'] as int, + ), + ), + ), + IconButton( + icon: const Icon(LucideIcons.trash2, size: 18), + tooltip: l?.bpResearchDeleteTooltip ?? 'Delete capture', + onPressed: () => _rowAction( + () => LocalDb.deleteBpResearchCapture(r['id'] as int), + ), + ), + ], + ), + ), + const SizedBox(height: S.x4), + Text( + l?.bpResearchExportHint ?? + 'Export the captures from Your data \u203a Export CSV, set ' + '\u201cBP research captures\u201d.', + style: F.cap.copyWith(color: p.ink2, height: 1.5), + ), + ], + ], + ), + ); + } + + /// One line per capture. Null stats are left out; a pending window says + /// it is still syncing rather than "no band data". + static String _windowSummary(Map r, AppLocalizations? l) { + final onehz = r['onehz_rows']; + final beats = r['rr_beats']; + final hr = r['hr_mean']; + final rmssd = r['rmssd_ms']; + final status = r['quality_status']; + if (status == 'pending') { + final parts = [ + l?.bpResearchPendingSync ?? + 'Band data is still syncing — refresh this window after ' + 'sync.', + ]; + if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); + if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); + if (onehz is num || beats is num) { + parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats so far'); + } + return parts.join(' · '); + } + if (onehz == null && beats == null) { + return l?.bpResearchNoData ?? + 'No band data in the window — stored as-is.'; + } + final parts = []; + if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); + if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); + parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats'); + if (status is String && status.isNotEmpty && status != 'ok') { + parts.add(status); + } + return parts.join(' · '); + } +} diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 2d24b6d13..5b1167dde 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -36,6 +36,7 @@ import '../../theme/theme_controller.dart'; import '../ui2.dart'; import 'alarm.dart'; import 'band_notifications.dart'; +import 'bp_research.dart'; import 'data.dart'; import 'gallery.dart'; import 'gestures.dart'; @@ -793,6 +794,13 @@ class MoreSettingsView extends StatelessWidget { 'Every component, at any text scale, in either ' 'theme', onTap: onGallery), + SetRow(LucideIcons.heartPulse, C.purple, + l?.settingsBpResearchRowTitle ?? 'BP research capture', + sub: l?.settingsBpResearchRowSub ?? + 'Experimental. Pair a cuff reading with the band ' + 'data from the 5 minutes before it, for CSV ' + 'export', + onTap: () => goto(c, const BpResearchScreen())), SetRow(LucideIcons.code, C.n500, l?.settingsDeveloperModeRowTitle ?? 'Developer mode', value: on, chevron: false, onTap: onToggleDev), diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart new file mode 100644 index 000000000..1be6b4b5c --- /dev/null +++ b/test/bp_research_capture_test.dart @@ -0,0 +1,358 @@ +// Pure window computation behind the BP research capture. +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/health/bp_research_capture.dart'; + +void main() { + const at = 1700000000000; // ms + // The default rest window: [at − 5 min, at). + const preStart = at - 5 * 60 * 1000; + + test('no band data in the window yields a NULL window, not zeroes', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: const [], + ); + expect(w, isNull); + }); + + test('the rest window lies BEFORE the measurement, inflation excluded', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // Inside the rest window (seconds base). + {'rec_ts': preStart ~/ 1000 + 60, 'hr': 60}, + // The measurement instant itself (the half-open end) and AFTER it: + // the cuff inflating. Outside the window — must not land in any stat. + {'rec_ts': at ~/ 1000, 'hr': 180}, + {'rec_ts': at ~/ 1000 + 1, 'hr': 190}, + ], + rrRows: const [], + ); + expect(w, isNotNull); + expect(w!.windowStartMs, preStart); + expect(w.windowEndMs, at); + expect(w.onehzRows, 1); + expect(w.hrMean, 60.0); + }); + + test('rows outside the window are ignored (seconds vs ms bases)', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // rec_ts is epoch SECONDS. Inside the rest window. + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 60}, + // 10 minutes before the window: outside, must not land in any stat. + {'rec_ts': preStart ~/ 1000 - 600, 'hr': 180}, + ], + rrRows: [ + // rr_ts_ms is epoch MS. Inside. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + ], + ); + expect(w, isNotNull); + expect(w!.onehzRows, 1); + expect(w.hrMean, 60.0); + expect(w.rrBeats, 1); + expect(w.rmssdMs, isNull); // one beat forms no successive difference + }); + + test('unsorted rows are sorted; duplicate timestamps deduplicated', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000 + 30, 'hr': 64}, + // Same second twice — one row, not two. + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 56}, + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 56}, + ], + rrRows: [ + {'rr_ts_ms': preStart + 3000, 'rr_ms': 900}, + // Same instant twice — one interval, not two. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + ], + ); + expect(w!.onehzRows, 2); + expect(w.rrBeats, 2); + expect(w.hrMean, 60.0); // (56 + 64) / 2 + }); + + test('beats of one record are NOT duplicates (beat identity)', () { + // decoded_rr keys beats by (rec_ts, beat_index); rr_ts_ms alone is + // rec_ts*1000 for EVERY beat of the record. Deduplicating by rr_ts_ms + // would drop real beats and corrupt RMSSD. + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // One record at rec_ts, four beats — same rr_ts_ms, different + // beat_index. + {'rr_ts_ms': preStart + 1000, 'beat_index': 0, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 1, 'rr_ms': 1100}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 2, 'rr_ms': 900}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 3, 'rr_ms': 1050}, + ], + ); + expect(w!.rrBeats, 4); // all four beats survive + expect(w.validIntervalCount, 4); + // All three successive pairs are usable (whole-second heuristic: the + // beats of one second share a time, so the pairs count as contiguous). + expect(w.validIntervalPairCount, 3); + expect(w.rmssdMs, isNotNull); + }); + + test('beat_ts_ms is the beat identity when the decoder provides it', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // Same (rr_ts_ms, beat_index) twice but DIFFERENT measured beat + // instants — two real beats, not a duplicate. + { + 'rr_ts_ms': preStart + 1000, + 'beat_index': 0, + 'beat_ts_ms': preStart + 1000, + 'rr_ms': 1000, + }, + { + 'rr_ts_ms': preStart + 1000, + 'beat_index': 0, + 'beat_ts_ms': preStart + 2000, + 'rr_ms': 1100, + }, + ], + ); + expect(w!.rrBeats, 2); + // 1000 ms apart: contiguous, one RMSSD pair. + expect(w.validIntervalPairCount, 1); + }); + + test('invalid HR rows do not drag the mean toward zero', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000, 'hr': 0}, + {'rec_ts': preStart ~/ 1000 + 1, 'hr': 58}, + {'rec_ts': preStart ~/ 1000 + 2, 'hr': 62}, + // Non-finite junk: rejected outright. + {'rec_ts': preStart ~/ 1000 + 3, 'hr': -5}, + ], + rrRows: const [], + ); + expect(w!.onehzRows, 4); // raw rows + expect(w.validHrSeconds, 2); // valid seconds + expect(w.hrMean, 60.0); // 0 and −5 excluded, not averaged in + }); + + test('coverage counts VALID seconds only; off-skin zeroes do not cover', () { + // 300 raw rows, 200 of them hr = 0 (band off skin): coverage must be + // 1/3, not 1.0 — an off-skin window must not escape the gappy verdict. + final onehz = [ + for (var i = 0; i < 300; i++) + {'rec_ts': preStart ~/ 1000 + i, 'hr': i < 200 ? 0 : 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: onehz, + rrRows: const [], + ); + expect(w!.onehzRows, 300); + expect(w.validHrSeconds, 100); + expect(w.coverageFraction, closeTo(1 / 3, 0.001)); + expect(w.qualityStatus, 'gappy'); + }); + + test('observed bounds come from VALID rows, not raw rows', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // Invalid rows at the edges must not extend the observed signal. + {'rec_ts': preStart ~/ 1000, 'hr': 0}, + {'rec_ts': preStart ~/ 1000 + 120, 'hr': 60}, + {'rec_ts': preStart ~/ 1000 + 180, 'hr': 62}, + {'rec_ts': preStart ~/ 1000 + 299, 'hr': 0}, + ], + rrRows: const [], + ); + expect(w!.observedStartMs, preStart + 120000); + expect(w.observedEndMs, preStart + 180000); + }); + + test('RMSSD over successive differences, min/max preserved', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, + {'rr_ts_ms': preStart + 3000, 'rr_ms': 900}, + ], + ); + expect(w!.rrBeats, 3); + expect(w.rrMsMin, 900.0); + expect(w.rrMsMax, 1100.0); + // diffs: +100, −200 → sqrt((100² + 200²)/2) = sqrt(25000) = 158.11… + expect(w.rmssdMs!, closeTo(158.11, 0.01)); + expect(w.validIntervalCount, 3); + expect(w.validIntervalPairCount, 2); + expect(w.hrMean, isNull); // no 1 Hz rows: absent, not zero + }); + + test( + 'RMSSD never spans a sensor gap; the pair-rejection fraction reports', + () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // A contiguous pair before the gap. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, + // THE GAP: two minutes of nothing. The pair across it must not + // enter RMSSD — a difference across a sensor gap is a fabricated + // HRV sample, not a real one. + {'rr_ts_ms': preStart + 140000, 'rr_ms': 800}, + // A contiguous pair after the gap. + {'rr_ts_ms': preStart + 141000, 'rr_ms': 850}, + ], + ); + expect(w!.rrBeats, 4); + expect(w.validIntervalCount, 4); + // Two of three successive pairs are contiguous; one spans the gap. + expect(w.validIntervalPairCount, 2); + // The metric is named for what it measures: the share of successive + // PAIRS rejected — not an interval-exclusion rate. + expect(w.rejectedIntervalFraction, closeTo(1 / 3, 0.001)); + // RMSSD over the two REAL pairs: diffs +100, −50 → sqrt((10000+2500)/2). + expect(w.rmssdMs!, closeTo(math.sqrt(12500 / 2), 0.01)); + // One rejected pair of three is under the >50% threshold, and no 1 Hz + // rows means coverage is NULL (absent) rather than low — so the honest + // verdict is 'ok', with the rejected-pair fraction carried alongside. + expect(w.qualityStatus, 'ok'); + }, + ); + + test('a window whose end lies in the future is pending', () { + // An internal, data-level state: the UI refuses future instants, so + // it can never produce one — this pins the honest labelling for any + // caller that still passes such a window. + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000, 'hr': 60}, + ], + rrRows: const [], + nowMs: at - 60000, // "now" is a minute before the measurement + ); + expect(w!.qualityStatus, 'pending'); + }); + + test('a well-covered window is ok; coverage is honest', () { + // 300 valid seconds of a half-open 300-second window = coverage 1.0 + // exactly — never more, because the end instant itself is excluded. + final onehz = [ + for (var i = 0; i < 300; i++) {'rec_ts': preStart ~/ 1000 + i, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: onehz, + rrRows: const [], + ); + expect(w!.qualityStatus, 'ok'); + expect(w.coverageFraction, closeTo(1.0, 0.001)); + expect(w.coverageFraction!, lessThanOrEqualTo(1.0)); + expect(w.validHrSeconds, 300); + expect(w.featureVersion, kResearchFeatureVersion); + }); + + test('requested bounds and observed bounds are distinct', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000 + 120, 'hr': 60}, + {'rec_ts': preStart ~/ 1000 + 180, 'hr': 62}, + ], + rrRows: const [], + ); + // Requested: the full 5 minutes. Observed: 60 s in the middle. + expect(w!.windowStartMs, preStart); + expect(w.windowEndMs, at); + expect(w.observedStartMs, preStart + 120000); + expect(w.observedEndMs, preStart + 180000); + expect(w.coverageFraction, closeTo(2 / 300, 0.001)); + }); + + test('a custom postMs window can include the measurement itself', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': at ~/ 1000 + 30, 'hr': 70}, // after the instant: inside now + ], + rrRows: const [], + postMs: 60000, + ); + expect(w!.windowEndMs, at + 60000); + expect(w.onehzRows, 1); + }); + test('a window whose local data does not provably reach its end is ' + 'pending (sync watermark), never a final verdict', () { + // Full, perfectly valid data — but the watermark proves the band + // has not synced up to the window END: the missing tail may still + // arrive, so 'pending', NOT 'ok' (and never 'no_data'/'gappy'). + final rows = >[ + for (int s = 0; s < 300; s++) {'rec_ts': at ~/ 1000 - 300 + s, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at - 60000, // synced only to T-60s + ); + expect(w, isNotNull); + expect(w!.qualityStatus, 'pending'); + // The data itself is still frozen — pending is a VERDICT about + // finality, not a rejection of the rows. + expect(w.onehzRows, 300); + // Once the watermark reaches the end, the SAME rows are final: + final w2 = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at, + ); + expect(w2!.qualityStatus, 'ok'); + }); + + test('pending outranks a would-be gappy classification (precedence)', () { + // Half the coverage missing AND the watermark short: the missing + // part may still arrive, so 'pending', not 'gappy'. + final rows = >[ + for (int s = 0; s < 150; s++) {'rec_ts': at ~/ 1000 - 300 + s, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at - 150000, + ); + expect(w!.qualityStatus, 'pending'); + }); + + test('an invalid beat breaks the RMSSD chain instead of being skipped', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + {'rr_ts_ms': at - 3000, 'beat_index': 0, 'rr_ms': 800}, + {'rr_ts_ms': at - 2000, 'beat_index': 0, 'rr_ms': 0}, + {'rr_ts_ms': at - 1000, 'beat_index': 0, 'rr_ms': 1000}, + ], + )!; + expect(w.validIntervalCount, 2); + expect(w.validIntervalPairCount, isNull); + expect(w.rmssdMs, isNull); + }); +} diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart new file mode 100644 index 000000000..4928f922a --- /dev/null +++ b/test/bp_research_db_test.dart @@ -0,0 +1,1893 @@ +// BP research store over the real LocalDb (sqflite_common_ffi). +import 'dart:convert' show jsonEncode; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/health/bp_research_capture.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +const _at = 1700000000000; // ms + +BpResearchCapture _capture( + int measuredAtMs, { + String? device, + BpResearchWindow? window, +}) => BpResearchCapture( + measuredAtMs: measuredAtMs, + device: device, + posture: 'sitting', + conditions: 'rest', + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: measuredAtMs, + window: window, +); + +const _win = BpResearchWindow( + windowStartMs: _at - 120000, + windowEndMs: _at + 120000, + onehzRows: 240, + rrBeats: 200, + hrMean: 62.5, + rrMsMean: 960, + rrMsMin: 800, + rrMsMax: 1100, + rmssdMs: 42, + featureVersion: kResearchFeatureVersion, + metaJson: null, +); + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'bp_research_db_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + final db = await LocalDb.instance; + await db.close(); + }); + + test( + 'a retake with no device replaces the reference, not duplicates it', + () async { + await LocalDb.putBpResearchCapture(_capture(_at)); + await LocalDb.putBpResearchCapture(_capture(_at, window: _win)); + final rows = await LocalDb.bpResearchCaptures(); + expect(rows, hasLength(1)); + expect(rows.first['device'], ''); + expect(rows.first['hr_mean'], 62.5); + }, + ); + + test('a named device stays distinct from the no-device row', () async { + await LocalDb.putBpResearchCapture(_capture(_at, device: 'omron')); + final rows = await LocalDb.bpResearchCaptures(); + expect(rows, hasLength(2)); // the '' row from the previous test + omron + expect(rows.where((r) => r['device'] == 'omron'), hasLength(1)); + }); + + test( + 'delete removes the window row too (the SQL cascade is inert)', + () async { + final db = await LocalDb.instance; + final before = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window', + ); + expect(before.first['c'], greaterThan(0)); + final refs = await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['omron'], + ); + await LocalDb.deleteBpResearchCapture(refs.first['id'] as int); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }, + ); + + test( + 'a retake that now finds band data replaces the absent window', + () async { + final later = _at + 60000; + await LocalDb.putBpResearchCapture(_capture(later)); + await LocalDb.putBpResearchCapture(_capture(later, window: _win)); + final db = await LocalDb.instance; + final windows = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id IN ' + '(SELECT id FROM bp_research_reference WHERE measured_at_ms = ?)', + [later], + ); + expect(windows.first['c'], 1); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }, + ); + + test( + 'a retro capture pairs with HISTORICAL rows and keeps entry time', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final measured = _at; // the cuff reading, this morning + final entered = _at + 6 * 3600 * 1000; // typed in this evening + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: measured, + measurementStartedAtMs: measured, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: entered, // ENTRY time, not measurement + device: 'omron', + bandDeviceId: LocalDb.kPrimaryDeviceId, + measurementSessionId: 'morning', + window: _win, + ), + ); + final r = (await LocalDb.bpResearchCaptures()).first; + expect(r['measured_at_ms'], measured); + expect(r['measurement_started_at_ms'], measured); + expect(r['measurement_finished_at_ms'], isNull); // no invented duration + expect(r['captured_at_ms'], entered); // entry vs measurement + expect(r['band_device_id'], LocalDb.kPrimaryDeviceId); + expect(r['measurement_session_id'], 'morning'); + }, + ); + + test( + 'a snapshot freezes the raw rows; re-processing writes a new revision', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final onehz = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 62}, + ]; + final rr = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 1000}, + {'rr_ts_ms': _at - 59000, 'rr_ms': 1050}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: onehz, + rrRows: rr, + ), + ), + snapshotOnehzRows: onehz, + snapshotRrRows: rr, + ); + final id = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final snap1 = await db.rawQuery( + 'SELECT revision, onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + expect(snap1, hasLength(1)); + expect(snap1.first['revision'], 1); + // The frozen rows are the exact input: reproducible. + expect(snap1.first['onehz_json'], contains('60')); + final win = await db.rawQuery( + 'SELECT snapshot_revision, feature_version, quality_status, ' + 'valid_interval_pair_count FROM bp_research_window ' + 'WHERE reference_id = ?', + [id], + ); + expect(win.first['snapshot_revision'], 1); + expect(win.first['feature_version'], kResearchFeatureVersion); + expect(win.first['quality_status'], isNotNull); + expect(win.first['valid_interval_pair_count'], 1); + }, + ); + + test('a retake keeps the reference id and writes revision 2, revision 1' + ' stays byte-identical', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final rows1 = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 62}, + ]; + final rr1 = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 1000}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rows1, + rrRows: rr1, + ), + ), + snapshotOnehzRows: rows1, + snapshotRrRows: rr1, + ); + final idBefore = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final json1 = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [idBefore], + )).first['onehz_json'] + as String; + // The retake: DIFFERENT sensor rows for the same natural reference. + final rows2 = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 64}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 66}, + ]; + final rr2 = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 900}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 118, + diastolicMmHg: 79, + capturedAtMs: _at + 1000, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rows2, + rrRows: rr2, + ), + ), + snapshotOnehzRows: rows2, + snapshotRrRows: rr2, + ); + final idAfter = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + // The reference id is STABLE — a retake is an update, not a new row. + expect(idAfter, idBefore); + final snaps = await db.rawQuery( + 'SELECT revision, onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? ORDER BY revision', + [idBefore], + ); + expect(snaps, hasLength(2)); + expect(snaps[0]['revision'], 1); + expect(snaps[1]['revision'], 2); + // Revision 1 is untouched — byte-identical history. + expect(snaps[0]['onehz_json'], json1); + expect(snaps[0]['onehz_json'], contains('60')); + expect(snaps[1]['onehz_json'], contains('64')); + // The window summary points at the CURRENT revision. + final win = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [idBefore], + ); + expect(win.first['snapshot_revision'], 2); + // The reference fields were updated in place. + final ref = await db.rawQuery( + 'SELECT systolic_mmhg FROM bp_research_reference WHERE id = ?', + [idBefore], + ); + expect(ref.first['systolic_mmhg'], 118.0); + }); + + test( + 'a reference correction (no new snapshot rows) keeps the snapshots', + () async { + final db = await LocalDb.instance; + // Re-capture the SAME natural reference with corrected values but no + // snapshot rows: a field fix must not touch the snapshot history. + final id = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final before = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 117, + diastolicMmHg: 78, + capturedAtMs: _at + 2000, + device: 'omron', + ), + ); + final after = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + expect(after.first['c'], before.first['c']); + final ref = await db.rawQuery( + 'SELECT systolic_mmhg FROM bp_research_reference WHERE id = ?', + [id], + ); + expect(ref.first['systolic_mmhg'], 117.0); + }, + ); + + test('overwriting an existing snapshot revision is an integrity error, ' + 'not a silent rewrite', () async { + final db = await LocalDb.instance; + final id = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final foreignJson = jsonEncode([ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 999}, + ]); + // A second writer claiming the SAME (reference, revision) key with + // DIFFERENT content must fail loudly: plain INSERT + UNIQUE. + await expectLater( + db.insert('bp_research_snapshot', { + 'reference_id': id, + 'revision': 1, + 'onehz_json': foreignJson, + 'rr_json': '[]', + 'created_at_ms': _at, + }), + throwsA(isA()), + ); + // The destination revision is untouched. + final kept = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [id], + ); + expect(kept, hasLength(1)); + expect(kept.first['onehz_json'], isNot(foreignJson)); + }); + + test('delete removes the snapshot rows too', () async { + final db = await LocalDb.instance; + final id = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + await LocalDb.deleteBpResearchCapture(id); + final left = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + expect(left.first['c'], 0); + }); + + test( + 'the research tables ride the backup restore and salvage lists', + () async { + expect(LocalDb.restoreTablesForTest, contains('bp_research_reference')); + expect(LocalDb.restoreTablesForTest, contains('bp_research_window')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_reference')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_window')); + // Parent before child, in both lists. + int posOf(List l, String t) => l.indexOf(t); + expect( + posOf(LocalDb.restoreTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.restoreTablesForTest, 'bp_research_window')), + ); + expect( + posOf(LocalDb.salvageTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.salvageTablesForTest, 'bp_research_window')), + ); + }, + ); + + // A foreign export's AUTOINCREMENT ids are meaningless on this install: + // its id=1 must never REPLACE an unrelated local capture that happens to + // hold id=1. The merge keys references on (measured_at_ms, device) and + // remaps each window onto the DESTINATION reference id. + test('restore merges captures by natural key, never by source id', () async { + // Start from a clean store: earlier tests in this file leave rows + // behind, and this one asserts exact row sets. + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // Local state: one capture (id=1 by AUTOINCREMENT) plus its window. + await LocalDb.putBpResearchCapture(_capture(_at, device: 'local')); + // A foreign export whose DIFFERENT capture also carries id=1. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER NOT NULL PRIMARY KEY, ' + 'window_start_ms INTEGER NOT NULL, window_end_ms INTEGER NOT NULL, ' + 'onehz_rows INTEGER, rr_beats INTEGER, hr_mean REAL, rr_ms_mean REAL, ' + 'rr_ms_min REAL, rr_ms_max REAL, rmssd_ms REAL, meta_json TEXT)', + ); + await src.insert('bp_research_reference', { + 'id': 1, // deliberately collides with the local capture's id + 'measured_at_ms': _at + 60000, + 'device': 'local', + 'posture': 'sitting', + 'conditions': 'rest', + 'systolic_mmhg': 130, + 'diastolic_mmhg': 85, + 'captured_at_ms': _at + 60000, + }); + await src.insert('bp_research_window', { + 'reference_id': 1, + 'window_start_ms': _at + 60000 - 120000, + 'window_end_ms': _at + 60000 + 120000, + 'onehz_rows': 240, + 'rr_beats': 200, + 'hr_mean': 71.0, + }); + await src.close(); + + final counts = await LocalDb.importFromDbFile(srcPath); + expect(counts['bp_research_reference'], 1); + expect(counts['bp_research_window'], 1); + + final db = await LocalDb.instance; + // Both captures survive: the foreign id=1 did not eat the local one. + final refs = await db.rawQuery( + 'SELECT measured_at_ms, systolic_mmhg FROM bp_research_reference ' + 'ORDER BY measured_at_ms', + ); + expect(refs, hasLength(2)); + expect(refs[0]['measured_at_ms'], _at); + expect(refs[0]['systolic_mmhg'], 120.0); + expect(refs[1]['measured_at_ms'], _at + 60000); + expect(refs[1]['systolic_mmhg'], 130.0); + // The imported window rides the imported reference's DESTINATION id, + // and no window is orphaned. + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + final importedWin = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window w ' + 'JOIN bp_research_reference r ON r.id = w.reference_id ' + 'WHERE r.measured_at_ms = ?', + [_at + 60000], + ); + expect(importedWin.first['hr_mean'], 71.0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('a colliding restore keeps the destination id and its window', () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // Local capture WITH a window. + await LocalDb.putBpResearchCapture( + _capture(_at, device: 'local', window: _win), + ); + final localId = + (await db0.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + + // A foreign export of the SAME instant (same natural key) with no + // window row: the capture's fields update, the window survives. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign3.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 7, + 'measured_at_ms': _at, + 'device': 'local', + 'posture': 'standing', + 'conditions': 'after exercise', + 'systolic_mmhg': 140, + 'diastolic_mmhg': 90, + 'captured_at_ms': _at + 1000, + }); + await src.close(); + + await LocalDb.importFromDbFile(srcPath); + + final db = await LocalDb.instance; + final refs = await db.rawQuery( + 'SELECT id, posture, systolic_mmhg FROM bp_research_reference', + ); + expect(refs, hasLength(1)); + // The destination id is KEPT, so the window stays attached. + expect(refs.first['id'], localId); + expect(refs.first['posture'], 'standing'); + expect(refs.first['systolic_mmhg'], 140.0); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + ); + expect(win.first['hr_mean'], 62.5); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('a re-import of the same export converges (idempotent merge)', () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign2.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 1, + 'measured_at_ms': _at + 120000, + 'device': '', + 'systolic_mmhg': 118, + 'diastolic_mmhg': 76, + 'captured_at_ms': _at + 120000, + }); + await src.close(); + + await LocalDb.importFromDbFile(srcPath); + await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final n = (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference ' + 'WHERE measured_at_ms = ?', + [_at + 120000], + )).first['c']; + expect(n, 1); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('a restore whose snapshot conflicts skips the window too ' + '(window/snapshot consistency)', () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_snapshot'); + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // LOCAL: a capture with snapshot revision 1 (rows A) and a window + // pointing at it. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'cuff', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final localId = + (await db0.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final localJson = + (await db0.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + )).first['onehz_json'] + as String; + final localHr = (await db0.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + )).first['hr_mean']; + + // FOREIGN: the SAME natural reference, a snapshot revision 1 with + // DIFFERENT rows (B), and a window whose features came from B — + // importing that window would point features at local revision 1, + // which holds A. Both must be skipped; the local pair stays. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign_conflict.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 42, + 'measured_at_ms': _at, + 'device': 'cuff', + 'systolic_mmhg': 121, + 'diastolic_mmhg': 81, + 'captured_at_ms': _at, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + await src.insert('bp_research_window', { + 'reference_id': 42, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': 300, + 'hr_mean': 77.7, + 'feature_version': 3, + 'snapshot_revision': 1, + }); + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + await src.insert('bp_research_snapshot', { + 'reference_id': 42, + 'revision': 1, + 'onehz_json': '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":99}]', + 'rr_json': '[]', + 'created_at_ms': _at, + }); + await src.close(); + await LocalDb.importFromDbFile(srcPath); + + final db = await LocalDb.instance; + // The local snapshot revision 1 is untouched — foreign content lost. + final snap = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + ); + expect(snap, hasLength(1)); + expect(snap.first['onehz_json'], localJson); + // The foreign window was SKIPPED: the local window survives. + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], localHr); + await databaseFactory.deleteDatabase(srcPath); + }); + + test( + 'a restore with an IDENTICAL snapshot is idempotent (window too)', + () async { + final db = await LocalDb.instance; + final localId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final localJson = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + )).first['onehz_json'] + as String; + // The SAME snapshot content under the same key: idempotent + // re-import, no duplicate revision rows, the window converges. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign_ident.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 43, + 'measured_at_ms': _at, + 'device': 'cuff', + 'systolic_mmhg': 120, + 'diastolic_mmhg': 80, + 'captured_at_ms': _at, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + await src.insert('bp_research_window', { + 'reference_id': 43, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': 1, + 'hr_mean': 60, + 'feature_version': 3, + 'snapshot_revision': 1, + }); + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + await src.insert('bp_research_snapshot', { + 'reference_id': 43, + 'revision': 1, + 'onehz_json': localJson, + 'rr_json': '[]', + 'created_at_ms': _at, + }); + await src.close(); + await LocalDb.importFromDbFile(srcPath); + await LocalDb.importFromDbFile(srcPath); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [localId], + ); + expect(snaps.first['c'], 1); + final win = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id = ?', + [localId], + ); + expect(win.first['c'], 1); + await databaseFactory.deleteDatabase(srcPath); + }, + ); + + test('the store rejects invalid references before writing anything', () async { + final db = await LocalDb.instance; + final before = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + )).first['c'] + as int; + BpResearchCapture ref(int m, double sys, double dia) => BpResearchCapture( + measuredAtMs: m, + systolicMmHg: sys, + diastolicMmHg: dia, + capturedAtMs: m, + device: 'validate', + window: _win, + ); + // NaN / infinity: rejected, never laundered through the bounds check. + for (final bad in [ + ref(_at + 1000000, double.nan, 80), + ref(_at + 1000000, double.infinity, 80), + ref(_at + 1000000, 120, double.nan), + ref(_at + 1000000, 120, double.negativeInfinity), + // Out of research bounds. + ref(_at + 1000000, 301, 80), + ref(_at + 1000000, 49, 80), + ref(_at + 1000000, 120, 201), + ref(_at + 1000000, 120, 19), + // dia >= sys. + ref(_at + 1000000, 120, 120), + ref(_at + 1000000, 110, 120), + ]) { + await expectLater(LocalDb.putBpResearchCapture(bad), throwsArgumentError); + } + // Boundary values are VALID: 300/200 passes the bounds, dia < sys. + await LocalDb.putBpResearchCapture(ref(_at + 1000000, 300, 200)); + // Nothing partial was left behind by the rejected writes. + final after = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + )).first['c'] + as int; + expect(after, before + 1); + // None of the REJECTED writes left a window or snapshot row behind: + // the only window/snapshot rows are the ones that BELONG to the one + // valid reference (rows with no owning reference must not exist). + final orphanW = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + )).first['c'] + as int; + final orphanS = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + )).first['c'] + as int; + expect(orphanW, 0); + expect(orphanS, 0); + await LocalDb.deleteBpResearchCapture(_at + 1000000); + }); + + test('the production beat query and window computation keep every beat ' + 'of one record (integration)', () async { + // The FULL production path, not synthetic maps: real decoded_rr rows + // (several beats of ONE record share rr_ts_ms = rec_ts*1000), the + // same COALESCE query the capture screen runs, the snapshot freeze, + // and researchWindowFrom on the queried rows. + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_rr'); + final recTs = (_at - 60000) ~/ 1000; // inside the rest window + // FOUR beats of that one record: identical rr_ts_ms, distinct + // beat_index; one carries a measured beat_ts_ms. + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 0, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 1000, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 1, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 1100, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 2, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 900, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 3, + 'rr_ts_ms': recTs * 1000, + 'beat_ts_ms': recTs * 1000 + 3000, + 'rr_ms': 1050, + }); + // THE PRODUCTION QUERY (same shape as the capture screen). + final start = _at - kResearchRestPreMs; + final end = _at + kResearchWindowPostMs; + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', + [LocalDb.kPrimaryDeviceId, start, end], + ); + expect(rr, hasLength(4)); // no beat was dropped as a "duplicate" + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: rr, + ); + expect(w, isNotNull); + expect(w!.rrBeats, 4); // all four beats survive the window computation + expect(w.validIntervalCount, 4); + // Beat 3 was MEASURED 3000 ms after beat 2 — beyond the 2500 ms beat-gap + // engineering default — so the pair across that gap is correctly NOT + // used for RMSSD: 3 successive beats = 2 RMSSD pairs, not 3. + expect(w.validIntervalPairCount, 2); + expect(w.rmssdMs, isNotNull); + // The snapshot freezes exactly these queried rows (beat fields ride + // along), so re-processing reproduces the same features. + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'integration', + window: w, + ), + snapshotOnehzRows: const [], + snapshotRrRows: rr, + ); + final snap = await db.rawQuery('SELECT rr_json FROM bp_research_snapshot'); + expect(snap, hasLength(1)); + expect(snap.first['rr_json'] as String, contains('beat_index')); + await LocalDb.deleteBpResearchCapture( + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int, + ); + }); + + test( + 'beat_ts_ms window membership follows the measured beat instant', + () async { + // A beat whose record second lies in the window but whose MEASURED + // instant does not must stay outside; the mirrored case (record + // outside, measured inside) must be kept. + final recIn = (_at - 10000) ~/ 1000; // record inside the window + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: [ + // Record second inside, measured instant BEFORE the window. + { + 'rr_ts_ms': recIn * 1000, + 'beat_index': 0, + 'beat_ts_ms': _at - kResearchRestPreMs - 5000, + 'rr_ms': 1000, + }, + // Record second before the window, measured instant inside. + { + 'rr_ts_ms': (_at - kResearchRestPreMs - 60000) ~/ 1000 * 1000, + 'beat_index': 0, + 'beat_ts_ms': _at - 60000, + 'rr_ms': 1100, + }, + ], + ); + expect(w, isNotNull); + expect(w!.rrBeats, 1); // only the measured-inside beat survives + expect(w.rrMsMean, 1100.0); + }, + ); + // ======================================================================== + // ATOMIC BP RESEARCH RESTORE (reference + snapshot + window, ONE unit). + // The regression behind these tests: the restore loop used to visit the + // three BP tables in SEPARATE passes and loaded the source snapshots only + // in the snapshot pass — so in the window pass the snapshot status map + // was EMPTY and every conflict-free window with a snapshot revision was + // silently skipped. Fresh-target restores lost their windows entirely. + // ======================================================================== + + Future makeForeignBpDb( + String name, { + required int refId, + required int measuredAtMs, + String device = 'restore', + double sys = 130, + double dia = 85, + List>? snapshots, + List>? windows, + }) async { + final srcPath = p.join(await databaseFactory.getDatabasesPath(), name); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': refId, + 'measured_at_ms': measuredAtMs, + 'device': device, + 'systolic_mmhg': sys, + 'diastolic_mmhg': dia, + 'captured_at_ms': measuredAtMs, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + for (final w in windows ?? const >[]) { + await src.insert('bp_research_window', w); + } + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + for (final s in snapshots ?? const >[]) { + await src.insert('bp_research_snapshot', s); + } + await src.close(); + return srcPath; + } + + Map foreignWindow( + int refId, { + int? snapshotRevision, + double hrMean = 77.7, + int onehzRows = 300, + }) => { + 'reference_id': refId, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': onehzRows, + 'rr_beats': 210, + 'hr_mean': hrMean, + 'rmssd_ms': 38.5, + 'feature_version': kResearchFeatureVersion, + 'snapshot_revision': snapshotRevision, + }; + + Map foreignSnapshot( + int refId, + int revision, { + required int hr, + }) => { + 'reference_id': refId, + 'revision': revision, + 'onehz_json': '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":$hr}]', + 'rr_json': '[]', + 'created_at_ms': _at, + }; + + Future clearBpTables() async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + } + + test('restore: a FRESH target restores reference + snapshot + window ' + 'as one unit (the regression)', () async { + await clearBpTables(); + // FRESH target: no local BP rows at all. The source carries a + // reference, snapshot revision 1, and a window naming revision 1. + final srcPath = await makeForeignBpDb( + 'bp_fresh_success.db', + refId: 7, + measuredAtMs: _at, + snapshots: [foreignSnapshot(7, 1, hr: 60)], + windows: [foreignWindow(7, snapshotRevision: 1, hrMean: 71.5)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); + final destId = refs.first['id'] as int; + final snaps = await db.rawQuery( + 'SELECT * FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + ); + expect(snaps, hasLength(1)); + expect( + snaps.first['onehz_json'], + '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":60}]', + ); + final wins = await db.rawQuery( + 'SELECT * FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['snapshot_revision'], 1); + expect(wins.first['hr_mean'], 71.5); + expect(wins.first['rmssd_ms'], 38.5); + // Counters: exactly 1 / 1 / 1, no skips. + expect(counts['bp_research_reference'], 1); + expect(counts['bp_research_snapshot'], 1); + expect(counts['bp_research_window'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 0); + expect(counts['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: an IDENTICAL snapshot re-import is idempotent and the ' + 'window converges', () async { + await clearBpTables(); + // LOCAL: reference + snapshot rev 1 (rows A) + a LOCAL window with + // DISTINCT features, so the test proves the import UPDATED the + // window rather than merely preserving a pre-existing one. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 130, + diastolicMmHg: 85, + capturedAtMs: _at, + device: 'restore', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final db = await LocalDb.instance; + final destId = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + // SOURCE: the SAME natural reference, the SAME snapshot rev 1 (rows + // A), but a window with DIFFERENT features (88.8) — the identical + // snapshot status must admit that window and converge it. + final srcPath = await makeForeignBpDb( + 'bp_ident_converge.db', + refId: 9, + measuredAtMs: _at, + device: 'restore', + snapshots: [foreignSnapshot(9, 1, hr: 60)], + windows: [foreignWindow(9, snapshotRevision: 1, hrMean: 88.8)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps.first['c'], 1); // no duplicate revision rows + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 88.8); // the IMPORTED window won + expect(wins.first['snapshot_revision'], 1); + // Re-import AGAIN: full idempotency, still one of each, same values. + final counts2 = await LocalDb.importFromDbFile(srcPath); + final snaps2 = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps2.first['c'], 1); + final wins2 = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(wins2, hasLength(1)); + expect(wins2.first['hr_mean'], 88.8); + // An identical snapshot is NOT a new import; nothing was skipped + // as a conflict or missing. + expect(counts['bp_research_snapshot'], 0); + expect(counts['bp_research_snapshot_conflicts'], 0); + expect(counts2['bp_research_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: a CONFLICTING snapshot skips the snapshot AND the ' + 'window, counters rise', () async { + await clearBpTables(); + // LOCAL: reference + snapshot rev 1 (rows A) + window A. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 130, + diastolicMmHg: 85, + capturedAtMs: _at, + device: 'restore', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final db = await LocalDb.instance; + final destId = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final localJson = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + )).first['onehz_json'] + as String; + final localHr = (await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + )).first['hr_mean']; + // SOURCE: same natural reference, snapshot rev 1 with DIFFERENT + // rows (hr 99), and a window computed from those rows. + final srcPath = await makeForeignBpDb( + 'bp_conflict_counters.db', + refId: 11, + measuredAtMs: _at, + device: 'restore', + snapshots: [foreignSnapshot(11, 1, hr: 99)], + windows: [foreignWindow(11, snapshotRevision: 1, hrMean: 95.5)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + // Local snapshot rev 1 stays byte-identical (A), foreign B lost. + final snap = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + ); + expect(snap, hasLength(1)); + expect(snap.first['onehz_json'], localJson); + // The foreign window was SKIPPED; local window A survives untouched. + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], localHr); + // Counters tell the truth: nothing imported, conflicts recorded. + expect(counts['bp_research_snapshot'], 0); + expect(counts['bp_research_window'], 0); + expect(counts['bp_research_snapshot_conflicts'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 1); + expect(counts['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: a window whose snapshot is MISSING in the source is ' + 'never imported', () async { + await clearBpTables(); + // SOURCE: a reference and a window naming snapshot revision 1 — + // but NO snapshot row at all. Its features have no raw-data basis + // here; importing the window would point at nothing. + final srcPath = await makeForeignBpDb( + 'bp_missing_snapshot.db', + refId: 13, + measuredAtMs: _at, + snapshots: const [], + windows: [foreignWindow(13, snapshotRevision: 1)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); // the reference itself is imported + final destId = refs.first['id'] as int; + final wins = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins.first['c'], 0); // the window was NOT imported + expect(counts['bp_research_window'], 0); + expect(counts['bp_research_window_missing_snapshot'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: a LEGACY snapshotless window (snapshot_revision NULL) ' + 'imports snapshotless, no fabricated revision', () async { + await clearBpTables(); + // SOURCE: v1-style capture — a window with snapshot_revision NULL + // and no snapshot rows. The documented legacy rule: import the + // window as-is, keep it snapshotless, never fabricate a revision. + final srcPath = await makeForeignBpDb( + 'bp_legacy_window.db', + refId: 15, + measuredAtMs: _at, + snapshots: const [], + windows: [foreignWindow(15, snapshotRevision: null, hrMean: 66.6)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); + final destId = refs.first['id'] as int; + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 66.6); + expect(wins.first['snapshot_revision'], null); // stays snapshotless + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps.first['c'], 0); // no revision was fabricated + expect(counts['bp_research_window'], 1); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: a reference ID collision maps snapshot and window to ' + 'the CORRECT destination reference', () async { + await clearBpTables(); + // LOCAL: one capture whose AUTOINCREMENT id is 1 (deliberately the + // same NUMBER the source uses for a DIFFERENT natural reference). + await LocalDb.putBpResearchCapture(_capture(_at, device: 'local')); + final db = await LocalDb.instance; + final localId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['local'], + )).first['id'] + as int; + // SOURCE: id 1 — a DIFFERENT natural reference (different time) — + // with its own snapshot and window. They must land on the SOURCE + // row's DESTINATION id, never on the local id that shares the + // number. + final srcPath = await makeForeignBpDb( + 'bp_id_collision.db', + refId: 1, + measuredAtMs: _at + 60000, + device: 'foreign', + snapshots: [foreignSnapshot(1, 1, hr: 70)], + windows: [foreignWindow(1, snapshotRevision: 1, hrMean: 72.0)], + ); + await LocalDb.importFromDbFile(srcPath); + final refs = await db.rawQuery( + 'SELECT id, device, measured_at_ms FROM bp_research_reference ' + 'ORDER BY measured_at_ms', + ); + expect(refs, hasLength(2)); // both captures survive + final foreignDestId = + refs.firstWhere((r) => r['device'] == 'foreign')['id'] as int; + // Snapshot and window point at the FOREIGN reference's destination + // id — never at the local row that merely shares the number 1. + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [foreignDestId], + ); + expect(snaps.first['c'], 1); + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [foreignDestId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 72.0); + expect(wins.first['snapshot_revision'], 1); + // The LOCAL reference keeps its window untouched (from _capture: + // the first put had none, so the count is 0 here) and no foreign + // row landed on it. + final localSnaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [localId], + ); + expect(localSnaps.first['c'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore: a repeated re-import of the SAME source creates no ' + 'duplicates and stable counts', () async { + await clearBpTables(); + final srcPath = await makeForeignBpDb( + 'bp_reimport.db', + refId: 17, + measuredAtMs: _at, + snapshots: [foreignSnapshot(17, 1, hr: 65)], + windows: [foreignWindow(17, snapshotRevision: 1, hrMean: 73.0)], + ); + final c1 = await LocalDb.importFromDbFile(srcPath); + final c2 = await LocalDb.importFromDbFile(srcPath); + final c3 = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + ); + expect(refs.first['c'], 1); // no duplicate references + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot', + ); + expect(snaps.first['c'], 1); // no duplicate snapshots + final wins = await db.rawQuery('SELECT COUNT(*) c FROM bp_research_window'); + expect(wins.first['c'], 1); // no duplicate windows + final json = await db.rawQuery( + 'SELECT onehz_json, rr_json FROM bp_research_snapshot', + ); + expect( + json.first['onehz_json'], + '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":65}]', + ); + expect(json.first['rr_json'], '[]'); // content unchanged + // First import reports 1/1/1; re-imports converge — the identical + // snapshot and the natural-key reference are not NEW imports. + expect(c1['bp_research_reference'], 1); + expect(c1['bp_research_snapshot'], 1); + expect(c1['bp_research_window'], 1); + expect(c2['bp_research_reference'], 1); // matched, updated in place + expect(c2['bp_research_snapshot'], 0); // identical, not new + expect(c2['bp_research_window'], 1); // re-written, still one row + expect(c3['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + test('restore-invariant: a window without snapshot lists is stored ' + 'SNAPSHOTLESS, never a fabricated revision (B2/B4)', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + // A window object carrying a BOGUS snapshotRevision=99 — without + // snapshot lists the store must NOT persist that claim. + final bogus = BpResearchWindow( + windowStartMs: _at - 300000, + windowEndMs: _at, + onehzRows: 10, + hrMean: 60.0, + featureVersion: kResearchFeatureVersion, + snapshotRevision: 99, + qualityStatus: 'ok', + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'invariant', + window: bogus, + ), + // NO snapshot lists. + ); + final win = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window w ' + 'JOIN bp_research_reference r ON r.id = w.reference_id ' + 'WHERE r.device = ?', + ['invariant'], + ); + expect(win, hasLength(1)); + // No revision is claimed that does not exist. + expect(win.first['snapshot_revision'], isNull); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot s ' + 'JOIN bp_research_reference r ON r.id = s.reference_id ' + 'WHERE r.device = ?', + ['invariant'], + ); + expect(snaps.first['c'], 0); + }); + + test('reprocess: a pending capture with a later watermark becomes final, ' + 'writes revision 2, keeps revision 1 byte-identical', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + // Band synced only up to T-60s at capture time: pending. + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1, + 'rec_ts': (_at - 60000) ~/ 1000, + 'counter': 0, + 'hr': 60, + }); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'reprocess', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ], + rrRows: const [], + dataThroughMs: _at - 60000, + ), + ), + snapshotOnehzRows: [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['reprocess'], + )).first['id'] + as int; + var win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'pending'); + expect(win.first['snapshot_revision'], 1); + final rev1Json = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + // The band syncs the rest of the window — up to the LAST whole + // second that can still lie inside the half-open window. + for (int s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 100 + s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + // The RR series syncs its tail too — the watermark is the EARLIER + // of both series, so HR alone would keep the window pending. + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 900, + 'rec_ts': (_at - 1000) ~/ 1000, + 'beat_index': 0, + 'rr_ts_ms': _at - 1000, + 'rr_ms': 900, + }); + // ...and the developer explicitly re-processes the capture. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision, hr_mean ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'ok'); + expect(win.first['snapshot_revision'], 2); // NEW revision + // Revision 1 stays byte-identical. + final rev1After = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + expect(rev1After, rev1Json); + // The reference itself was never touched. + final ref = await db.rawQuery( + 'SELECT measured_at_ms, systolic_mmhg, diastolic_mmhg, captured_at_ms ' + 'FROM bp_research_reference WHERE id = ?', + [refId], + ); + expect(ref.first['measured_at_ms'], _at); + expect(ref.first['systolic_mmhg'], 120.0); + expect(ref.first['diastolic_mmhg'], 80.0); + }); + + test('reprocess stays pending when the sync still does not reach the ' + 'window end', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 500, + 'rec_ts': (_at - 240000) ~/ 1000, + 'counter': 0, + 'hr': 62, + }); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 118, + diastolicMmHg: 78, + capturedAtMs: _at, + device: 'stillpending', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: [ + {'rec_ts': (_at - 240000) ~/ 1000, 'hr': 62}, + ], + rrRows: const [], + dataThroughMs: _at - 240000, + ), + ), + snapshotOnehzRows: [ + {'rec_ts': (_at - 240000) ~/ 1000, 'hr': 62}, + ], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['stillpending'], + )).first['id'] + as int; + // Re-process WITHOUT new data: must stay pending — no fabricated + // final verdict. + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT quality_status FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'pending'); + }); + test('an empty, not-final window is PENDING, keeps its row, and ' + 're-processing attaches a new revision once data arrives', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + // Szenario: cuff reading "just now", band has synced NOTHING yet — + // watermark 0 < window end. The capture must KEEP a pending window + // row (not lose it to null), so re-processing can find it. + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: const [], + nowMs: _at, + dataThroughMs: 0, + ); + expect(w, isNotNull); // the regression: no more silent null + expect(w!.qualityStatus, 'pending'); + expect(w.onehzRows, isNull); // missing ≠ 0 + expect(w.rrBeats, isNull); + expect(w.hrMean, isNull); + expect(w.rmssdMs, isNull); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'syncfix', + window: w, + ), + snapshotOnehzRows: const [], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['syncfix'], + )).first['id'] + as int; + var win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); // the window row SURVIVED the store + expect(win.first['quality_status'], 'pending'); + // Re-process BEFORE any sync: must stay pending, must NOT lose the + // row, must NOT invent a revision over empty rows. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['quality_status'], 'pending'); + // The band syncs the full window tail now... + for (int s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1000 + s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 2000, + 'rec_ts': (_at - 1000) ~/ 1000, + 'beat_index': 0, + 'rr_ts_ms': _at - 1000, + 'rr_ms': 900, + }); + // ...and re-processing attaches REAL data plus a new revision. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision, onehz_rows, hr_mean ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'ok'); // final now + expect(win.first['snapshot_revision'], 1); // FIRST real revision + expect(win.first['onehz_rows'], 300); + expect(win.first['hr_mean'], 60.0); + final snap = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot WHERE reference_id = ?', + [refId], + ); + expect(snap.first['c'], 1); + // A SECOND re-process with unchanged data writes revision 2 and + // keeps revision 1 byte-identical. + final rev1 = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + await LocalDb.reprocessBpResearchCapture(refId); + final win2 = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win2.first['snapshot_revision'], 2); + final rev1After = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + expect(rev1After, rev1); + }); + + test('a final, provably empty window is still an honest NULL window ' + '(no pending-forever regression)', () async { + // Watermark provably covers the window end, the window is in the + // past, and there is STILL nothing: null is CORRECT (no_data + // honesty), not a fabricated pending row. + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: const [], + nowMs: _at + 600000, + dataThroughMs: _at + 600000, + ); + expect(w, isNull); + }); + + test('reprocess after the decoded rows were pruned keeps the frozen window', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + final rows = [ + for (var s = 0; s < 300; s++) {'rec_ts': (_at - 300000) ~/ 1000 + s, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'pruned', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rows, + rrRows: const [], + ), + ), + snapshotOnehzRows: rows, + snapshotRrRows: const [], + ); + // Newer data exists, so the window is final, but its own rows are gone. + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1, + 'rec_ts': _at ~/ 1000 + 3600, + 'counter': 0, + 'hr': 60, + }); + final refId = (await db.rawQuery('SELECT id FROM bp_research_reference')) + .first['id'] as int; + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], 60.0); + expect(win.first['snapshot_revision'], 1); + }); + + test('reprocess of a capture stored without a window creates the window', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'late', + ), + ); + for (var s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + final refId = (await db.rawQuery('SELECT id FROM bp_research_reference')) + .first['id'] as int; + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['snapshot_revision'], 1); + }); +} diff --git a/test/bp_research_isolation_test.dart b/test/bp_research_isolation_test.dart new file mode 100644 index 000000000..bf9d0ec1b --- /dev/null +++ b/test/bp_research_isolation_test.dart @@ -0,0 +1,45 @@ +// Nothing outside the allow-list may name the BP research store: a wrist +// series regressed against cuff readings inside the app would be a cuffless +// blood pressure feature. Same mechanism as observation_isolation_test.dart. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// Readers for the dev screen and the CSV export only. +const _allowed = { + 'lib/data/db.dart', + 'lib/data/csv_export.dart', + 'lib/health/bp_research_capture.dart', + 'lib/ui2/profile/bp_research.dart', +}; + +void main() { + test('no file outside the allow-list names a BP research table', () { + final offenders = []; + final lib = Directory('lib'); + for (final f in lib.listSync(recursive: true).whereType()) { + if (!f.path.endsWith('.dart')) continue; + final rel = p.normalize(f.path); + final s = f.readAsStringSync(); + final hit = s.contains('bp_research_reference') || + s.contains('bp_research_window') || + s.contains('bp_research_snapshot') || + s.contains('bpResearchCaptures') || + s.contains('putBpResearchCapture') || + s.contains('reprocessBpResearchCapture') || + s.contains('deleteBpResearchCapture'); + if (hit && !_allowed.contains(rel)) offenders.add(rel); + } + expect(offenders, isEmpty, + reason: 'files naming the BP research store must be on the allow-list ' + 'in test/bp_research_isolation_test.dart'); + }); + + test('the allow-list files themselves all exist', () { + for (final rel in _allowed) { + expect(File(rel).existsSync(), isTrue, reason: '$rel has vanished'); + } + }); +} diff --git a/test/bp_research_migration_test.dart b/test/bp_research_migration_test.dart new file mode 100644 index 000000000..ff02f4250 --- /dev/null +++ b/test/bp_research_migration_test.dart @@ -0,0 +1,139 @@ +// The BP research tables are additive with no ladder rung: an existing +// install gets them from the open-time repair pass, a fresh one from onCreate. +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; + +Future _dbPath(String name) async => + p.join(await databaseFactory.getDatabasesPath(), name); + +/// Build a database file at [version] with [ddl] applied, then close it. +Future _seedOldDb( + String name, + int version, + List ddl, +) async { + final path = await _dbPath(name); + await databaseFactory.deleteDatabase(path); + final db = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: version, + onCreate: (db, _) async { + for (final s in ddl) { + await db.execute(s); + } + }, + ), + ); + await db.close(); +} + +/// Open [name] through LocalDb and assert it did not quarantine-and-rebuild. +Future _openThroughLocalDb(String name) async { + await LocalDb.close(); + LocalDb.lastRebuild = null; + LocalDb.dbName = name; + final db = await LocalDb.instance; + expect( + LocalDb.lastRebuild, + isNull, + reason: + 'the upgrade bricked and fell back to quarantine-and-rebuild: ' + '${LocalDb.lastRebuild?.cause}', + ); + final rows = await db.rawQuery('PRAGMA user_version'); + expect((rows.first.values.first as num?)?.toInt(), LocalDb.schemaVersion); + return db; +} + +const _expectedRefV2Cols = [ + 'measurement_started_at_ms', + 'measurement_finished_at_ms', + 'band_device_id', + 'measurement_session_id', + 'time_precision', +]; + +const _expectedWinV2Cols = [ + 'observed_start_ms', + 'observed_end_ms', + 'valid_hr_seconds', + 'valid_interval_count', + 'valid_interval_pair_count', + 'coverage_fraction', + 'rejected_interval_fraction', + 'quality_status', + 'feature_version', + 'snapshot_revision', +]; + +Future> _columns(Database db, String table) async { + final info = await db.rawQuery('PRAGMA table_info($table)'); + return {for (final c in info) c['name'] as String}; +} + +void main() { + final created = []; + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + tearDownAll(() async { + await LocalDb.close(); + for (final n in created) { + await databaseFactory.deleteDatabase(await _dbPath(n)); + } + }); + + test( + 'fresh install creates the bp research tables', + () async { + const name = 'bp_migrate_fresh_test.db'; + created.add(name); + await LocalDb.close(); + LocalDb.lastRebuild = null; + LocalDb.dbName = name; + await databaseFactory.deleteDatabase(await _dbPath(name)); + final db = await LocalDb.instance; + expect(LocalDb.lastRebuild, isNull); + final refCols = await _columns(db, 'bp_research_reference'); + final winCols = await _columns(db, 'bp_research_window'); + for (final c in _expectedRefV2Cols) { + expect(refCols, contains(c)); + } + for (final c in _expectedWinV2Cols) { + expect(winCols, contains(c)); + } + final snapCols = await _columns(db, 'bp_research_snapshot'); + expect(snapCols, containsAll(['reference_id', 'revision', 'onehz_json'])); + }, + ); + + test( + 'an existing v54 database gets the bp research tables on open', + () async { + const name = 'bp_migrate_v54_test.db'; + created.add(name); + await _seedOldDb(name, 54, const []); + final db = await _openThroughLocalDb(name); + expect( + await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ + 'bp_research_reference', + ]), + isNotEmpty, + ); + expect( + await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ + 'bp_research_snapshot', + ]), + isNotEmpty, + ); + final refCols = await _columns(db, 'bp_research_reference'); + for (final c in _expectedRefV2Cols) { + expect(refCols, contains(c)); + } + }, + ); +} diff --git a/test/bp_research_ui_test.dart b/test/bp_research_ui_test.dart new file mode 100644 index 000000000..f3f531a23 --- /dev/null +++ b/test/bp_research_ui_test.dart @@ -0,0 +1,83 @@ +// BP research window summary: pending never reads as "no band data". +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/profile/bp_research.dart'; + +void main() { + group('BpResearchScreen.windowSummary', () { + test('pending with no rows shows the sync hint, not "No band data"', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'pending', + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, contains('Band data is still syncing')); + expect(s, isNot(contains('No band data'))); + }); + + test('pending with partial data shows the hint plus available metrics', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'pending', + 'onehz_rows': 120, + 'rr_beats': 80, + 'hr_mean': 62.0, + 'rmssd_ms': 41.0, + }); + expect(s, contains('Band data is still syncing')); + expect(s, contains('HR 62 bpm')); + expect(s, contains('RMSSD 41 ms')); + expect(s, contains('120 1 Hz rows, 80 beats so far')); + expect(s, isNot(contains('No band data'))); + }); + + test('final empty window keeps the honest no-data text', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'no_data', + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, 'No band data in the window — stored as-is.'); + }); + + test('final empty window without status keeps the no-data text', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': null, + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, 'No band data in the window — stored as-is.'); + }); + + test('ok window shows the existing summary without a status suffix', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'ok', + 'onehz_rows': 300, + 'rr_beats': 295, + 'hr_mean': 58.4, + 'rmssd_ms': 47.2, + }); + expect(s, isNot(contains('syncing'))); + expect(s, contains('HR 58 bpm')); + expect(s, contains('RMSSD 47 ms')); + expect(s, contains('300 1 Hz rows, 295 beats')); + expect(s, isNot(contains(' ok'))); + }); + + test('gappy window appends its status to the existing summary', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'gappy', + 'onehz_rows': 210, + 'rr_beats': 180, + 'hr_mean': 61.0, + 'rmssd_ms': 38.0, + }); + expect(s, contains('210 1 Hz rows, 180 beats')); + expect(s.endsWith('gappy'), isTrue); + }); + }); +} diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 05a6a0a55..adfa52b38 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -271,6 +271,8 @@ const _notComponents = { // Where the hydration notification lands: a Scaffold route that reads and // writes the day's journal metrics. The one control on it — FieldStepper — // IS in the gallery. + // Dev-mode BP research route: a Scaffold over its own tables. + 'BpResearchScreen', // The coach chat and its BYOK setup: Scaffold routes that own an engine, a // 120 s network call and the keychain. `CoachFigure` — the part a gallery can // actually hold — IS in it. diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py new file mode 100644 index 000000000..b82626e9e --- /dev/null +++ b/tool/bp_research_model.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +"""BP research offline model — an EXPERIMENTAL analysis prototype. + +Reads the `bp_research` CSV export (set "BP research captures") produced by +the app and evaluates a personally calibrated HR/HRV linear model against +cuff-only baselines. Runs OUTSIDE the app runtime, on the researcher's +machine; it never touches app health data, never writes to the phone, and +its outputs are research results, not health records. + +NOT A MEDICAL DEVICE. NOT A VALIDATED BLOOD PRESSURE MEASUREMENT. +The formulas below are a research draft for reproducible data collection, +evaluated here only so the dataset's value can be judged on real captures. +No synthetic data here claims physiological validity; synthetic fixtures +are for MATH tests only. + +Usage: + python3 bp_research_model.py --csv bp_research.csv [--out report.txt] + +Model (research draft): + H = mean of valid HR in the window (hr_mean) + V = RMSSD over valid contiguous interval pairs (rmssd_ms) + L = ln((V + eps) / 1 ms), eps = 1e-3 ms, numerical stability only + z = [1, (H - H0) / sH, (L - L0) / sL]^T + prediction_k = theta_k^T z + +Learning levels: + A: only the personal offset (theta[0]) updates — scalar Kalman. + theta[1], theta[2] stay 0, so level A is NOT a sensor model in any + predictive sense; it is reported as 'adaptive_cuff_offset_baseline'. + B: full parameter vector — scalar-per-parameter Kalman with Joseph- + form covariance. Off by default and EXPERIMENTAL. Enabled per + reference ONLY from causal, already-processed history: at least + MIN_SLOPE_SAMPLES previously updated aggregated references with + finite features and sufficient spread in BOTH H and L. Before the + gate opens (and on any gate failure) the run falls back to level A + and the report says so. The transition A->B is a documented + covariance hand-over: p_offset seeds the offset diagonal of P and + the level-A theta carries over unchanged (both tested). + +Evaluation discipline: + · a prediction is ALWAYS recorded before its reference updates the + model (prequential evaluation); + · references of one session id are NOT independent states — they are + aggregated (mean) before entering the model; + · back-dated references trigger a full chronological replay; + · baselines: (1) last calibration cuff value, (2) cuff-only time + model (mean), (3) the HR/HRV model — reported side by side. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from dataclasses import dataclass, field + +EPSILON_MS = 1e-3 # numerical stability only; never replaces missing data + +# Documented research defaults. NOT clinically validated. P, Q, R are the +# scalar Kalman covariances; the numbers say "a cuff reference is worth +# more than yesterday's personal offset", nothing more. +DEFAULT_R_MMHG = 25.0 # reference measurement variance (±5 mmHg SD) +DEFAULT_Q_OFFSET = 4.0 # per-day drift allowance on the personal offset +DEFAULT_P0 = 400.0 # initial offset uncertainty (±20 mmHg SD) +MIN_SLOPE_SAMPLES = 20 # level B needs at least this many aggregated refs +MIN_FEATURE_SPREAD = 0.25 # and this much normalized spread in H and L +# Quality admission (documented research rule, NOT a validated criterion): +# a capture enters the model only with usable features and a quality +# status the rule accepts. 'pending' (window not final), 'no_data' and +# missing features are excluded; 'gappy' is admitted — it is usable data +# with an honest warning flag, and excluding it would bias the dataset +# toward clean, unrepresentative windows. +ADMITTED_QUALITY = frozenset({"ok", "gappy"}) + + +def is_admitted_quality(status: str | None, + *, + admit_missing_quality: bool = False) -> bool: + """Strict, documented research admission rule: only 'ok' and 'gappy' + enter the model. UNKNOWN quality (None, empty, anything else) is NOT + admitted — and NEVER re-admitted via --admit-missing-quality, which + exists ONLY for historical rows whose quality metadata is genuinely + absent (None). Default stays strict and reproducible.""" + if status in ADMITTED_QUALITY: + return True + if status is None: + return admit_missing_quality + return False +# Explicit exclusion status — an aggregated session carries this when any +# member's quality is KNOWN to be non-admitted ('pending', 'no_data', …). +# --admit-missing-quality can NEVER re-admit it: that flag exists ONLY for +# historical rows whose quality metadata is genuinely absent (None). +EXCLUDED_MIXED = "excluded_mixed_quality" + + +def _fold_member_quality(members) -> str | None: + """Fold member qualities into one honest session verdict. + + · ok/gappy everywhere → worst admitted status ('gappy' over 'ok'). + · any KNOWN non-admitted member → EXCLUDED_MIXED: excluded in every + mode, never re-admitted by the compatibility flag. + · all members None (historical, no metadata) → None: admission then + follows --admit-missing-quality. + · admitted mixed with genuinely-missing → conservative None (the + flag decides downstream; without it the session is excluded). + """ + rank = {"ok": 0, "gappy": 1} + if any(m.quality is not None and not is_admitted_quality(m.quality) + for m in members): + return EXCLUDED_MIXED + if all(m.quality is None for m in members): + return None + if any(m.quality is None for m in members): + # Admitted mixed with genuinely-missing: conservative — the + # admission flag decides via None (excluded without it). + return None + return max((m.quality for m in members), + key=lambda q: rank.get(q, -1)) + + +# Session aggregation span: members of one explicit session id are only +# aggregated when they lie within this span (engineering default, 30 min — +# a few cuff readings of one sitting). Same label, farther apart: NOT one +# session; each reference stays independent. +MAX_SESSION_SPAN_MS = 30 * 60 * 1000 + +# Feature normalization. Documented, arbitrary-but-fixed engineering +# anchors; a change requires retraining or transforming the parameters. +H0_BPM = 60.0 +SH_BPM = 20.0 +L0 = math.log(40.0 + EPSILON_MS) # ln of a 40 ms RMSSD anchor +SL = 1.0 + + +@dataclass +class Row: + measured_at_ms: int + sys_mmhg: float + dia_mmhg: float + hr_mean: float | None + rmssd_ms: float | None + session_id: str | None + quality: str | None + coverage: float | None + + +@dataclass +class Model: + """Personal calibration state for one of systolic / diastolic.""" + theta: list[float] # [offset, h_slope, l_slope] + P: list[list[float]] # covariance, level B (diagonal-ish) + p_offset: float # scalar covariance, level A + last_cuff: float | None = None + predictions: list[dict] = field(default_factory=list) + + @staticmethod + def hand_over_to_level_b(m: "Model") -> "Model": + """Documented A->B transition: theta carries over unchanged, the + offset variance p_offset seeds the offset diagonal of P, and the + slope variances start from DEFAULT_P0 (nothing about them was ever + learned in level A).""" + return Model( + theta=list(m.theta), + P=[[m.p_offset, 0, 0], [0, DEFAULT_P0, 0], [0, 0, DEFAULT_P0]], + p_offset=m.p_offset, + last_cuff=m.last_cuff, + ) + + @staticmethod + def initial(cuff_mean: float) -> "Model": + # Calibration baseline, NOT a sensor-backed prediction: slopes + # start at zero, the offset starts at the cuff mean. + return Model( + theta=[cuff_mean, 0.0, 0.0], + P=[[DEFAULT_P0, 0, 0], [0, DEFAULT_P0, 0], [0, 0, DEFAULT_P0]], + p_offset=DEFAULT_P0, + ) + + +def features(hr: float | None, rmssd: float | None) -> list[float] | None: + """z = [1, (H-H0)/sH, (L-L0)/sL]; None when H or V is missing — + missing data never becomes a zero feature. NaN and infinities are + rejected like any other unusable input, never laundered into a + feature value.""" + if hr is None or rmssd is None: + return None + if not (math.isfinite(hr) and math.isfinite(rmssd) and hr > 0 and rmssd > 0): + return None + l = math.log(rmssd + EPSILON_MS) + return [1.0, (hr - H0_BPM) / SH_BPM, (l - L0) / SL] + + +def predict(m: Model, z: list[float]) -> float: + return sum(t * zi for t, zi in zip(m.theta, z)) + + +def update_level_a(m: Model, z: list[float], ref: float, + delta_days: float) -> None: + """Scalar Kalman on the offset only (slopes stay frozen). + + The A->B hand-over is explicit: when a later run switches this model + to level B, [Model.hand_over_to_level_b] seeds the offset diagonal of + P from p_offset and carries theta over unchanged — no undocumented + mixing of the scalar and matrix covariances.""" + p_minus = m.p_offset + DEFAULT_Q_OFFSET * max(delta_days, 0.0) + k = p_minus / (p_minus + DEFAULT_R_MMHG) + pred = predict(m, z) + m.theta[0] += k * (ref - pred) + m.p_offset = (1.0 - k) * p_minus + + +def update_level_b(m: Model, z: list[float], ref: float, + delta_days: float) -> None: + """Full parameter Kalman with Joseph-form covariance update.""" + n = 3 + q = DEFAULT_Q_OFFSET * max(delta_days, 0.0) + p_minus = [[m.P[i][j] + (q if i == j else 0.0) for j in range(n)] + for i in range(n)] + # innovation gain K = P z / (R + z^T P z) + pz = [sum(p_minus[i][j] * z[j] for j in range(n)) for i in range(n)] + denom = DEFAULT_R_MMHG + sum(zi * pzi for zi, pzi in zip(z, pz)) + k = [pzi / denom for pzi in pz] + pred = predict(m, z) + resid = ref - pred + m.theta = [m.theta[i] + k[i] * resid for i in range(n)] + # Joseph form: (I - K z^T) P (I - K z^T)^T + K R K^T + a = [[(1.0 if i == j else 0.0) - k[i] * z[j] for j in range(n)] + for i in range(n)] + ap = [[sum(a[i][t] * p_minus[t][j] for t in range(n)) for j in range(n)] + for i in range(n)] + apa = [[sum(ap[i][t] * a[j][t] for t in range(n)) for j in range(n)] + for i in range(n)] + for i in range(n): + for j in range(n): + m.P[i][j] = apa[i][j] + DEFAULT_R_MMHG * k[i] * k[j] + + +class CsvDataError(ValueError): + """A row of the CSV export is corrupt (missing/invalid mandatory field, + unparseable or non-finite number, invalid timestamp). Raised INSTEAD of + a raw traceback so the CLI can report a structured, understandable + research error. Corrupt is corrupt — it is never silently laundered + into None/missing.""" + + +def load_rows(path: str) -> list[Row]: + rows: list[Row] = [] + with open(path, newline="", encoding="utf-8") as f: + for i, r in enumerate(csv.DictReader(f), start=2): + def required(key: str) -> float: + v = (r.get(key) or "").strip() + if not v: + raise CsvDataError( + f"CSV row {i}: required field '{key}' is empty") + try: + x = float(v) + except ValueError: + raise CsvDataError( + f"CSV row {i}: required field '{key}' is not a " + f"number: {v!r}") from None + if not math.isfinite(x): + raise CsvDataError( + f"CSV row {i}: required field '{key}' is not " + f"finite: {v!r}") + return x + + def optional(key: str) -> float | None: + # Empty = honestly missing (stays None downstream). A + # NON-EMPTY value that does not parse, or parses to + # NaN/inf, is CORRUPT — an error, never a quiet None + # that would read as "no data". + v = (r.get(key) or "").strip() + if not v: + return None + try: + x = float(v) + except ValueError: + raise CsvDataError( + f"CSV row {i}: field '{key}' is not a number: " + f"{v!r}") from None + if not math.isfinite(x): + raise CsvDataError( + f"CSV row {i}: field '{key}' is not finite: {v!r}") + return x + + measured = required("measured_at_ms") + if not measured.is_integer(): + raise CsvDataError( + f"CSV row {i}: 'measured_at_ms' must be a whole " + f"number of milliseconds, got {measured!r}") + rows.append(Row( + measured_at_ms=int(measured), + sys_mmhg=required("systolic_mmhg"), + dia_mmhg=required("diastolic_mmhg"), + hr_mean=optional("hr_mean"), + rmssd_ms=optional("rmssd_ms"), + session_id=(r.get("measurement_session_id") or "").strip() or None, + quality=(r.get("quality_status") or "").strip() or None, + coverage=optional("coverage_fraction"), + )) + rows.sort(key=lambda x: x.measured_at_ms) + return rows + + +def aggregate_sessions(rows: list[Row], + admit_missing_quality: bool = False) -> list[Row]: + """Multiple cuff readings of one sitting are NOT independent + physiological states — average them into one reference before they + enter the model. + + Session identity is EXPLICIT: only rows sharing a measurement_session_id + can be aggregated, and only when they lie within MAX_SESSION_SPAN_MS of + each other (chained: consecutive members, not min-to-max of an + arbitrarily long chain). No implicit calendar-day aggregation — the + same label hours apart stays separate references, and rows without a + session id never merge with anything. + + Within one aggregated session the REFERENCE values are the session + mean; the FEATURES are the coverage-weighted mean of the members' + features (they all describe the same few minutes of the same sitting — + a 'best member's features' pick would silently borrow a different + member's window instead of representing the session). + """ + explicit: list[Row] = [] + solo: list[Row] = [] + for r in rows: + (explicit if r.session_id else solo).append(r) + out: list[Row] = list(solo) + by_label: dict[str, list[Row]] = {} + for r in explicit: + by_label.setdefault(r.session_id, []).append(r) + for label, group in by_label.items(): + group.sort(key=lambda g: g.measured_at_ms) + cluster = [group[0]] + clusters: list[list[Row]] = [] + for g in group[1:]: + if g.measured_at_ms - cluster[-1].measured_at_ms <= MAX_SESSION_SPAN_MS: + cluster.append(g) + else: + clusters.append(cluster) + cluster = [g] + clusters.append(cluster) + for members in clusters: + if len(members) == 1: + out.append(members[0]) + continue + n = len(members) + weights = [(m.coverage or 0.0) for m in members] + def wmean(vals: list[float | None]) -> float | None: + pairs = [(v, w) for v, w in zip(vals, weights) + if v is not None and w > 0] + if not pairs: + return None + tw = sum(w for _, w in pairs) + return sum(v * w for v, w in pairs) / tw + out.append(Row( + measured_at_ms=sum(m.measured_at_ms for m in members) / n, + sys_mmhg=sum(m.sys_mmhg for m in members) / n, + dia_mmhg=sum(m.dia_mmhg for m in members) / n, + hr_mean=wmean([m.hr_mean for m in members]), + rmssd_ms=wmean([m.rmssd_ms for m in members]), + session_id=label, + # STRICT member fold with EXPLICIT exclusion statuses — + # 'known non-admitted quality' (→ EXCLUDED_MIXED, never + # compatibility-admitted) must stay distinct from + # 'genuinely missing historical quality' (→ None, which + # follows the compatibility flag at admission time). + quality=_fold_member_quality(members), + coverage=(sum(weights) / n if all( + m.coverage is not None for m in members) else None), + )) + out.sort(key=lambda x: x.measured_at_ms) + return out + + +def mae(xs: list[float]) -> float: + return sum(abs(x) for x in xs) / len(xs) if xs else float("nan") + + +def signed_mean(xs: list[float]) -> float: + return sum(xs) / len(xs) if xs else float("nan") + + +def run(rows: list[Row], level_b: bool = False, + admit_missing_quality: bool = False) -> dict: + """Chronological prequential replay. + + FAIR COMPARISON: all three models are evaluated on the EXACT SAME + target set — the admitted aggregated references AFTER the calibration + row. A reference without usable features updates NO model (features + would be fabricated for the sensor model alone), so all three n's are + identical by construction. + + Level B is enabled causally, per reference, from ALREADY-PROCESSED + history only: at least MIN_SLOPE_SAMPLES previously UPDATED references + with finite features and spread in BOTH H and L among them. No future + row of the CSV is inspected. When the gate has not opened (or the run + did not ask for level B), the update falls back to level A and the + report says so. + """ + aggregated = aggregate_sessions(rows, + admit_missing_quality=admit_missing_quality) + if not aggregated: + return {"error": "no rows"} + + # Quality admission (documented research rule). aggregate_sessions + # already folded every member's quality into the aggregate: a session + # with ANY non-admitted member ('ok + pending') carries quality None + # and lands here, never silently inside the model. + admitted = [r for r in aggregated + if is_admitted_quality(r.quality, + admit_missing_quality= + admit_missing_quality)] + excluded_quality = len(aggregated) - len(admitted) + if not admitted: + # Structured, parseable research report instead of an + # IndexError on admitted[0]: rows exist, but none passes the + # quality admission rule. + return { + "error": "no admitted rows", + "rows_total": len(rows), + "rows_aggregated": len(aggregated), + "rows_excluded_quality": excluded_quality, + "admission_rule": { + "admitted_quality": sorted(ADMITTED_QUALITY), + "unknown_quality_admitted": admit_missing_quality, + "max_session_span_ms": MAX_SESSION_SPAN_MS, + }, + } + + # Calibration row: the first admitted reference seeds the models. + # Baselines start from it too, so all models see the same history. + first = admitted[0] + m_sys = Model.initial(first.sys_mmhg) + m_dia = Model.initial(first.dia_mmhg) + + usable = admitted[1:] # prequential: predict, then update + targets: list[Row] = [] + excluded_no_features = 0 + last_t = first.measured_at_ms + + # Baseline 2: last calibration cuff value — defined from the FIRST + # admitted row on, so its n matches everyone else's. + last_cuff_sys = first.sys_mmhg + last_cuff_dia = first.dia_mmhg + # Baseline 3: cuff-only time model — the running mean of every admitted + # reference seen so far, features never involved. + seen_sys = [first.sys_mmhg] + seen_dia = [first.dia_mmhg] + + # Causal level-B gate state: history of the FEATURE VECTORS of the + # references that were actually processed (updated on), never future + # rows. + processed_z: list[list[float]] = [] + level_b_updates = 0 + level_a_updates = 0 + # The A->B hand-over happens EXACTLY ONCE, at the first causally + # admissible level-B update: the live models are re-seeded from their + # level-A state (theta carried over, p_offset into P[0][0], slope + # variances at DEFAULT_P0) and every later level-B update continues the + # matrix covariance. Without this the level-B updates would run on the + # INITIAL P, discarding everything level A learned about the offset. + level_b_started = False + level_b_started_at: int | None = None + + per_target: list[dict] = [] + + for r in usable: + z = features(r.hr_mean, r.rmssd_ms) + if z is None: + excluded_no_features += 1 + continue + delta_days = (r.measured_at_ms - last_t) / 86400000.0 + last_t = r.measured_at_ms + targets.append(r) + + # 1. prequential predictions — recorded BEFORE any update. + pred_s = predict(m_sys, z) + pred_d = predict(m_dia, z) + + # 2. baseline: last cuff value, no WHOOP features. + # 3. baseline: running cuff mean, no WHOOP features. + # Both are evaluated on the same target as the model. + per_target.append({ + "measured_at_ms": r.measured_at_ms, + "model_mode": ("level_b" if (level_b and _b_gate(processed_z)) + else "level_a"), + "pred_model_sys": pred_s, + "pred_model_dia": pred_d, + "pred_last_cuff_sys": last_cuff_sys, + "pred_last_cuff_dia": last_cuff_dia, + "pred_cuff_mean_sys": sum(seen_sys) / len(seen_sys), + "pred_cuff_mean_dia": sum(seen_dia) / len(seen_dia), + "ref_sys": r.sys_mmhg, + "ref_dia": r.dia_mmhg, + }) + + # 4. update AFTER recording the predictions. The mode decision uses + # ONLY processed history (no future rows, no len(usable)). + use_b = level_b and _b_gate(processed_z) + if use_b and not level_b_started: + m_sys = Model.hand_over_to_level_b(m_sys) + m_dia = Model.hand_over_to_level_b(m_dia) + level_b_started = True + level_b_started_at = len(processed_z) + if use_b: + update_level_b(m_sys, z, r.sys_mmhg, delta_days) + update_level_b(m_dia, z, r.dia_mmhg, delta_days) + level_b_updates += 1 + else: + update_level_a(m_sys, z, r.sys_mmhg, delta_days) + update_level_a(m_dia, z, r.dia_mmhg, delta_days) + level_a_updates += 1 + processed_z.append(z) + last_cuff_sys = r.sys_mmhg + last_cuff_dia = r.dia_mmhg + seen_sys.append(r.sys_mmhg) + seen_dia.append(r.dia_mmhg) + + def stats(pred_key: str, ref_key: str) -> dict: + errs = [t[pred_key] - t[ref_key] for t in per_target] + if not errs: + return {"n": 0} + mean = signed_mean(errs) + var = sum((e - mean) ** 2 for e in errs) / len(errs) + return { + "n": len(errs), + "mae_mmhg": round(mae(errs), 2), + "mean_signed_mmhg": round(mean, 2), + "sd_mmhg": round(math.sqrt(var), 2), + } + + return { + "rows_total": len(rows), + "rows_aggregated": len(aggregated), + "rows_excluded_quality": excluded_quality, + "rows_excluded_no_features": excluded_no_features, + "admission_rule": { + "admitted_quality": sorted(ADMITTED_QUALITY), + "unknown_quality_admitted": admit_missing_quality, + "max_session_span_ms": MAX_SESSION_SPAN_MS, + }, + "updates": { + "level_a": level_a_updates, + "level_b": level_b_updates, + "level_b_requested": level_b, + "level_b_started": level_b_started, + "level_b_started_after_refs": level_b_started_at, + "level_b_gate": { + "min_processed_refs": MIN_SLOPE_SAMPLES, + "min_feature_spread_h_and_l": MIN_FEATURE_SPREAD, + }, + # Why level B never opened, when it was requested but never + # started: too few processed references, or too little spread + # in H or L — the run fell back to level A throughout. + "level_b_fallback_reason": ( + None if (not level_b or level_b_started) + else "gate never opened: fewer than " + f"{MIN_SLOPE_SAMPLES} processed references with " + f">={MIN_FEATURE_SPREAD} spread in BOTH H and L"), + }, + "systolic": { + "baseline_last_cuff": stats("pred_last_cuff_sys", "ref_sys"), + "baseline_cuff_time_model": stats("pred_cuff_mean_sys", "ref_sys"), + # Level A holds sensor slopes at zero: an offset tracker, not a + # sensor model. Named for what it is. + "adaptive_cuff_offset_baseline": stats("pred_model_sys", "ref_sys"), + }, + "diastolic": { + "baseline_last_cuff": stats("pred_last_cuff_dia", "ref_dia"), + "baseline_cuff_time_model": stats("pred_cuff_mean_dia", "ref_dia"), + "adaptive_cuff_offset_baseline": stats("pred_model_dia", "ref_dia"), + }, + "model_state": { + "theta_sys": [round(t, 3) for t in m_sys.theta], + "theta_dia": [round(t, 3) for t in m_dia.theta], + "p_offset_sys": round(m_sys.p_offset, 3), + "p_offset_dia": round(m_dia.p_offset, 3), + "feature_anchors": {"H0_bpm": H0_BPM, "sH_bpm": SH_BPM, + "L0": round(L0, 4), "sL": SL, + "epsilon_ms": EPSILON_MS}, + }, + } + + +def _b_gate(processed_z: list[list[float]]) -> bool: + """Level-B admission from CAUSAL history only: enough processed + references, finite features, and spread in BOTH H and L. Falls back to + level A on any failure (the caller reports the fallback).""" + if len(processed_z) < MIN_SLOPE_SAMPLES: + return False + hs = [z[1] for z in processed_z] + ls = [z[2] for z in processed_z] + if any(not math.isfinite(v) for v in hs + ls): + return False + return (max(hs) - min(hs) >= MIN_FEATURE_SPREAD + and max(ls) - min(ls) >= MIN_FEATURE_SPREAD) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--csv", required=True, help="the bp_research CSV export") + ap.add_argument("--level-b", action="store_true", + help="enable experimental full-parameter learning " + "(level B; requires independent feature variation)") + ap.add_argument("--out", help="write the report as JSON instead of stdout") + ap.add_argument("--admit-missing-quality", action="store_true", + help="COMPATIBILITY MODE (off by default): admit rows " + "with an UNKNOWN quality status — e.g. exports " + "from before quality_status existed. Strict, " + "reproducible default stays: unknown is excluded.") + args = ap.parse_args() + + try: + rows = load_rows(args.csv) + except CsvDataError as e: + report = {"error": "corrupt csv", "detail": str(e)} + text = json.dumps(report, indent=2) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text + "\n") + else: + print(text) + print(f"error: {e}", file=sys.stderr) + return 2 + report = run(rows, level_b=args.level_b, + admit_missing_quality=args.admit_missing_quality) + text = json.dumps(report, indent=2) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text + "\n") + else: + print(text) + print( + "\nRESEARCH OUTPUT ONLY — not a medical measurement, not a " + "validated blood pressure estimate.", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py new file mode 100644 index 000000000..9098f5a65 --- /dev/null +++ b/tool/test_bp_research_model.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +"""Math-only tests for tool/bp_research_model.py. + +Synthetic data here verifies MATHEMATICS (Kalman recursion, feature math, +session aggregation, prequential discipline, causal level-B gating, fair +baselines) — it claims NO physiological validity and is never used as +evidence of medical accuracy. + +Run: python3 tool/test_bp_research_model.py +""" +import math +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) +import bp_research_model as m + + +def approx(a, b, tol=1e-9): + assert abs(a - b) <= tol, f"{a} != {b}" + + +def _row(t, sys_, dia, hr=70.0, rm=40.0, sess=None, q="ok", cov=1.0): + return m.Row(t, sys_, dia, hr, rm, sess, q, cov) + + +def test_features(): + # z = [1, (H-60)/20, (ln(V+eps) - ln(40+eps))] + z = m.features(80.0, 40.0) + approx(z[0], 1.0) + approx(z[1], 1.0) + approx(z[2], 0.0, 1e-6) + # Missing data never becomes a zero feature. + assert m.features(None, 40.0) is None + assert m.features(80.0, None) is None + assert m.features(80.0, 0.0) is None # RMSSD 0 = absent, not ln(0) + # NaN and infinities are rejected, never laundered into features. + assert m.features(float("nan"), 40.0) is None + assert m.features(80.0, float("inf")) is None + assert m.features(80.0, float("nan")) is None + assert m.features(float("-inf"), 40.0) is None + + +def test_level_a_converges(): + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + # Feed the same reference repeatedly: the offset must converge to it. + for _ in range(200): + m.update_level_a(mdl, z, 130.0, delta_days=0.01) + approx(mdl.theta[0], 130.0, 0.5) + # Slopes stay frozen at zero in level A. + approx(mdl.theta[1], 0.0) + approx(mdl.theta[2], 0.0) + # The covariance shrinks: repeated references increase certainty. + assert mdl.p_offset < m.DEFAULT_P0 + + +def test_level_b_joseph(): + mdl = m.Model.initial(120.0) + z = m.features(70.0, 35.0) + p_before = [row[:] for row in mdl.P] + m.update_level_b(mdl, z, 125.0, delta_days=0.1) + # P stays symmetric positive-definite-ish under the Joseph form. + for i in range(3): + for j in range(3): + approx(mdl.P[i][j], mdl.P[j][i], 1e-12) + assert all(mdl.P[i][i] >= 0 for i in range(3)) + assert mdl.P != p_before + + +def test_prediction_is_recorded_before_update(): + # Prequential discipline: with ONE usable reference after calibration, + # the recorded prediction must equal the initial calibration baseline + # (no feature influence yet — slopes are zero at start). + rows = [_row(0, 120.0, 80.0), _row(86_400_000, 122.0, 82.0)] + rep = m.run(rows) + # First usable row: prediction = theta^T z = 120 + 0 + 0 = 120. + approx(rep["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"], 2.0, 0.01) + + +def test_level_b_runs_without_name_error(): + # 25 references with real feature variation: level B must actually + # execute past its gate (>= 20 processed, spread in H AND L) and never + # raise NameError. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + hr = 60.0 + (i % 5) * 8.0 + rm = 25.0 + (i % 4) * 15.0 + rows.append(_row((i + 1) * 86_400_000, 120.0 + i * 0.5, 80.0, + hr=hr, rm=rm)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] > 0 + assert rep["updates"]["level_a"] >= m.MIN_SLOPE_SAMPLES + # The gate opens only after enough CAUSAL history, never from the + # total row count of the CSV. + assert rep["updates"]["level_b"] == len(rows) - 1 - rep["updates"]["level_a"] + + +def test_level_b_falls_back_without_variation(): + # Plenty of references but ZERO variation in L: the gate must stay + # shut and every update falls back to level A, reported as such. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + rows.append(_row((i + 1) * 86_400_000, 120.0, 80.0, + hr=60.0 + (i % 5) * 8.0, rm=40.0)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] == 0 + assert rep["updates"]["level_a"] == 25 + + +def test_level_b_uses_no_future_information(): + # The FIRST 19 references look exactly like a different, feature-rich + # future — the gate must not inspect them. With only 10 references + # total, level B can never open even though the CSV is full of spread. + rows = [_row(0, 120.0, 80.0)] + for i in range(10): + rows.append(_row((i + 1) * 86_400_000, 120.0 + i, 80.0, + hr=60.0 + i * 5, rm=25.0 + i * 10)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] == 0 + assert rep["updates"]["level_a"] == 10 + # And the predictions of the first targets are IDENTICAL with and + # without --level-b requested: the flag must not change the past. + rep2 = m.run(rows, level_b=False) + a = rep["systolic"]["adaptive_cuff_offset_baseline"] + b = rep2["systolic"]["adaptive_cuff_offset_baseline"] + assert a["mae_mmhg"] == b["mae_mmhg"] + + +def test_quality_exclusion(): + # 'pending' and 'no_data' captures are excluded by the admission rule + # and reported; 'gappy' is admitted. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q="pending"), + _row(2 * 86_400_000, 122.0, 82.0, q="no_data"), + _row(3 * 86_400_000, 123.0, 83.0, q="gappy")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 2 + # Admitted: the 'ok' calibration row and the 'gappy' target. + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_level_b_handover_happens_once_in_run(): + # END-TO-END: level-A updates run first (gate shut), the gate opens + # causally, the hand-over happens EXACTLY ONCE, and the FIRST + # level-B update runs on P[0][0] = the p_offset level A actually + # learned — not the initial DEFAULT_P0. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + hr = 60.0 + (i % 5) * 8.0 + rm = 25.0 + (i % 4) * 15.0 + rows.append(_row((i + 1) * 86_400_000, 120.0 + i * 0.5, 80.0, + hr=hr, rm=rm)) + rep = m.run(rows, level_b=True) + u = rep["updates"] + assert u["level_b_requested"] is True + assert u["level_b_started"] is True + assert u["level_b"] == 25 - m.MIN_SLOPE_SAMPLES + assert u["level_a"] == m.MIN_SLOPE_SAMPLES + # The hand-over fired after exactly MIN_SLOPE_SAMPLES processed refs. + assert u["level_b_started_after_refs"] == m.MIN_SLOPE_SAMPLES + assert u["level_b_fallback_reason"] is None + + +def test_level_b_p_offset_carries_into_level_b(): + # The hand-over must carry the LEARNED p_offset: after many level-A + # updates the offset covariance is far below DEFAULT_P0, so P[0][0] + # at hand-over must be that learned value. + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + for _ in range(100): + m.update_level_a(mdl, z, 128.0, delta_days=0.01) + handed = m.Model.hand_over_to_level_b(mdl) + assert handed.P[0][0] == mdl.p_offset + assert handed.P[0][0] < m.DEFAULT_P0 + + +def test_mixed_session_quality_excludes_the_session(): + # 'ok + pending' in ONE session: the whole session is excluded — + # never silently admitted through a None -> "" back door. + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="pending")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_all_admitted_session_quality_stays_admitted(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="gappy")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 0 + # The aggregate carries the worst ADMITTED status ('gappy'). + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_unknown_quality_is_excluded_by_default(): + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q=None)] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 + assert rep["admission_rule"]["unknown_quality_admitted"] is False + + +def test_unknown_quality_admitted_only_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q=None)] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 0 + assert rep["admission_rule"]["unknown_quality_admitted"] is True + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_level_a_to_level_b_handover(): + # The documented transition: theta carries over unchanged, p_offset + # seeds the offset diagonal of P, slopes start at DEFAULT_P0. + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + for _ in range(50): + m.update_level_a(mdl, z, 128.0, delta_days=0.01) + handed = m.Model.hand_over_to_level_b(mdl) + assert handed.theta == mdl.theta + assert handed.P[0][0] == mdl.p_offset + assert handed.P[1][1] == m.DEFAULT_P0 + assert handed.P[2][2] == m.DEFAULT_P0 + + +def test_session_aggregation(): + # Three readings of one session are NOT three independent states. + rows = [ + _row(1000, 120.0, 80.0, sess="s1"), + _row(60_000, 124.0, 84.0, sess="s1"), + _row(120_000, 122.0, 82.0, sess="s1"), + ] + agg = m.aggregate_sessions(rows) + assert len(agg) == 1 + approx(agg[0].sys_mmhg, 122.0) + approx(agg[0].dia_mmhg, 82.0) + + +def test_session_label_conflict_keeps_references_apart(): + # The SAME session label hours apart is NOT one session: each + # reference stays independent (no implicit day/label aggregation). + rows = [ + _row(0, 120.0, 80.0, sess="morning"), + _row(60_000, 121.0, 81.0, sess="morning"), + # 6 hours later, same label: a different sitting. + _row(6 * 3_600_000, 130.0, 90.0, sess="morning"), + ] + agg = m.aggregate_sessions(rows) + assert len(agg) == 2 + approx(agg[1].sys_mmhg, 130.0) + + +def test_missing_features_excluded_not_zeroed(): + rows = [_row(0, 120.0, 80.0), + # No band data: excluded, never treated as HR 0. + _row(86_400_000, 121.0, 81.0, hr=None, rm=None, q="no_data"), + # Quality 'ok' but the features are STILL absent: the feature + # exclusion is what must catch this one, not the quality rule. + _row(2 * 86_400_000, 122.0, 82.0, hr=None, rm=None, q="ok")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 + assert rep["rows_excluded_no_features"] == 1 + # The only post-calibration target had no features: nothing is left to + # evaluate — an honest n = 0, not a fabricated prediction. + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_fair_baseline_target_sets(): + # All models are evaluated on the SAME targets: identical n across + # last-cuff, cuff-mean and the model. + rows = [_row(0, 120.0, 80.0)] + for i in range(8): + rows.append(_row((i + 1) * 86_400_000, 120.0 + i, 80.0 + i * 0.5, + hr=60.0 + i * 3, rm=30.0 + i * 5)) + rep = m.run(rows) + for group in ("systolic", "diastolic"): + ns = {k: v["n"] for k, v in rep[group].items()} + assert len(set(ns.values())) == 1, ns + assert all(n == 8 for n in ns.values()), ns + + +def test_chronological_replay(): + # Back-dated rows: the CSV order must not matter, only measured_at_ms. + r1 = _row(86_400_000, 122.0, 82.0) + r0 = _row(0, 120.0, 80.0) + a = m.run([r1, r0]) + b = m.run([r0, r1]) + assert a["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"] == \ + b["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"] + + + + +# ====================================================================== +# C: the compatibility mode re-admits ONLY genuinely missing quality. +# A session excluded for a KNOWN bad member (pending, no_data) carries +# the EXPLICIT EXCLUDED_MIXED status and stays excluded in every mode. +# ====================================================================== + +def test_ok_pending_session_stays_excluded_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="pending")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_ok_no_data_session_stays_excluded_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="no_data")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_ok_gappy_session_is_admitted_as_gappy_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="gappy")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 0 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_all_missing_quality_session_follows_the_flag(): + # Every member genuinely lacks quality metadata (historical data): + # excluded by default, admitted ONLY with the explicit flag. + rows = [_row(0, 120.0, 80.0, sess="s1", q=None), + _row(60_000, 122.0, 82.0, sess="s1", q=None)] + rep_default = m.run(rows) + # ONE aggregated session row (all members None) → excluded by default. + assert rep_default["rows_excluded_quality"] == 1 + rep_compat = m.run(rows, admit_missing_quality=True) + assert rep_compat["rows_excluded_quality"] == 0 + + +def test_admitted_mixed_with_missing_is_conservative(): + # 'ok + genuinely missing' (no KNOWN bad member): excluded without + # the flag; the flag admits it because nothing known is wrong. + rows = [_row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q=None)] + rep_default = m.run(rows) + # The two members aggregate into ONE session row, whose folded + # quality is None (conservative) → excluded without the flag. + assert rep_default["rows_excluded_quality"] == 1 + rep_compat = m.run(rows, admit_missing_quality=True) + assert rep_compat["rows_excluded_quality"] == 0 + + +def test_excluded_mixed_status_is_never_admitted(): + # The fold's explicit exclusion status itself never passes admission. + assert m.is_admitted_quality(m.EXCLUDED_MIXED) is False + assert m.is_admitted_quality(m.EXCLUDED_MIXED, + admit_missing_quality=True) is False + assert m.is_admitted_quality("pending") is False + assert m.is_admitted_quality("no_data") is False + + +# ====================================================================== +# D: no crash when nothing is admitted — a structured, parseable +# research report instead of an IndexError. +# ====================================================================== + +def test_empty_csv_returns_no_rows_error(): + rep = m.run([]) + assert rep["error"] == "no rows" + + +def test_only_pending_rows_return_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="pending"), + _row(86_400_000, 121.0, 81.0, q="pending")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_total"] == 2 + assert rep["rows_excluded_quality"] == 2 + assert rep["admission_rule"]["unknown_quality_admitted"] is False + + +def test_only_no_data_rows_return_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="no_data")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_excluded_quality"] == 1 + + +def test_only_unknown_quality_returns_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="weird_status")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_excluded_quality"] == 1 + + +def test_calibration_row_then_only_excluded_still_reports(): + # One valid calibration point, then only excluded rows: no target + # rows, but a structured report — never a crash. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q="pending")] + rep = m.run(rows) + assert "error" not in rep + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_admitted_rows_without_features_report_zero_targets(): + # Admitted rows exist, but none carries usable HR/RMSSD features: + # the report is structured, targets are zero, no exception. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, hr=None, rm=None, q="ok")] + rep = m.run(rows) + assert "error" not in rep + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + assert rep["rows_excluded_no_features"] == 1 + + +def test_load_rows_rejects_corrupt_mandatory_numbers(): + import csv as _csv, tempfile + bad = [ + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n,120,80\n", "empty required"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\nabc,120,80\n", "non-numeric"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,abc,80\n", "bad sys"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,120,\n", "empty dia"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,nan,80\n", "nan sys"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,inf,80\n", "inf sys"), + ] + for content, why in bad: + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write(content) + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError(f"corrupt CSV accepted: {why}") + finally: + os.unlink(path) + + +def test_load_rows_rejects_invalid_timestamp(): + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg\n" + "1700000000.5,120,80\n") + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError("fractional measured_at_ms accepted") + finally: + os.unlink(path) + + +def test_load_rows_rejects_corrupt_optional_numbers(): + # A non-empty optional field that does not parse (or is nan/inf) is a + # corrupt row, never a quiet None that would read as "no data". + import tempfile + bad = [ + ("1700000000,120,80,abc,40\n", "non-numeric hr"), + ("1700000000,120,80,70,nan\n", "nan rmssd"), + ("1700000000,120,80,70,inf\n", "inf rmssd"), + ] + for tail, why in bad: + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg," + "hr_mean,rmssd_ms\n" + tail) + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError(f"corrupt optional field accepted: {why}") + finally: + os.unlink(path) + + +def test_load_rows_keeps_empty_optionals_as_none(): + # Empty optional fields are honestly missing, not corrupt. + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg," + "hr_mean,rmssd_ms\n1700000000,120,80,,\n") + path = f.name + try: + rows = m.load_rows(path) + assert len(rows) == 1 + assert rows[0].hr_mean is None + assert rows[0].rmssd_ms is None + finally: + os.unlink(path) + + +def test_main_reports_corrupt_csv_structured(): + # The CLI exits 2 with a parseable JSON error report, no traceback. + import json as _json, subprocess, tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg\n" + "1700000000,abc,80\n") + path = f.name + try: + proc = subprocess.run( + [sys.executable, os.path.join(os.path.dirname(__file__), + "bp_research_model.py"), + "--csv", path], + capture_output=True, text=True) + assert proc.returncode == 2, proc.stderr + report = _json.loads(proc.stdout) + assert report["error"] == "corrupt csv" + assert "systolic_mmhg" in report["detail"] + assert "Traceback" not in proc.stderr + finally: + os.unlink(path) + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn() + print(f"PASS {name}") + print("ALL MATH TESTS PASSED")