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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 344 additions & 0 deletions Tools/parity_dispositions.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@
]
},
"authority": {
"files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"},
"functions": {"count": 4479, "sha256": "bdb69394501d448ac150e19fa571eb18999e54ee7312d4c5517367d35926a419"},
"properties": {"count": 463, "sha256": "7d80508acfd6418a9f1dc099d65a6890e9b663fb9b056ac2e939b0e0e42d488e"},
"constants": {"count": 1957, "sha256": "22820a60a99ee0bf0e9dbaf4f56e0416a445c552d8e05479c8cb09b47cf397a4"},
"files": {"count": 506, "sha256": "7c1aa896ae06001a3869be1164233da7d6af9f909334334a917be56b4832c511"},
"functions": {"count": 4503, "sha256": "3cd89d9b79d6228b0e44fc2c89b272eb76a54a903e805bcec142f6da416cbce5"},
"properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"},
"constants": {"count": 1968, "sha256": "2c88ab0d322028ad38a0bdf4118894231ecbb7200ab28afe095e5de336a8d7bb"},
"file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"},
"function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"},
"property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"},
"constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"},
"unpaired_files": {"count": 376, "sha256": "654d951b8137dbb2266b83734fa1e3f1cb1648f15c1aab3d015cdef7e79deb75"},
"unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"},
"unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"},
"unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"}
"unpaired_files": {"count": 380, "sha256": "6e9cd41c3ee7c16a244e5ef95ff80cb31b27838afbab860416754bc91559adfe"},
"unpaired_functions": {"count": 4137, "sha256": "e4abeab38983324cb9716af8d6be4518ca80ce123f13785ee27d2795be306a00"},
"unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"},
"unpaired_constants": {"count": 606, "sha256": "d334a29da52e658603db294deb3ff23e9625d682b1914703fc3bb09ebc97ac9a"}
}
}
2 changes: 1 addition & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ android {
applicationId = "com.noop.whoop"
minSdk = 26
targetSdk = 34
versionCode = 534
versionCode = 538
versionName = "11.8.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand Down
74 changes: 54 additions & 20 deletions android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,11 @@ object AnalyticsEngine {
// Threaded rather than read from a global so this stays a pure function, and defaulted so every
// existing caller and test is byte-identical.
effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS,
// Trusted in-bed bounds from a source that cannot feed the motion detector (notably Oura Ring 4),
// or from an explicit user bedtime/wake pair. A source-provided hypnogram is used verbatim;
// otherwise the selected V1/V2 recipe stages the bounded cardio streams. Default empty keeps every
// existing caller and motion-capable device byte-identical.
sleepWindowHints: List<SleepWindowHint> = emptyList(),
): DayResult {

// Precompute the day's UTC bounds ONCE (#996). isoDay is a FIXED-UTC formatter, so
Expand Down Expand Up @@ -443,29 +448,58 @@ object AnalyticsEngine {
// #804 Fix A: fold in the caller's device-provided hypnogram (see [providedSleep]). Empty = the
// byte-identical motion-only path. Otherwise enrich each provided session's nightly restingHR/avgHRV
// from THIS day's hr/rr over its window (the stored ring row carries neither), using the SAME helpers
// detectSleep populates a session with, then keep only the detected sessions that DON'T overlap a
// provided one (provided is authoritative where they collide; a separate nap survives).
val allSessions: List<DetectedSleep> = if (providedSleep.isEmpty()) {
refinedSessions
} else {
val rrSorted = rr.sortedBy { it.ts }
val enrichedProvided = providedSleep.map { s ->
// #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to
// preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only
// session reports what it measured, that clause is not merely redundant — an HR-only night
// that measured a resting HR but no HRV (no R-R banked) would take the short-circuit and
// skip the fill every other session gets. The rule is uniform: fill what is missing.
if (s.restingHR != null && s.avgHRV != null) s
else s.copy(
restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr),
avgHRV = s.avgHRV ?: SleepStager.sessionAvgHRV(s.start, s.end, rrSorted),
)
// detectSleep populates a session with. Explicit/user hints win over a provided session; otherwise a
// provided hypnogram is authoritative over motion detection. Disjoint detected naps survive.
val rrSorted = rr.sortedBy { it.ts }
val enrichedProvided = providedSleep.map { s ->
if (s.restingHR != null && s.avgHRV != null) s
else s.copy(
restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr),
avgHRV = s.avgHRV ?: SleepStager.sessionAvgHRV(s.start, s.end, rrSorted),
)
}

// A hint supplies only the missing DETECTION boundary; the normal stager and physiology functions
// still compute NOOP's stages/RHR/HRV from the raw streams. This is what lets an Oura 4 night flow
// into Sleep, Rest, vitals and Charge without pretending its movement-gated 0x47 events are a
// continuous accelerometer. When Oura did expose SleepNet stages, preserve those ring-provided
// stages and only derive the physiology around them.
val hintedSessions = sleepWindowHints.mapNotNull { hint ->
if (hint.start < 0L || hint.end <= hint.start || hint.end - hint.start > 16L * 3_600L) {
return@mapNotNull null
}
val keptDetected = refinedSessions.filter { d ->
enrichedProvided.none { it.start < d.end && d.start < it.end }
val supplied = hint.stages
?.mapNotNull { s ->
val start = maxOf(hint.start, s.start)
val end = minOf(hint.end, s.end)
if (end <= start) null else StageSegment(start, end, s.stage)
}
?.takeIf { it.isNotEmpty() }
val stages = supplied ?: if (useSleepStagerV2) {
SleepStagerV2.stageSession(hint.start, hint.end, gravity, hr, rrSorted, resp)
} else {
SleepStager.stageSession(hint.start, hint.end, gravity, hr, rrSorted, resp)
}
keptDetected + enrichedProvided
DetectedSleep(
start = hint.start,
end = hint.end,
efficiency = SleepStager.efficiency(hint.start, hint.end, stages),
stages = stages,
restingHR = SleepStager.sessionRestingHR(hint.start, hint.end, hr),
avgHRV = SleepStager.sessionAvgHRV(hint.start, hint.end, rrSorted),
hrOnly = supplied == null && gravity.size < 2,
)
}
// Trusted hints win over every overlapping source. A provided hypnogram then wins over motion
// detection, and disjoint detector sessions (for example, a separate nap) remain visible.
val keptProvided = enrichedProvided.filter { provided ->
hintedSessions.none { hinted -> provided.start < hinted.end && hinted.start < provided.end }
}
val keptDetected = refinedSessions.filter { detected ->
hintedSessions.none { hinted -> detected.start < hinted.end && hinted.start < detected.end } &&
keptProvided.none { provided -> detected.start < provided.end && provided.start < detected.end }
}
val allSessions = (keptDetected + keptProvided + hintedSessions).sortedBy { it.start }
// Sessions attributed to `day` = those whose end falls on `day` (LOCAL day, #277). `day` is
// the caller's local-day key; attribute by the same offset so the bucket and the key agree.
val matched = allSessions.filter { tsInDay(it.end) }
Expand Down
15 changes: 15 additions & 0 deletions android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ data class DetectedSleep(
val hrOnly: Boolean = false,
)

/**
* A trusted in-bed boundary supplied outside the motion detector.
*
* Oura Ring 4 does not expose a continuous gravity stream, so its ring-provided SleepNet session (when
* available), a sufficiently long automatic-resting window, or a user-marked bedtime/wake pair must be
* able to provide the bounds while the normal NOOP stager derives physiology inside them. [stages] is
* non-null only when the source already supplied a hypnogram; otherwise V1/V2 stages the bounded raw
* cardio streams. The detector remains unchanged for every source that has motion.
*/
data class SleepWindowHint(
val start: Long,
val end: Long,
val stages: List<StageSegment>? = null,
)

/**
* AASM-style metrics from a session's stage segments.
* Mirrors Swift `SleepStager.HypnogramMetrics`.
Expand Down
Loading
Loading