Daytime stress: score against a personal cross-day baseline, not the day's own calm hours - #463
Daytime stress: score against a personal cross-day baseline, not the day's own calm hours#463vishk23 wants to merge 7 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.
…meline baseline-relative The .baselineRelative daytime-stress mode existed but was never fed real history, so StressView still scored every intraday hour against the day's OWN calm hours (.dayRelative). This adds the missing caller-side folding path and wires StressView to use it. DaytimeBaselines.swift (new): - dayDaytimeAggregate(hr:rr:tzOffsetSeconds:): one day's daytime aggregates, computed with the SCORER's own hourly bucketing / waking window / minHourHRSamples gate so the folded value is exactly the quantity the mode later z-scores against. HR = P10 of waking-hour mean HRs (the validated ~65 bpm calm floor); RMSSD = P50 (median/typical), deliberately NOT the symmetric calm ceiling: the RMSSD term is spread-scaled (Baselines.sigma), so a median anchor keeps a typical hour neutral instead of stacking a false stress term on top of the HR term. - foldDaytimeBaselines(days:): replays the trailing per-day aggregates through Baselines.foldHistory (the same Winsorized-EWMA path as the nightly baselines) with daytimeHRCfg/daytimeRMSSDCfg. The RMSSD baseline is withheld (nil -> HR-only) until >= minNightsSeed daytime-RR days exist. - scoringMode(history:): the single graceful-degradation gate — .baselineRelative once the personal HR baseline is usable, else .dayRelative (cold start / all-sparse history stays byte-identical to before). StressView: builds the baselines from the trailing 30 local days (today excluded, unworn days skipped without an R-R read) after the existing HR-count guard, then passes the resolved mode to DaytimeStress.analyze. The timeline's explanatory copy is now mode-aware (personal baseline vs. today's own calm hours). Purely additive — a user without enough worn history keeps exactly today's behaviour. Tests: DaytimeBaselinesTests (11) cover the P10/P50 aggregate definitions, the gate + waking mirroring, the fold, the degradation gate, and the fold->score round trip including the anti-stacking property. swift test: 1054 pass; the macOS app target compiles.
Real 1Hz wrist data shows daytime RMSSD is artifact-dominated (hourly values swing ~40->430 ms as posture/motion break the R-R stream), an order of magnitude noisier than overnight recumbent HRV. rawScore sums the HRV z equal-weight with the HR z, so an artifact hour can move the combined score by the full band on noise alone. The r~0.6 baseline-relative margin was validated HR-only; adding an unvalidated noisy term risks pushing the score below that ceiling. Hold RMSSD out of the LIVE score behind daytimeRMSSDScoringEnabled (false). The whole RMSSD half -- aggregate, fold, daytime_rmssd config, rawScore term -- stays built and unit-tested; only its arrival at production scoring is gated. Flipping the flag is one line with a tripwire test that inverts with it.
…-stress minutes (parity with PR #413)
…live-score gate (parity with PR #463)
…readout
The BEATS PER MINUTE card labelled its x-axis with the data extent's own
endpoints ("00:05 / 10:10 / Now") with nothing between them, and the
tap/drag pinpoint printed the value alone — "100 bpm somewhere between 6
and 9 AM". This change makes the axis read like a clock and the scrub
answer WHEN:
- chartTimeTicks (Charts.kt, pure + JVM-tested): round wall-clock ticks
chosen by the visible span (>=20h → 6h, 12h → 3h, 6h → 2h, 3h → 1h,
1h → 15min), stepped in LOCAL time from the window's midnight so a
window crossing midnight labels "00:00" and DST stays round; the
spring-forward gap dedupes to one tick.
- OverviewHRChart draws a subtle dotted gridline at each tick (hairline
token, dash 4/6, under the sleep band and curve), and the new
HrTimeAxisLabels strip centres each "HH:mm" under its gridline via
the same per-bucket timestamp interpolation the markers use, keeping
the "Now" end label on the live un-zoomed day.
- LineChart gains an OPTIONAL index-aligned timestamps parameter: when
present the ryanbr#463 selection label is prefixed with the sample's local
clock time ("14:32 · 87 bpm"). Only the Today HR card passes it (with
a bpm formatter); every other caller is byte-identical.
Locked by ChartTimeTicksTest (intervals, midnight, DST, interpolation)
and two new ChartSelectionLabelTest cases for the time prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…readout
The BEATS PER MINUTE card labelled its x-axis with the data extent's own
endpoints ("00:05 / 10:10 / Now") with nothing between them, and the
tap/drag pinpoint printed the value alone — "100 bpm somewhere between 6
and 9 AM". This change makes the axis read like a clock and the scrub
answer WHEN:
- chartTimeTicks (Charts.kt, pure + JVM-tested): round wall-clock ticks
chosen by the visible span (>=20h → 6h, 12h → 3h, 6h → 2h, 3h → 1h,
1h → 15min), stepped in LOCAL time from the window's midnight so a
window crossing midnight labels "00:00" and DST stays round; the
spring-forward gap dedupes to one tick.
- OverviewHRChart draws a subtle dotted gridline at each tick (hairline
token, dash 4/6, under the sleep band and curve), and the new
HrTimeAxisLabels strip centres each "HH:mm" under its gridline via
the same per-bucket timestamp interpolation the markers use, keeping
the "Now" end label on the live un-zoomed day.
- LineChart gains an OPTIONAL index-aligned timestamps parameter: when
present the ryanbr#463 selection label is prefixed with the sample's local
clock time ("14:32 · 87 bpm"). Only the Today HR card passes it (with
a bpm formatter); every other caller is byte-identical.
Locked by ChartTimeTicksTest (intervals, midnight, DST, interpolation)
and two new ChartSelectionLabelTest cases for the time prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refresh against current main so CI re-runs. The previous run's
`test (NoopLocalAccess)` leg failed after 45m against a tree 274 commits old;
NoopLocalAccess is untouched by this branch (which only changes
StrandAnalytics, StressView and the Android twins) and its logs have since
expired, so the failure is a stalled runner rather than a code defect.
Verified locally on the merged tree:
- `Packages/NoopLocalAccess` — swift build 84s, swift test 9/9 pass (the leg
that failed in CI).
- `Packages/StrandAnalytics` — 1160/1160 pass.
- Android `./gradlew testFullDebugUnitTest` — 3082 tests, only the
pre-existing `SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySync`
("NoopApplication is not attached") fails; it fails on clean main too.
|
Merged current On the failing check: the red leg was Verified on the merged tree:
Also worth flagging for triage: #413 is a strict subset of this PR. Its three commits are the first three here ( |
Daytime stress: score against a personal cross-day baseline (Oura-style), not the day's own calm hours
Today's intraday stress timeline (
DaytimeStress) z-scores each waking hour against that same day's calm hours (.dayRelative). That masks an all-day-elevated day: a stressful day pulls its own floor up, so nothing reads as high. This wires the alternative that a personal, cross-day rolling baseline enables —.baselineRelative— the same shape Oura uses.The pieces (all additive; each degrades honestly on thin history)
DaytimeBaselines(new) — folds a person's trailing per-day daytime streams into personal baselines via the identical Winsorized-EWMA path the nightly baselines use (Baselines.foldHistory). Each day contributes one aggregate: HR = the 10th-percentile of waking-hour mean HRs (a calm-hour reference), RMSSD = the median waking-hour RMSSD. Today is excluded from its own baseline.DaytimeStress.baselineRelative(hr:rmssd:)(mode) — z-scores each waking hour against the cross-day baseline instead of a day-local reference. The+15 bpm"high" margin is translated onto the shared 0–3 squash curve so an hourly point keeps the daily score's scale and bands exactly.Baselines— two new metric configs (daytime_hr,daytime_rmssd) with bounds/floors appropriate to waking (warmer, noisier) physiology, distinct from the nightlyresting_hr/hrvconfigs.StressView— resolves the mode from the trailing ~30 local days after the existing HR-count guard;.baselineRelativeonce there's enough real worn daytime-HR history for a usable baseline, else.dayRelative(the unchanged default). Cold start and all-sparse/imported windows keep today's exact current behavior.Validation (against real data)
The
.baselineRelativeHR margin was tuned on a 26-day Oura-reference correlation: a ~15 bpm daytime-HR elevation over the pooled 10th-percentile baseline is where elevated HR starts reading as Oura-comparable "high" stress (r≈0.6). A per-day baseline scored worse in the same comparison (r 0.43–0.53) — which is the whole reason for the cross-day reference.I also replayed the pipeline against ~18 days of my own 1 Hz wrist data:
RMSSD is built but gated OUT of the live score (
daytimeRMSSDScoringEnabled = false)rawScoresums the HRV z equal-weight with the HR z. On real wrist data, daytime RMSSD off the strap is artifact-dominated — hourly values swing ~40→430 ms as posture/motion/talking break the R-R stream, an order of magnitude noisier than overnight recumbent HRV. Enabling it before it's shown to improve the correlation risks pushing the combined score below the HR-only r≈0.6 it was validated at.So the whole RMSSD half — aggregate, fold, config,
rawScoreterm — is built and unit-tested, but held out of production scoring behind a single documented flag until it has its own Oura-reference validation pass. Flipping the flag is a one-line change with a tripwire test that inverts with it. This mirrors how I've kept other unvalidated engines (the SWC-tierHRVReadiness) behind a flag until validated on real data.Notes
StrandAnalyticssuite green; macOS build green.android/…/analytics/DaytimeBaselines.ktmirrors the Swift caller-side fold exactly — the same P10 daytime-HR / P50 daytime-RMSSD per-day aggregates (reusing DaytimeStress's ownfloorDiv/bucketSeconds/isWakingHour/minHourHrSamples/HrvAnalyzerso the folded value equals the value later scored against), the sameBaselines.foldHistorypath withdaytimeHRCfg/daytimeRMSSDCfg, theusable-gated RMSSD-withhold, thescoringModedegradation gate, and thedaytimeRMSSDScoringEnabled = falselive-score flag with its inverting tripwire test — so Swift and Kotlin yield byte-identical baselines and scores. The 12 SwiftDaytimeBaselinesTestsare mirrored 1:1 as KotlinDaytimeBaselinesTestwith identical inputs and expected values, all green under./gradlew :app:testFullDebugUnitTest(stacked on the stress: baseline-relative daytime mode + Oura-comparable high-stress minutes #413 Kotlin twin).