Skip to content

Estimate nominal rate with a trimmed mean, not the median - #19

Merged
cboulay merged 1 commit into
devfrom
fix/nominal-rate-quantization-bias
Aug 20, 2026
Merged

Estimate nominal rate with a trimmed mean, not the median#19
cboulay merged 1 commit into
devfrom
fix/nominal-rate-quantization-bias

Conversation

@cboulay

@cboulay cboulay commented Aug 20, 2026

Copy link
Copy Markdown
Member

Problem

When a timestamped stream has no explicit rate attribute, the slicer inferred its sample rate as 1 / median(dt). When timestamps are quantized to a grid coarser than the true fractional period — as real CereLink-class hardware writes them, stepping in 4e-8 s — the per-sample intervals take only two values straddling the truth. The median snaps to whichever grid point holds the most intervals and stays there regardless of sample count, so it reports 30012.005 Hz for a stream actually delivering 30000.104 Hz: 0.04%, or ~0.8 ms of sample misplacement by the end of a 2 s chunk. Small, systematic, and invisible without a test that knows the true rate.

Fix

Replace the fallback estimator with infer_nominal_rate(): the mean of the intervals within [0.5, 1.5] × median. The mean averages across the quantization grid and recovers the true period; the trim discards real gaps and the backward clock steps (and their paired forward excursions) that would otherwise drag a raw mean. Returns 0.0 for un-estimable input, which callers already read as "irregular → CoordinateAxis".

Precedence is unchanged. An explicit TimeSeries.rate or a timestamps.attrs["rate"] is still checked first and used verbatim — infer_nominal_rate runs only when neither exists (exactly the case the old 1/median(dt) covered). Files that carry a rate hint (including our custom NWB files that stamp rate on the timestamps dataset) are unaffected.

Tests

New quantised_nwb_path fixture and tests covering: the quantization bias (median says 30012, estimator recovers 30000.1), gap rejection, backward-clock-step rejection, degenerate inputs, and the end-to-end slicer path.

Full suite: 205 passed, ruff clean.

🤖 Generated with Claude Code

A timestamped stream with no explicit rate attribute had its sample rate
inferred as 1 / median(dt). When stamps are quantised to a grid coarser than
the true fractional period (real CereLink-class hardware steps in 4e-8 s), the
per-sample intervals take two values straddling the truth, so the median snaps
to one grid point and stays there for every sample count -- reading 30012.005 Hz
for a stream delivering 30000.104 Hz (0.04%, ~0.8 ms of sample misplacement by
the end of a 2 s chunk).

Replace that fallback with infer_nominal_rate: the mean of the intervals within
[0.5, 1.5] * median, which averages across the grid to recover the true period
while the trim discards real gaps and the backward clock steps (and their paired
forward excursions) that would otherwise drag a raw mean. Returns 0.0 for
un-estimable input, which callers already read as "irregular".

Only the last-resort estimator changes; an explicit TimeSeries.rate or a
timestamps `rate` attribute still take precedence and are used verbatim, so
files that carry a rate hint are unaffected. Adds a quantised-timestamp fixture
and tests covering the bias, gap/backstep rejection, degenerate input, and the
end-to-end slicer path.
@cboulay
cboulay merged commit 463d0fc into dev Aug 20, 2026
14 checks passed
@cboulay
cboulay deleted the fix/nominal-rate-quantization-bias branch August 20, 2026 03:44
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