Estimate nominal rate with a trimmed mean, not the median - #19
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a timestamped stream has no explicit
rateattribute, the slicer inferred its sample rate as1 / 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. Returns0.0for un-estimable input, which callers already read as "irregular → CoordinateAxis".Precedence is unchanged. An explicit
TimeSeries.rateor atimestamps.attrs["rate"]is still checked first and used verbatim —infer_nominal_rateruns only when neither exists (exactly the case the old1/median(dt)covered). Files that carry a rate hint (including our custom NWB files that stamprateon the timestamps dataset) are unaffected.Tests
New
quantised_nwb_pathfixture 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