From e9ce5a437c2da23f024090a6046808b3fe5a5ddf Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:49:16 -0400 Subject: [PATCH] Surface the HRV panel the YCBT rings were already sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body-data record (05 33) carries SDNN, pNN50, RMSSD and an LF/HF pair alongside the HRV scalar. YCBTHealthRecords walked straight past all of them — the offsets were documented in the doc comment, but there was no MeasurementKind to put them in, so they were decoded past and dropped. Neither Oura nor Ultrahuman surfaces this breakdown, so on a YCBT ring it is a straight win. LF/HF is computed rather than read. The record has its own lfHf at @24, but it is one byte standing in for a ratio whose real range is ~0.5-3, so it carries an implicit scale the SDK never states. The ratio is recomputed from the LF and HF powers instead, where whatever scale those two share cancels in the quotient. The captured hardware record settles it: @24 reads 0x0d = 1.3 at a /10 scale against a derived 1200/900 = 1.33. They agree — which also corroborates every offset in the panel, since a misread would land these in the thousands rather than in ordinary adult resting ranges. Gated on a new .hrvDetail capability riding IS_HAS_PRESSURE (byte 22 bit 6) — the bit the vendor SDK gates the whole 05 33 query on, and therefore the same bit stress and fatigue already ride. Not ISHASHRV: that governs the scalar, which arrives from a different record and the live stream, so a ring can have HRV and still have nothing to break it down. The jring, Colmi QRing and LuckRing families have no equivalent record and never claim it; the real R99 leaves the bit clear and NAKs 05 33, so it resolves to the scalar and no panel. YCBTDriver drops the history at the source for any ring that doesn't declare it. Kept off the dashboard deliberately: the panel's kinds have no MetricKey, which makes a Today or Vitals card structurally impossible rather than merely absent. It lives at Vitals > HRV > HRV detail — two taps, since these are read occasionally to understand a trend, not glanced at. No zone colouring either: RMSSD alone spans an order of magnitude across healthy adults, so painting a green/amber/red band would be inventing a threshold. Load index and sympathetic tone stay on the floor — proprietary composites with no stated scale or definition — as do body fat, uric acid, ketones and blood lipids from the neighbouring records, which no optical ring can measure at all. docs/project/hrv-metrics.md documents every offset, formula and range, and says plainly what is refused and why. Co-Authored-By: Claude Opus 5 --- PulseLoop/App/AppTheme.swift | 4 + PulseLoop/Health/HealthKitTypeMappings.swift | 10 + PulseLoop/Models/PulseModels.swift | 31 ++++ PulseLoop/Persistence/SeedData.swift | 21 ++- .../ColmiSmartHealthCoordinator.swift | 3 + PulseLoop/RingProtocol/RingEventBridge.swift | 18 ++ PulseLoop/RingProtocol/TK5Coordinator.swift | 3 + PulseLoop/RingProtocol/YCBTCoordinator.swift | 3 + PulseLoop/RingProtocol/YCBTDriver.swift | 5 + .../RingProtocol/YCBTHealthRecords.swift | 53 +++++- PulseLoop/RingProtocol/YCBTProtocol.swift | 7 + PulseLoop/Services/PulseServices.swift | 57 +++--- PulseLoop/Views/AutonomicDetailView.swift | 171 ++++++++++++++++++ PulseLoop/Views/MetricDetailView.swift | 63 +++++++ PulseLoop/Views/RootViews.swift | 2 + PulseLoop/Wearables/WearableCapability.swift | 9 + PulseLoopTests/EventBridgeTests.swift | 20 ++ PulseLoopTests/HrvDetailCapabilityTests.swift | 126 +++++++++++++ PulseLoopTests/PairingMatchingTests.swift | 9 +- PulseLoopTests/YCBTHealthRecordsTests.swift | 63 ++++++- PulseLoopTests/YCBTSupportFunctionTests.swift | 10 +- docs/hardware/index.md | 3 + docs/project/hrv-metrics.md | 147 +++++++++++++++ mkdocs.yml | 1 + 24 files changed, 801 insertions(+), 38 deletions(-) create mode 100644 PulseLoop/Views/AutonomicDetailView.swift create mode 100644 PulseLoopTests/HrvDetailCapabilityTests.swift create mode 100644 docs/project/hrv-metrics.md diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 8ffa068..5478cb1 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -3,6 +3,10 @@ import SwiftUI enum AppRoute: Hashable { case activityDetail(UUID) case metricDetail(MetricKind) + /// The HRV panel — SDNN, RMSSD, pNN50, LF/HF. Reached only from the HRV detail screen, and only + /// on rings that declare `.hrvDetail`, so it stays two taps off the dashboard rather than adding + /// six cards to it. + case autonomicDetail case activityTrends case recordSelect case logPastActivity diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index f318f37..f484645 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -53,6 +53,16 @@ enum HealthKitTypeMappings { // HealthKit has both (`respiratoryRate`, `vo2Max`), but exporting them needs new share // types plus their own per-type toggles to keep the sync opt-in per metric. Follow-up. return nil + case .rmssd, .pnn50, .lfPower, .hfPower, .lfHfRatio: + // HealthKit models HRV as a single `heartRateVariabilitySDNN` type and has nothing for + // RMSSD, pNN50 or spectral power. There is no honest destination for these. + return nil + case .sdnn: + // The one that *could* map — `heartRateVariabilitySDNN` — is already occupied by the + // ring's `hrv` scalar. Whether that scalar simply *is* the ring's SDNN is unverified, so + // exporting both would either double-report the same measurement or silently disagree + // with itself. Left unmapped until a hardware session settles which is which. + return nil } } diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..7e386be 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -27,6 +27,19 @@ enum MeasurementKind: String, Codable, CaseIterable { // body-data record (byte 16). Append only — raw values persisted. case respiratoryRate = "resp_rate" case vo2max + // The YCBT body-data record's HRV panel (`05 33`, `DataUnpack` case 51) — standard time- and + // frequency-domain measures the ring computes and reports alongside the single `hrv` scalar. + // Only the YCBT families produce these; see `WearableCapability.hrvDetail`. Append only. + case sdnn + case rmssd + case pnn50 + case lfPower = "lf_power" + case hfPower = "hf_power" + case lfHfRatio = "lf_hf_ratio" + + /// The HRV panel: everything derived from beat-to-beat variability beyond the `hrv` scalar. + /// Grouped so the UI can render them together without restating the list at each call site. + static let autonomicKinds: [MeasurementKind] = [.rmssd, .sdnn, .pnn50, .lfPower, .hfPower, .lfHfRatio] /// Display unit for a measurement of this kind. var unit: String { @@ -41,6 +54,24 @@ enum MeasurementKind: String, Codable, CaseIterable { case .bloodSugar: return "mg/dL" case .respiratoryRate: return "brpm" // breaths per minute case .vo2max: return "mL/kg/min" + case .sdnn, .rmssd: return "ms" + case .pnn50: return "%" + case .lfPower, .hfPower: return "ms²" + case .lfHfRatio: return "" // a ratio of two powers — dimensionless + } + } + + /// Short human label. Only the HRV-panel kinds need one today: the rest are titled by + /// `MetricKind`, which these deliberately do not join (they are never dashboard cards). + var shortTitle: String { + switch self { + case .sdnn: return "SDNN" + case .rmssd: return "RMSSD" + case .pnn50: return "pNN50" + case .lfPower: return "LF power" + case .hfPower: return "HF power" + case .lfHfRatio: return "LF/HF" + default: return rawValue } } } diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd4..1322c4d 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -55,7 +55,10 @@ enum SeedData { state: .connected, capabilities: [ .heartRate, .spo2, .steps, .sleep, .battery, .remSleep, - .stress, .hrv, .temperature, .bloodPressure, .bloodSugar, .fatigue + .stress, .hrv, .temperature, .bloodPressure, .bloodSugar, .fatigue, + // The demo ring is a maximal one, so `.hrvDetail` is declared here too — otherwise + // the Autonomic screen would be unreachable without YCBT hardware to hand. + .hrvDetail ] )) @@ -280,6 +283,22 @@ enum SeedData { if day == -18 { hrv = 92 } // spike → above baseline add(.hrv, hrv.rounded(), hrvTs) + // The HRV panel — same overnight instant as the HRV scalar, since on a real ring they come + // out of the same body-data record. RMSSD and pNN50 track HRV (they measure the same + // parasympathetic activity), so they move with it rather than independently; LF/HF is + // derived from the two powers exactly as the decoder derives it. + let rmssd = Swift.max(8, hrv * 0.85 + sin(phase * 0.7) * 6) + let sdnn = Swift.max(12, hrv * 1.25 + sin(phase * 0.35) * 9) + let pnn50 = Swift.max(1, Swift.min(60, rmssd * 0.4 + sin(phase * 0.9) * 4)) + let lf = 900 + sin(phase * 0.4) * 320 + let hf = Swift.max(120, rmssd * 14 + sin(phase * 0.8) * 140) + add(.rmssd, rmssd.rounded(), hrvTs) + add(.sdnn, sdnn.rounded(), hrvTs) + add(.pnn50, pnn50.rounded(), hrvTs) + add(.lfPower, lf.rounded(), hrvTs) + add(.hfPower, hf.rounded(), hrvTs) + add(.lfHfRatio, lf / hf, hrvTs) + // Blood pressure — 1 pair/day (morning). Normal→Stage 2; a couple of high days show red. let bpTs = calendar.date(bySettingHour: 8, minute: 0, second: 0, of: dayStart) ?? dayStart var sys = 116 + sin(phase * 0.45) * 10 diff --git a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift index 04f6089..d15a54c 100644 --- a/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift +++ b/PulseLoop/RingProtocol/ColmiSmartHealthCoordinator.swift @@ -196,6 +196,9 @@ final class ColmiSmartHealthCoordinator: WearableCoordinator { let bitmapGatedCapabilities: Set = [ .temperature, .bloodPressure, .stress, .bloodSugar, .manualBloodPressure, .hrv, .manualHrv, + // The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress — all of them + // are fields of the one `05 33` body-data record that bit gates. + .hrvDetail, ] let iconSystemName = "circle.circle.fill" diff --git a/PulseLoop/RingProtocol/RingEventBridge.swift b/PulseLoop/RingProtocol/RingEventBridge.swift index 3d91422..5e8cae4 100644 --- a/PulseLoop/RingProtocol/RingEventBridge.swift +++ b/PulseLoop/RingProtocol/RingEventBridge.swift @@ -36,6 +36,20 @@ enum RingEventBridge { static let respiratoryRateRange: ClosedRange = 4...60 /// Plausible VO₂max, in mL/kg/min (sedentary floor to elite-athlete ceiling). static let vo2maxRange: ClosedRange = 10...90 + /// Plausible SDNN and RMSSD, in milliseconds — one gate, because they are the same quantity + /// measured two ways and share a range. Wider than `hrvRange` on purpose: that gate covers the + /// ring's single summary scalar, whereas a short-window SDNN over a restful night can + /// legitimately run higher. The ceiling is well past any physiological value and exists only to + /// drop a misframed u16. + static let beatIntervalMsRange: ClosedRange = 1...500 + /// pNN50 is a percentage of successive beat intervals differing by more than 50 ms. + static let pnn50Range: ClosedRange = 0...100 + /// LF and HF spectral power, in ms². Zero is a real reading (no power in the band), so the floor + /// is inclusive; the ceiling is a misframe guard, not a physiological claim. + static let spectralPowerRange: ClosedRange = 0...50_000 + /// LF/HF ratio. Bounded away from zero because the ring reports it as a scaled integer and `0` + /// is its "no sample" filler rather than a real balance. + static let lfHfRatioRange: ClosedRange = 0.01...20 /// Sanity ceilings for one intraday activity bucket (~15 min): well above any human cadence so /// only clearly-misframed packets are rejected. static let maxBucketSteps = 5000 @@ -170,6 +184,10 @@ enum RingEventBridge { case .bloodSugar: return bloodSugarRange.contains(value) case .respiratoryRate: return respiratoryRateRange.contains(Int(value)) case .vo2max: return vo2maxRange.contains(Int(value)) + case .sdnn, .rmssd: return beatIntervalMsRange.contains(Int(value)) + case .pnn50: return pnn50Range.contains(value) + case .lfPower, .hfPower: return spectralPowerRange.contains(value) + case .lfHfRatio: return lfHfRatioRange.contains(value) } } diff --git a/PulseLoop/RingProtocol/TK5Coordinator.swift b/PulseLoop/RingProtocol/TK5Coordinator.swift index fe10521..1ae50bc 100644 --- a/PulseLoop/RingProtocol/TK5Coordinator.swift +++ b/PulseLoop/RingProtocol/TK5Coordinator.swift @@ -120,6 +120,9 @@ final class TK5Coordinator: WearableCoordinator { let bitmapGatedCapabilities: Set = [ .temperature, .bloodPressure, .manualBloodPressure, .stress, .fatigue, .bloodSugar, + // The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress and fatigue — + // all three are fields of the one `05 33` body-data record that bit gates. + .hrvDetail, ] let iconSystemName = "circle.circle.fill" diff --git a/PulseLoop/RingProtocol/YCBTCoordinator.swift b/PulseLoop/RingProtocol/YCBTCoordinator.swift index bf35dfd..9232b39 100644 --- a/PulseLoop/RingProtocol/YCBTCoordinator.swift +++ b/PulseLoop/RingProtocol/YCBTCoordinator.swift @@ -111,6 +111,9 @@ final class YCBTCoordinator: WearableCoordinator { .temperature, .bloodPressure, .manualBloodPressure, .stress, .fatigue, .bloodSugar, .hrv, .manualHrv, + // The HRV panel (SDNN/RMSSD/pNN50/LF/HF) rides `IS_HAS_PRESSURE` with stress and fatigue — + // all three are fields of the one `05 33` body-data record that bit gates. + .hrvDetail, .findDevice, ] diff --git a/PulseLoop/RingProtocol/YCBTDriver.swift b/PulseLoop/RingProtocol/YCBTDriver.swift index a036eaf..4d258dc 100644 --- a/PulseLoop/RingProtocol/YCBTDriver.swift +++ b/PulseLoop/RingProtocol/YCBTDriver.swift @@ -251,6 +251,11 @@ final class YCBTDriver: WearableDriver { case .stress: return capabilities.contains(.stress) case .fatigue: return capabilities.contains(.fatigue) case .temperature: return capabilities.contains(.temperature) + // The HRV panel is gated on its own capability rather than `.hrv`: a ring can report the + // single HRV scalar (from `05 09` or the `06 03` live stream) without having the `05 33` + // body-data record these fields live in. + case .sdnn, .rmssd, .pnn50, .lfPower, .hfPower, .lfHfRatio: + return capabilities.contains(.hrvDetail) } } diff --git a/PulseLoop/RingProtocol/YCBTHealthRecords.swift b/PulseLoop/RingProtocol/YCBTHealthRecords.swift index ecc2fbc..146414e 100644 --- a/PulseLoop/RingProtocol/YCBTHealthRecords.swift +++ b/PulseLoop/RingProtocol/YCBTHealthRecords.swift @@ -177,8 +177,22 @@ enum YCBTHealthRecords { /// The SDK's `pressure` is the **stress** score and `body` the **fatigue** score (that is how the /// SmartHealth UI labels `BodyData.getPressureValue()` / `getBodyStateValue()`). This record is the /// proof that the ring *stores* stress rather than the app deriving it — the old TK5 capability note - /// said the opposite. Load index, sympathetic tone, SDNN, pNN50, RMSSD and LF/HF have no - /// `MeasurementKind`; they are left on the floor rather than force-fitted into one. + /// said the opposite. + /// + /// **The HRV panel.** SDNN, pNN50, RMSSD and the LF/HF pair are now decoded — see + /// `WearableCapability.hrvDetail`, which rides `IS_HAS_PRESSURE` exactly as `.stress`/`.fatigue` + /// do, because that one bit gates this whole record's query. + /// + /// Two fields are still deliberately left on the floor: **load index (@4–5)** and **sympathetic + /// tone (@12–13)** are vendor-proprietary composites with no stated scale, no unit, and no + /// published definition. Unlike SDNN or RMSSD there is nothing to check them against, so + /// surfacing them would be inventing a metric rather than reporting one. + /// + /// **`lfHf` at @24 is read but not trusted.** It is a single byte standing in for a ratio whose + /// real range is roughly 0.5–3, so it must carry an implicit scale factor the SDK never states. + /// Rather than guess it, the ratio is recomputed here from the LF and HF powers — whatever common + /// scale those two share cancels, so the quotient is right even though the absolute powers' + /// unit is inferred. /// /// Those two scores go through `score` (digit-concatenated, the app's 1…100 scale) while HRV goes /// through `composite` (milliseconds) — see both doc comments for why the same byte pair is read two @@ -204,6 +218,41 @@ enum YCBTHealthRecords { if r.count > 16, r[16] > 0 { events.append(.historyMeasurement(kind: .vo2max, value: Double(r[16]), timestamp: ts)) } + events.append(contentsOf: hrvPanel(r, timestamp: ts)) + } + return events + } + + /// The HRV panel from a body-data record, guarded field by field. + /// + /// Every field sits past byte 16, so each read repeats the same `r.count` gate the VO₂max read + /// uses — the SDK's `length >= cursor + 25` branch for the rumoured short-prefix firmware. A `0` + /// is the ring's "no sample" filler for all of these, so zero means absent, not measured-as-zero. + /// (`RingEventBridge` allows a genuine zero for LF/HF power, but a record that reports no HRV at + /// all reports it as zeros across the panel, which the `> 0` gates already drop.) + private static func hrvPanel(_ r: [UInt8], timestamp ts: Date) -> [RingDecodedEvent] { + var events: [RingDecodedEvent] = [] + + if r.count > 15 { + let sdnn = YCBTBytes.u16(r, 14) + if sdnn > 0 { events.append(.historyMeasurement(kind: .sdnn, value: Double(sdnn), timestamp: ts)) } + } + if r.count > 17, r[17] > 0 { + events.append(.historyMeasurement(kind: .pnn50, value: Double(r[17]), timestamp: ts)) + } + if r.count > 19 { + let rmssd = YCBTBytes.u16(r, 18) + if rmssd > 0 { events.append(.historyMeasurement(kind: .rmssd, value: Double(rmssd), timestamp: ts)) } + } + + guard r.count > 23 else { return events } + let lf = YCBTBytes.u16(r, 20) + let hf = YCBTBytes.u16(r, 22) + if lf > 0 { events.append(.historyMeasurement(kind: .lfPower, value: Double(lf), timestamp: ts)) } + if hf > 0 { events.append(.historyMeasurement(kind: .hfPower, value: Double(hf), timestamp: ts)) } + // Derived rather than read from @24 — see the note on `bodyData`. Needs both halves. + if lf > 0, hf > 0 { + events.append(.historyMeasurement(kind: .lfHfRatio, value: Double(lf) / Double(hf), timestamp: ts)) } return events } diff --git a/PulseLoop/RingProtocol/YCBTProtocol.swift b/PulseLoop/RingProtocol/YCBTProtocol.swift index 3cca23d..6de5fcc 100644 --- a/PulseLoop/RingProtocol/YCBTProtocol.swift +++ b/PulseLoop/RingProtocol/YCBTProtocol.swift @@ -477,6 +477,13 @@ enum YCBTSupportFunction { // gate no bit can satisfy is a dead one (`PairingMatchingTests`). Bit(byte: 22, bit: 6, minLength: 23, capability: .fatigue), // IS_HAS_PRESSURE (same record) + // **The HRV panel rides the same bit, for the same reason.** SDNN, pNN50, RMSSD and the LF/HF + // pair are fields @14–24 of that one `05 33` record, so a ring with `IS_HAS_PRESSURE` clear is + // never asked for them either. `ISHASHRV` (1.1) is deliberately *not* the gate here: it governs + // the single HRV scalar, which arrives separately in the `05 09` combined record and on the + // `06 03` live stream — a ring can have HRV and still have no body-data record to break it down. + Bit(byte: 22, bit: 6, minLength: 23, capability: .hrvDetail), // IS_HAS_PRESSURE (same record) + // Find-my-ring. `DeviceSupportFunctionUtil.isHasFindDevice` reads it, and `MeAntiLostActivity` // hides the whole screen without it. Bit(byte: 6, bit: 4, minLength: 14, capability: .findDevice), // ISHASFINDDEVICE diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 56d685b..c00ca73 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -195,32 +195,39 @@ enum MetricsService { DeviceRepository.devices(context: context) } + /// Ordinary resting-adult ranges per kind, for demo/debug rows. A table rather than a `switch` + /// so adding a `MeasurementKind` doesn't push `insertMockMeasurement` further past SwiftLint's + /// cyclomatic-complexity gate — and so the whole set can be read at a glance. + /// + /// `wholeNumber` marks the kinds whose display precision is integral; only temperature and the + /// LF/HF ratio carry a decimal. + static let mockValueRanges: [MeasurementKind: (range: ClosedRange, wholeNumber: Bool)] = [ + .heartRate: (62...86, true), + .spo2: (96...99, true), + .stress: (20...70, true), + .hrv: (30...90, true), + .temperature: (33...36, false), + .bloodPressureSystolic: (110...130, true), + .bloodPressureDiastolic: (70...85, true), + .fatigue: (20...70, true), + .bloodSugar: (85...110, true), + .respiratoryRate: (12...18, true), + .vo2max: (35...50, true), + // The HRV panel, so demo data exercises the Autonomic screen with something plausible. + .sdnn: (40...110, true), + .rmssd: (25...85, true), + .pnn50: (5...35, true), + .lfPower: (400...1600, true), + .hfPower: (300...1400, true), + .lfHfRatio: (0.6...2.8, false), + ] + static func insertMockMeasurement(kind: MeasurementKind, context: ModelContext) { - let value: Double - switch kind { - case .heartRate: - value = Double(Int.random(in: 62...86)) - case .spo2: - value = Double(Int.random(in: 96...99)) - case .stress: - value = Double(Int.random(in: 20...70)) - case .hrv: - value = Double(Int.random(in: 30...90)) - case .temperature: - value = Double.random(in: 33...36) - case .bloodPressureSystolic: - value = Double(Int.random(in: 110...130)) - case .bloodPressureDiastolic: - value = Double(Int.random(in: 70...85)) - case .fatigue: - value = Double(Int.random(in: 20...70)) - case .bloodSugar: - value = Double(Int.random(in: 85...110)) - case .respiratoryRate: - value = Double(Int.random(in: 12...18)) - case .vo2max: - value = Double(Int.random(in: 35...50)) - } + // A kind absent from the table is a programming error, not a runtime case — every one is + // listed, and `testEveryMeasurementKindHasAMockRange` fails if a new one isn't. + let spec = mockValueRanges[kind] ?? (0...100, true) + let raw = Double.random(in: spec.range) + let value = spec.wholeNumber ? raw.rounded() : raw let row = MeasurementRepository.insertMeasurement( kind: kind, value: value, diff --git a/PulseLoop/Views/AutonomicDetailView.swift b/PulseLoop/Views/AutonomicDetailView.swift new file mode 100644 index 0000000..cf4cdac --- /dev/null +++ b/PulseLoop/Views/AutonomicDetailView.swift @@ -0,0 +1,171 @@ +import SwiftUI +import SwiftData + +/// The HRV panel: the time- and frequency-domain measures the YCBT body-data record (`05 33`) +/// reports alongside the single HRV scalar every ring gives. +/// +/// Reached only from the HRV detail screen, and only on rings that declare `.hrvDetail` — two taps +/// off the dashboard rather than six new cards on it. These are numbers you read occasionally to +/// understand a trend, not ones you glance at on a home screen. +/// +/// One card per measure: latest value, a sparkline over the selected period, and a plain-language +/// explanation. No zone colouring — unlike heart rate or SpO₂ these have no population-normal band +/// worth drawing, and inventing one would be exactly the black box the project exists to avoid. +struct AutonomicDetailView: View { + @Environment(\.modelContext) private var modelContext + + @State private var period: MetricDetailView.DetailPeriod = .week + @State private var series: [MeasurementKind: [MetricSample]] = [:] + /// Observed so the cards refresh when a background sync lands while this is open. + @State private var dataChange = PulseDataChange.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + periodSelector + ForEach(MeasurementKind.autonomicKinds, id: \.self) { kind in + card(for: kind) + } + explainer + } + .padding(16) + .padding(.bottom, 40) + .pulseGlassContainer(spacing: 18) + } + .background(PulseColors.background) + .pageChrome("HRV Detail") + .task(id: period) { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + } + + private var periodSelector: some View { + Picker("Period", selection: $period) { + ForEach(MetricDetailView.DetailPeriod.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + } + + // MARK: - Per-measure card + + @ViewBuilder + private func card(for kind: MeasurementKind) -> some View { + let samples = series[kind] ?? [] + // A ring that reports some of the panel but not all shouldn't show empty cards for the rest. + if !samples.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(kind.shortTitle.uppercased()) + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Spacer() + Text(formatted(samples.last?.value, kind: kind)) + .font(PulseFont.numberXL).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + if !kind.unit.isEmpty { + Text(kind.unit) + .font(PulseFont.caption).foregroundStyle(PulseColors.textMuted) + } + } + if samples.count >= 2 { + MiniSparkline(values: samples.map(\.value), color: PulseColors.hrv) + .frame(height: 40) + } + HStack(spacing: 0) { + stat("Average", formatted(average(samples), kind: kind)) + stat("Min", formatted(samples.map(\.value).min(), kind: kind)) + stat("Max", formatted(samples.map(\.value).max(), kind: kind)) + } + Text(blurb(for: kind)) + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + } + + private func stat(_ title: String, _ value: String) -> some View { + VStack(spacing: 4) { + Text(title.uppercased()) + .font(PulseFont.caption2).tracking(0.6) + .foregroundStyle(PulseColors.textMuted).lineLimit(1) + Text(value) + .font(PulseFont.footnote.weight(.semibold)).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .minimumScaleFactor(0.6).lineLimit(1) + } + .frame(maxWidth: .infinity) + } + + private var explainer: some View { + VStack(alignment: .leading, spacing: 6) { + Text("WHAT THIS MEANS").font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text("These break the single HRV number down into its parts. They're computed by the ring's " + + "own firmware from beat-to-beat timing, so treat them as wellness signals rather than " + + "clinical measurements — and read them as trends against your own history, not against " + + "anyone else's numbers.") + .font(PulseFont.footnote.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + // MARK: - Copy + + private func blurb(for kind: MeasurementKind) -> String { + switch kind { + case .rmssd: + return "The beat-to-beat measure most tied to rest and recovery. It tracks the parasympathetic " + + "\"rest and digest\" side, and tends to fall after hard training, alcohol, or a poor night." + case .sdnn: + return "Overall variability across the whole window. It picks up slower rhythms than RMSSD, so " + + "it moves more with the length of the recording than with any single night." + case .pnn50: + return "The share of consecutive beats differing by more than 50 ms. It moves with RMSSD and is " + + "another read on parasympathetic activity." + case .lfPower: + return "Power in the low-frequency band. Often described as sympathetic, but it reflects a mix of " + + "influences including blood-pressure regulation — worth watching as a trend, not a verdict." + case .hfPower: + return "Power in the high-frequency band, driven largely by breathing. It rises with slow, deep " + + "breathing and with restful sleep." + case .lfHfRatio: + return "The balance between the two bands, computed here from the LF and HF powers themselves. " + + "It's commonly read as a stress-versus-recovery balance, though that reading is debated." + default: + return "" + } + } + + // MARK: - Data + + private func reload() { + let now = Date() + let start = now.addingTimeInterval(-Double(period.days) * 86_400) + var next: [MeasurementKind: [MetricSample]] = [:] + for kind in MeasurementKind.autonomicKinds { + let rows = MetricsRepository.measurements( + kind: kind, start: start, end: now, limit: 500, context: modelContext + ) + // The repository returns newest-first; a chart wants chronological order. + next[kind] = rows.reversed().map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + } + series = next + } + + private func average(_ samples: [MetricSample]) -> Double? { + guard !samples.isEmpty else { return nil } + return samples.reduce(0) { $0 + $1.value } / Double(samples.count) + } + + /// Ratios need a decimal; everything else in the panel is a whole number at display precision. + private func formatted(_ value: Double?, kind: MeasurementKind) -> String { + guard let value, value.isFinite else { return "--" } + return kind == .lfHfRatio ? String(format: "%.2f", value) : "\(Int(value.rounded()))" + } +} diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 0928f37..ae60cfe 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -10,6 +10,7 @@ struct MetricDetailView: View { let metric: MetricKind @Binding var path: NavigationPath @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] @@ -60,6 +61,15 @@ struct MetricDetailView: View { case .month: return .thirtyDays } } + /// Window length in days, for callers that query `Measurement` rows directly rather than + /// through `MetricsService.metricRange` (which is keyed by `MetricKey`). + var days: Int { + switch self { + case .today: return 1 + case .week: return 7 + case .month: return 30 + } + } } var body: some View { @@ -68,6 +78,7 @@ struct MetricDetailView: View { periodSelector chartSection statTiles + if metric == .hrv, showsAutonomicPanel { autonomicRow } legend explainer if isEstimatedMetric { disclaimer } @@ -203,6 +214,58 @@ struct MetricDetailView: View { Rectangle().fill(PulseColors.borderSubtle).frame(width: 1, height: 34) } + // MARK: - Autonomic panel entry + + /// Whether to offer the HRV-panel tap-through: the ring must declare `.hrvDetail` and there must + /// actually be something behind the tap. Deliberately a *row on this screen* rather than a + /// dashboard card — six more tiles on Today or Vitals would bury the metrics people open the app + /// for, and these are read occasionally, not glanced at. + private var showsAutonomicPanel: Bool { + MetricsService.activeCapabilities(context: modelContext, ble: ble).contains(.hrvDetail) + && MeasurementKind.autonomicKinds.contains { + // `latestMeasurement` is a `fetchLimit: 1` probe — an existence check, cheap enough + // to call from `body`. + MetricsRepository.latestMeasurement(kind: $0, context: modelContext) != nil + } + } + + private var autonomicRow: some View { + Button { path.append(AppRoute.autonomicDetail) } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("HRV DETAIL") + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text(autonomicPreview) + .font(PulseFont.footnote).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1).minimumScaleFactor(0.7) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("HRV detail. \(autonomicPreview)") + .accessibilityHint("Opens SDNN, RMSSD, pNN50 and LF/HF trends") + } + + /// The three most-recognised measures, latest value each. Falls back to a prompt rather than a + /// row of dashes when the panel exists but the period holds nothing. + private var autonomicPreview: String { + let parts: [String] = [MeasurementKind.rmssd, .sdnn, .pnn50].compactMap { kind in + guard let row = MetricsRepository.latestMeasurement(kind: kind, context: modelContext) else { return nil } + let unit = kind.unit.isEmpty ? "" : " \(kind.unit)" + return "\(kind.shortTitle) \(Int(row.value.rounded()))\(unit)" + } + return parts.isEmpty ? "SDNN, RMSSD, pNN50 and LF/HF" : parts.joined(separator: " · ") + } + // MARK: - Legend private var legend: some View { diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 3a16886..09bc46d 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -117,6 +117,8 @@ struct RootAppView: View { case let .metricDetail(metric): MetricDetailView(metric: metric, path: $path) .pulseZoomDestination(route, in: zoomNS) + case .autonomicDetail: + AutonomicDetailView() case .activityTrends: ActivityTrendsView(path: $path) case .recordSelect: diff --git a/PulseLoop/Wearables/WearableCapability.swift b/PulseLoop/Wearables/WearableCapability.swift index 899e22d..5910bf2 100644 --- a/PulseLoop/Wearables/WearableCapability.swift +++ b/PulseLoop/Wearables/WearableCapability.swift @@ -53,6 +53,15 @@ enum WearableCapability: String, CaseIterable, Codable, Sendable { // `0xbc 0x2a`). Drives the workout vitals plan: a device with no `manualSpo2` but with this // capability shows the latest logged value instead of pretending it can spot-measure. case spo2History + + /// The ring reports the **HRV panel** — SDNN, RMSSD, pNN50 and the LF/HF pair — and not just the + /// single `hrv` scalar every family gives. Today that means the YCBT body-data record (`05 33`), + /// so the jring, Colmi QRing and LuckRing families never declare it and the Autonomic screen is + /// simply absent for them. + /// + /// Separate from `.hrv` on purpose: those two answer different questions. Every supported ring + /// can produce an HRV number; only these can break it down. + case hrvDetail } extension Set where Element == WearableCapability { diff --git a/PulseLoopTests/EventBridgeTests.swift b/PulseLoopTests/EventBridgeTests.swift index 999d0c3..ae269e2 100644 --- a/PulseLoopTests/EventBridgeTests.swift +++ b/PulseLoopTests/EventBridgeTests.swift @@ -88,6 +88,7 @@ final class EventBridgeTests: XCTestCase { .heartRate: 70, .spo2: 97, .stress: 40, .hrv: 55, .temperature: 36.6, .bloodPressureSystolic: 118, .bloodPressureDiastolic: 79, .fatigue: 20, .bloodSugar: 99, .respiratoryRate: 14, .vo2max: 42, + .sdnn: 48, .rmssd: 55, .pnn50: 12, .lfPower: 1200, .hfPower: 900, .lfHfRatio: 1.33, ] let now = Date() for kind in MeasurementKind.allCases { @@ -105,6 +106,25 @@ final class EventBridgeTests: XCTestCase { } } + /// The demo/debug value table is keyed rather than switched, so nothing makes it exhaustive at + /// compile time. This does it at test time: a new `MeasurementKind` without a range would + /// otherwise silently seed 0–100 for a metric measured in ms² or as a ratio. + func testEveryMeasurementKindHasAMockRange() { + for kind in MeasurementKind.allCases { + guard let spec = MetricsService.mockValueRanges[kind] else { + XCTFail("new MeasurementKind \(kind) — add a plausible demo range to mockValueRanges") + continue + } + XCTAssertTrue( + RingEventBridge.events( + for: .historyMeasurement(kind: kind, value: spec.range.lowerBound, timestamp: Date()), + now: Date() + ).count == 1, + "\(kind)'s demo floor is outside its own persistence gate" + ) + } + } + func testHeartRateSampleMapsThrough() { let events = RingEventBridge.events(for: .heartRateSample(bpm: 72, timestamp: Date())) XCTAssertEqual(events.count, 1) diff --git a/PulseLoopTests/HrvDetailCapabilityTests.swift b/PulseLoopTests/HrvDetailCapabilityTests.swift new file mode 100644 index 0000000..d771fbe --- /dev/null +++ b/PulseLoopTests/HrvDetailCapabilityTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import PulseLoop + +/// The HRV panel exists on exactly one record — the YCBT `05 33` body data — so it must be invisible +/// on every ring that can't produce it, and offered only when the ring itself claims the bit that +/// gates that record. These pin both directions across all six supported families. +@MainActor +final class HrvDetailCapabilityTests: XCTestCase { + + /// Every coordinator, and everything it could ever grant (baseline plus bitmap-gated). + private var everyFamily: [(name: String, granted: Set)] { + let coordinators: [any WearableCoordinator] = [ + JringCoordinator(), ColmiCoordinator(), LuckRingCoordinator(), + ColmiSmartHealthCoordinator(), TK5Coordinator(), YCBTCoordinator(), + ] + return coordinators.map { + ("\(type(of: $0))", $0.capabilities.union($0.bitmapGatedCapabilities)) + } + } + + // MARK: - Which families can ever offer it + + /// The jring's `0x24` packet, the Colmi QRing's big-data channel and the LuckRing's K6 dataTypes + /// carry a single HRV scalar and nothing to break it down. None of them may claim the panel by + /// any route — the Autonomic screen is simply absent there. + func testNonYCBTFamiliesNeverOfferTheHrvPanel() { + let ycbtFamilies = ["ColmiSmartHealthCoordinator", "TK5Coordinator", "YCBTCoordinator"] + for family in everyFamily where !ycbtFamilies.contains(family.name) { + XCTAssertFalse(family.granted.contains(.hrvDetail), + "\(family.name) has no body-data record and must not claim .hrvDetail") + } + } + + /// …and all three YCBT families can, since they share one driver and one record. + func testEveryYCBTFamilyCanOfferTheHrvPanel() { + let coordinators: [any WearableCoordinator] = [ + ColmiSmartHealthCoordinator(), TK5Coordinator(), YCBTCoordinator(), + ] + for coordinator in coordinators { + XCTAssertTrue(coordinator.bitmapGatedCapabilities.contains(.hrvDetail), + "\(type(of: coordinator)) should defer the panel to the ring's own bitmap") + XCTAssertFalse(coordinator.capabilities.contains(.hrvDetail), + "\(type(of: coordinator)): a baseline entry would be an unconditional promise") + } + } + + // MARK: - The bit itself + + /// The panel rides `IS_HAS_PRESSURE` (byte 22, bit 6) — the bit the vendor app gates the whole + /// `05 33` query on — so it is claimed exactly when stress and fatigue are. + func testPanelIsClaimedWithStressAndFatigue() { + var bitmap = [UInt8](repeating: 0, count: 32) + bitmap[22] = 1 << 6 + let claimed = YCBTSupportFunction.capabilities(from: bitmap) + + XCTAssertTrue(claimed.contains(.hrvDetail)) + XCTAssertTrue(claimed.contains(.stress)) + XCTAssertTrue(claimed.contains(.fatigue)) + } + + /// `ISHASHRV` (byte 1, bit 1) governs the single HRV scalar, which reaches the app from the + /// `05 09` combined record and the `06 03` live stream. A ring with HRV but no body-data record + /// must get the scalar and *not* the panel — this is the distinction the separate capability exists + /// to draw. + func testHrvScalarBitAloneDoesNotGrantThePanel() { + var bitmap = [UInt8](repeating: 0, count: 32) + bitmap[1] = 1 << 1 + let claimed = YCBTSupportFunction.capabilities(from: bitmap) + + XCTAssertTrue(claimed.contains(.hrv), "the scalar is claimed") + XCTAssertFalse(claimed.contains(.hrvDetail), "the breakdown is not") + } + + /// The real R99 (firmware 2.32) leaves `IS_HAS_PRESSURE` clear — byte 22 is `0x20`, bit 5 not + /// bit 6 — and NAKs `05 33` outright. It must resolve to no panel, exactly as it resolves to no + /// stress and no fatigue. + func testTheRealR99GetsNoPanel() { + let claimed: [UInt8] = [ + 0xf9, 0x09, 0x00, 0x00, 0x00, 0x00, 0x0c, 0xd8, + 0x10, 0x04, 0x01, 0xb2, 0xb6, 0x00, 0x40, 0x0f, + 0x00, 0x14, 0x50, 0x00, 0x00, 0x00, 0x20, 0x00, + ] + let bitmap = claimed + [UInt8](repeating: 0, count: 60 - claimed.count) + let derived = YCBTSupportFunction.capabilities(from: bitmap) + XCTAssertFalse(derived.contains(.hrvDetail)) + + let refined = ColmiSmartHealthCoordinator().refinedCapabilities(bitmapDerived: derived) + XCTAssertFalse(refined.contains(.hrvDetail), + "a ring that NAKs the body-data record must not offer its fields") + } + + /// A truncated or garbage bitmap means "no opinion", so the family baseline stands — and since + /// the panel is never a baseline entry, that resolves to no panel rather than to one granted by + /// accident. + func testATruncatedBitmapDoesNotGrantThePanel() { + for length in [0, 5, 13] { + let refined = TK5Coordinator().refinedCapabilities( + bitmapDerived: YCBTSupportFunction.capabilities(from: [UInt8](repeating: 0xFF, count: length)) + ) + XCTAssertFalse(refined.contains(.hrvDetail), "length \(length)") + } + } + + // MARK: - Persistence round-trip + + /// Capabilities persist as a CSV on `Device`, so a newly appended case has to survive the trip — + /// otherwise the panel would vanish whenever the set is read back from the store rather than the + /// live connection. + func testPanelCapabilitySurvivesTheCSVRoundTrip() { + let original: Set = [.heartRate, .hrv, .hrvDetail, .stress] + XCTAssertEqual(Set(csv: original.csv), original) + XCTAssertTrue(original.csv.contains("hrvDetail")) + } + + // MARK: - The kinds it gates + + /// The panel's `MeasurementKind`s deliberately have no `MetricKey`, which is what keeps them off + /// Today and Vitals entirely — they are reachable only through the HRV detail screen. + func testPanelKindsAreNotDashboardMetrics() { + let dashboardKinds = Set(MetricKey.allCases.map(\.rawValue)) + for kind in MeasurementKind.autonomicKinds { + XCTAssertFalse(dashboardKinds.contains(kind.rawValue), + "\(kind) must not be a dashboard card — it lives behind the HRV screen") + } + } +} diff --git a/PulseLoopTests/PairingMatchingTests.swift b/PulseLoopTests/PairingMatchingTests.swift index d049619..cf8b6b6 100644 --- a/PulseLoopTests/PairingMatchingTests.swift +++ b/PulseLoopTests/PairingMatchingTests.swift @@ -574,7 +574,7 @@ final class PairingMatchingTests: XCTestCase { XCTAssertEqual(refined, [ .heartRate, .spo2, .spo2History, .steps, .battery, - .hrv, .manualHrv, .bloodPressure, .manualBloodPressure, + .hrv, .manualHrv, .hrvDetail, .bloodPressure, .manualBloodPressure, .temperature, .stress, .fatigue, .bloodSugar, .sleep, .remSleep, .manualHeartRate, .manualSpo2, @@ -588,17 +588,18 @@ final class PairingMatchingTests: XCTestCase { /// `ISHASFATIGUE` in the SDK; what there is, is `DataSyncUtils` gating the *entire* body-data query /// (`05 33` — the record that carries both scores, as its `pressure` and `body` fields) on /// `IS_HAS_PRESSURE`. A ring with that bit clear is never even asked for the record, so it can no more - /// produce a fatigue score than a stress one. The two therefore arrive and depart together. + /// produce a fatigue score than a stress one. The two therefore arrive and depart together — as does + /// `.hrvDetail`, the HRV panel that lives in the same record's bytes @14–24. func testFatigueAndStressAreClaimedTogetherOrNotAtAll() { let tk5 = TK5Coordinator() var bodyDataRing = [UInt8](repeating: 0, count: 27) bodyDataRing[22] = 1 << 6 // IS_HAS_PRESSURE let claimed = YCBTSupportFunction.capabilities(from: bodyDataRing) - XCTAssertEqual(claimed, [.stress, .fatigue]) + XCTAssertEqual(claimed, [.stress, .fatigue, .hrvDetail]) let refined = tk5.refinedCapabilities(bitmapDerived: claimed) - XCTAssertEqual(refined, tk5.capabilities.union([.stress, .fatigue])) + XCTAssertEqual(refined, tk5.capabilities.union([.stress, .fatigue, .hrvDetail])) // Never one without the other, whichever way the bit falls. XCTAssertEqual(refined.contains(.stress), refined.contains(.fatigue)) let silent = tk5.refinedCapabilities(bitmapDerived: []) diff --git a/PulseLoopTests/YCBTHealthRecordsTests.swift b/PulseLoopTests/YCBTHealthRecordsTests.swift index 7ee6392..76400a2 100644 --- a/PulseLoopTests/YCBTHealthRecordsTests.swift +++ b/PulseLoopTests/YCBTHealthRecordsTests.swift @@ -217,6 +217,65 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(timestamps(in: events).first, YCBTBytes.date(836_694_044)) } + /// The HRV panel out of the same captured record: `sdnn:u16@14` = 48 ms, `pnn50@17` = 12 %, + /// `rmssd:u16@18` = 55 ms, `lf:u16@20` = 1200, `hf:u16@22` = 900. All five land in ordinary adult + /// resting ranges, which is the corroboration that the offsets are right — a misread would put + /// them in the thousands or at zero. + func testBodyDataDecodesTheHrvPanel() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord) + XCTAssertEqual(values(.sdnn, in: events).first ?? 0, 48, accuracy: 0.001) + XCTAssertEqual(values(.pnn50, in: events).first ?? 0, 12, accuracy: 0.001) + XCTAssertEqual(values(.rmssd, in: events).first ?? 0, 55, accuracy: 0.001) + XCTAssertEqual(values(.lfPower, in: events).first ?? 0, 1200, accuracy: 0.001) + XCTAssertEqual(values(.hfPower, in: events).first ?? 0, 900, accuracy: 0.001) + XCTAssertEqual(MeasurementKind.rmssd.unit, "ms") + XCTAssertEqual(MeasurementKind.pnn50.unit, "%") + XCTAssertEqual(MeasurementKind.lfHfRatio.unit, "", "a ratio of two powers is dimensionless") + } + + /// **The cross-check that settles byte 24.** The record carries its own `lfHf` at @24, a single + /// byte standing in for a ratio whose real range is ~0.5–3 — so it must be scaled, and the SDK + /// never says by how much. PulseLoop doesn't guess: it recomputes the ratio from the LF and HF + /// powers, whose shared (and also unstated) scale cancels in the quotient. + /// + /// Here the two agree — @24 is `0x0d` = 13, i.e. 1.3 at a ÷10 scale, against 1200 ÷ 900 = 1.33 — + /// which corroborates the whole panel's offsets from real captured bytes rather than inference. + /// The derived value is still the one published, because it stays right even on a firmware that + /// scales @24 differently. + func testLfHfRatioIsDerivedFromThePowersAndAgreesWithTheRecordsOwnByte() { + let events = YCBTHealthRecords.bodyData(capturedBodyRecord) + let derived = values(.lfHfRatio, in: events).first ?? 0 + XCTAssertEqual(derived, 1200.0 / 900.0, accuracy: 0.0001) + + let onWireTenths = Double(capturedBodyRecord[24]) / 10 + XCTAssertEqual(derived, onWireTenths, accuracy: 0.05, + "the derived ratio agrees with the record's own lfHf byte at a ÷10 scale") + } + + /// A record reporting no HRV at all fills the whole panel with zeros, and zero is the ring's + /// "no sample" filler — not a measurement of zero variability. + func testAllZeroHrvPanelPublishesNothing() { + var record = capturedBodyRecord + for index in 14...24 { record[index] = 0 } + let events = YCBTHealthRecords.bodyData(record) + + for kind in MeasurementKind.autonomicKinds { + XCTAssertTrue(values(kind, in: events).isEmpty, "\(kind) should be absent, not zero") + } + XCTAssertEqual(values(.hrv, in: events).count, 1, "the HRV scalar at @6 is unaffected") + } + + /// LF present but HF missing can't produce a ratio — and must not divide by zero. + func testRatioNeedsBothPowers() { + var record = capturedBodyRecord + record[22] = 0; record[23] = 0 // hf = 0 + let events = YCBTHealthRecords.bodyData(record) + + XCTAssertEqual(values(.lfPower, in: events).first ?? 0, 1200, accuracy: 0.001) + XCTAssertTrue(values(.hfPower, in: events).isEmpty) + XCTAssertTrue(values(.lfHfRatio, in: events).isEmpty) + } + /// The scale, at the seam where it is easy to get wrong: a whole-number score has fraction 0, and /// `(7, 0)` is 70 — not 7, and not 7.0. `RingEventBridge.stressRange` (1…100) can't catch a 10× /// error, so this is the only place it is pinned. @@ -336,8 +395,8 @@ final class YCBTHealthRecordsTests: XCTestCase { XCTAssertEqual(YCBTHealthRecords.decode(blood, type: .blood).count, 3) // sys + dia + hr XCTAssertEqual(YCBTHealthRecords.decode(sport, type: .sport).count, 1) XCTAssertEqual(YCBTHealthRecords.decode(temperature, type: .temperature).count, 2) - // hrv + stress + fatigue + vo2max - XCTAssertEqual(YCBTHealthRecords.decode(capturedBodyRecord, type: .bodyData).count, 4) + // hrv + stress + fatigue + vo2max + the HRV panel (sdnn, pnn50, rmssd, lf, hf, lf/hf) + XCTAssertEqual(YCBTHealthRecords.decode(capturedBodyRecord, type: .bodyData).count, 10) // 8 records × (systolic + diastolic + spo2 + respiratory rate + hrv); temp and blood sugar are // the unmeasured fillers in this capture, and the cumulative step counter is not published. XCTAssertEqual(YCBTHealthRecords.decode(capturedAllRecords, type: .all).count, 8 * 5) diff --git a/PulseLoopTests/YCBTSupportFunctionTests.swift b/PulseLoopTests/YCBTSupportFunctionTests.swift index a7a8b16..ac643ee 100644 --- a/PulseLoopTests/YCBTSupportFunctionTests.swift +++ b/PulseLoopTests/YCBTSupportFunctionTests.swift @@ -46,7 +46,9 @@ final class YCBTSupportFunctionTests: XCTestCase { (15, 2, [.manualBloodPressure]),// ISHASTESTBLOOD (15, 3, [.manualSpo2]), // ISHASTESTSPO2 (17, 3, [.bloodSugar]), // ISHASBLOODSUGAR - (22, 6, [.stress, .fatigue]), // IS_HAS_PRESSURE — the whole `05 33` record + // IS_HAS_PRESSURE gates the whole `05 33` record, so it yields every capability whose + // data lives in it: stress (@8–9), fatigue (@10–11) and the HRV panel (@14–24). + (22, 6, [.stress, .fatigue, .hrvDetail]), (23, 0, [.manualHrv]), // IS_HAS_HRV_MEASUREMENT ] for entry in expected { @@ -80,7 +82,7 @@ final class YCBTSupportFunctionTests: XCTestCase { YCBTSupportFunction.capabilities(from: allOnes), [ .steps, .sleep, .heartRate, .bloodPressure, .spo2, .hrv, .findDevice, .temperature, - .bloodSugar, .stress, .fatigue, + .bloodSugar, .stress, .fatigue, .hrvDetail, .manualHeartRate, .manualBloodPressure, .manualSpo2, .manualHrv, ] ) @@ -154,7 +156,7 @@ final class YCBTSupportFunctionTests: XCTestCase { XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 22, set: [(22, 6)])), []) XCTAssertEqual( YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(22, 6)])), - [.stress, .fatigue] + [.stress, .fatigue, .hrvDetail] ) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 23, set: [(23, 0)])), []) XCTAssertEqual(YCBTSupportFunction.capabilities(from: bitmap(length: 24, set: [(23, 0)])), [.manualHrv]) @@ -249,7 +251,7 @@ final class YCBTSupportFunctionTests: XCTestCase { guard case let .supportFunctions(claimed) = events.first else { return XCTFail("expected .supportFunctions, got \(events)") } - XCTAssertEqual(claimed, [.heartRate, .spo2, .stress, .fatigue]) + XCTAssertEqual(claimed, [.heartRate, .spo2, .stress, .fatigue, .hrvDetail]) } /// The `02 1b` reply reaches the debug feed with its value (`InnerUtils.isJieLiChipScheme`: 3/4/5). diff --git a/docs/hardware/index.md b/docs/hardware/index.md index a11d7a5..f57c911 100644 --- a/docs/hardware/index.md +++ b/docs/hardware/index.md @@ -162,6 +162,7 @@ been validated end to end on hardware. | Blood pressure | ✅² | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪 | ❔⁷ | | Blood sugar | ✅³ | ❌ | ❌ | ❌ | ❌ | ❔⁷ | 🧪⁴ | ❔⁷ | | HRV | ✅ | ✅ | ✅ | ✅ | ✅ | 🧪 | 🧪 | ❔⁷ | +| HRV detail (SDNN/RMSSD/pNN50/LF-HF)¹¹ | ❌ | ❌ | ❌ | ❌ | ❌ | ❔⁷ | ❔⁷ | ❔⁷ | | Stress | ✅ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪 | ❔⁷ | | Fatigue | ✅ | ✅ | ✅ | ✅ | ✅ | ❌⁸ | 🧪 | ❔⁷ | | Skin temperature | ❌ | ✅ | ✅ | ✅ | ✅ | ❔⁷ | 🧪⁴ | ❔⁷ | @@ -180,6 +181,8 @@ been validated end to end on hardware. ⁸ Not claimed: no capability bit names fatigue, so it can be neither gated nor honestly promised on hardware nobody has connected. The first real sync decides. ¹⁰ From the `05 09` combined record, not a dedicated log — the R10M has no `05 1A` all-day SpO₂ history, so PulseLoop never issues that query for it. +¹¹ The panel behind the single HRV number, and a YCBT exclusive: it lives entirely in the `05 33` body-data record, which the 56ff, QRing-Colmi and LuckRing protocols have no equivalent of. On the YCBT families it is claimed **per ring**, riding `IS_HAS_PRESSURE` — the same bit that gates stress and fatigue, because all three are fields of that one record. Neither Oura nor Ultrahuman surfaces this. See [HRV detail](../project/hrv-metrics.md). + The TK5 stores stress and fatigue on the ring itself (the body-data record), which an earlier version of this page denied — it also reads respiratory rate and VO₂max, which no other supported ring exposes. Its whole column is 🧪 until the [on-device checkpoint](tk5.md#needs-on-device-confirmation) clears. ## Not Supported by PulseLoop diff --git a/docs/project/hrv-metrics.md b/docs/project/hrv-metrics.md new file mode 100644 index 0000000..9930e9f --- /dev/null +++ b/docs/project/hrv-metrics.md @@ -0,0 +1,147 @@ +--- +title: HRV detail +description: The HRV panel — SDNN, RMSSD, pNN50 and LF/HF — where each value comes from, how it is computed, which rings have it, and what PulseLoop deliberately refuses to show. +--- + +# HRV detail + +Every supported ring reports a single **HRV** number. Some also report the panel behind it: the +standard time- and frequency-domain measures that number is summarising. + +That panel is what this page documents. It is worth noting that **neither Oura nor Ultrahuman +surfaces it** — both show one HRV figure — so on the right hardware a $20 ring gives you strictly +more autonomic detail than a $349 one. + +The decoder is +[`YCBTHealthRecords.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/RingProtocol/YCBTHealthRecords.swift) +and the screen is +[`AutonomicDetailView.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Views/AutonomicDetailView.swift). +Both are covered by tests that lock every number on this page. + +## Where to find it + +**Vitals → HRV → HRV detail.** + +Deliberately two taps in, and deliberately not on Today or Vitals as cards. These are numbers you +read occasionally to understand a trend, not ones you glance at — six more dashboard tiles would +bury the metrics people actually open the app for. The panel's `MeasurementKind`s have no +`MetricKey`, which is what makes a dashboard card structurally impossible rather than merely +absent today. + +## Which rings have it + +| Family | HRV scalar | HRV panel | +|---|---|---| +| jring / 56ff | ✅ | ❌ — no body-data record exists | +| Colmi / Yawell (QRing) | ✅ | ❌ — no body-data record exists | +| LuckRing / TK18 | ✅ | ❌ — no body-data record exists | +| Colmi / Yawell (SmartHealth) | ❔ per ring | ❔ per ring | +| TK5 | ❔ per ring | ❔ per ring | +| R10M / LittleMeatball | ❔ per ring | ❔ per ring | + +The panel lives entirely in the YCBT **body-data record** (`05 33`), so only the three YCBT +families can produce it at all — and among those it is claimed **per ring**, not per family. + +### The gate + +`WearableCapability.hrvDetail` rides **`IS_HAS_PRESSURE` (byte 22, bit 6)** of the ring's own +`02 01` capability bitmap. That is the bit the vendor SDK gates the entire `05 33` query on: + +```java +if (YCBTClient.isSupportFunction(FunctionConstant.IS_HAS_PRESSURE)) { + arrayList.add(DATATYPE.Health_History_Body_Data); +} +``` + +A ring with that bit clear is never asked for the record, so it can no more produce SDNN than it +can produce a stress score — which is why the panel is claimed and withheld together with +`.stress` and `.fatigue`, the two other fields of that same record. + +!!! note "Why not `ISHASHRV`?" + Byte 1 bit 6 governs the **scalar**, which arrives from a different record (`05 09`) and from + the `06 03` live stream. A ring can have HRV and still have no body-data record to break it + down, so the panel needs its own capability rather than riding `.hrv`. + +The real **R99** (firmware 2.32) is the worked example: byte 22 is `0x20` — bit 5, not bit 6 — and +it NAKs `05 33` outright, so it gets the scalar and no panel. + +## The metrics + +Byte offsets are within the 28-byte body-data record (`DataUnpack` case 51); `u16` is +little-endian. + +| Metric | Offset | Unit | Plausible range | What it is | +|---|---|---|---|---| +| **RMSSD** | `u16` @18 | ms | 1–500 | Root mean square of successive beat-interval differences. The measure most tied to rest and recovery — it tracks parasympathetic ("rest and digest") activity and falls after hard training, alcohol, or a poor night. | +| **SDNN** | `u16` @14 | ms | 1–500 | Standard deviation of beat intervals across the window. Captures slower rhythms than RMSSD, so it moves more with recording length than with any single night. | +| **pNN50** | `u8` @17 | % | 0–100 | Share of consecutive beats differing by more than 50 ms. Moves with RMSSD; another read on parasympathetic activity. | +| **LF power** | `u16` @20 | ms² | 0–50 000 | Power in the low-frequency band. Often called sympathetic, but it reflects a mix including blood-pressure regulation. | +| **HF power** | `u16` @22 | ms² | 0–50 000 | Power in the high-frequency band, driven largely by breathing. Rises with slow, deep breathing and restful sleep. | +| **LF/HF** | *derived* | ratio | 0.01–20 | Balance between the two bands. Commonly read as stress-versus-recovery, though that reading is debated. | + +Ranges are enforced in +[`RingEventBridge`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/RingProtocol/RingEventBridge.swift) +before anything is persisted. They are misframe guards, not physiological claims — a value outside +them indicates a misdecoded record, since a ring replays its whole log on every sync and one bad +record would otherwise re-persist forever. + +Zero means **absent**, not measured-as-zero: `0` is the ring's "no sample" filler across the whole +panel. + +### LF/HF is computed, not read + +The record carries its own `lfHf` at **@24** — and PulseLoop ignores it. + +It is a single byte standing in for a ratio whose real range is roughly 0.5–3, so it must carry an +implicit scale factor, and the SDK never states one. Rather than guess, the ratio is recomputed: + +``` +LF/HF = lfPower / hfPower +``` + +Whatever common scale LF and HF share **cancels in the quotient**, so the derived ratio is correct +even though the absolute powers' unit is inferred. + +The captured hardware record the tests are built on confirms the approach: @24 reads `0x0d` = 13, +i.e. 1.3 at a ÷10 scale, against a derived 1200 ÷ 900 = **1.33**. The two agree — which is also +the corroboration that every offset in the table above is right, since a misread would put these +values in the thousands or at zero rather than in ordinary adult resting ranges. + +## No zone colouring, and why + +Unlike heart rate or SpO₂, these have no population-normal band worth drawing. RMSSD alone spans +roughly an order of magnitude across healthy adults and shifts with age, fitness, posture and +recording length. Painting a green/amber/red band on that would be inventing a threshold — exactly +the black box this project exists to avoid. + +Read them as trends against your own history. + +## Not exported to Apple Health + +HealthKit models HRV as one type, `heartRateVariabilitySDNN`, and has nothing for RMSSD, pNN50 or +spectral power. The one that could map — SDNN — is already occupied by the ring's HRV scalar, and +whether that scalar simply *is* the ring's SDNN is unverified. Exporting both would either +double-report one measurement or silently disagree with itself, so the panel stays in-app until a +hardware session settles which is which. + +## What we deliberately do not surface + +The same records carry several other fields. They are decoded past, not accidentally missed: + +| Field | Where | Why not | +|---|---|---| +| **Load index** | `05 33` @4–5 | Vendor-proprietary composite. No stated scale, no unit, no published definition — nothing to check it against. | +| **Sympathetic tone** | `05 33` @12–13 | Same: a proprietary index, not a standard measure. | +| **Body fat** | `05 09` @15–16 | The ring has no bioimpedance sensor. Any figure here is derived from the profile you typed in, not measured. | +| **Uric acid** | `05 2F` @7–9 | No optical ring can measure uric acid. | +| **Blood ketones** | `05 2F` @10–12 | No optical ring can measure ketones. | +| **Blood lipids** | `05 2F`, four fractions | No optical ring can measure a lipid panel. | + +The bottom four are the reason this section exists. Surfacing them would make the app look more +capable and would be straightforwardly dishonest — the hardware cannot measure them, whatever the +firmware reports. (The same reasoning is already applied to the jring's "blood sugar", which is a +profile-derived estimate and is labelled as one everywhere it appears.) + +The top two are a softer call: they are real outputs of a real algorithm, but with no definition +to hold them to, showing them would be reporting a number rather than a metric. If the vendor +scale is ever pinned down, they can join the panel. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..40520c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - HRV detail: project/hrv-metrics.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md