feat(sensor): add experimental RR_INTERVAL beat-to-beat pathway - #220
feat(sensor): add experimental RR_INTERVAL beat-to-beat pathway#220tobymurray wants to merge 1 commit into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds experimental RR interval sensor support. The change adds a sensor type, an ChangesRR interval support
Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp`:
- Around line 139-149: In
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp:139-149,
update getSource() to compare SOURCE as a float against valid Source values and
return UNKNOWN for non-integral, non-finite, negative, or out-of-range metadata
before any narrowing conversion. In the same file at 203-208, update getFlags()
to validate FLAGS as finite, non-negative, and integral before converting it to
an integer type.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 05d6169e-9e7b-456e-b42d-4df7f566a82e
📒 Files selected for processing (4)
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hppLibs/Header/SDK/SensorLayer/SensorTypes.hppTests/Host/CMakeLists.txtTests/Host/sensorlayer/RrIntervalParser_test.cpp
7c1b7db to
c469d04
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp (1)
147-155: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winReject fractional source and flag metadata.
1.5fcurrently becomesSource::OPTICALandDISCONTINUITY. Metadata values must be exact discrete values before conversion.
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp#L147-L155: comparesdirectly with the validSourcefloat values. Do not narrow it first.Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp#L216-L220: after the safe range check, reject a value when conversion touint32_tdoes not round-trip to the original float.Tests/Host/sensorlayer/RrIntervalParser_test.cpp#L106-L128: add1.5ftobadand expectUNKNOWNwith all flags clear.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp` around lines 147 - 155, Reject fractional metadata values before narrowing: in Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp lines 147-155, update the source parsing switch to compare s directly against the valid Source float values; in lines 216-220, after the safe range check, reject values whose uint32_t conversion does not round-trip to the original float. In Tests/Host/sensorlayer/RrIntervalParser_test.cpp lines 106-128, add 1.5f to the invalid inputs and verify the result is UNKNOWN with all flags clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp`:
- Around line 112-134: Update SensorDataParserRrInterval::isDataValid() to
require the RR_MS field to be present, finite, and strictly greater than zero,
using the appropriate standard finite-value check. Ensure getRrMs(), getBpm(),
and timestamp accessors rely on this validity result so malformed frames expose
invalid/default values, and add coverage for NaN, infinity, zero, and negative
RR_MS inputs.
In `@Tests/Host/sensorlayer/RrIntervalParser_test.cpp`:
- Around line 21-25: Update the RrData storage in the RrData::data() path so it
returns an actually constructed SDK::Sensor::Data object under the existing
C++17 test target, using placement construction and the required lifetime-safe
access. Preserve the aligned buffer layout and operator-> behavior.
---
Duplicate comments:
In `@Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp`:
- Around line 147-155: Reject fractional metadata values before narrowing: in
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp lines
147-155, update the source parsing switch to compare s directly against the
valid Source float values; in lines 216-220, after the safe range check, reject
values whose uint32_t conversion does not round-trip to the original float. In
Tests/Host/sensorlayer/RrIntervalParser_test.cpp lines 106-128, add 1.5f to the
invalid inputs and verify the result is UNKNOWN with all flags clear.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e298c0f8-e727-42df-a880-35c6429251b0
📒 Files selected for processing (4)
Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hppLibs/Header/SDK/SensorLayer/SensorTypes.hppTests/Host/CMakeLists.txtTests/Host/sensorlayer/RrIntervalParser_test.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
- Tests/Host/CMakeLists.txt
- Libs/Header/SDK/SensorLayer/SensorTypes.hpp
|
Thanks for this, and for framing the open questions so precisely — that made them answerable. All three have answers and they're favourable, so this shouldn't be closed. Details below, then review comments. Open questions1. Is the R-R field recoverable before HR-source arbitration collapses it? Yes.The strap's Extending it means widening the decoded-sample type (today it carries kind/value/trust) or adding a parallel R-R callback, then publishing 0x44 from a new sub-sensor. Nothing structural in the way. One implementation note for whoever writes it: the decoder currently only knows the 16-bit-value, contact-detected and contact-supported flag bits. A producer must also account for the optional 2-byte energy-expended field (bit 3), which sits before the R-R array when present, or the offsets will be wrong on straps that report it. 2. Per-beat timestamps and decimation. Resolved — and you don't actually need per-beat timestamps.Your read of the rate adapter is correct, and I measured it rather than trusting the reading: five R-R values sharing one arrival instant, pushed through the periodic path against a 1000 ms listener period, deliver 1 of 5. Exactly the failure you predicted. But sensors registered as Distinct per-beat timestamps also work as a fallback (5 of 5 delivered), and the adapter is more forgiving than "gates on the frame timestamp" implies — the emit rule passes anything slower than roughly 2x the requested period. Measured against a 1000 ms listener period: 40–90 bpm passes 100%, 120 bpm drops to ~50%, 180 bpm to ~33%. Requesting 500 ms restores 100% at every rate. Important for your planned simulator replay mock: the SDK simulator does not currently implement that event-based bypass. 3. Who owns the tick to ms conversion? Producer-side, and float milliseconds is safe to pin.1/1024 s = 125/128 ms = 0.9765625 ms is exactly representable in binary floating point, and n x 125/128 stays exact in float32 across any plausible tick count. Converting producer-side is therefore lossless, and field [0] as float milliseconds is the contract — no silent unit change waiting to happen. On the premiseOne correction worth making before it becomes the quotable justification in the commit history: the "the wrist sensor is below the HRV floor" framing is no longer accurate. Without going into specifics, we're now evaluating a wrist-optical path that would also produce per-beat intervals. That makes this contract more valuable rather than less. A wire format validated by two independent producers is in far better shape than one designed against a single hypothetical, and it means Review commentsBlocking
Decide float vs Worth adding while it's still freeA graded confidence field at [3]. A realistic optical producer can supply a per-beat confidence value rather than a boolean. Projecting that down onto the 1-bit Smaller points
Not yours, but notedYour second commit's narrowing fix applies verbatim to Net: the design holds up, the open questions are answered, and the two blocking items are both small. Happy to see this land once those are addressed. |
35f467b to
6d58e23
Compare
getSource() narrowed the SOURCE field to uint8_t to switch on it. That is undefined behaviour when the field holds NaN, either infinity, or anything outside the destination range, and the switch default cannot save it because the cast forms the switch condition. UBSan reports it on a NaN source. Matching the float against the wire values instead removes the conversion altogether, and fixes a second-order bug on the way: a fractional 1.5f used to truncate into OPTICAL and 2.5f into EXTERNAL, where only exact values are a source. The Source values are small integers and exactly representable, so the equality is not fragile. Latent rather than live -- the kernel writes 0/1/2 and reaching this needs corrupt data -- but the parser is the boundary where corrupt data should stop. Noted by rryles reviewing UNAWatch#220, which hit the same pattern in a new parser. HEART_RATE_EX cannot take that PR's fix of carrying the field as u32, since it already has a shipped kernel producer and five app consumers, so it takes the float-space guard instead.
getSource() narrowed the SOURCE field to uint8_t to switch on it. That is undefined behaviour when the field holds NaN, either infinity, or anything outside the destination range, and the switch default cannot save it because the cast forms the switch condition. UBSan reports it on a NaN source. Matching the float against the wire values instead removes the conversion altogether, and fixes a second-order bug on the way: a fractional 1.5f used to truncate into OPTICAL and 2.5f into EXTERNAL, where only exact values are a source. The Source values are small integers and exactly representable, so the equality is not fragile. Latent rather than live -- the kernel writes 0/1/2 and reaching this needs corrupt data -- but the parser is the boundary where corrupt data should stop. Noted by rryles reviewing UNAWatch#220, which hit the same pattern in a new parser. HEART_RATE_EX cannot take that PR's fix of carrying the field as u32, since it already has a shipped kernel producer and five app consumers, so it takes the float-space guard instead.
getSource() narrowed the SOURCE field to uint8_t to switch on it. That is undefined behaviour when the field holds NaN, either infinity, or anything outside the destination range, and the switch default cannot save it because the cast forms the switch condition. UBSan reports it on a NaN source. Matching the float against the wire values instead removes the conversion altogether, and fixes a second-order bug on the way: a fractional 1.5f used to truncate into OPTICAL and 2.5f into EXTERNAL, where only exact values are a source. The Source values are small integers and exactly representable, so the equality is not fragile. Latent rather than live -- the kernel writes 0/1/2 and reaching this needs corrupt data -- but the parser is the boundary where corrupt data should stop. Noted by rryles reviewing UNAWatch#220, which hit the same pattern in a new parser. HEART_RATE_EX cannot take that PR's fix of carrying the field as u32, since it already has a shipped kernel producer and five app consumers, so it takes the float-space guard instead.
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
PR UNAWatch#167 asked whether the watch emits per-beat events or only a raw PPG waveform, and shipped a probe so someone with hardware could answer it. It was declined, correctly -- a maintainer answered from firmware knowledge instead, so the code was never needed. The answer is the valuable part and it lived only in a closed PR's comments: HEART_BEAT emits nothing (HR detection is frequency-domain), PPG is 20 Hz single channel, and optical HRV can only ever work at rest because mid-exercise HRV requires an electrical measurement. That last point is physics, and it is what makes the chest-strap pathway in UNAWatch#220 structurally necessary rather than merely convenient -- so record the link between them explicitly. Keep the probe too. Every answer above is scoped to today's firmware, and UNA said a higher-rate PPG mode and on-chip HRV are both being explored, so re-running the diagnostic is the fastest way to find out what changed.
a5c508a to
c9dcd44
Compare
f3fd50b to
a5dfc76
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Docs/ExternalSensors.md`:
- Around line 119-124: Update the event-based delivery documentation in both
referenced examples to state that a 1 s-period adapter drops beats at or above
120 bpm, matching the strict “more than half a sample period” comparison;
preserve the existing explanation about beats sharing an arrival instant.
- Around line 169-175: Update the guidance in ExternalSensors.md to require
producers to write every registered field, including FLAGS, on every frame.
Clarify that DISCONTINUITY should be set only on the first frame after reconnect
and that subsequent frames should explicitly write the current flags, typically
zero, to prevent the reused Driver sample buffer from retaining the bit.
- Around line 88-89: Update the parser type reference in the RR_INTERVAL
documentation to use the public SDK name, SDK::SensorDataParser::RrInterval,
instead of SensorDataParserRrInterval, keeping the text aligned with the current
SDK header contract.
In `@Docs/SensorsLayer.md`:
- Around line 165-166: In Docs/SensorsLayer.md, add one blank line after each
**Fields**: label before its following table to satisfy markdownlint MD058,
including both affected table sections.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 82f1a02f-82c3-4da0-b98b-ce06973bc67d
📒 Files selected for processing (6)
Docs/ExternalSensors.mdDocs/SensorsLayer.mdLibs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hppLibs/Header/SDK/SensorLayer/SensorTypes.hppTests/Host/CMakeLists.txtTests/Host/sensorlayer/RrIntervalParser_test.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
- Libs/Header/SDK/SensorLayer/SensorTypes.hpp
- Tests/Host/CMakeLists.txt
- Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp
|
Thanks for the revisions — everything I flagged as blocking is properly addressed, and the Rather than send another round of requested changes, I've pushed two commits on top ( Defects — places a producer written faithfully from the header would still have been wrong
Proposals — your call, and easy to drop
The last two are downstream of the optical question. Without going into specifics: the wrist path we're evaluating produces one interval per beat with a per-beat graded confidence, which is why CodeRabbit threadsThree fixed: the parser type name in Two declined, each with the reasoning in the thread rather than here: the One structural question, which I deliberately haven't acted onAbout half the header is producer contract — Worth moving the producer half into Where it standsTests are at 19 and Could you review the two commits when you get a chance? I've changed wording you wrote and added contract rules you didn't, so I'd rather you checked them than inherited them — particularly the tolerance constant and the reserved |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Libs/Source/Simulator/Components/SensorDataQueue.cpp`:
- Around line 142-148: Update Sensor::DataQueue::pushData() to bypass
mSRA.shouldEmit() for event-based RR_INTERVAL frames, preserving every replayed
beat including shared or repeated timestamps. Retain the existing
SampleRateAdapter gating for periodic sensor frames, and add an integration test
covering replayed R-R beats.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 31c7e818-b8ff-472e-a90e-fabac8c8a418
📒 Files selected for processing (6)
Docs/ExternalSensors.mdLibs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hppLibs/Header/SDK/Simulator/Components/SampleRateAdapter.hppLibs/Source/Simulator/Components/SensorDataQueue.cppTests/Host/CMakeLists.txtTests/Host/simulator/SampleRateAdapter_test.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
- Tests/Host/CMakeLists.txt
- Docs/ExternalSensors.md
- Libs/Header/SDK/SensorLayer/DataParsers/SensorDataParserRrInterval.hpp
| * | ||
| * How much gets dropped for a given input rate is NOT "one sample per period" — | ||
| * see the delivery rule on Sensor::SampleRateAdapter, which is stated there once | ||
| * and pinned in Tests/Host/simulator/SampleRateAdapter_test.cpp. Note that what | ||
| * is handed to shouldEmit() is the frame timestamp in microseconds, not a clock | ||
| * read at call time, so a driver that back-dates a sample is gated on the | ||
| * back-dated instant. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Bypass rate gating for event-based RR_INTERVAL frames.
Sensor::DataQueue::pushData() still calls mSRA.shouldEmit() for every frame at Line 152. Replayed R-R frames with shared or repeated timestamps can therefore lose beats. SharedArrivalInstantKeepsOneOfN and RepeatedIdenticalInstantPassesOnce explicitly demonstrate this behavior.
Route event-based RR_INTERVAL frames around SampleRateAdapter. Keep rate gating for periodic sensors. Add a Sensor::DataQueue integration test for replayed R-R beats.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Libs/Source/Simulator/Components/SensorDataQueue.cpp` around lines 142 - 148,
Update Sensor::DataQueue::pushData() to bypass mSRA.shouldEmit() for event-based
RR_INTERVAL frames, preserving every replayed beat including shared or repeated
timestamps. Retain the existing SampleRateAdapter gating for periodic sensor
frames, and add an integration test covering replayed R-R beats.
8a8bf16 to
6264299
Compare
|
I was feeling a bit intimidated at the prospect of getting this all to land in one shot so I pulled the simulator work out as a follow on. I think that helps clarify that this contract has to stand on its own and what commitments can and cannot be met right now. Your two commits are folded into the squash, co-authored. Answers to what you raised:
The reserved
Removed, replaced by
I moved everything into the header to avoid the duplication and risk of drift. I generally come down on the side of preferring inline documentation vs. external documentation (though I acknowledge this one is beefy).
Restored after I had removed it as belonging to the adapter. You were right that a producer built today falls into the gap regardless of which component owns the rule. It is now a producer obligation anchored to the producer's own output rather than to any platform's behaviour, so it carries no borrowed authority. The header also separates its 240 bpm, an emission ceiling, from
No argument here but I've removed the simulator facet entirely from this PR to try and narrow the focus.
Not designed around at this stage, though I've also done nothing with it. It's captured as a bullet in the "Open questions" section of the description, but that's it so far. |
c0a9a9a to
fb29a02
Compare
|
I've been churning on this, I think the amount I've put in the code comments really becomes problematic as a contract. I'm going to pivot back the other direction (which you had done) and externalize the documentation. |
HRV and DFA-alpha1 -- the basis of readiness, recovery and training-threshold metrics -- are computed from beat-to-beat R-R intervals. HEART_RATE and HEART_RATE_EX both carry a rate, and no existing type carries the intervals themselves, even though a BLE strap reports them in the same 0x2A37 notification as the BPM and the decoder discards them. This adds SDK::Sensor::Type::RR_INTERVAL (0x44) and SensorDataParser::RrInterval so the HRV pipeline can be built against a fixed shape ahead of the firmware side. EXPERIMENTAL: 0x44 is not stable until a producer has been written against it. Two are in view, a BLE strap and a wrist-optical path, and the contract is shaped for both rather than for one. One interval per frame. Data has no in-band length field, so a frame cannot honestly carry a variable-length burst; the several intervals of one notification are several frames, and a consumer iterates DataBatch::size(). rr_ms is float, source and flags u32. A float32 mask is exact only below 2^24, so a sparse high bit would round a low one away, and the union's integer member also avoids float-to-int narrowing UB. The frame timestamp is the beat instant the interval ends on. That is the invariant, not how a producer reaches it. Unlike a field this cannot be tightened later, because a consumer relying on true beat instants breaks the moment a producer stops supplying them. Loss detection is an exported operation, checkContinuity(), not an exported number. There is no sequence number, so a disagreement between t[n] - t[n-1] and rr[n] is the only trace a dropped frame leaves. The comparison must be signed and in microseconds or a reordered pair wraps to an enormous forward gap, which is the shape every consumer given a bare threshold would get wrong. It takes a microsecond stamp: getTimestamp() sits beside getTimestampUs(), differs by a factor of 1000, and both reach uint64_t without a diagnostic, so the wrong one returns a confident GAP on a contiguous pair -- and a millisecond stamp is indistinguishable at runtime from a microsecond stamp of a session 1000x younger. The budget is a window rather than a bound, and errs loose deliberately: the expensive error is the false positive, which discards the windows the strap was connected for. It does not key on the stamping class, because sizing a tighter one for DETECTOR_STAMPED needs detector-to-stamp jitter and there is no detector. The consumer's half is written down because nothing can check it: the stamp passed in is the previous frame whose isDataValid() held, whatever verdict that frame got. The producer's half is in ExternalSensors.md, beside the 0x2A37 flags-byte walk it has to perform anyway -- the optional energy-expended field sits before the R-R array, and a decoder assuming a fixed offset misreads every interval on a strap that reports it. The header keeps the consumer contract and points at the page rather than carrying a copy. Why the budget is the size it is, and which values are still open, are in the pull request. SensorsLayer.md gains 0x44 and the HEART_RATE_EX row it never had. Co-authored-by: Ross Ryles <ross@unawatch.com>
fb29a02 to
189429c
Compare
feat(sensor): experimental RR_INTERVAL beat-to-beat pathway
An R-R interval is the gap between one heartbeat and the next. RMSSD, SDNN and DFA-alpha1 are computed from those gaps, and readiness, recovery and training-threshold metrics are built on those in turn.
Nothing in the SDK carries them.
HEART_RATEandHEART_RATE_EXboth carry a rate. A Bluetooth strap already sends the intervals to the watch in the same0x2A37notification the kernel reads for BPM, where the decoder drops them, so an app has nothing to compute HRV from. This adds the SDK side of a sensor type that keeps them:SDK::Sensor::Type::RR_INTERVAL(0x44) and a parser for it, opt-in and experimental. There is no firmware producer yet, so 0x44 is not stable until one has been written against it.None of this is Polar specific. R-R intervals are a standard field of the Bluetooth Heart Rate Service, so any compliant strap provides them the same way.
The firmware ask is to stop discarding the R-R field; the kernel already parses
0x2A37for BPM and the intervals are in the same bytes. One warning for whoever writes that. The R-R array is the last field of the notification and its offset moves, because flags bit 3 inserts a two byte energy-expended field ahead of it. Get that wrong and every interval is misread on a strap that reports it, and the misread values still look like plausible intervals.ExternalSensors.mdhas the layout.Decisions
The header keeps the consumer contract,
ExternalSensors.mdowns the producer half, and the reasoning below lives here rather than in either. In short:Two producers are in view, a strap and a wrist-optical path, and the contract is shaped for both rather than for one. That is why the continuity budget does not key on
Source, and why field[3]is reserved for a graded per-beat confidence rather than defined now.One interval per frame.
Datahas no length field, so a notification carrying several intervals arrives as several frames.rr_msis float milliseconds;sourceandflagsare u32, since a bitmask carried in a float is only exact below 2^24.The frame timestamp is the beat instant its interval ends on. This is the one decision that cannot be relaxed later, because a consumer relying on true beat instants breaks the moment a producer stops supplying them.
Loss detection is an exported operation,
checkContinuity(), rather than an exported threshold. The comparison has to be signed and in microseconds or a reordered pair wraps to an enormous forward gap, which is the shape every consumer given a bare number would get wrong independently. It also deletes the overloads that would widen a narrower stamp:getTimestamp()sits next togetTimestampUs()and differs by a factor of 1000, so passing the wrong one returns a confidentGAPon a contiguous pair with no diagnostic, and no runtime guard can catch that. How far that deletion reaches is under Open.Artefact policy belongs to the consumer, which is what
ARTIFACT_SUSPECTis for. No sequence number: it would be a second loss-detection mechanism whose obligation falls on a producer that does not exist, whenDISCONTINUITYalready covers the loss a producer knows about. Appending one stays safe, so that is revisitable.Pivots
kContiguityToleranceMsis gone, replaced bycheckContinuity()and a derived budget. Not because 250 ms was wrong, but because this repository cannot defend it. It was sized partly on the connection interval our accessory central negotiates, and the whole accessory surface here isKind,Stateand a device name. @rryles holds a figure this repository does not, and if it should be applied, the right shape is the two-argument overload called from the layer that knows the link.There is one continuity budget rather than one per stamping class. A tighter budget for
DETECTOR_STAMPEDwas written and then removed: its dominant term is detector-to-stamp jitter, nothing here can measure it, and neither producer in view sets the flag. The magnitude would have been a guess that producers were then required to hold under, and a producer that exceeded it would have every window discarded, which is the false positive this contract treats as the expensive error. The flag keeps its bit and its meaning, so a second budget lands with the detector that can size one, andcontinuityToleranceMs()stays a member function so that costs no consumer a call site.The 170 bpm sizing basis became
kGuaranteedDetectableBpmat 200 bpm, pinned to 220-minus-age at age 20.The minimum delivery period is restored as a producer obligation, and is now anchored to the producer's own shortest interval rather than to the simulator's rate adapter, so it carries no borrowed authority. A lost beat reads
CONTIGUOUSonce the whole missing interval fits inside the budget, which the margin puts at a rate abovekGuaranteedDetectableBpmrather than at it; a stated residual with a test.You asked whether the producer half belonged in
ExternalSensors.mdbeside the0x2A37decode notes, leaving the header with the consumer contract and a pointer. I said no. That was the wrong call and I have reversed it.Concentrating the prose in the header did not prevent drift; it put the copy that rots into the artifact that ships, and the duplication it was meant to avoid existed anyway. So the split is the one you proposed: the header keeps the consumer contract,
ExternalSensors.mdowns the producer half beside the0x2A37walk it already owned, and this description carries the reasoning.Open
Source::ECG = 3is not held by astatic_assert. The kernel HR arbiter defines these values and has none for ECG, unlike the other three. If the arbiter later assigns 3 to something else, every frame already written is mislabelled, so this wants settling before anything ships against 0x44.kLostBeatMarginFractionis chosen with no measurement behind its magnitude, and it may point the wrong way. A larger margin narrows the budget and so buys more false positives, and HRV has largely collapsed by 200 bpm, so a smaller figure looks more consistent with the rest of the file. Beat-to-beat variation measured near the detection ceiling would settle it.checkContinuity({getTimestamp()})compiles and reads a confidentGAPon a contiguous pair; and by a pointer-to-member, which names the surviving overload directly. Widening the overload set closes one route and opens another, because a predicate can only refuse the spellings someone thought of. Closing the class wants a tagged microsecond instant instead, and the only callers today are this file's own tests, so it is cheaper now than at any later point.HEART_BEAT(0x40): it is declared but has no parser and no field layout, and differencing beat-peak instants would put the interval reconstruction, and the loss detection with it, in every consumer.HeartRateEx::getSource()has the same unguarded float narrowing this fixed