diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift new file mode 100644 index 0000000000..a0ea9aa93e --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift @@ -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.. 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 + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/NightStandDownTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/NightStandDownTests.swift new file mode 100644 index 0000000000..384a821e05 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/NightStandDownTests.swift @@ -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)) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index e4107eea73..139f3f49e2 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -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-` id inside BLEManager (which @@ -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. diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index c9a5db7808..b2948c5c86 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -5,6 +5,7 @@ import Security import WhoopProtocol import WhoopStore import OuraProtocol +import StrandAnalytics // item 27: NightStandDown, the learned night band the all-day HR hold stands down for // The live-HR suspend listens for the screen going dark, which is a UIKit notification on iOS and an // NSWorkspace one on macOS (see installScreenStateObservers). #if os(iOS) @@ -261,6 +262,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// SetNotification is the official app's `ff` instead of `3f` (OURA_PROTOCOL.md s2.3). The next /// connect re-reads it, so switching the toggle off restores the default with nothing left on the ring. private let notifyMaskFull: () -> Bool + /// Item 27: the Experimental "All-day heart rate & HRV" toggle, read at every decision so a flip takes + /// effect within one re-engage tick / one history-fetch tick, never at the next launch. + private let allDayLiveHR: () -> Bool + /// Item 27: the learned night band the daytime-HR hold stands down for while the toggle is on; nil at + /// cold start (the screen rule then applies as before). Supplied by the app layer from the same sleep + /// learner the battery night-guard reads. + private let nightBand: () -> NightStandDown.Band? private let log: (String) -> Void private let onBattery: (Int) -> Void /// Fired with the ring's TRUE model label ("Oura Ring 3/4/5") once the GetProductInfo hardware id resolves @@ -728,6 +736,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// overlaps a fetch already in flight - the driver's own phase is the guard, so this is safe to call /// both right after reaching `.streaming` and from the periodic timer). private func fetchHistoryIfIdle() { + resumeAfterStandDownIfReleased() // item 27: the one tick that still runs while suspended guard let driver, driver.phase == .streaming else { return } // Arm the per-drain state: where we sought from (reboot detection), the stored-sample high-water // mark the cursor will commit from, and the stall/deadline guards. @@ -1236,10 +1245,43 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// exceeds a genuine glance-and-pocket. private let liveHRSuspendDelay: TimeInterval = 300 - /// True once the screen has been off long enough that the ring should be left alone. Everything else - /// keys off this one predicate, so the suspend and the resume can never disagree about the rule. + /// True once the ring should be left alone. Everything else keys off this one predicate — the + /// suspend, the resume, and `OuraDriver.liveHRWanted` at auth — so no two gates can disagree. private var liveHRSuspended: Bool { - Self.shouldSuspendLiveHR(screenOffAt: screenOffAt, now: Date(), delay: liveHRSuspendDelay) + Self.shouldSuspendLiveHR(screenOffAt: screenOffAt, now: Date(), delay: liveHRSuspendDelay, + allDay: allDayPolicyNow()) + } + + /// Item 27: what the all-day toggle contributes to the stand-down decision, sampled now. + private func allDayPolicyNow(_ now: Date = Date()) -> AllDayLiveHR { + guard allDayLiveHR() else { return .off } + return .on(band: nightBand(), nowSecOfDay: Self.localSecOfDay(now)) + } + + /// Local time-of-day in seconds [0, 86400), in the CURRENT zone so a traveller's night follows them. + nonisolated static func localSecOfDay(_ now: Date) -> Int { + let c = Calendar.current.dateComponents([.hour, .minute, .second], from: now) + return (c.hour ?? 0) * 3_600 + (c.minute ?? 0) * 60 + (c.second ?? 0) + } + + /// Item 27 — the Experimental "All-day heart rate & HRV" toggle as the suspend policy sees it. + /// + /// WHY. The ring produces daytime HR ONLY while a client holds it in daytime-HR mode; there is no + /// banked daytime family it emits on its own. The screen-off suspend was built for the night (holding + /// the ring overnight killed its sleep suite, r = −0.93 over 11 nights) but its gate — the screen — + /// is also dark for most of a working day, so from the night that build shipped the daytime 5-min HR + /// bins fell from 123–144/144 to a median of ~16, and windowed rMSSD by day emptied with them. With + /// the toggle ON the stand-down keys on the learned NIGHT band instead: outside it a dark screen no + /// longer suspends (the 15 s re-engage keeps the ring in daytime mode and the ring banks `0x80` for + /// the 300 s drain, exactly the pre-#1526 daytime behaviour); inside it the screen-off grace applies + /// unchanged, so the merged night fix is untouched. The trade — the ring's own daytime PPG costs + /// charge — is the user's, which is why this is a default-OFF toggle and not a new default. + enum AllDayLiveHR: Equatable { + /// Toggle off: the screen rule alone, byte-identical to before this policy existed. + case off + /// Toggle on. `band` nil = no learned sleep schedule yet (cold start): the screen rule applies + /// rather than a made-up clock, and the suspend line says so. + case on(band: NightStandDown.Band?, nowSecOfDay: Int) } /// Pure policy so it is testable without a `CBCentralManager` (this class owns one and cannot be built @@ -1249,9 +1291,16 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// - screenOffAt: when the screen went dark, nil while the user is present. /// - now: the clock, injected so a test need not sleep for the grace window. /// - delay: the grace window. - nonisolated static func shouldSuspendLiveHR(screenOffAt: Date?, now: Date, delay: TimeInterval) -> Bool { - guard let off = screenOffAt else { return false } - return now.timeIntervalSince(off) >= delay + /// - allDay: the all-day toggle's contribution; `.off` is the pre-item-27 rule exactly. + nonisolated static func shouldSuspendLiveHR(screenOffAt: Date?, now: Date, delay: TimeInterval, + allDay: AllDayLiveHR = .off) -> Bool { + guard let off = screenOffAt, now.timeIntervalSince(off) >= delay else { return false } + switch allDay { + case .off: return true + case .on(let band, let secOfDay): + guard let band else { return true } // cold start: no learned night, keep the screen rule + return NightStandDown.contains(band, secOfDay: secOfDay) + } } /// What `screenOffAt` must be at construction time, given whether the screen is ALREADY dark. @@ -1318,6 +1367,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { authKey: @escaping () -> Data?, persist: @escaping (Streams) -> Void = { _ in }, persistSleepSession: @escaping (CachedSleepSession) -> Void = { _ in }, + allDayLiveHR: @escaping () -> Bool = { false }, + nightBand: @escaping () -> NightStandDown.Band? = { nil }, log: @escaping (String) -> Void = { _ in }, onBattery: @escaping (Int) -> Void = { _ in }, onModel: @escaping (String) -> Void = { _ in }, @@ -1332,6 +1383,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { self.authKey = authKey self.persist = persist self.persistSleepSession = persistSleepSession + self.allDayLiveHR = allDayLiveHR + self.nightBand = nightBand self.log = log self.onBattery = onBattery self.onModel = onModel @@ -2546,10 +2599,52 @@ public final class OuraLiveSource: NSObject, ObservableObject { private func logLiveHRSuspendOnce() { guard !loggedLiveHRSuspend else { return } loggedLiveHRSuspend = true - log("Oura: live-HR re-engage SUSPENDED - screen off \(Int(liveHRSuspendDelay / 60)) min, leaving the " + let why: String + switch allDayPolicyNow() { + case .off: why = "" + case .on(let band, _): + why = band.map { " inside the night stand-down \(NightStandDown.describe($0)) (all-day HR on)" } + ?? " (all-day HR on, but no learned sleep schedule yet - screen rule applies)" + } + log("Oura: live-HR re-engage SUSPENDED - screen off \(Int(liveHRSuspendDelay / 60)) min\(why), leaving the " + "ring free to run its own night suite (history fetch continues every \(Int(historyFetchInterval))s)") } + /// Item 27: the stand-down ended while the screen stayed dark — the learned night band closed (or the + /// toggle was flipped on during the day). The screen-on path re-arms via `handleScreenCameBack`; with + /// nothing touching the phone, the ONLY tick still running while suspended is the 300 s history fetch, + /// so that is where this is checked (the re-engage timer was stopped by the suspend and cannot notice + /// its own release). Mirrors the screen-on resume minus clearing `screenOffAt`: the screen IS still + /// off, and the next band entry must find the clock already past the grace. + /// + /// The line is always-on: this is the one path where a wrong band would hold the ring all night, so + /// the log names the band and the local time it fired at (a resume stamped INSIDE its own band is the + /// defect, readable without Test Centre), and says whether it re-armed the hold now or left it to the + /// next `.streaming` — it does not claim a re-arm it did not make. + private func resumeAfterStandDownIfReleased() { + guard loggedLiveHRSuspend, reengageTimer == nil, !liveHRSuspended else { return } + loggedLiveHRSuspend = false + loggedUnexpectedLiveHRWhileSuspended = false + let now = Date() + let at = NightStandDown.describeSecOfDay(Self.localSecOfDay(now)) + let why: String + switch allDayPolicyNow(now) { + case .off: why = "all-day HR turned off" + case .on(let band, _): + why = band.map { "night stand-down \(NightStandDown.describe($0)) ended at \(at) (all-day HR on)" } + ?? "no learned sleep schedule at \(at) (all-day HR on)" + } + guard reachedStreaming, driver != nil else { + log("Oura: live-HR re-engage RESUMED - \(why), screen still off; no live link, the next " + + "connect arms it") + return + } + log("Oura: live-HR re-engage RESUMED - \(why), screen still off; re-arming the hold now") + lastLivePulseAt = now // same watchdog re-stamp as the screen-on resume + startReengageTimer() + reengageLiveHR() + } + /// Actively turn daytime-HR mode off rather than merely declining to re-arm it. `reengageLiveHR`'s own /// doc cites a ~20 s auto-revert (OURA_PROTOCOL.md s5.7) as the reason "just stop poking it" should be /// enough - 08-17/18 falsified that for THIS build: green 0x80 never collapsed, any hour, including a @@ -3216,8 +3311,26 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { // driver goes straight to `.streaming` with no daytime-HR write; the log says which. let wanted = !liveHRSuspended driver?.liveHRWanted = wanted - log(wanted ? "Oura: auth OK - enabling live HR" - : "Oura: auth OK - live HR suspended (screen off), daytime HR left untouched") + // Item 27: say which policy decided, on BOTH outcomes. With the toggle off the line is the + // pre-item-27 text byte for byte; with it on, an enabling connect names the band and which + // side of it the clock is on, so a day of "enabling live HR" with no SUSPENDED line reads as + // the band excluding the day rather than as a screen that never went dark (the 09-17 16:11 + // read-out had to infer that from the 5 min grace — the toggle's state was in no line). + var inBand = "" + var policy = "" + switch allDayPolicyNow() { + case .off: + break + case .on(let band?, let sec): + let span = NightStandDown.describe(band) + inBand = ", night stand-down \(span)" + let side = NightStandDown.contains(band, secOfDay: sec) ? "inside" : "outside" + policy = " (all-day HR on, \(side) night stand-down \(span))" + case .on(nil, _): + policy = " (all-day HR on, no learned sleep schedule yet - screen rule applies)" + } + log(wanted ? "Oura: auth OK - enabling live HR\(policy)" + : "Oura: auth OK - live HR suspended (screen off\(inBand)), daytime HR left untouched") } else { log("Oura: WARNING auth status \(status.rawValue)") } diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index 568cf5f10d..fd296556bf 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -1,6 +1,7 @@ import Foundation import Combine import WhoopStore +import StrandAnalytics import OuraProtocol /// Runs exactly ONE device's live BLE at a time, driven by `DeviceRegistry.activeDeviceId`. @@ -56,6 +57,9 @@ final class SourceCoordinator: ObservableObject { /// previously invisible). Passed straight into `StandardHRSource`. Defaults to a no-op so existing /// call sites (and tests) compile unchanged. private let straplog: (String) -> Void + /// Item 27: the learned night band an Oura ring's all-day HR hold stands down for (nil = cold start). + /// Read at each decision by `OuraLiveSource`; the app layer derives it from the sleep learner. + private let ouraNightBand: () -> NightStandDown.Band? // MARK: - State @@ -115,7 +119,8 @@ final class SourceCoordinator: ObservableObject { setWhoopPreferredPeripheral: @escaping (String?) -> Void, setWhoopActiveDeviceId: @escaping (String) -> Void, connectedPeripheralUUID: AnyPublisher, - straplog: @escaping (String) -> Void = { _ in }) { + straplog: @escaping (String) -> Void = { _ in }, + ouraNightBand: @escaping () -> NightStandDown.Band? = { nil }) { self.registry = registry self.live = live self.storeHandle = storeHandle @@ -125,6 +130,7 @@ final class SourceCoordinator: ObservableObject { self.setWhoopActiveDeviceId = setWhoopActiveDeviceId self.connectedPeripheralUUID = connectedPeripheralUUID self.straplog = straplog + self.ouraNightBand = ouraNightBand } // MARK: - Wiring @@ -434,6 +440,8 @@ final class SourceCoordinator: ObservableObject { } } }, + allDayLiveHR: { UserDefaults.standard.bool(forKey: AppModel.ouraAllDayLiveHRKey) }, // item 27 + nightBand: ouraNightBand, // item 27 log: straplog, onBattery: { [live] pct in live.setBattery(Double(pct)) }, onModel: { [registry] model in registry.setModel(id, model: model) }, // #772: correct a name-guessed gen diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f48591a1fd..b57b21f23f 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -216017,7 +216017,51 @@ } }, "no time of its own": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "keine eigene Zeit"}}, "es": {"stringUnit": {"state": "translated", "value": "sin hora propia"}}, "fr": {"stringUnit": {"state": "translated", "value": "pas d'heure propre"}}, "it": {"stringUnit": {"state": "translated", "value": "nessun orario proprio"}}, "pl": {"stringUnit": {"state": "translated", "value": "brak własnej godziny"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "sem hora própria"}}, "ru": {"stringUnit": {"state": "translated", "value": "без своего времени"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有单独时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有單獨時間"}} - } } + } }, + "Experimental · All-day heart rate": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Experimentell · Herzfrequenz den ganzen Tag"}}, + "es": {"stringUnit": {"state": "translated", "value": "Experimental · Frecuencia cardiaca todo el día"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Expérimental · Fréquence cardiaque toute la journée"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Experimental · Frequência cardíaca todo o dia"}}, + "it": {"stringUnit": {"state": "translated", "value": "Sperimentale · Frequenza cardiaca tutto il giorno"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Eksperymentalne · Tętno przez cały dzień"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Экспериментально · Пульс весь день"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "实验性 · 全天心率"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "實驗性 · 全天心率"}} + } }, + "Keeps the ring measuring heart rate through the day, standing it down only for your night.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Lässt den Ring tagsüber weiter die Herzfrequenz messen und pausiert nur für deine Nacht."}}, + "es": {"stringUnit": {"state": "translated", "value": "Mantiene el anillo midiendo la frecuencia cardiaca durante el día y solo lo deja en reposo para tu noche."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Garde la bague en mesure de fréquence cardiaque toute la journée et ne la met en pause que pour ta nuit."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Mantém o anel a medir a frequência cardíaca durante o dia, parando apenas para a tua noite."}}, + "it": {"stringUnit": {"state": "translated", "value": "Mantiene l'anello a misurare la frequenza cardiaca durante il giorno, fermandolo solo per la tua notte."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Utrzymuje pomiar tętna przez pierścień w ciągu dnia, wstrzymując go tylko na twoją noc."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Кольцо продолжает измерять пульс в течение дня и делает паузу только на вашу ночь."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "让戒指全天持续测量心率,仅在你的夜间停止。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "讓戒指全天持續測量心率,僅在你的夜間停止。"}} + } }, + "All-day heart rate & HRV": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz & HRV den ganzen Tag"}}, + "es": {"stringUnit": {"state": "translated", "value": "Frecuencia cardiaca y VFC todo el día"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Fréquence cardiaque et VFC toute la journée"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Frequência cardíaca e VFC todo o dia"}}, + "it": {"stringUnit": {"state": "translated", "value": "Frequenza cardiaca e HRV tutto il giorno"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Tętno i HRV przez cały dzień"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Пульс и ВСР весь день"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "全天心率与 HRV"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "全天心率與 HRV"}} + } }, + "The ring only measures daytime heart rate while NOOP keeps it in that mode, and NOOP stops asking whenever the screen has been off for five minutes — which protects the ring's own sleep tracking at night, but also leaves a pocketed phone's day blank on the Heart Rate and HRV charts. On, NOOP keeps asking through the day and stops only for your usual night, learned from your sleep history (an hour before your typical bedtime to an hour after your usual wake), so the night is unchanged. Costs ring battery: the ring runs its own optical sensor all day. Until enough nights are learned it behaves as if off. Off by default.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Ring misst die Herzfrequenz tagsüber nur, solange NOOP ihn in diesem Modus hält, und NOOP hört damit auf, sobald der Bildschirm fünf Minuten aus war – das schützt nachts das eigene Schlaf-Tracking des Rings, lässt aber den Tag eines Telefons in der Tasche in den Diagrammen für Herzfrequenz und HRV leer. Eingeschaltet fragt NOOP den ganzen Tag weiter und pausiert nur für deine übliche Nacht, gelernt aus deinem Schlafverlauf (eine Stunde vor deiner typischen Schlafenszeit bis eine Stunde nach deinem üblichen Aufwachen), die Nacht bleibt also unverändert. Kostet Ringakku: der Ring betreibt seinen optischen Sensor den ganzen Tag. Bis genug Nächte gelernt sind, verhält es sich wie ausgeschaltet. Standardmäßig aus."}}, + "es": {"stringUnit": {"state": "translated", "value": "El anillo solo mide la frecuencia cardiaca diurna mientras NOOP lo mantiene en ese modo, y NOOP deja de pedirlo cuando la pantalla lleva cinco minutos apagada, lo que protege el propio seguimiento del sueño del anillo por la noche, pero también deja en blanco el día de un teléfono en el bolsillo en las gráficas de frecuencia cardiaca y VFC. Activado, NOOP sigue pidiéndolo durante el día y solo se detiene para tu noche habitual, aprendida de tu historial de sueño (desde una hora antes de tu hora típica de acostarte hasta una hora después de tu despertar habitual), así que la noche no cambia. Consume batería del anillo: su sensor óptico funciona todo el día. Hasta que se aprendan suficientes noches se comporta como si estuviera desactivado. Desactivado por defecto."}}, + "fr": {"stringUnit": {"state": "translated", "value": "La bague ne mesure la fréquence cardiaque de jour que tant que NOOP la maintient dans ce mode, et NOOP cesse de le demander dès que l'écran est éteint depuis cinq minutes – ce qui protège le suivi du sommeil propre à la bague la nuit, mais laisse aussi la journée d'un téléphone resté en poche vide sur les graphiques Fréquence cardiaque et VFC. Activé, NOOP continue de le demander toute la journée et ne s'arrête que pour ta nuit habituelle, apprise de ton historique de sommeil (d'une heure avant ton heure de coucher typique à une heure après ton réveil habituel), la nuit reste donc inchangée. Consomme la batterie de la bague : son capteur optique fonctionne toute la journée. Tant qu'assez de nuits ne sont pas apprises, se comporte comme désactivé. Désactivé par défaut."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "O anel só mede a frequência cardíaca diurna enquanto o NOOP o mantém nesse modo, e o NOOP deixa de pedir sempre que o ecrã está apagado há cinco minutos — o que protege o próprio registo de sono do anel à noite, mas também deixa em branco o dia de um telemóvel no bolso nos gráficos de Frequência cardíaca e VFC. Ligado, o NOOP continua a pedir durante o dia e só pára para a tua noite habitual, aprendida do teu histórico de sono (de uma hora antes da tua hora típica de deitar até uma hora depois do teu acordar habitual), pelo que a noite não muda. Gasta bateria do anel: o seu sensor ótico funciona todo o dia. Até serem aprendidas noites suficientes comporta-se como desligado. Desligado por predefinição."}}, + "it": {"stringUnit": {"state": "translated", "value": "L'anello misura la frequenza cardiaca diurna solo finché NOOP lo mantiene in quella modalità, e NOOP smette di chiederla quando lo schermo è spento da cinque minuti: questo protegge il tracciamento del sonno dell'anello di notte, ma lascia anche vuota la giornata di un telefono in tasca nei grafici Frequenza cardiaca e HRV. Attivo, NOOP continua a chiederla per tutto il giorno e si ferma solo per la tua notte abituale, appresa dalla cronologia del sonno (da un'ora prima dell'ora in cui vai a letto di solito a un'ora dopo il tuo risveglio abituale), quindi la notte non cambia. Consuma batteria dell'anello: il suo sensore ottico lavora tutto il giorno. Finché non sono apprese abbastanza notti si comporta come se fosse disattivato. Disattivato per impostazione predefinita."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Pierścień mierzy tętno w ciągu dnia tylko wtedy, gdy NOOP utrzymuje go w tym trybie, a NOOP przestaje o to prosić, gdy ekran jest wygaszony od pięciu minut – to chroni własne śledzenie snu pierścienia w nocy, ale też zostawia dzień telefonu w kieszeni pusty na wykresach tętna i HRV. Po włączeniu NOOP prosi o to przez cały dzień i zatrzymuje się tylko na twoją zwykłą noc, wyuczoną z historii snu (od godziny przed typową porą zasypiania do godziny po zwykłej pobudce), więc noc pozostaje bez zmian. Zużywa baterię pierścienia: jego czujnik optyczny działa cały dzień. Dopóki nie zostanie wyuczonych dość nocy, zachowuje się jak wyłączone. Domyślnie wyłączone."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Кольцо измеряет дневной пульс только пока NOOP держит его в этом режиме, а NOOP перестаёт запрашивать его, когда экран выключен пять минут — это защищает собственное отслеживание сна кольца ночью, но оставляет день телефона в кармане пустым на графиках пульса и ВСР. Включено: NOOP запрашивает его весь день и останавливается только на вашу обычную ночь, выученную по истории сна (за час до обычного отхода ко сну и до часа после обычного пробуждения), так что ночь не меняется. Расходует батарею кольца: его оптический датчик работает весь день. Пока не выучено достаточно ночей, ведёт себя как выключенное. По умолчанию выключено."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "戒指只有在 NOOP 让它保持该模式时才会测量白天心率,而屏幕关闭五分钟后 NOOP 就会停止请求——这在夜间保护了戒指自身的睡眠追踪,但也会让手机放在口袋里的白天在心率和 HRV 图表上一片空白。开启后,NOOP 全天持续请求,仅在你从睡眠记录中学到的惯常夜间停止(从通常就寝前一小时到通常醒来后一小时),因此夜间不受影响。会消耗戒指电量:戒指的光学传感器全天运行。在学到足够的夜晚之前,其行为与关闭时相同。默认关闭。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "戒指只有在 NOOP 讓它保持該模式時才會測量白天心率,而螢幕關閉五分鐘後 NOOP 就會停止請求——這在夜間保護了戒指自身的睡眠追蹤,但也會讓手機放在口袋裡的白天在心率和 HRV 圖表上一片空白。開啟後,NOOP 全天持續請求,僅在你從睡眠紀錄中學到的慣常夜間停止(從通常就寢前一小時到通常醒來後一小時),因此夜間不受影響。會消耗戒指電量:戒指的光學感測器全天運作。在學到足夠的夜晚之前,其行為與關閉時相同。預設關閉。"}} + } } }, "version": "1.0" } diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index e0f0cdf31f..01085802c8 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { /// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only — /// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey]. @AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false + @AppStorage(AppModel.ouraAllDayLiveHRKey) private var ouraAllDayLiveHREnabled = false // item 27 /// #1545 opt-in: score Effort with Banister's exponential TRIMP instead of Edwards' heart-rate zones. /// Default OFF — it re-scores the whole window against a different recipe. See @@ -1837,6 +1838,7 @@ struct SettingsView: View { // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries // a second copy; the persisted keys and reversible disable actions remain unchanged there. if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard } + if model.repo.activeDeviceIsOura { ouraAllDayLiveHRCard } // item 27 sleepStagingCard rawSensorDiagnosticsCard } @@ -1971,6 +1973,32 @@ struct SettingsView: View { } } + /// Item 27: keep the Oura ring in daytime-HR mode while the screen is off during the DAY. The ring + /// produces daytime heart rate (and the beats behind windowed rMSSD) only while a client holds that + /// mode, so the screen-keyed suspend that protects the night suite also empties a pocketed-phone day. + /// ON stands the hold down only for the learned night band; OFF is today's behaviour. Oura-only. + private var ouraAllDayLiveHRCard: some View { + SettingsSection( + icon: "waveform.path.ecg", + title: "Experimental · All-day heart rate", + blurb: "Keeps the ring measuring heart rate through the day, standing it down only for your night." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Toggle(isOn: $ouraAllDayLiveHREnabled) { + Text("All-day heart rate & HRV") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("The ring only measures daytime heart rate while NOOP keeps it in that mode, and NOOP stops asking whenever the screen has been off for five minutes — which protects the ring's own sleep tracking at night, but also leaves a pocketed phone's day blank on the Heart Rate and HRV charts. On, NOOP keeps asking through the day and stops only for your usual night, learned from your sleep history (an hour before your typical bedtime to an hour after your usual wake), so the night is unchanged. Costs ring battery: the ring runs its own optical sensor all day. Until enough nights are learned it behaves as if off. Off by default.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + // MARK: - Diagnostics (every model) /// Raw-sensor CSV export — a read-only diagnostic over the decoded streams NOOP already stores diff --git a/StrandTests/OuraLiveHRSuspendPolicyTests.swift b/StrandTests/OuraLiveHRSuspendPolicyTests.swift index 1036e2dd93..6c0d774c4c 100644 --- a/StrandTests/OuraLiveHRSuspendPolicyTests.swift +++ b/StrandTests/OuraLiveHRSuspendPolicyTests.swift @@ -1,4 +1,5 @@ import XCTest +import StrandAnalytics @testable import Strand /// `OuraLiveSource.shouldSuspendLiveHR` — when the live-HR re-engage stands down because nobody is looking. @@ -103,3 +104,58 @@ final class OuraLiveHRSuspendPolicyTests: XCTestCase { screenOffAt: nil, now: t0.addingTimeInterval(8 * 3600), delay: delay)) } } + +// MARK: - Item 27: the all-day HR toggle keys the stand-down on the learned night, not the screen + +/// Why: the ring emits daytime HR only while a client holds daytime-HR mode, so the screen-keyed suspend +/// emptied every working day from the night it shipped (daytime 5-min bins 123–144/144 → median ~16). +/// With the toggle ON the same predicate stands down only inside `NightStandDown`'s learned band; OFF is +/// byte-identical to the rule above. Pinned here; that the ring then banks a full day and still runs its +/// night suite is a strap claim, owed on hardware. +extension OuraLiveHRSuspendPolicyTests { + + private var night: NightStandDown.Band { NightStandDown.Band(startSec: 21 * 3_600 + 1_800, endSec: 7 * 3_600 + 1_800) } + private var pastGrace: Date { Date(timeIntervalSince1970: 1_760_000_000).addingTimeInterval(3_600) } + private var screenOff: Date { Date(timeIntervalSince1970: 1_760_000_000) } + + func testToggleOffIsTheScreenRuleExactly() { + XCTAssertTrue(OuraLiveSource.shouldSuspendLiveHR(screenOffAt: screenOff, now: pastGrace, delay: 300, allDay: .off)) + XCTAssertFalse(OuraLiveSource.shouldSuspendLiveHR(screenOffAt: nil, now: pastGrace, delay: 300, allDay: .off)) + } + + func testToggleOnDoesNotSuspendOutsideTheNightBand() { + // Midday, phone in a pocket for an hour: keep holding the ring. + for sec in [7 * 3_600 + 1_800, 9 * 3_600, 12 * 3_600, 18 * 3_600, 21 * 3_600 + 1_799] { + XCTAssertFalse(OuraLiveSource.shouldSuspendLiveHR( + screenOffAt: screenOff, now: pastGrace, delay: 300, allDay: .on(band: night, nowSecOfDay: sec)), + "\(sec / 3_600)h is daytime") + } + } + + func testToggleOnSuspendsInsideTheNightBandAfterTheGrace() { + for sec in [21 * 3_600 + 1_800, 23 * 3_600, 0, 3 * 3_600, 7 * 3_600 + 1_799] { + XCTAssertTrue(OuraLiveSource.shouldSuspendLiveHR( + screenOffAt: screenOff, now: pastGrace, delay: 300, allDay: .on(band: night, nowSecOfDay: sec)), + "\(sec / 3_600)h is inside the night") + } + // The screen-off grace still applies inside the band: an evening on the phone keeps live HR. + XCTAssertFalse(OuraLiveSource.shouldSuspendLiveHR( + screenOffAt: screenOff, now: screenOff.addingTimeInterval(60), delay: 300, + allDay: .on(band: night, nowSecOfDay: 23 * 3_600))) + // And presence always wins. + XCTAssertFalse(OuraLiveSource.shouldSuspendLiveHR( + screenOffAt: nil, now: pastGrace, delay: 300, allDay: .on(band: night, nowSecOfDay: 23 * 3_600))) + } + + func testToggleOnWithoutALearnedNightKeepsTheScreenRule() { + // Cold start: no band ⇒ do not invent a clock; behave as if the toggle were off. + XCTAssertTrue(OuraLiveSource.shouldSuspendLiveHR( + screenOffAt: screenOff, now: pastGrace, delay: 300, allDay: .on(band: nil, nowSecOfDay: 12 * 3_600))) + } + + /// The band the app layer hands the policy is the learner's own (midsleep ± half the typical night, ±1 h). + func testBandComesFromTheLearnedSchedule() { + let band = NightStandDown.band(habitualMidsleepSec: 2 * 3_600 + 1_800, typicalSleepHours: 8) + XCTAssertEqual(band, night) + } +} diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3dcf98fd10..c1db03687e 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,70 @@ "identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb", "platform": "swift", "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::band/4#1", + "identity_sha256": "279cb2405461c4c49d765ff8d0bc275876d35952878bbbff5b71d40f1fc602b7", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::contains/2#1", + "identity_sha256": "b547fe41ab8a4ae2ee20c442b15483dd1b57a3e281b7f464b3c0be65565a13d9", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::describe/1#1", + "identity_sha256": "3cb014f4bc535d81415fa967a7cd27f25d6e14360ebe0529513231e384c6d38c", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::describeSecOfDay/1#1", + "identity_sha256": "3e064b9ae741e558ad649c0e57a597cea6a81319faeb00591be05d06a5f5d773", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::floorMod/2#1", + "identity_sha256": "d984745dfee3f61d2c32f9bb8bcebd3303ae5c8b358a1efa4dac46273ddff4ab", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::leadSeconds", + "identity_sha256": "cd0107095c358d0068b84578c039015ac9814de60b60bf09adae07ac101c3882", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::tailSeconds", + "identity_sha256": "c256516b6fd590fb27b3d954aad36a6ae72f1c47681e40d06d0cbf08e3815506", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift", + "identity_sha256": "c81cc7100101d3ac1d81e244330c66815450899d6194627ad8f0cbfeb48dee5f", + "platform": "swift", + "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 25481c080f..a3e51e85c7 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,17 +18,17 @@ ] }, "authority": { - "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4467, "sha256": "89193c662ac8a26e009ea954a06b1017b8dc0163cc33695a43c0bd7610bbc918"}, + "files": {"count": 503, "sha256": "802a14fbc466570cc1218d94b896153890834aa1145f0586edacd80e39985083"}, + "functions": {"count": 4472, "sha256": "8fc22be026247ff599deb59da98caa196ebcf476398570b261775e05120b39ff"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, - "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, + "constants": {"count": 1954, "sha256": "bb8085fe11260646503e93559cbf98e8254cc10112a499907bf9f0a630676da8"}, "file_pairs": {"count": 72, "sha256": "d2e92c41a46e76927016cd9254cdc7ac9a2222142f002b063715b6650c402421"}, "function_pairs": {"count": 184, "sha256": "849418e724ed78c7d28d52c5e37be548c05ce02253f661ca4c2470f24ce21bc9"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, - "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, - "unpaired_files": {"count": 379, "sha256": "c73d98b5389e33803b26a7e4df4e5b2a7e6590e5f5d03b84f225910cfcd4d123"}, - "unpaired_functions": {"count": 4110, "sha256": "59be0bc6c3a8228656c330869bdc5641f3b667922b23db6a0a1d334246c0d762"}, + "constant_pairs": {"count": 679, "sha256": "9b2547cb6d4e1e37e9c8636389c81da24880a26c4d37b10e1586eec4df3a9cdd"}, + "unpaired_files": {"count": 380, "sha256": "3df79a5e98a505efef44b2f57808efb3ca838f7d31d290b15ad8afdb401c440a"}, + "unpaired_functions": {"count": 4115, "sha256": "249916a8c3fdf0a72985a07badf273a880c21efa75b5c22a18f7d64cf37a5807"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, - "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} + "unpaired_constants": {"count": 596, "sha256": "69b8c07c329f5e11def964704571261a785c14f45458ad5e4cb64c816c6974c8"} } }