Skip to content

DEV-1030 Incorrect recording duration after SD card import (CASE:8952) - #301

Closed
MAzalya wants to merge 1 commit into
masterfrom
DEV-1030
Closed

MAzalya wants to merge 1 commit into
masterfrom
DEV-1030

Conversation

@MAzalya

@MAzalya MAzalya commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@MAzalya
MAzalya requested a review from jyong15 September 17, 2026 06:37
marknolan added a commit that referenced this pull request Sep 17, 2026
… zeros

The exact-zero rejection this branch already carried is right for the firmware
fault it was written for and wrong for everything else that makes the counter
read backwards. A corrupted non-zero value, a duplicated packet and a reordered
one all still added 2^24 ticks - testNonZeroBackwardStepIsStillAWrap asserted
exactly that.

PR #301 found the gap and fixed the missing half: it detects reordered and
duplicated packets by comparing the backward step against a window derived from
the sampling rate. That idea is adopted here. Its polarity is not: #301 makes
"corrupt" the default and calls something a wrap only when it clears
maxTicks - 10 periods, so a roll-over preceded by more than ten lost samples is
misread as corruption. Forward motion has to be the default.

Each sample is now classified by its modular forward distance from the last:
duplicate, reordered, invalid zero, or forward - and forward, which is a wrap
when the raw value fell, is what everything else falls through to.

Three things in that are load-bearing, each verified by mutation:

  - The comparison is modular, not on unwrapped values. Asking whether the new
    candidate is below the last one misses a packet arriving late from BEFORE a
    wrap boundary: its candidate sits nearly a modulo ahead, so it is accepted,
    and the next real sample is read as a second wrap. 16777206, 5, 16777206,
    70 costs 512 s twice under the old shape.

  - The window is eight sample periods, not a fraction of the modulo. A reorder
    swaps adjacent packets; a dropout spanning the wrap point is most of a
    modulo. At modulo/8 on the 2-byte counter every dropout between 1.75 s and
    2.0 s reads as a reorder and the wrap is silently lost - and that is an
    ordinary Bluetooth gap.

  - An unknown rate gives a window of zero, never infinity. 32768/0 is
    POSITIVE_INFINITY in Java, and an infinite window makes every backward step
    a reorder and loses every wrap - worse than the naive rule being replaced.
    #301 derives the window without that guard, so a device whose rate has not
    been read yet silently reverts to the original defect.

Callers derive the window per sample through getReorderWindowTicks(), so a rate
written mid-session is picked up by the next sample and there is no cached
window to reset. It comes from getSamplingRateShimmer(), which is populated on
both paths before the first sample is parsed - seeded at construction, set from
the SD header before the first record, and set during the Bluetooth connect.

Shimmer2 and Shimmer2R get a window of zero. Their tick domain is unsettled -
this class divides their 16-bit counter by 32768 while the C# API divides by
1024 - so a rate-derived window would be wrong in one of the two. They keep the
behaviour they have always had.

The first sample of a stream is passed through rather than measured against the
reset state. Under a modular rule a first raw value near the top of the range
would otherwise read as a packet reordered across a boundary and place a whole
recording one modulo early.

Rule and vectors: log-and-stream-common, docs/SHIMMER3_STREAMING_DATA_FORMAT.md
section 2.1 and Test/conformance/timestamp_unwrap.json.

Co-Authored-By: Mas Azalya <43565312+MAzalya@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marknolan

Copy link
Copy Markdown
Member

Superseded by #300, which adopts the half of this that I had missed. Closing here, but
the reorder/duplicate detection is yours and it is carried across with credit on the
commit.

What this PR got right, and #300 did not

My branch rejected an exact 0x000000 and nothing else, so a corrupted non-zero value, a
duplicated packet and a reordered one all still added 2²⁴ ticks. My own test
testNonZeroBackwardStepIsStillAWrap asserted that as correct behaviour. It isn't, and
this PR is what showed it — detecting the general case by comparing the backward step
against a window derived from the sampling rate. #300 now does that.

The six-device table in DEV-1030 is also better evidence of the mechanism than anything I
had: every device's error an integer multiple of 512 s, against one clean device as the
control. I had one file. That table is going onto DEV-1023.

Why the polarity is inverted in #300

The rule here makes "corrupt" the default and calls something a roll-over only when the
backward step clears maxTicks − 10 · expectedTicksPerSample. #300 makes forward motion
the default
and carves out reorder and invalid-zero as the narrow cases. Same input, and
the failure modes swap places.

Three things I found simulating this rule against the bytes of the affected recording,
each of which the inverted form avoids:

1. At the rate the affected trial actually ran, the fix takes over the whole file.
expectedTicksPerSample = getRtcClockFreq() / getSamplingRateShimmer().

rate returned period of the 201 samples after the bad record
32768/65 = 504.1230769… 65.000000 1 classified corrupt — exact, works
504.12 (rounded) 65.000397 201 — all of them

At the rounded rate the interpolated value overshoots each following real sample by four
ten-thousandths of a tick, so every one is reclassified corrupt and replaced. From the
first bad record onward the file is on a synthesised nominal-rate ramp rather than the
device's clock, and any genuine jitter or gap after that point is erased.

The duration still comes out right, which is exactly why the manual check looked clean. An
end-to-end duration comparison cannot see this; a unit test catches it immediately.

2. An unset rate silently restores the original defect. getSamplingRateShimmer()
returns maxSetRate, initialised to 0.0, over a map that can be empty. Java evaluates
32768.0 / 0.0 to Infinity without throwing, so maxTicks − 10 · Infinity is -Infinity
and -delta > -Infinity is always true — every backward step becomes a roll-over. The
zero record yields 16777216, the exact +2²⁴ the fix exists to prevent, with the
corruption counter reading 0. #300 returns a window of zero for every shape of unknown
rate, and has a test for infinity, NaN and negatives specifically because a division is
what produces them.

3. A roll-over preceded by more than ten lost samples is misread as corruption. This is
the polarity itself: with "corrupt" as the default, packet loss before a wrap flips the
classification. It self-heals on the following sample, so the cost is small — but it is the
case that decides which way round the rule should be, and wrap-after-heavy-loss-24bit in
the shared vectors pins it.

What else changed on the way

Reviewing the merge turned up two problems in my rule, not this one:

Where it is now

The rule is specified once in log-and-stream-common
(#136), with 26
machine-readable conformance vectors that the Java, C#, pyshimmer and web SDK suites all
run. Four implementations of one wire format drifted apart precisely because nobody could
check them against each other; that is now a test rather than a review habit.

wrap-after-heavy-loss-24bit, wrap-spanning-dropout-1p8s-16bit and
reorder-across-wrap-boundary-24bit are each the case that one of the superseded rules
gets wrong — including two of mine.

Worth a look at #300 when you have a moment, and thanks for catching the gap.

@marknolan

Copy link
Copy Markdown
Member

Closing as superseded by #300 — see the comment above. The reorder/duplicate detection from here is carried across with a Co-Authored-By on the commit; reopen if you would rather take it forward on this branch instead.

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.

2 participants