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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,11 @@ public enum AnalyticsEngine {
return (red: redSum / kept, ir: irSum / kept)
}

/// Nightly gated mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed
/// Gap (seconds) that ends one @82 measurement window, and the in-band seconds a window needs to count.
public static let spo2WindowMaxGapSec = 2
public static let spo2WindowMinValidSec = 5

/// Nightly windowed mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed
/// `sessions`, with the sample count it rests on — or nil when no in-band reading fell inside any
/// span. (#112, tracking #103.)
///
Expand Down Expand Up @@ -1377,17 +1381,45 @@ public enum AnalyticsEngine {
/// value in range is positive (70...100), so round-half-up and round-half-away-from-zero agree —
/// no platform-divergence risk from the rounding rule itself. Byte-parity twin of the Kotlin
/// `nightlySpo2CandidateMean`.
///
/// WINDOWED (wearable5.0 FINDINGS §13): the strap measures in 30 s windows every 1,200 s during sleep;
/// @82 is 0 outside them and inside holds a % or a status code per second. Seconds with a nonzero @82
/// are grouped into windows (gap <= `spo2WindowMaxGapSec`), each window with at least
/// `spo2WindowMinValidSec` in-band seconds yields its median, and the night is the mean of those
/// medians, so a thin window (1-4 valid seconds) cannot weigh like a full one. `samples` = the in-band
/// seconds of the windows that counted.
public static func nightlySpo2CandidateMean(_ sessions: [SleepSession],
aux: [V18AuxSample]) -> (mean: Int, samples: Int)? {
guard !sessions.isEmpty, !aux.isEmpty else { return nil }
var sum = 0, kept = 0
for a in aux {
guard let v = a.auxByte82, (70...100).contains(v) else { continue }
guard sessions.contains(where: { $0.start <= a.ts && a.ts <= $0.end }) else { continue }
sum += v; kept += 1
let rows = aux.filter { a in
guard let v = a.auxByte82, v != 0 else { return false }
return sessions.contains(where: { $0.start <= a.ts && a.ts <= $0.end })
}.sorted { $0.ts < $1.ts }
// Split into windows first (a gap over `spo2WindowMaxGapSec` starts a new one), then reduce each.
var windows: [[Int]] = []
var lastTs: Int?
for a in rows {
if let l = lastTs, a.ts - l <= spo2WindowMaxGapSec {
windows[windows.count - 1].append(a.auxByte82!)
} else {
windows.append([a.auxByte82!])
}
lastTs = a.ts
}
guard kept > 0 else { return nil }
return (mean: Int((Double(sum) / Double(kept)).rounded()), samples: kept)
var medians: [Double] = []
var kept = 0
for w in windows {
let valid = w.filter { (70...100).contains($0) }.sorted()
guard valid.count >= spo2WindowMinValidSec else { continue }
let m = valid.count / 2
medians.append(valid.count % 2 == 1 ? Double(valid[m]) : Double(valid[m - 1] + valid[m]) / 2.0)
kept += valid.count
}
guard !medians.isEmpty else { return nil }
// Sequential left-to-right sum so the Kotlin twin's sum is bit-identical.
var sum = 0.0
for m in medians { sum += m }
return (mean: Int((sum / Double(medians.count)).rounded()), samples: kept)
}

/// The plausible range for a raw Oura `0x6F` SpO2 sample before the ceiling transform below.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import WhoopProtocol
import WhoopStore
@testable import StrandAnalytics

/// #112 / #103 — the nightly gated mean of the 5/MG SpO2 candidate byte.
/// #112 / #103 — the nightly windowed SpO2 from the 5/MG @82 byte (wearable5.0 FINDINGS §13).
///
/// This exists to make a volunteer's contribution checkable. The candidate cannot be promoted while two
/// straps disagree about it, and breaking that tie means a third wearer comparing their nightly figure
/// against the one the WHOOP app reports. Reading it off a scrolling chart is not an instrument; one
/// number is. These pin what that number counts and — more importantly — what it refuses to count.
/// Byte-parity twin of the Kotlin `Spo2CandidateNightlyTest`: same fixtures, same expected values. The strap
/// measures in 30 s windows; each window with enough in-band seconds yields its median and the night is
/// the mean of those medians. These pin what counts and, more importantly, what does not.
final class Spo2CandidateNightlyTests: XCTestCase {

private func session(_ start: Int, _ durSec: Int) -> SleepSession {
Expand All @@ -18,67 +17,56 @@ final class Spo2CandidateNightlyTests: XCTestCase {

private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) }

func testMeanAndSampleCountOverTheSession() {
let r = AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(1100, 94), aux(1200, 96), aux(1300, 95)])
XCTAssertEqual(r?.mean, 95)
XCTAssertEqual(r?.samples, 3)
}

/// The count is not decoration: a mean over 3 readings and a mean over 3000 are different evidence,
/// and a volunteer's correlation built on the first would be worthless.
func testSampleCountTravelsWithTheMean() {
let many = (0..<50).map { aux(1000 + $0, 93) }
XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean([session(900, 600)], aux: many)?.samples, 50)
/// One window of consecutive seconds starting at `start`.
private func window(_ start: Int, _ values: [Int?]) -> [V18AuxSample] {
values.enumerated().map { aux(start + $0.offset, $0.element) }
}

/// Out-of-band values are DIAGNOSTIC CODES and SATURATION SENTINELS, not low blood oxygen. Averaging
/// them in would produce a number that is not a percentage of anything — the single most damaging
/// thing this helper could do, because it would look plausible.
func testSubSeventyAndSentinelValuesAreExcluded() {
func testOneWindowYieldsItsMedian() {
let r = AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)],
aux: [aux(1100, 94), aux(1150, 3), aux(1200, 0), aux(1250, 0x80), aux(1300, 96)])
XCTAssertEqual(r?.mean, 95, "only the two in-band readings may count")
XCTAssertEqual(r?.samples, 2)
[session(1000, 3600)], aux: window(1100, [94, 95, 96, 96, 96, 97, 97, 97, 98, 97]))
XCTAssertEqual(r?.mean, 97) // median (96 + 97) / 2 = 96.5, rounded half up
XCTAssertEqual(r?.samples, 10)
}

func testDaytimeSamplesOutsideTheSessionAreExcluded() {
let r = AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(500, 99), aux(1100, 94), aux(5000, 88)])
XCTAssertEqual(r?.samples, 1)
XCTAssertEqual(r?.mean, 94)
func testNightIsTheMeanOfWindowMedians() {
let a = window(1100, [96, 96, 96, 96, 96]) + window(2300, [99, 99, 99, 99, 99, 99, 99])
let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: a)
XCTAssertEqual(r?.mean, 98) // (96 + 99) / 2 = 97.5, not the per-second mean 97.75
XCTAssertEqual(r?.samples, 12)
}

func testNilWhenNothingInBandFallsInsideASession() {
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(1100, 5)]))
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(9999, 95)]))
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([], aux: [aux(1100, 95)]))
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: []))
/// Status codes (sub-70 and bit-7 values) stay in the window but never count as readings; a window
/// with fewer than five in-band seconds is dropped instead of weighing like a full one.
func testCodesAndThinWindowsDoNotCount() {
let full = window(1100, [160, 95, 128, 95, 95, 32, 96, 95])
let thin = window(2300, [78, 160, 128, 16])
let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: full + thin)
XCTAssertEqual(r?.mean, 95)
XCTAssertEqual(r?.samples, 5)
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: thin))
}

/// A record with no @82 at all is not a zero reading.
func testMissingBytesAreSkippedNotCountedAsZero() {
let r = AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(1100, nil), aux(1200, 96)])
XCTAssertEqual(r?.mean, 96)
XCTAssertEqual(r?.samples, 1)
/// A gap longer than two seconds ends a window, so two halves of three seconds are two thin windows.
func testGapSplitsWindows() {
let a = window(1100, [95, 95, 95]) + window(1106, [95, 95, 95])
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 3600)], aux: a))
}

/// Found 2026-08-24 on the Oura twin (`nightlySpo2CeilingMean`) and fixed here too — same bug, same
/// helper shape. `sum / kept` on two `Int`s truncates toward zero; three samples averaging
/// 97.666... must round to 98, not floor to 97.
func testMeanRoundsRatherThanFloors() {
let r = AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(1100, 98), aux(1200, 98), aux(1300, 97)])
XCTAssertEqual(r?.mean, 98, "97.666... must round to 98, not floor to 97")
func testOutsideSessionZeroAndMissingAreSkipped() {
let a = window(500, [99, 99, 99, 99, 99]) + window(1100, [94, nil, 94, 94, 94, 94])
let r = AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: a)
XCTAssertEqual(r?.mean, 94)
XCTAssertEqual(r?.samples, 5)
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([], aux: a))
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: []))
}

/// The boundaries are inclusive, matching the decoder's own `70...100` emit gate exactly.
/// Boundaries are inclusive, matching the decoder's own 70...100 emit gate exactly.
func testBandBoundariesMatchTheDecoderGate() {
XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(1100, 70), aux(1200, 100)])?.samples, 2)
[session(1000, 600)], aux: window(1100, [70, 70, 100, 100, 100]))?.samples, 5)
XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean(
[session(1000, 600)], aux: [aux(1100, 69), aux(1200, 101)]))
[session(1000, 600)], aux: window(1100, [69, 69, 101, 101, 101])))
}
}
4 changes: 2 additions & 2 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
"files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"},
"functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"},
"properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"},
"constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"},
"constants": {"count": 2005, "sha256": "6242483adfc62637dcf25457e21efbc562a444c6056793a1f7211074271cf165"},
"file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"},
"function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"},
"property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"},
"constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"},
"constant_pairs": {"count": 704, "sha256": "5d0e4243925599ca41eb4511a4573b7e1528a1dfb7e0837dc6a84c68f5571166"},
"unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"},
"unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"},
"unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"},
Expand Down
42 changes: 33 additions & 9 deletions android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,12 @@ object AnalyticsEngine {
return (redSum / kept).toInt() to (irSum / kept).toInt()
}

/** Gap (seconds) that ends one @82 measurement window (wearable5.0 FINDINGS §13). */
const val SPO2_WINDOW_MAX_GAP_SEC: Long = 2L

/** In-band seconds a window needs before its median counts. */
const val SPO2_WINDOW_MIN_VALID_SEC: Int = 5

/**
* Nightly gated mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed [sessions],
* paired with the sample count it rests on — or null when no in-band reading fell inside any span.
Expand All @@ -1176,23 +1182,41 @@ object AnalyticsEngine {
* toward zero, silently biasing every candidate down by up to 0.99. All values here are positive
* (70..100), so the rounding-rule choice (half-up vs half-away-from-zero) is inert. Byte-parity
* twin of the Swift `nightlySpo2CandidateMean`.
*
* WINDOWED (wearable5.0 FINDINGS §13): nonzero @82 seconds are grouped into measurement windows, each
* window with at least [SPO2_WINDOW_MIN_VALID_SEC] in-band seconds yields its median, and the night is
* the mean of those medians. Byte-parity twin of the Swift version.
*/
internal fun nightlySpo2CandidateMean(
sessions: List<DetectedSleep>,
aux: List<V18AuxRow>,
): Pair<Int, Int>? {
if (sessions.isEmpty() || aux.isEmpty()) return null
var sum = 0L
val rows = aux.filter { a ->
val v = a.auxByte82
v != null && v != 0L && sessions.any { a.ts in it.start..it.end }
}.sortedBy { it.ts }
// Split into windows first (a gap over SPO2_WINDOW_MAX_GAP_SEC starts a new one), then reduce each.
val windows = ArrayList<MutableList<Long>>()
var lastTs: Long? = null
for (a in rows) {
val l = lastTs
if (l != null && a.ts - l <= SPO2_WINDOW_MAX_GAP_SEC) windows.last() += a.auxByte82!! else windows += mutableListOf(a.auxByte82!!)
lastTs = a.ts
}
val medians = ArrayList<Double>()
var kept = 0
for (a in aux) {
val v = a.auxByte82 ?: continue
if (v < 70L || v > 100L) continue
if (sessions.none { a.ts in it.start..it.end }) continue
sum += v
kept += 1
for (w in windows) {
val valid = w.filter { it in 70L..100L }.sorted()
if (valid.size < SPO2_WINDOW_MIN_VALID_SEC) continue
val m = valid.size / 2
medians += if (valid.size % 2 == 1) valid[m].toDouble() else (valid[m - 1] + valid[m]).toDouble() / 2.0
kept += valid.size
}
if (kept == 0) return null
return Pair((sum.toDouble() / kept.toDouble()).roundToInt(), kept)
if (medians.isEmpty()) return null
var sum = 0.0
for (m in medians) sum += m
return Pair((sum / medians.size).roundToInt(), kept)
}

/**
Expand Down
Loading