Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

/// The hours of the local day in which a wearable's daytime-HR mode should stand down so the device can
/// run its own night suite — derived from the sleep schedule NOOP already learns, never from a fixed clock.
///
/// WHY THIS EXISTS. An Oura ring produces daytime heart rate ONLY while a client holds it in daytime-HR
/// mode (`DHR_mode:3`); there is no banked daytime HR family it emits on its own. NOOP's screen-off suspend
/// (#1526) stops holding the ring so its sleep suite can run — the right call at night, r = −0.93 between
/// the overnight hold and the ring producing SpO2 / a hypnogram / `0x6A` — but a screen-off gate is also
/// true for most of a working day, so from the night that build shipped the daytime 5-min HR bins went from
/// 123–144/144 to a median of ~16, and windowed rMSSD by day emptied with them (the daytime beats are the
/// same `0x80` records). This band is what lets the stand-down key on the NIGHT instead of on the screen.
///
/// Bedtime is `habitualMidsleepSec − typicalSleepHours / 2`, wake is `+ typicalSleepHours / 2`, exactly the
/// derivation `BatteryEstimator.bedtimeAlert` uses, so the two policies can never disagree about when the
/// user's night is; the band then opens `leadSeconds` before that bedtime (an early night must still stand
/// down) and closes `tailSeconds` after that wake (a lie-in must not re-arm the hold). Cold start — fewer
/// nights than the learner needs — yields nil, and the caller falls back to the screen rule: inventing a
/// 23:00 band would hold or release the ring at the wrong hour for exactly the shift/late sleepers the
/// learner exists for. A nap outside the band is not stood down for; that is a known limit of this first cut.
///
/// Pure and clock-free so it is `swift test`-able like `BatteryEstimator.bedtimeAlert`.
public enum NightStandDown {

/// A circular local-time band `[startSec, endSec)` in seconds-of-day; `endSec` may be numerically
/// smaller than `startSec` when the band crosses midnight, which the usual night does.
public struct Band: Equatable, Sendable {
public let startSec: Int
public let endSec: Int
public init(startSec: Int, endSec: Int) { self.startSec = startSec; self.endSec = endSec }
}

/// How long before the learned bedtime the stand-down opens. One hour covers an early night without
/// giving the evening away: the screen-off grace still applies inside the band, so an evening spent on
/// the phone keeps live HR until it is pocketed.
public static let leadSeconds = 3_600
/// How long after the learned wake the stand-down stays closed. One hour covers a lie-in; after it the
/// ring is held again even with the screen still dark, which is what puts the morning back on the chart.
public static let tailSeconds = 3_600

static let secondsPerDay = 86_400

/// The night band for a learned schedule, or nil at cold start (no learned midsleep / no typical night).
public static func band(habitualMidsleepSec: Int?, typicalSleepHours: Double?,
leadSeconds: Int = leadSeconds, tailSeconds: Int = tailSeconds) -> Band? {
guard let midsleep = habitualMidsleepSec, let hours = typicalSleepHours, hours > 0,
(0..<secondsPerDay).contains(midsleep) else { return nil }
let half = Int((hours * 1_800).rounded())
// A schedule whose padded night would cover the whole day has nothing left to call "day"; treat
// it as unlearned rather than hold the ring never.
guard 2 * half + leadSeconds + tailSeconds < secondsPerDay else { return nil }
return Band(startSec: floorMod(midsleep - half - leadSeconds, secondsPerDay),
endSec: floorMod(midsleep + half + tailSeconds, secondsPerDay))
}

/// Whether a local second-of-day falls inside the band, circularly.
public static func contains(_ band: Band, secOfDay: Int) -> Bool {
let s = floorMod(secOfDay, secondsPerDay)
if band.startSec <= band.endSec {
return s >= band.startSec && s < band.endSec
}
return s >= band.startSec || s < band.endSec
}

/// `HH:MM–HH:MM` for a log line.
public static func describe(_ band: Band) -> String {
"\(describeSecOfDay(band.startSec))–\(describeSecOfDay(band.endSec))"
}

/// `HH:MM` for a local second-of-day (wrapped into the day first), for a log line.
public static func describeSecOfDay(_ secOfDay: Int) -> String {
let s = floorMod(secOfDay, secondsPerDay)
return String(format: "%02d:%02d", s / 3_600, (s % 3_600) / 60)
}

static func floorMod(_ a: Int, _ n: Int) -> Int {
let r = a % n
return r < 0 ? r + n : r
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import XCTest
@testable import StrandAnalytics

/// `NightStandDown` — the learned night band an Oura ring's daytime-HR hold stands down for when the
/// all-day HR toggle is on. Pure; the live source's `shouldSuspendLiveHR` composes it with the screen rule.
final class NightStandDownTests: XCTestCase {

private func clock(_ h: Int, _ m: Int = 0) -> Int { h * 3_600 + m * 60 }

/// Midsleep 02:30 on an 8 h night: bedtime 22:30, wake 06:30 → band 21:30 → 07:30 across midnight.
func testBandIsBedtimeMinusLeadToWakePlusTail() {
let band = NightStandDown.band(habitualMidsleepSec: clock(2, 30), typicalSleepHours: 8)
XCTAssertEqual(band, NightStandDown.Band(startSec: clock(21, 30), endSec: clock(7, 30)))
XCTAssertEqual(NightStandDown.describe(band!), "21:30–07:30")
}

/// The RESUMED line stamps the local time it fired at, so a resume inside its own band is readable.
func testDescribeSecOfDayWrapsIntoTheDay() {
XCTAssertEqual(NightStandDown.describeSecOfDay(clock(8, 3)), "08:03")
XCTAssertEqual(NightStandDown.describeSecOfDay(0), "00:00")
XCTAssertEqual(NightStandDown.describeSecOfDay(-60), "23:59")
XCTAssertEqual(NightStandDown.describeSecOfDay(86_400 + clock(1)), "01:00")
}

func testContainsIsCircularAcrossMidnight() {
let band = NightStandDown.Band(startSec: clock(21, 30), endSec: clock(7, 30))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: clock(21, 30))) // start inclusive
XCTAssertTrue(NightStandDown.contains(band, secOfDay: clock(23, 59)))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: 0))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: clock(7, 29)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: clock(7, 30))) // end exclusive
XCTAssertFalse(NightStandDown.contains(band, secOfDay: clock(12)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: clock(21, 29)))
// A second-of-day past the day wraps instead of falling out.
XCTAssertTrue(NightStandDown.contains(band, secOfDay: 86_400 + clock(1)))
}

/// A shift worker sleeping 09:00 → 16:00 (midsleep 12:30, 7 h): band 08:00 → 17:00, no midnight crossing.
func testDaytimeSleeperBandDoesNotCrossMidnight() {
let band = NightStandDown.band(habitualMidsleepSec: clock(12, 30), typicalSleepHours: 7)!
XCTAssertEqual(band, NightStandDown.Band(startSec: clock(8), endSec: clock(17)))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: clock(12)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: clock(2)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: clock(22)))
}

/// Cold start (no learned schedule) is nil — the caller keeps the screen rule rather than a made-up clock.
func testColdStartIsNil() {
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: nil, typicalSleepHours: 8))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: clock(2), typicalSleepHours: nil))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: clock(2), typicalSleepHours: 0))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: -1, typicalSleepHours: 8))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: 86_400, typicalSleepHours: 8))
}

/// A padded night that would swallow the whole day is unlearned, not "never hold the ring".
func testWholeDayNightIsNil() {
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: clock(2), typicalSleepHours: 22.5))
XCTAssertNotNil(NightStandDown.band(habitualMidsleepSec: clock(2), typicalSleepHours: 21.9))
}

/// Same bedtime derivation as the battery night-guard: midsleep − half the night, circular.
func testBedtimeMatchesTheBatteryNightGuardDerivation() {
// 00:30 midsleep, 7.5 h → bedtime 20:45 → band opens 19:45; wake 04:15 → band closes 05:15.
let band = NightStandDown.band(habitualMidsleepSec: clock(0, 30), typicalSleepHours: 7.5)!
XCTAssertEqual(band.startSec, clock(19, 45))
XCTAssertEqual(band.endSec, clock(5, 15))
}
}
25 changes: 24 additions & 1 deletion Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,8 @@ final class AppModel: ObservableObject {
// path. Timestamp matches BLEManager.log()'s "HH:mm:ss" so the lines read consistently.
straplog: { [weak self] line in
self?.live.append(log: "[\(AppModel.logTimeFormatter.string(from: Date()))] \(line)")
})
},
ouraNightBand: { [weak self] in self?.ouraNightBand() }) // item 27
coordinator.start()
self.deviceRegistry = registry
// #1303: adoption re-points the strap onto its stable `whoop-<serial>` id inside BLEManager (which
Expand Down Expand Up @@ -2124,6 +2125,28 @@ final class AppModel: ObservableObject {
set { UserDefaults.standard.set(newValue, forKey: Self.ouraNotifyMaskFullKey) }
}

/// Item 27 (EXPERIMENTAL, default OFF): keep the Oura ring in its daytime-HR mode while the phone's screen
/// is off during the DAY, standing it down only for the learned night band (`NightStandDown`), instead
/// of on every screen-off. The ring emits daytime heart rate — and the beats behind windowed rMSSD —
/// only while a client holds that mode, so with the screen-keyed suspend a pocketed phone empties the
/// day. ON costs ring battery (its own daytime PPG); OFF is today's behaviour, and the night is
/// unchanged either way. No effect without an Oura ring; cold start (no learned schedule) keeps OFF's rule.
static let ouraAllDayLiveHRKey = "noopOuraAllDayLiveHR"
var ouraAllDayLiveHR: Bool {
get { UserDefaults.standard.bool(forKey: Self.ouraAllDayLiveHRKey) }
set { UserDefaults.standard.set(newValue, forKey: Self.ouraAllDayLiveHRKey) }
}

/// Item 27: the learned night band for the all-day HR stand-down — the SAME midsleep + typical-night
/// inputs the battery night-guard reads (`refreshHabitualMidsleep`, hourly), so the two policies share
/// one notion of the user's night. nil at cold start.
func ouraNightBand() -> NightStandDown.Band? {
NightStandDown.band(
habitualMidsleepSec: habitualMidsleepCache,
typicalSleepHours: BatteryEstimator.typicalSleepHours(
nightlyHours: repo.days.compactMap { $0.totalSleepMin.map { $0 / 60.0 } }))
}

/// Recompute the v5 skin-temp suite snapshots (cycle phase + body clock) from the current history.
/// Called from the analytics pass and when the cycle opt-in flips. Honest-nil throughout: cycle is
/// nil unless opted in; circadian is nil unless a usable activity profile exists.
Expand Down
Loading
Loading