stress: baseline-relative daytime mode + Oura-comparable high-stress minutes - #413
stress: baseline-relative daytime mode + Oura-comparable high-stress minutes#413vishk23 wants to merge 3 commits into
Conversation
… minutes DaytimeStress.analyze gains an additive `mode` parameter (default .dayRelative, byte-identical to before) alongside a new .baselineRelative case that z-scores each waking hour against a PERSONAL rolling baseline (Oura-style) instead of the day's own calm-hour reference. Reuses Baselines' existing Winsorized-EWMA machinery via two new MetricCfg entries (daytime_hr, daytime_rmssd). A missing personal RMSSD baseline (e.g. an imported Oura-era day with no R-R history) gracefully falls back to HR-only scoring, honestly flagged via Result.hrOnlyFallback. Also adds Result.highStressMinutes: an Oura-comparable "time in high stress" total (scored waking hours at/above highBandFloor, in minutes), computed uniformly in both modes. Extracted Baselines.sigma(_:) (the abs-dev-to-Gaussian-sigma conversion) out of deviation() so DaytimeStress's baseline-relative path shares the exact same conversion rather than duplicating the 1.253 constant. StressView's call site is unchanged (mode defaults to .dayRelative), so nothing the Stress screen currently shows changes.
…Oura tuning A 26-day Oura-reference correlation found a pooled/rolling personal daytime-HR baseline (10th-percentile HR pooled across days, ~65 bpm) with a fixed ~15 bpm margin correlates best with Oura's own stress signal (r~0.6, HR-only) — better than scaling the threshold by this person's own day-to-day spread, and much better than a per-day baseline (r 0.43-0.53), confirming the cross-day rolling-EWMA design over a day-local one. Replaces .baselineRelative's HR sdHR (previously Baselines.sigma(hrBaseline), i.e. derived from personal variability) with a value solved so that exactly "baseline + baselineRelativeHighMarginBPM" lands on highBandFloor on the shared squash curve (DaytimeStress.marginToSigma). The RMSSD term is unchanged (still Baselines.sigma-scaled) since it wasn't part of the validated comparison — flagged as an open tuning seam pending an HR+HRV validation pass, which is expected to beat the HR-only r~0.6 ceiling. Also documents how this hourly, daytime-baseline mode relates to the two other baseline-relative surfaces already on the Stress screen: the daily StressModel score (nightly resting-HR/HRV vs a local trailing-30-day mean/SD, not routed through Baselines.swift) and the today-only Advanced HRV card (StressIndex/HRVFreqDomain, no baseline at all) — different grain and different signal, so this is a complementary lens, not a parallel reimplementation of either. Test fixtures updated to the new margin-calibrated sd (a 65 bpm baseline, elevations expressed in terms of the validated 15 bpm margin so the expected band crossings are exact), plus a direct test of the margin-to-sigma identity.
…-stress minutes (parity with PR #413)
|
Triage note from the author's side: this PR is fully superseded by #463 and should be closed. I have deliberately not closed it myself — flagging so a maintainer can. The evidence:
Nothing here is lost by closing it — every line lands via #463. (For context on #463's old red check: its |
|
Closing as subsumed by #463. The evidence is unambiguous: this PR's three commits are #463's first three (two of them patch-id identical), and its eight files are a strict subset of #463's thirteen. Everything here lands with #463, so keeping both open just splits review attention across the same change. #463 is now green and mergeable — the CI failure it carried turned out to be a stalled runner rather than a real failure; that leg passes in 53s after a merge with main. |
Summary
DaytimeStress.ScoringModealongside the existing day-relative default:.baselineRelative(hr:rmssd:)z-scores each waking hour against a PERSONAL rolling baseline (Oura-style) instead of the day's own calm-hour reference. Existing callers (StressView's call site is untouched) keep the exact prior day-relative behavior — the default parameter value is.dayRelative.Baselines.metricCfgentries (daytime_hr,daytime_rmssd) so the baseline-relative mode reuses the SAME Winsorized-EWMA machinery (Baselines.update/foldHistory) that backs the nightly HRV/resting-HR baselines elsewhere in the app, rather than a one-off implementation. Distinct from the nightly configs because daytime HR runs warmer than nocturnal resting HR (posture, thermic effect) — reusing the nightly configs would systematically over-read stress.Result.hrOnlyFallback— mirrors the existing per-hour graceful-nil inrawScore, just at the whole-baseline grain.Result.highStressMinutes: an Oura-comparable "time in high stress" total (scored waking hours at/abovehighBandFloor, converted to minutes), computed uniformly in both modes — maps onto Oura'sstress_high_sfor comparison (coarser, hourly grain vs Oura's ~5-minute grain).Baselines.sigma(_:)(the abs-dev-to-Gaussian-σ conversion) out ofdeviation()so it has one definition, shared by the new baseline-relative RMSSD term.Validated tuning (mid-implementation correction)
A 26-day Oura-reference correlation found a pooled/rolling personal daytime-HR baseline (10th-percentile daytime HR pooled across days, ~65 bpm) with a fixed ~15 bpm margin correlates best with Oura's own stress signal (r≈0.6, HR-only) — clearly better than a per-day baseline (r 0.43–0.53, because an all-day-elevated day pulls its own floor up and masks the stress). This confirms the cross-day rolling-EWMA design over a day-local one, and gave a concrete number to calibrate against:
.baselineRelative's HR term now usesDaytimeStress.marginToSigma(marginBPM: baselineRelativeHighMarginBPM, atBand: highBandFloor)for its spread — solved so that exactly "baseline + 15 bpm" lands onhighBandFlooron the shared squash curve — rather than scaling by this person's own day-to-day HR spread. The RMSSD term is unchanged (still spread-scaled viaBaselines.sigma) since RMSSD wasn't part of the validated comparison; that's flagged as an open tuning seam, expected to beat the HR-only r≈0.6 ceiling once an HR+HRV comparison exists. Both constants are named, documented, and cite the validation inline (DaytimeStress.baselineRelativeHighMarginBPM, thedaytime_hr/daytime_rmssdconfig comments).Relationship to the existing baseline-relative surface on the Stress screen
Read
StressView.swiftend to end before building this, since the Stress screen already has baseline-relative content:StressModel— the DAILY 0–3 score already compares last night's NIGHTLY resting-HR/HRV to a plain trailing 30-day mean/SD, computed locally inStressView(its own privateStressMath, not routed throughBaselines.swiftat all). Backs the "Stress" tile, the "Resting HR"/"HRV vs 30-day baseline" marker tiles.StressIndex+HRVFreqDomain, a today-only descriptive lens computed from today's R-R, no baseline at all..baselineRelative— an HOURLY breakdown of TODAY from DAYTIME/waking-hours HR+RMSSD against a PERSONAL cross-day rolling baseline. Different grain (hourly vs daily), different signal (daytime waking-hours vs nightly sleep vitals), different underlying baseline (newdaytime_hr/daytime_rmssdvs the existing local ad-hoc trailing mean/SD). Complementary lens on the same underlying autonomic signal, not a duplicate/competing implementation — documented inline in theScoringModedoc comment for future readers.Test plan
cd Packages/StrandAnalytics && swift test— 1047/1047 green (1038 pre-existing + 9 new: 7 inDaytimeStressTests.swift, 2 inBaselinesTests.swift).testDayRelativeDefaultIsByteIdenticalToExplicitModeproves the implicit (omittedmode) and explicit.dayRelativecalls produce a byte-identicalResult(every field), and all pre-existingDaytimeStressTests/BaselinesTestspass completely unmodified.testBaselineRelativeModeRecoversMultipleInjectedElevationsinjects 4 distinct HR elevations (at-baseline, +7, +15 validated margin, +30) and asserts strictly increasing scores, with the +15 bpm hour landing exactly onhighBandFloor.highStressMinutes > 0;highStressMinutescounts ALL high-band scored hours (not just the trailing sustained-high run, proven with a fixture where they diverge).testBaselineRelativeNilRMSSDFallsBackToHROnlyAndFlagsDegraded— HR-only fallback fires, produces a non-empty timeline, never crashes.testMarginToSigmaLandsExactlyOnBand) and of the newBaselines.sigma/daytime_hr/daytime_rmssdadditions.xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build— BUILD SUCCEEDED, 0 errors.StressView.swift's call site is unchanged (relies on the.dayRelativedefault), so nothing the Stress screen currently shows changes.Android
Kotlin twin included + gradle-tested (10 new tests green). The Kotlin twin (
android/app/src/main/java/com/noop/analytics/{DaytimeStress,Baselines}.kt) mirrors the Swift structure identically: the same twometricCfgentries (daytime_hr/daytime_rmssd), aScoringModesealed interface (DayRelative/BaselineRelative(hr, rmssd)) matching the Swift enum, the samebaselineRelativeHighMarginBPM = 15.0constant, themarginToSigmaidentity, the sharedBaselines.sigma()extraction, and the newhighStressMinutes/hrOnlyFallbackresult fields — so Swift and Kotlin yield byte-identical scores. The 9 Swift tests are mirrored as Kotlin tests with identical inputs and expected values (8 inDaytimeStressTest, 2 inBaselinesSigmaDaytimeTest), all green under./gradlew :app:testFullDebugUnitTest.