Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ final class AppModel: ObservableObject {
// L3 stress-onset detector state: a rolling R-R buffer + the replay-safe detector state (persisted
// via BiofeedbackPrefs so a relaunch can't re-fire), carried verbatim between evaluations.
private var rrBuf: [Int] = []
/// Which live R-R packet `rrBuf` last took, so each packet enters it once (`RRPacketCursor`).
private var stressPackets = RRPacketCursor()
private var stressState = BiofeedbackPrefs.loadStressState()

/// Import source currently writing to the local store, if any.
Expand Down Expand Up @@ -1071,6 +1073,10 @@ final class AppModel: ObservableObject {
/// baseline + rate limit), persisted via `BiofeedbackPrefs` so a relaunch can't re-fire. Honest /
/// non-clinical: "stress" is an autonomic proxy vs the user's own baseline, never a diagnosis.
private func evaluateStress() {
// Once per R-R packet. `ingestHR` runs from both the heart-rate and the R-R sink, so a packet reached
// this once or twice, its intervals entered `rrBuf` as often, and the detector's slow baseline
// advanced on every call rather than every packet.
guard stressPackets.isNew(live.rrSeq) else { return }
let fresh = live.rr.filter { $0 > 300 && $0 < 2000 } // plausible R-R (30–200 bpm)
guard !fresh.isEmpty else { return }
rrBuf.append(contentsOf: fresh)
Expand Down
17 changes: 17 additions & 0 deletions Strand/Screens/RRPacketObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,20 @@ extension View {
}
}
}

/// The same rule for a consumer that is not a view: take a live R-R packet once, keyed on `rrSeq`.
///
/// A `@Published` sink runs inside `willSet`, before the new value lands, so a handler that reads `live` from
/// a sink sees the packet the strap sent BEFORE the one being written. `setRRIntervals` moves `rr` and `rrSeq`
/// together, so `live.rr` always belongs to `live.rrSeq`, whichever sink is running: asking this cursor whether
/// that sequence is new takes every packet exactly once, however many sinks reach the handler for it.
struct RRPacketCursor {
private(set) var lastSeq = 0

/// True the first time `seq` is offered, and false for any repeat of it.
mutating func isNew(_ seq: Int) -> Bool {
guard seq != lastSeq else { return false }
lastSeq = seq
return true
}
}
51 changes: 51 additions & 0 deletions StrandTests/RRPacketCursorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import XCTest
import Combine
@testable import Strand

/// A handler reached from `LiveState`'s heart-rate AND R-R sinks — as `AppModel.ingestHR`, and through it the stress
/// check-in, is — takes each R-R packet once when it asks `RRPacketCursor`.
@MainActor
final class RRPacketCursorTests: XCTestCase {

func testASequenceIsNewOnlyTheFirstTime() {
var cursor = RRPacketCursor()
XCTAssertFalse(cursor.isNew(0)) // nothing has arrived yet
XCTAssertTrue(cursor.isNew(1))
XCTAssertFalse(cursor.isNew(1))
XCTAssertTrue(cursor.isNew(2))
}

/// Writes a real `LiveState` the way BLEManager's standard-HR path does — the packet's intervals first, then the
/// heart rate only when it changed — and collects `live.rr` from both sinks, as AppModel wires them.
private func feedStrap(_ packets: [(bpm: Int, rr: Int)], throughCursor: Bool) -> [Int] {
let live = LiveState()
var cursor = RRPacketCursor()
var taken: [Int] = []
let handler = {
if throughCursor, !cursor.isNew(live.rrSeq) { return }
taken.append(contentsOf: live.rr)
}
var subscriptions = Set<AnyCancellable>()
live.$heartRate.sink { _ in handler() }.store(in: &subscriptions)
live.$rr.sink { _ in handler() }.store(in: &subscriptions)
for packet in packets {
live.setRRIntervals([packet.rr])
if live.heartRate != packet.bpm { live.heartRate = packet.bpm }
}
return taken
}

/// 21 packets, the heart rate moving every second one (and on the last, so it too is handed over).
private let packets = (0...20).map { (bpm: 70 + $0 / 2, rr: 800 + $0) }

func testEachPacketIsTakenOnceAndInOrder() {
XCTAssertEqual(feedStrap(packets, throughCursor: true), packets.map(\.rr))
}

/// What the stress check-in did before: the same packets, read from both sinks with no cursor.
func testWithoutTheCursorPacketsAreTakenMoreThanOnce() {
let taken = feedStrap(packets, throughCursor: false)
XCTAssertGreaterThan(taken.count, packets.count)
XCTAssertEqual(Array(Set(taken)).sorted(), packets.map(\.rr))
}
}
Loading