Skip to content

Daytime stress: score against a personal cross-day baseline, not the day's own calm hours - #463

Open
vishk23 wants to merge 7 commits into
ryanbr:mainfrom
vishk23:stress-baseline-relative-daytime
Open

Daytime stress: score against a personal cross-day baseline, not the day's own calm hours#463
vishk23 wants to merge 7 commits into
ryanbr:mainfrom
vishk23:stress-baseline-relative-daytime

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

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 nightly resting_hr/hrv configs.
  • StressView — resolves the mode from the trailing ~30 local days after the existing HR-count guard; .baselineRelative once 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 .baselineRelative HR 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:

  • Density: waking hours carry ~3600 HR samples/hr — ~12× the aggregation gate, so the feature actually activates rather than silently degrading.
  • Calm-HR baseline: the P10-of-waking-hour-means lands ~65 bpm, matching the tuning reference, so the +15 bpm → ~80 bpm high threshold is physiologically sane for a real wearer.

RMSSD is built but gated OUT of the live score (daytimeRMSSDScoringEnabled = false)

rawScore sums 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, rawScore term — 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-tier HRVReadiness) behind a flag until validated on real data.

Notes

  • Full StrandAnalytics suite green; macOS build green.
  • Kotlin twin included + gradle-tested (12 tests green). android/…/analytics/DaytimeBaselines.kt mirrors the Swift caller-side fold exactly — the same P10 daytime-HR / P50 daytime-RMSSD per-day aggregates (reusing DaytimeStress's own floorDiv/bucketSeconds/isWakingHour/minHourHrSamples/HrvAnalyzer so the folded value equals the value later scored against), the same Baselines.foldHistory path with daytimeHRCfg/daytimeRMSSDCfg, the usable-gated RMSSD-withhold, the scoringMode degradation gate, and the daytimeRMSSDScoringEnabled = false live-score flag with its inverting tripwire test — so Swift and Kotlin yield byte-identical baselines and scores. The 12 Swift DaytimeBaselinesTests are mirrored 1:1 as Kotlin DaytimeBaselinesTest with 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).
  • Opening for review + validation — if the intraday timeline reads wrong on your own worn data, that's exactly the feedback I'm after.

vishk23 added 4 commits July 14, 2026 12:30
… 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.
stevendo pushed a commit to stevendo/noop that referenced this pull request Jul 16, 2026
…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>
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…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.
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Merged current main (274 commits, no conflicts) to clear the UNSTABLE state and get a fresh CI run.

On the failing check: the red leg was test (NoopLocalAccess), which failed after 45m01s on 14 Jul. That is a stalled runner, not a code defect — this branch does not touch Packages/NoopLocalAccess at all (only StrandAnalytics, StressView, and the Android twins), and every other package leg passed in under two minutes in the same run. Its logs have since expired, so I reproduced the leg locally on the merged tree instead: swift build 84s, swift test 9/9 pass.

Verified on the merged tree:

  • Packages/NoopLocalAccess — 9/9 (the leg that went red).
  • Packages/StrandAnalytics — 1160/1160.
  • Strand/Screens/StressView.swift is app-target Swift that no default CI job compiles, so I built it: xcodegen generate + xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NOBUILD SUCCEEDED.
  • Android ./gradlew testFullDebugUnitTest — 3082 tests, 1 failure: SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental ("NoopApplication is not attached"), which also fails on a clean main and is unrelated to this branch.

Also worth flagging for triage: #413 is a strict subset of this PR. Its three commits are the first three here (feat(stress): add baseline-relative daytime-stress mode…, fix(stress): calibrate…, feat(android): Kotlin twin… — two of the three are patch-id-identical), and this branch adds three more on top. #413 can be closed as superseded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant