pop_resample computes a wrong resampling ratio for non-integer sampling rates, and labels the result with the requested rate

Open
#396 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
85/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
data

Research direction

Start in src/eegprep/functions/popfunc/pop_resample.py at _resample_ratio and trace how its p and q values reach _resample_segment and event-latency rescaling. Add focused tests for the listed non-integer rate pairs and a pop_resample round trip, then verify that the returned ratio, output data length, pnts, and srate agree.

Written by the indexing model from the issue text.

Description

Found while auditing the base dependencies for browser installability (#395). The dependency question turned out to be the smaller half.

The defect

src/eegprep/functions/popfunc/pop_resample.py:208:

def _resample_ratio(freq, srate):
    rational_approx = sp.nsimplify(float(freq) / float(srate), tolerance=1e-12)
    p, q = rational_approx.as_numer_denom()
    return int(p), int(q)

sympy.nsimplify looks for a simple symbolic expression, not a rational one. When the ratio is not close to a simple fraction it returns an irrational product, and then as_numer_denom() hands back a numerator that is not an integer, which int() silently truncates.

For a 2000 Hz recording resampled to 999.9 Hz, the true ratio is 0.49995:

nsimplify       -> 2**(222/367)*3**(8/367)*5**(270/367)*7**(31/367)/12
as_numer_denom  -> p = 2**(222/367)*...  (float 5.999400),  q = 12
int(p), int(q)  -> (5, 12)  ->  ratio 0.416667      # 16.66% low

p and q then drive both the polyphase resampling (_resample_segment(segment, p, q, ...), line 183) and the event-latency rescaling (line 199), while line 192 stamps output["srate"] = float(freq) unconditionally. So the data is resampled by the wrong factor and then labeled with the rate that was requested. Event latencies are rescaled by the same wrong ratio, so the result is internally consistent and gives the reader nothing to notice.

How often

Sweeping realistic downsampling pairs, source rates 250, 256, 500, 512, 1000, 1024, 2000, 2048, 5000, 999.9, 1023.4, 512.03 against targets 100, 125, 128, 200, 250, 256, 500, 512:

24 of 86 pairs return a wrong ratio. Every one of them has a non-integer source rate; every integer pair is exact. The worst cases:

asked for actually produced labeled error
512.03 Hz -> 128 Hz 117.04 Hz 128 Hz 8.57%
1023.4 Hz -> 125 Hz 116.96 Hz 125 Hz 6.43%
1023.4 Hz -> 250 Hz 238.79 Hz 250 Hz 4.48%
1023.4 Hz -> 128 Hz 122.81 Hz 128 Hz 4.06%
999.9 Hz -> 256 Hz 249.97 Hz 256 Hz 2.35%

A non-integer srate is not exotic: hardware reports rates like 999.9 and 1023.4, and any srate that arrived as a float from an earlier division lands in the same place.

The fix is in the standard library, and it deletes a dependency

from fractions import Fraction

def _resample_ratio(freq, srate):
    ratio = Fraction(float(freq) / float(srate)).limit_denominator(10**6)
    return ratio.numerator, ratio.denominator

Fraction(...).limit_denominator() returns a rational by construction, so the truncation cannot happen. It reproduces nsimplify exactly on all thirteen well-behaved pairs I checked (250/1000 -> 1/4, 44100/48000 -> 147/160, 1000/1024 -> 125/128, and so on) and is exact on the ones nsimplify gets wrong: 999.9/2000 -> 9999/20000, 1023.4/2000 -> 5117/10000, both with zero error.

The denominator bound is the one judgment call. 10**6 keeps the polyphase filter orders sane while covering rates to four decimal places; _resample_segment is where a very large q would hurt, so it is worth choosing that bound deliberately rather than inheriting it from this issue.

It also removes sympy from the install, which pop_resample.py:9 imports for this one call and nothing else uses anywhere in src/. That is 4.1 MB and one package off every install, and in the browser it is 4.1 MB off a budget where the whole read path is 4.2 MB (#395).

Tests worth having

  • The rate pairs above, asserting the returned ratio against Fraction, so a future change of method cannot silently reintroduce truncation.
  • A round trip through pop_resample asserting output["pnts"] matches srate and the data length, which is the invariant the current code breaks: the label and the data disagree, and nothing checks them against each other.

Reproduction script is three lines of sympy plus fractions; happy to open the pull request with the fix and the tests if that is useful.

Dominant language
Python
Stars
35
Forks
6
Avg merge
3d 20h
Merged PRs (30d)
66

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from sccn/eegprep

All issues in sccn/eegprep

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.