swharden/FftSharp

FFTfreq: possible off-by-one error for one-sided FFTs

Closed this issue ยท 7 comments

Could it be possible that there is some offset when computing the sample frequency by calling FftSharp.Transform.FFTfreq()?

These are the steps I've followed and the results obtained:

  • Create a 1-cycle 2-Hz-sinus wave at a sampling rate of 16 Hz (see 16 points 2 Hz sinus .txt) (The offset is less noticeable the higher the sampling frequency).
  • Compute the FFT using:
signalFFT = FftSharp.Transform.FFTmagnitude(signalWindowed);
freq = FftSharp.Transform.FFTfreq(sampleFreq, signalFFT.Length);
plotFFT.Plot.AddScatterLines(freq, signalFFT);
  • The plot shows the frequency of the signal at 1.78 Hz instead of 2 Hz.
    Frequency offset

Possible explanation:

  • The array returned by FftSharp.Transform.FFTmagnitude() has one more point than half its size: new double[input.Length / 2 + 1] (2n-1 +1).
  • Inside function FftSharp.Transform.FFTfreq(), the frequency is computed as:
if (OneSided)
    double fftPeriodHz = sampleRate / pointCount / 2;
else
    double fftPeriodHz = sampleRate / pointCount;
  • The problem is that pointCount is passed as the length of the array returned by FftSharp.Transform.FFTmagnitude() (2n-1 +1), so it has one more point than what is required to compute fftPeriodHz.

So, i can think of two options:

  1. Subtract 1 from the array's length when calling FftSharp.Transform.FFTfreq(). This might be counterintuitive and not obvious.
  2. Perform the subtraction inside FftSharp.Transform.FFTfreq():
if (OneSided)
    double fftPeriodHz = sampleRate / (pointCount-1) / 2;
else
    double fftPeriodHz = sampleRate / (pointCount-1);

This last option produces the correct result for the sinus wave mentioned at the beginning. I hesitated before doing any PR since I'm not sure which one would you consider more appropriate.
Correct frequency

Hi @arthurits!

Regarding the two possible options, here are my thoughts:

Option 1: User supplies Length - 1

Subtract 1 from the array's length when calling FftSharp.Transform.FFTfreq(). This might be counterintuitive and not obvious.

I agree with you this would be confusing.

However, an overload that accepts the array itself could achieve this without requiring the user to have special knowledge. Adding this overload may be useful either way.

Option 2:

Perform the subtraction inside FftSharp.Transform.FFTfreq(): This last option produces the correct result for the sinus wave mentioned at the beginning.

This sounds like the way to go.

What does Python do?

When in doubt I often try to mimic what commonly used Python packages do.

It would be ideal to analyze sample data with Python's function and write a test to confirm our values match theirs exactly. If you create a PR you don't have to go through the trouble of doing all that, but I'll probably add it in before merging.

Relevant files (note to self):

Pull Request

I hesitated before doing any PR since I'm not sure which one would you consider more appropriate.

If you want to create a PR, I'd be happy to work on it and merge it in shortly! If not, that's okay too, I'll fix this on my own within the next few days ๐Ÿ‘

Thanks for your feedback @swharden! ๐Ÿ‘
PR has been created implementing your suggestions, except the Python tests.

I realized that options 1 and 2 can't both be implemented at the same time, so I modified the PR.

Option 1: User supplies Length - 1

However, an overload that accepts the array itself could achieve this without requiring the user to have special knowledge. Adding this overload may be useful either way.

I believe the overload option to be the most practical one. This overload should compute the correct pointCount and call the original FFTfreq.

Option 2:

This sounds like the way to go.

However, the subtraction can't be implemented here because the user could call the overload passing the array which computes the correct length and calls the orginal FFTfreq function where no subtraction should be done.
In this case, the XML function documentation should clearly state what value is expected for the pointCount parameter.

Good points! I'm glad you liked the overload idea, and PR #50 is looking great

BTW I found I already implemented "official" Python/numpy fftfreq and comparison, so I think the correct behavior (or at least the common behavior shared with a really big Python library) is already locked-in

[Test]
public void Test_VsNumpy_FftFreq()
{
double[] fftFreq = FftSharp.Transform.FFTfreq(sampleRate: 48_000, pointCount: values.Length, oneSided: false);
double[] numpyFftFreq = LoadData.Double("fftFreq.txt");
Assert.AreEqual(numpyFftFreq.Length, fftFreq.Length);
for (int i = 0; i < fftFreq.Length; i++)
Assert.AreEqual(numpyFftFreq[i], fftFreq[i], 1e-10);
}

0.0
93.75
187.5
281.25
375.0
468.75
562.5
656.25
750.0
843.75
937.5
1031.25
1125.0
1218.75
1312.5
1406.25
1500.0
1593.75
1687.5
1781.25
1875.0
1968.75
2062.5
2156.25
2250.0
2343.75
2437.5
2531.25
2625.0
2718.75
2812.5
2906.25
3000.0
3093.75
3187.5
3281.25
3375.0
3468.75
3562.5
3656.25
3750.0
3843.75
3937.5
4031.25
4125.0
4218.75
4312.5
4406.25
4500.0
4593.75
4687.5
4781.25
4875.0
4968.75
5062.5
5156.25
5250.0
5343.75
5437.5
5531.25
5625.0
5718.75
5812.5
5906.25
6000.0
6093.75
6187.5
6281.25
6375.0
6468.75
6562.5
6656.25
6750.0
6843.75
6937.5
7031.25
7125.0
7218.75
7312.5
7406.25
7500.0
7593.75
7687.5
7781.25
7875.0
7968.75
8062.5
8156.25
8250.0
8343.75
8437.5
8531.25
8625.0
8718.75
8812.5
8906.25
9000.0
9093.75
9187.5
9281.25
9375.0
9468.75
9562.5
9656.25
9750.0
9843.75
9937.5
10031.25
10125.0
10218.75
10312.5
10406.25
10500.0
10593.75
10687.5
10781.25
10875.0
10968.75
11062.5
11156.25
11250.0
11343.75
11437.5
11531.25
11625.0
11718.75
11812.5
11906.25
12000.0
12093.75
12187.5
12281.25
12375.0
12468.75
12562.5
12656.25
12750.0
12843.75
12937.5
13031.25
13125.0
13218.75
13312.5
13406.25
13500.0
13593.75
13687.5
13781.25
13875.0
13968.75
14062.5
14156.25
14250.0
14343.75
14437.5
14531.25
14625.0
14718.75
14812.5
14906.25
15000.0
15093.75
15187.5
15281.25
15375.0
15468.75
15562.5
15656.25
15750.0
15843.75
15937.5
16031.25
16125.0
16218.75
16312.5
16406.25
16500.0
16593.75
16687.5
16781.25
16875.0
16968.75
17062.5
17156.25
17250.0
17343.75
17437.5
17531.25
17625.0
17718.75
17812.5
17906.25
18000.0
18093.75
18187.5
18281.25
18375.0
18468.75
18562.5
18656.25
18750.0
18843.75
18937.5
19031.25
19125.0
19218.75
19312.5
19406.25
19500.0
19593.75
19687.5
19781.25
19875.0
19968.75
20062.5
20156.25
20250.0
20343.75
20437.5
20531.25
20625.0
20718.75
20812.5
20906.25
21000.0
21093.75
21187.5
21281.25
21375.0
21468.75
21562.5
21656.25
21750.0
21843.75
21937.5
22031.25
22125.0
22218.75
22312.5
22406.25
22500.0
22593.75
22687.5
22781.25
22875.0
22968.75
23062.5
23156.25
23250.0
23343.75
23437.5
23531.25
23625.0
23718.75
23812.5
23906.25
-24000.0
-23906.25
-23812.5
-23718.75
-23625.0
-23531.25
-23437.5
-23343.75
-23250.0
-23156.25
-23062.5
-22968.75
-22875.0
-22781.25
-22687.5
-22593.75
-22500.0
-22406.25
-22312.5
-22218.75
-22125.0
-22031.25
-21937.5
-21843.75
-21750.0
-21656.25
-21562.5
-21468.75
-21375.0
-21281.25
-21187.5
-21093.75
-21000.0
-20906.25
-20812.5
-20718.75
-20625.0
-20531.25
-20437.5
-20343.75
-20250.0
-20156.25
-20062.5
-19968.75
-19875.0
-19781.25
-19687.5
-19593.75
-19500.0
-19406.25
-19312.5
-19218.75
-19125.0
-19031.25
-18937.5
-18843.75
-18750.0
-18656.25
-18562.5
-18468.75
-18375.0
-18281.25
-18187.5
-18093.75
-18000.0
-17906.25
-17812.5
-17718.75
-17625.0
-17531.25
-17437.5
-17343.75
-17250.0
-17156.25
-17062.5
-16968.75
-16875.0
-16781.25
-16687.5
-16593.75
-16500.0
-16406.25
-16312.5
-16218.75
-16125.0
-16031.25
-15937.5
-15843.75
-15750.0
-15656.25
-15562.5
-15468.75
-15375.0
-15281.25
-15187.5
-15093.75
-15000.0
-14906.25
-14812.5
-14718.75
-14625.0
-14531.25
-14437.5
-14343.75
-14250.0
-14156.25
-14062.5
-13968.75
-13875.0
-13781.25
-13687.5
-13593.75
-13500.0
-13406.25
-13312.5
-13218.75
-13125.0
-13031.25
-12937.5
-12843.75
-12750.0
-12656.25
-12562.5
-12468.75
-12375.0
-12281.25
-12187.5
-12093.75
-12000.0
-11906.25
-11812.5
-11718.75
-11625.0
-11531.25
-11437.5
-11343.75
-11250.0
-11156.25
-11062.5
-10968.75
-10875.0
-10781.25
-10687.5
-10593.75
-10500.0
-10406.25
-10312.5
-10218.75
-10125.0
-10031.25
-9937.5
-9843.75
-9750.0
-9656.25
-9562.5
-9468.75
-9375.0
-9281.25
-9187.5
-9093.75
-9000.0
-8906.25
-8812.5
-8718.75
-8625.0
-8531.25
-8437.5
-8343.75
-8250.0
-8156.25
-8062.5
-7968.75
-7875.0
-7781.25
-7687.5
-7593.75
-7500.0
-7406.25
-7312.5
-7218.75
-7125.0
-7031.25
-6937.5
-6843.75
-6750.0
-6656.25
-6562.5
-6468.75
-6375.0
-6281.25
-6187.5
-6093.75
-6000.0
-5906.25
-5812.5
-5718.75
-5625.0
-5531.25
-5437.5
-5343.75
-5250.0
-5156.25
-5062.5
-4968.75
-4875.0
-4781.25
-4687.5
-4593.75
-4500.0
-4406.25
-4312.5
-4218.75
-4125.0
-4031.25
-3937.5
-3843.75
-3750.0
-3656.25
-3562.5
-3468.75
-3375.0
-3281.25
-3187.5
-3093.75
-3000.0
-2906.25
-2812.5
-2718.75
-2625.0
-2531.25
-2437.5
-2343.75
-2250.0
-2156.25
-2062.5
-1968.75
-1875.0
-1781.25
-1687.5
-1593.75
-1500.0
-1406.25
-1312.5
-1218.75
-1125.0
-1031.25
-937.5
-843.75
-750.0
-656.25
-562.5
-468.75
-375.0
-281.25
-187.5
-93.75

correct behavior (or at least the common behavior shared with a really big Python library) is already locked-in

Actually, this discussion has been about one-sided FFT frequency calculation. That's not the default behavior of Python/numpy, and the one-sided behavior hasn't been sufficiently tested, so it's still worth this closer inspection

Wow, that got way more complicated than I expected!

This fix modifies the math of FftFreq() only for half-size FFTs so it doesn't affect the full FFT math.

This is the new behavior. What do you think? If you like it I'll merge and publish a new package ๐Ÿš€

// generate signal with 2 Hz sine wave
int sampleCount = 16;
double sampleRateHz = 16;
double sinFrequencyHz = 2;
double[] samples = Enumerable.Range(0, sampleCount)
    .Select(i => Math.Sin(2 * Math.PI * sinFrequencyHz * i / sampleRateHz))
    .ToArray();

// get FFT
double[] fft = FftSharp.Transform.FFTmagnitude(samples);
double[] fftKnown = { 0, 0, 1, 0, 0, 0, 0, 0, 0 };
Assert.That(fft, Is.EqualTo(fftKnown).Within(1e-10));

// calculate FFT frequencies both ways
double[] fftFreqKnown = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
double[] fftFreq = FftSharp.Transform.FFTfreq(sampleRateHz, fft.Length); // <---- original overload
double[] fftFreq2 = FftSharp.Transform.FFTfreq(sampleRateHz, fft); // <---- new overload
Assert.That(fftFreq, Is.EqualTo(fftFreqKnown));
Assert.That(fftFreq2, Is.EqualTo(fftFreqKnown));

Test_FftFreq_KnownValues_signal
Test_FftFreq_KnownValues_fft

This looks really good! Documentation of the functions is clear enough and the point about the one-sided FFT is just on the spot.
I'd say it's ready to be merged.
Nice work! ๐Ÿคฉ