Skip to content
Draft
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
12 changes: 12 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,18 @@ public enum HRVAnalyzer {
!(beatAccurateFraction < beatAccuracyMinFraction)
}

/// Whether a night's intervals may be handed to another app as beat-to-beat data (Apple Health's
/// heartbeat series). A reader computes its own successive-difference HRV from them, so they must clear
/// both gates NOOP applies before trusting the same statistic itself: no over-counted beats
/// (`successiveDiffIsTrustworthy`) and individually accurate values rather than a record period
/// decomposed across one timestamp (`beatValuesAreTrustworthy`). Pure; `tsSec` and `rrMs` are parallel.
public static func beatSeriesIsExportable(tsSec: [Int], rrMs: [Double]) -> Bool {
let verdict = classifyCoverage(coverage: rrCoverage(tsSec: tsSec, rrMs: rrMs),
collapsed: collapsedCoverage(tsSec: tsSec, rrMs: rrMs))
return successiveDiffIsTrustworthy(verdict)
&& beatValuesAreTrustworthy(beatAccurateFraction: beatAccurateFraction(tsSec: tsSec, rrMs: rrMs))
}

/// Tolerance BELOW 1.0 treated as "fits", the mirror of `coveragePlausibleCeiling`. Same allowance,
/// same caveat: a ROUNDING allowance rather than a tuned threshold, because whole-second timestamps
/// under-report as easily as they over-report. Where the real boundary sits still needs coverage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import XCTest
@testable import StrandAnalytics

/// Beat-to-beat export: another app computes its own successive-difference HRV from these intervals, so a
/// night is exported only when NOOP would trust the same statistic from the same beats.
final class HRVBeatSeriesExportTests: XCTestCase {

/// One beat per second, each stamped where the previous interval says it lands.
func testACleanBeatTrainIsExportable() {
let ts = Array(1_000..<1_600)
let rr = ts.map { _ in 1_000.0 }
XCTAssertTrue(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr))
}

/// Two beats a second for a heart beating once a second: the stream banks more beat-time than the
/// clock spans, and its successive differences are not the heart's.
func testAnOverCountedNightIsNotExportable() {
var ts: [Int] = [], rr: [Double] = []
for i in 0..<600 {
ts.append(1_000 + i); rr.append(900)
ts.append(1_000 + i); rr.append(905)
}
XCTAssertFalse(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr))
}

/// A banked record: five intervals decomposed across one timestamp. The sum is right, the individual
/// values are not beat-to-beat measurements.
func testADecomposedRecordIsNotExportable() {
var ts: [Int] = [], rr: [Double] = []
for record in 0..<120 {
for _ in 0..<5 { ts.append(1_000 + record * 5); rr.append(1_000) }
}
XCTAssertFalse(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr))
}
}
142 changes: 142 additions & 0 deletions Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,49 @@ public enum HealthWriteback {
"noop:\(kind):\(identity)"
}

/// When each day's nightly vitals are stamped: the midpoint of the LONGEST night whose wake falls on
/// that day. `dayOf` maps a unix second to the day string the vitals rows are keyed by.
///
/// Inside the night, not on its edge. The values are nightly aggregates, and a reader of Health picks
/// them by the sleep window they describe — Bevel shows no Recovery when HRV or resting HR is not
/// captured during the sleep window. A sample stamped exactly at wake sits on that boundary. The
/// longest night rather than the latest, so a nap after the main sleep does not carry the night's
/// values out of the night they came from.
///
/// The midpoint of a bridged night can fall in the awake gap between its fragments, or in an awake
/// segment of one. It is still inside the `.inBed` sample, but a reader that keys off asleep samples
/// would not see it, so an instant that lands outside every asleep interval moves to the nearest
/// asleep second. A night whose stages carry no timing has only `.unspecified` asleep intervals and
/// keeps its midpoint.
public static func vitalsInstantByDay(_ entries: [MergedSleepEntry],
dayOf: (Int) -> String) -> [String: Int] {
var longest: [String: MergedSleepEntry] = [:]
for entry in entries where entry.spanEnd > entry.spanStart {
let day = dayOf(entry.spanEnd)
if let current = longest[day],
current.spanEnd - current.spanStart >= entry.spanEnd - entry.spanStart { continue }
longest[day] = entry
}
return longest.mapValues { entry in
let mid = entry.spanStart + (entry.spanEnd - entry.spanStart) / 2
let asleep = entry.intervals.filter { $0.kind != .awake && $0.end > $0.start }
if asleep.isEmpty || asleep.contains(where: { $0.start <= mid && mid < $0.end }) { return mid }
// Closest second inside each asleep interval; the nearest wins, the earlier on a tie. A plain loop:
// the equivalent `map { }.min { }` chain exceeded the type-checker's budget on CI.
var best = mid
var bestDistance = Int.max
for interval in asleep {
let candidate: Int = min(max(mid, interval.start), interval.end - 1)
let distance: Int = abs(candidate - mid)
if distance < bestDistance || (distance == bestDistance && candidate < best) {
best = candidate
bestDistance = distance
}
}
return best
}
}

/// The vitals key: `noop:<metricId>:<day>`.
public static func appleHealthVitalKey(metricId: String, day: String) -> String {
appleHealthExternalUUID(kind: metricId, identity: day)
Expand All @@ -199,6 +242,105 @@ public enum HealthWriteback {
appleHealthExternalUUID(kind: "workout", identity: "\(startTs)")
}

/// The beat-to-beat key: `noop:heartbeat:<startTs>`, the night's sleep key identity. Carried by every
/// heartbeat-series sample written for that night, so the night can be cleared as a unit.
public static func appleHealthHeartbeatKey(startTs: Int) -> String {
appleHealthExternalUUID(kind: "heartbeat", identity: "\(startTs)")
}

// MARK: - Beat-to-beat (heartbeat series)

/// One heartbeat-series sample: its start (unix seconds) and each beat's offset from it.
public struct HeartbeatSeriesChunk: Equatable {
public struct Beat: Equatable {
public let offset: Double
/// The beat does not follow the previous one directly: beats between them were not recorded.
public let precededByGap: Bool
public init(offset: Double, precededByGap: Bool) {
self.offset = offset; self.precededByGap = precededByGap
}
}
public let start: Double
public let beats: [Beat]
public init(start: Double, beats: [Beat]) { self.start = start; self.beats = beats }
}

/// The longest heartbeat-series sample, in seconds — the 5-minute window NOOP's own SDNN index uses,
/// short enough for a reader to window its own statistics over.
public static let heartbeatChunkSeconds: Double = 300

/// How far a beat may sit from the time its interval predicts before it is read as a gap. The row
/// stamps are whole seconds, so a continuous train drifts by up to one second against them.
public static let heartbeatGapToleranceSeconds: Double = 2

/// Beat times from stored R-R rows (`tsSec` parallel to `rrMs`, ascending). Each beat lands one interval
/// after the previous; a row whose stamp disagrees with that by more than the tolerance starts again from
/// its own stamp as a gap. Chunks split at `heartbeatChunkSeconds`. Non-positive intervals are skipped.
///
/// A row stamped at or before the beat already placed is dropped. HealthKit refuses a series whose beats
/// do not strictly advance ("Heartbeats must be added in order"), and one such row failed the whole
/// night. It happens when the placed beats run ahead of their whole-second stamps: a phone copy of 15
/// real nights had 0 to 29 per night, the previous beat sitting 0 to 1.9 s past the late row's stamp.
/// The beat after a dropped one is still predicted from the last beat placed, so the train carries on.
public static func heartbeatSeriesPlan(tsSec: [Int], rrMs: [Int]) -> [HeartbeatSeriesChunk] {
guard tsSec.count == rrMs.count else { return [] }
var chunks: [HeartbeatSeriesChunk] = []
var chunkStart = 0.0
var beats: [HeartbeatSeriesChunk.Beat] = []
var previous: Double?
for (ts, rr) in zip(tsSec, rrMs) where rr > 0 {
let stamp = Double(ts)
var time = stamp
var gap = true
if let previous {
let predicted = previous + Double(rr) / 1_000
if abs(predicted - stamp) <= heartbeatGapToleranceSeconds { time = predicted; gap = false }
else if stamp <= previous { continue }
}
if beats.isEmpty || time - chunkStart >= heartbeatChunkSeconds {
if !beats.isEmpty { chunks.append(.init(start: chunkStart, beats: beats)) }
chunkStart = time
beats = []
gap = false
}
beats.append(.init(offset: time - chunkStart, precededByGap: gap))
previous = time
}
if !beats.isEmpty { chunks.append(.init(start: chunkStart, beats: beats)) }
return chunks
}

/// What identifies a night's exported beats: a change in any of these rewrites the night.
public static func heartbeatFingerprint(tsSec: [Int], rrMs: [Int]) -> String {
"\(tsSec.count):\(tsSec.first ?? 0):\(tsSec.last ?? 0):\(rrMs.reduce(0, +))"
}

/// Which nights' heartbeat series to rewrite or clear. `nights` pairs each night's key with its current
/// fingerprint, nil when it has no exportable beats; `written` is what was last written per key.
/// A night is rewritten when its fingerprint moved, cleared when it was written and is no longer
/// exportable, and left alone otherwise. `kept` is the written record for the nights left alone; a key
/// outside `nights` has aged out of the window and drops out of the record without touching Health.
public static func heartbeatSyncPlan(nights: [(key: String, fingerprint: String?)],
written: [String: String])
-> (rewrite: [(key: String, fingerprint: String)], clear: [String], kept: [String: String]) {
var rewrite: [(key: String, fingerprint: String)] = []
var clear: [String] = []
var kept: [String: String] = [:]
for night in nights {
switch (night.fingerprint, written[night.key]) {
case let (current?, previous) where current != previous:
rewrite.append((night.key, current))
case let (current?, _):
kept[night.key] = current
case (nil, _?):
clear.append(night.key)
case (nil, nil):
break
}
}
return (rewrite, clear, kept)
}

// MARK: - #1503 stranded-records sweep completion tracking
//
// The one-off sweep that clears Apple Health records written under the OLD device-id-keyed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,126 @@ final class HealthWritebackTests: XCTestCase {
swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"])
XCTAssertEqual(swept, ["restingHeartRate", "sleepAnalysis"])
}

// MARK: - Vitals stamp inside the night

private func entry(_ spanStart: Int, _ spanEnd: Int) -> HealthWriteback.MergedSleepEntry {
.init(keyStartTs: spanStart, spanStart: spanStart, spanEnd: spanEnd, intervals: [], allKeyStartTs: [spanStart])
}

/// Day = whole days since `start`, so attribution is by wake and independent of the test host's zone.
private func dayOf(_ ts: Int) -> String { "d\((ts - start) / 86_400)" }

func testVitalsAreStampedAtTheNightsMidpointNotItsWake() {
let night = entry(start, start + 8 * 3600)
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night], dayOf: dayOf), ["d0": start + 4 * 3600])
}

/// A nap that wakes later the same day must not pull the night's values out of the night.
func testTheLongestNightOwnsTheDayNotTheLatest() {
let night = entry(start, start + 8 * 3600)
let nap = entry(start + 14 * 3600, start + 15 * 3600)
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night, nap], dayOf: dayOf), ["d0": start + 4 * 3600])
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([nap, night], dayOf: dayOf), ["d0": start + 4 * 3600])
}

func testEachDayIsStampedByTheNightThatWokeOnIt() {
let first = entry(start, start + 8 * 3600)
let second = entry(start + 86_400, start + 86_400 + 6 * 3600)
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([first, second], dayOf: dayOf),
["d0": start + 4 * 3600, "d1": start + 86_400 + 3 * 3600])
}

/// A bridged night whose midpoint falls in the awake gap between fragments is stamped at the nearest
/// asleep second instead.
func testAMidpointInABridgedWakeGapMovesToTheNearestAsleepSecond() {
let bridged = HealthWriteback.MergedSleepEntry(
keyStartTs: start, spanStart: start, spanEnd: start + 8 * 3600,
intervals: [.init(start: start, end: start + 3 * 3600, kind: .light),
.init(start: start + 3 * 3600, end: start + 4 * 3600 + 1800, kind: .awake),
.init(start: start + 4 * 3600 + 1800, end: start + 8 * 3600, kind: .deep)],
allKeyStartTs: [start, start + 4 * 3600 + 1800])
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([bridged], dayOf: dayOf), ["d0": start + 4 * 3600 + 1800])
}

func testAMidpointInsideAnAsleepIntervalStays() {
let night = HealthWriteback.MergedSleepEntry(
keyStartTs: start, spanStart: start, spanEnd: start + 8 * 3600,
intervals: [.init(start: start, end: start + 8 * 3600, kind: .unspecified)], allKeyStartTs: [start])
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night], dayOf: dayOf), ["d0": start + 4 * 3600])
}

func testANightWithNoSpanStampsNothing() {
XCTAssertEqual(HealthWriteback.vitalsInstantByDay([entry(start, start)], dayOf: dayOf), [:])
}

// MARK: - Beat-to-beat (heartbeat series)

private func assertOffsets(_ chunk: HealthWriteback.HeartbeatSeriesChunk, _ expected: [Double],
file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(chunk.beats.count, expected.count, file: file, line: line)
for (beat, offset) in zip(chunk.beats, expected) {
XCTAssertEqual(beat.offset, offset, accuracy: 1e-9, file: file, line: line)
}
}

func testBeatsLandOneIntervalAfterThePreviousNotOnTheirWholeSecondStamp() {
// 800 ms beats stamped to whole seconds: 1000, 1000, 1001, 1002, 1003
let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_000, 1_001, 1_002, 1_003],
rrMs: [800, 800, 800, 800, 800])
XCTAssertEqual(plan.count, 1)
XCTAssertEqual(plan[0].start, 1_000)
assertOffsets(plan[0], [0, 0.8, 1.6, 2.4, 3.2])
XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, false, false, false])
}

func testAStampFarFromItsPredictedTimeIsAGap() {
let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001, 1_060], rrMs: [1_000, 1_000, 1_000])
assertOffsets(plan[0], [0, 1, 60])
XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, true])
}

func testARowStampedBehindThePlacedBeatsIsDroppedSoTheSeriesStaysInOrder() {
// Shape from a real night: the placed beats run 1.8 s ahead of the stamps, then a 676 ms row also
// stamped 1 002 predicts 1 004.476, outside the tolerance, and its own stamp is behind the beat
// already placed at 1 003.8. Placing it there is the out-of-order add HealthKit rejects.
let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001, 1_002, 1_002, 1_004],
rrMs: [1_000, 1_900, 1_900, 676, 900])
assertOffsets(plan[0], [0, 1.9, 3.8, 4.7])
XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, false, false])
}

func testARowStampedExactlyOnThePlacedBeatIsDropped() {
let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_003, 1_003], rrMs: [1_000, 3_000, 2_033])
assertOffsets(plan[0], [0, 3])
}

func testSeriesSplitAtFiveMinutes() {
let ts = Array(0..<601).map { 1_000 + $0 }
let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: ts, rrMs: ts.map { _ in 1_000 })
XCTAssertEqual(plan.map(\.start), [1_000, 1_300, 1_600])
XCTAssertEqual(plan.map { $0.beats.count }, [300, 300, 1])
XCTAssertFalse(plan[1].beats[0].precededByGap)
}

func testNonPositiveIntervalsAreSkippedAndMismatchedInputPlansNothing() {
XCTAssertEqual(HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001], rrMs: [0, 1_000])[0].beats.count, 1)
XCTAssertEqual(HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000], rrMs: []), [])
}

func testTheHeartbeatKeySharesTheSleepIdentity() {
XCTAssertEqual(HealthWriteback.appleHealthHeartbeatKey(startTs: 42), "noop:heartbeat:42")
}

func testHeartbeatSyncRewritesMovedNightsClearsUntrustedOnesAndForgetsAgedOut() {
let plan = HealthWriteback.heartbeatSyncPlan(
nights: [(key: "new", fingerprint: "a"), (key: "same", fingerprint: "b"),
(key: "moved", fingerprint: "c2"), (key: "untrusted", fingerprint: nil),
(key: "never", fingerprint: nil)],
written: ["same": "b", "moved": "c1", "untrusted": "d", "agedOut": "e"])
XCTAssertEqual(plan.rewrite.map(\.key), ["new", "moved"])
XCTAssertEqual(plan.rewrite.map(\.fingerprint), ["a", "c2"])
XCTAssertEqual(plan.clear, ["untrusted"])
XCTAssertEqual(plan.kept, ["same": "b"])
}
}
Loading
Loading