Time the day around the user's own sleep schedule - #127
Open
ak710 wants to merge 3 commits into
Open
Conversation
The Colmi big-data timeline (stage 0x04) and the YCBT timeline (tag 3) both report REM, and both decoders have always stored it as a SleepStageBlock. It just never got any further: SleepSummary carried light/deep/awake only, so REM never reached the sleep score, the Sleep tab, or the coach — which was still hard-coded to tell the model "light/deep/awake only, no REM" on every ring. Carry remMinutes through SleepSummary, collapseByDay and averageStages, and report it as remPct. Nil rather than zero throughout when the ring reported no REM stage at all: a jring's 0x11 timeline genuinely has none, and "absent" and "you slept no REM" are different claims. This also fixes a scoring side-effect. hasAwakeSignal falls back to asking whether the stage timeline accounted for essentially the whole night, and REM was excluded from that sum — so a fully described REM night looked only ~80% covered, failed the 0.95 gate, and had its awake reading thrown away as "no signal", costing it 45% of the awake sub-score. REM now counts toward coverage. REM is measured but still not scored: the light band (ideal 50-60%) is calibrated for a no-REM decoder that lumps REM into light, so re-weighting the score would move every stored night and needs the versioned recompute readiness does. Noted in SleepScore.calculate for the sleep-score rework. The coach's caveat is now chosen per night from that night's own blocks rather than from the connected ring's capabilities — stored nights outlive the ring that recorded them, so switching rings must not retro-actively disclaim last week's REM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1 scored duration (35), deep % (30), light % (20) and awake % (15). Three of those four had problems. Duration scored SleepSession.totalMinutes, which SleepSegmentation sets from end - start — time in bed, not time asleep. A night with 8h in bed and 90 min awake was credited as 8h of sleep. Awake minutes now come off the top. Light % was scored against a band (ideal 50-60%) calibrated for a no-REM decoder that lumps REM minutes into light. On a ring that tags REM separately, the same night reads ~20 points lower and was scored harshly for a split that was correct. Light is now the residual of deep and REM, so it is reported and not scored — scoring it counted the same night twice. Awake % was scored at 55% of its points when the ring gave no usable wake signal, which quietly docked every jring night for a sensor it never had. Missing signals now leave the denominator instead, exactly as readiness does: a jring is scored out of 80, a first week out of 90, and coverage is reported alongside the number. That is what makes one score comparable across hardware — an ideal night now scores the same on both. REM joins as a contributor (20 pts, ideal 20-25%), and bedtime consistency as a new one (10 pts), computed from the user's own median bedtime over the previous 14 nights on an axis wrapped around midnight, so 23:40 and 00:20 average to midnight rather than noon. The night being scored is excluded from its own baseline — including it would drag the median toward it and forgive exactly the drift the contributor exists to notice. Sleep efficiency is deliberately absent: with totalMinutes being time in bed it works out to exactly 1 - awake %, so it would restate restfulness while looking like an independent sixth signal. No migration needed. The production sync path stores every SleepSession with a nil score and every screen computes live from the stage blocks, so v2 took effect immediately; only demo and imported data carry a stored score. algorithmVersion is stamped anyway, for a future change that does need one. Full contributor table, thresholds and rationale in docs/project/sleep-score.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four windows on the Sleep tab: get light by, last coffee by, finish eating by, wind down from. Ultrahuman's equivalents are one of the most-cited reasons people pick it over Oura. Anchored on the user's own bedtime and wake rather than on sunrise. Ultrahuman derives its windows from solar times, which needs location; this doesn't, for two reasons. The project's principles keep location optional, and the guidance these windows encode is already phrased relative to your own sleep — "get bright light within an hour or two of waking", not "at sunrise". A night-shift schedule (asleep 09:00, awake 17:00) produces a coherent set of windows for exactly that reason, where a sunrise-based one would produce nonsense. The cost is stated plainly in the docs and in the source: the light window cannot say whether it is actually light outside. It says when the body clock is most responsive, not when the sun is up. The schedule is a median bedtime and wake over the last 14 nights, needing seven. Days are collapsed so each contributes one of each, and only sessions of three hours or more count — a 20-minute nap's start and end are not a schedule and would drag both medians. Times are averaged on an axis wrapped around midnight, reusing the placement BedtimeBaseline already defines. Both offsets are placed against the midnight *ending* today, so a −60 (23:00) bedtime lands tonight and a +30 (00:30) bedtime lands tomorrow — both being tonight's sleep. An earlier version special-cased the two signs and put the caffeine cutoff for a 00:30 sleeper at 16:30 yesterday; the midnight test caught it, and the uniform expression is what makes the two sides agree. Nothing renders until the schedule is established, and each window's reason is disclosed on tap — four bare times with no explanation would be instructions rather than guidance, and these are general rules of thumb applied to a schedule, not findings from the user's data. Offsets and rationale in docs/project/circadian-windows.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #118
Merge order: 8 of 9.⚠️ Stacked on #124, which is itself stacked on #120 — this diff contains all three commits. Merge #120 then #124 first.
What
Four windows on the Sleep tab: get light by (wake + 2 h), last coffee by (bedtime − 8 h), finish eating by (bedtime − 3 h), wind down from (bedtime − 1 h).
Anchored on sleep, not sunrise — and why
Ultrahuman derives its equivalents from solar times, which needs location. I didn't, for two reasons:
A night-shift schedule (asleep 09:00, awake 17:00) produces coherent windows for exactly that reason, where a sunrise-based one would produce nonsense —
testANightShiftScheduleStillWorks.The cost, stated plainly in the docs and the source: this cannot tell you whether it is actually light outside. It says when the body clock is most responsive, not when the sun is up. On a dark winter morning that means a lamp, and the app can't know the difference.
The midnight case
Both offsets are placed against the midnight ending today:
startOfToday + 24 h + offset. That one expression is what makes the two sides agree — a −60 (23:00) bedtime lands tonight, a +30 (00:30) bedtime lands tomorrow, both being tonight's sleep.My first version special-cased the two signs and put the caffeine cutoff for a 00:30 sleeper at 16:30 yesterday.
testAPastMidnightBedtimeCountsBackIntoTodaycaught it.The schedule
Median bedtime and wake over the last 14 nights, needing 7. Days collapsed so each contributes one of each, and only sessions of ≥3 hours count — a 20-minute nap's start and end are not a schedule and would drag both medians (
testNapsAreExcludedFromTheSchedule).Nothing renders until the schedule is established. Each window's reason is disclosed on tap — four bare times with no explanation would be instructions, and these are general rules of thumb applied to a schedule, not findings from the user's data.
Testing
CircadianWindowsTests— 12 tests, concentrated on the date arithmetic. Full suite green (908). Docs:docs/project/circadian-windows.md.