Skip to content

v26: decode the per-record metadata, including the channel index at frame-abs 25 - #24

Open
sunny-noop wants to merge 1 commit into
OpenStrap:mainfrom
sunny-noop:feat/v26-record-metadata
Open

v26: decode the per-record metadata, including the channel index at frame-abs 25#24
sunny-noop wants to merge 1 commit into
OpenStrap:mainfrom
sunny-noop:feat/v26-record-metadata

Conversation

@sunny-noop

Copy link
Copy Markdown

v26: decode the per-record metadata the record already carries

Gen5PpgWaveform currently surfaces four fields. The record carries more, and one of the bytes it
drops is the one that separates the interleaved optical channels. Without it a burst is several
channels mixed together, which is why anything downstream of the v26 waveform is hard to make work.

Scope: gen5 v26 only. Corpus is 86,859 v26 records from two bands worn by two different people
(18,023 and 68,836). Every record observed has an inner length of 76.

1. Frame-abs 25 is an acquisition-channel index (0..7)

This is the field the current burstIndex doc goes looking for and correctly rules out ("NOT a
channel/LED id, ranges past 26"). It is a different byte. burstIndex at frame-abs 21 does indeed run
1..72 and 1..84 on the two bands, so that note stands. Frame-abs 25 is the channel index.

Distribution:

value band A band B
0 1,505 4,933
1 4,231 12,268
2 4,674 17,221
3 3,669 21,061
4 2,451 9,656
5 694 2,215
6 147 652
7 133 81
0xFD to 0xFF 519 (0.3%) 749 (0.1%)

Three things say "channel" rather than "another counter":

It multiplexes inside a single burst. Splitting the stream into bursts on a gap of more than 5 s,
53% of bursts on band A and 59% on band B contain more than one value of this byte, in short runs. A
time or sequence counter would not alternate and come back.

Each value runs its own gain. The gain bytes at frame-abs 79 and 80 have a characteristic setting
per channel. On band A, channels 3 and 4 sit mostly at setting 80 while channels 1 and 2 sit at 160,
and channel 7 uses much higher index values than the rest. That is a per-channel front-end
configuration, not a label.

Each value carries its own pulse. Taking runs of 8 or more consecutive records with the same
channel index and treating the concatenated samples as 24 Hz, the dominant spectral peak between 0.5
and 4 Hz falls in the cardiac band (0.7 to 3.5 Hz) for:

channel band A band B
0 47/49 (96%) 141/154 (92%)
1 138/142 (97%) 389/415 (94%)
2 153/157 (97%) 491/538 (91%)
3 118/123 (96%) 627/674 (93%)
4 77/80 (96%) 272/297 (92%)
5 22/22 (100%) 56/67 (84%)

So every channel is a real independent optical measurement, not padding or a duplicate.

What I am not claiming is which wavelength each index is. I can show the byte separates distinct
acquisition channels; I cannot show from these captures that channel 2 is red and channel 3 is IR, so
the field is exposed as an index with no wavelength mapping.

Values outside 0..7 appear on about 0.2% of records (0xFD to 0xFF). They still look like a pulse, so I
have not called them invalid, but they are not a channel index either. subChannelKnown gates to 0..7
and returns null otherwise, following the activityClassKnown pattern.

2. Frame-abs 19 is a u16, not a byte

rawByte19 reads inner[11]. The high byte inner[12] is nonzero on 99.17% of band A records and
98.95% of band B records, so the current read discards most of the value. The fixture already in
this repo shows it: the u16 is 18350, the byte read gives 174, and the discarded high byte is 71.

Read as a u16 it has an exact closed form. It is an integer k in 0..99 packed as a Q15 fraction:

segmentId == (k * 32768) ~/ 100

That holds for 86,859 of 86,859 records, across 99 distinct values on one band and 100 on the
other. The value is constant across the records of a burst (91% to 93% of bursts under the crude
5-second split above, so probably all of them under a better burst definition).

segmentIndex returns k. What k counts is not established and I have not claimed it. rawByte19
is kept and deprecated rather than removed.

3. The block after the waveform

Frame-abs 75 to 82 carries a f32 and four bytes that nothing currently reads:

offset field what I can say
75 signalMetric f32 Roughly an order of magnitude lower when flagB is set, on both bands. Reads as a per-record quality or residual where low means clean. Scale unpinned.
79 / 80 gainSetting / gainIndex Front-end gain, characteristic per channel (see section 1).
81 / 82 flagA / flagB Roughly complementary. flagB is set on 57% to 75% of records. Meaning otherwise unestablished.
23 frontEndMetaRaw u16 No meaning established. It does not track heart rate, motion, the waveform, gain or signalMetric. Exposed raw so the gap is documented rather than silently skipped.

I have deliberately not named these beyond what the numbers support.

4. Short records

This block sits after the waveform, past the existing 67-byte floor, so a record with fewer samples
could carry a waveform and no metadata. Rather than raise kGen5V26MinInnerLen and start rejecting
records that currently decode, the trailing fields are nullable and gated on
kGen5V26MinInnerLenWithMeta (75). A short record decodes its waveform, its channel index and its
segment id, and returns null for the rest instead of a stand-in value. There is a test for it.

Every real record I have is 76 bytes, so this path is defensive rather than observed.

What changed

segmentId (u16) with segmentIndex, subChannel with a gated subChannelKnown, frontEndMetaRaw,
signalMetric, gainSetting, gainIndex, flagA, flagB, and kGen5V26MinInnerLenWithMeta.
rawByte19 is deprecated, not removed. The waveform decode is untouched, so the frozen parity oracle
is unaffected.

Note that the CRC32 at frame-abs 84 is the standard gen5 frame trailer that parseFrame already
checks, not a v26 field. I checked before writing this up in case it needed exposing. It does not.

Verification

dart analyze clean, dart test 164 passed (5 new, plus the usual 4 skips for the capture replay set
that lives beside the repo). The new tests cover the u16 versus byte read, the Q15 packing, the
metadata block against the existing real fixture, the out-of-range channel gate, and the short-record
path.

…rame-abs 25

Adds subChannel/segmentId/gain/flags; segmentId corrects a u16 that was being read as a byte.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sunny-noop, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cdf6ef49-177e-43b9-9e51-88067da90553

📥 Commits

Reviewing files that changed from the base of the PR and between 3b93df1 and beeda02.

📒 Files selected for processing (3)
  • lib/openstrap_protocol.dart
  • lib/src/gen5_records.dart
  • test/gen5_historical_test.dart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant