Skip to main content

comnum_math/
fft.rs

1//! FFT plans with the NumPy conventions.
2//!
3//! [`Fft`] wraps the forward and inverse plans of `rustfft` for one length:
4//! [`Fft::forward`] computes `X_k = Σ_n x_n e^{-j2πkn/N}` (unnormalised) and
5//! [`Fft::inverse`] `x_n = (1/N) Σ_k X_k e^{+j2πkn/N}`, the conventions of `numpy.fft`
6//! and of OptiCommPy. Plans are cached per thread by [`plan`], so creating an `Fft` for
7//! a length already seen costs nothing, and the work space of the transforms is kept per
8//! thread next to them: a transform allocates nothing once its length has been seen.
9//! [`fftfreq`] and [`angular_frequencies`] give
10//! the frequency of every bin in the same order, [`fftshift`] reorders a spectrum by
11//! increasing frequency and [`centred_bin`] maps centred subcarriers to bins. The
12//! unitary transforms are [`fft_unitary_in_place`] and [`ifft_unitary_in_place`].
13//!
14//! # Examples
15//! ```
16//! use comnum_math::fft::{fft, ifft, fftfreq};
17//! use comnum_math::C64;
18//! let x: Vec<C64> = (0..8).map(|n| C64::new(n as f64, 0.0)).collect();
19//! let y = ifft(&fft(&x));
20//! assert!(x.iter().zip(&y).all(|(a, b)| (a - b).norm() < 1e-12));
21//! assert_eq!(fftfreq(4, 1.0), vec![0.0, 0.25, -0.5, -0.25]);
22//! ```
23
24use crate::C64;
25use num_complex::Complex32 as C32;
26use rustfft::FftPlanner;
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::sync::Arc;
30
31/// Forward and inverse FFT plans of one length.
32#[derive(Clone)]
33pub struct Fft {
34    n: usize,
35    fwd: Arc<dyn rustfft::Fft<f64>>,
36    inv: Arc<dyn rustfft::Fft<f64>>,
37    // the in-place work space of each plan (`get_inplace_scratch_len`)
38    fwd_scratch: usize,
39    inv_scratch: usize,
40}
41
42impl std::fmt::Debug for Fft {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "Fft(n = {})", self.n)
45    }
46}
47
48thread_local! {
49    static PLANS: RefCell<(FftPlanner<f64>, HashMap<usize, Fft>)> = RefCell::new((FftPlanner::new(), HashMap::new()));
50    // the work space of the transforms of this thread, grown to the largest length seen
51    static SCRATCH: RefCell<Vec<C64>> = const { RefCell::new(Vec::new()) };
52    // the same in single precision, for the solver `nlse::SplitStepF32`
53    static PLANS_F32: RefCell<(FftPlanner<f32>, HashMap<usize, FftF32>)> =
54        RefCell::new((FftPlanner::new(), HashMap::new()));
55    static SCRATCH_F32: RefCell<Vec<C32>> = const { RefCell::new(Vec::new()) };
56}
57
58/// Run `f` on `len` values of the work space of this thread (grown, never shrunk).
59fn with_scratch(len: usize, f: impl FnOnce(&mut [C64])) {
60    SCRATCH.with(|c| {
61        // a transform never calls back into another one, so the cell is free; a nested
62        // borrow (impossible today) would fall back to a buffer of its own
63        if let Ok(mut s) = c.try_borrow_mut() {
64            if s.len() < len {
65                s.resize(len, C64::new(0.0, 0.0));
66            }
67            f(&mut s[..len]);
68        } else {
69            f(&mut vec![C64::new(0.0, 0.0); len]);
70        }
71    });
72}
73
74/// The forward and inverse plans of one length in single precision, cached per thread
75/// with their work space as [`Fft`]; both transforms are unnormalised (the solver
76/// `nlse::SplitStepF32` folds the `1/N` into its linear step).
77#[derive(Clone)]
78pub(crate) struct FftF32 {
79    fwd: Arc<dyn rustfft::Fft<f32>>,
80    inv: Arc<dyn rustfft::Fft<f32>>,
81    scratch: usize,
82}
83
84/// The (cached) single-precision plans of length `n ≥ 1`.
85pub(crate) fn plan_f32(n: usize) -> FftF32 {
86    PLANS_F32.with(|cell| {
87        let mut guard = cell.borrow_mut();
88        let (planner, cache) = &mut *guard;
89        if let Some(p) = cache.get(&n) {
90            return p.clone();
91        }
92        let (fwd, inv) = (planner.plan_fft_forward(n), planner.plan_fft_inverse(n));
93        let scratch = fwd.get_inplace_scratch_len().max(inv.get_inplace_scratch_len());
94        let p = FftF32 { fwd, inv, scratch };
95        cache.insert(n, p.clone());
96        p
97    })
98}
99
100impl FftF32 {
101    /// Run `f` on the work space of this thread.
102    fn with_scratch(&self, f: impl FnOnce(&mut [C32])) {
103        SCRATCH_F32.with(|c| {
104            if let Ok(mut s) = c.try_borrow_mut() {
105                if s.len() < self.scratch {
106                    s.resize(self.scratch, C32::new(0.0, 0.0));
107                }
108                f(&mut s[..self.scratch]);
109            } else {
110                f(&mut vec![C32::new(0.0, 0.0); self.scratch]);
111            }
112        });
113    }
114
115    /// In-place forward transform (unnormalised).
116    pub(crate) fn forward(&self, x: &mut [C32]) {
117        self.with_scratch(|s| self.fwd.process_with_scratch(x, s));
118    }
119
120    /// In-place inverse transform, not divided by `N`.
121    pub(crate) fn inverse_unnormalized(&self, x: &mut [C32]) {
122        self.with_scratch(|s| self.inv.process_with_scratch(x, s));
123    }
124}
125
126/// The (cached) plans of length `n ≥ 1`.
127pub fn plan(n: usize) -> Fft {
128    assert!(n >= 1, "FFT length must be positive");
129    PLANS.with(|cell| {
130        let mut guard = cell.borrow_mut();
131        let (planner, cache) = &mut *guard;
132        if let Some(p) = cache.get(&n) {
133            return p.clone();
134        }
135        let (fwd, inv) = (planner.plan_fft_forward(n), planner.plan_fft_inverse(n));
136        let (fwd_scratch, inv_scratch) = (fwd.get_inplace_scratch_len(), inv.get_inplace_scratch_len());
137        let p = Fft { n, fwd, inv, fwd_scratch, inv_scratch };
138        cache.insert(n, p.clone());
139        p
140    })
141}
142
143impl Fft {
144    /// The transform length.
145    pub fn len(&self) -> usize {
146        self.n
147    }
148
149    /// Whether the length is zero (never, a plan has `n ≥ 1`).
150    pub fn is_empty(&self) -> bool {
151        self.n == 0
152    }
153
154    /// In-place forward transform (unnormalised).
155    ///
156    /// # Panics
157    /// If `x.len() != self.len()`.
158    pub fn forward(&self, x: &mut [C64]) {
159        assert_eq!(x.len(), self.n, "FFT length mismatch");
160        with_scratch(self.fwd_scratch, |s| self.fwd.process_with_scratch(x, s));
161    }
162
163    /// In-place inverse transform, divided by `N`.
164    ///
165    /// # Panics
166    /// If `x.len() != self.len()`.
167    pub fn inverse(&self, x: &mut [C64]) {
168        assert_eq!(x.len(), self.n, "FFT length mismatch");
169        with_scratch(self.inv_scratch, |s| self.inv.process_with_scratch(x, s));
170        let s = 1.0 / self.n as f64;
171        for v in x.iter_mut() {
172            *v *= s;
173        }
174    }
175}
176
177/// Forward FFT of a slice (allocates the output).
178pub fn fft(x: &[C64]) -> Vec<C64> {
179    let mut y = x.to_vec();
180    if !y.is_empty() {
181        plan(y.len()).forward(&mut y);
182    }
183    y
184}
185
186/// Inverse FFT of a slice, divided by `N` (allocates the output).
187pub fn ifft(x: &[C64]) -> Vec<C64> {
188    let mut y = x.to_vec();
189    if !y.is_empty() {
190        plan(y.len()).inverse(&mut y);
191    }
192    y
193}
194
195/// The unitary forward DFT in place, `X_k = Σ_n x_n e^{-j2πkn/N} / √N` (energy
196/// preserving).
197///
198/// # Examples
199/// ```
200/// use comnum_math::{C64, energy, fft};
201/// let mut x = vec![C64::new(1.0, 0.0), C64::new(0.0, 2.0), C64::new(-1.0, 1.0)];
202/// let e = energy(&x);
203/// fft::fft_unitary_in_place(&mut x);
204/// assert!((energy(&x) - e).abs() < 1e-12);
205/// fft::ifft_unitary_in_place(&mut x);
206/// assert!((x[1] - C64::new(0.0, 2.0)).norm() < 1e-12);
207/// ```
208pub fn fft_unitary_in_place(x: &mut [C64]) {
209    if x.is_empty() {
210        return;
211    }
212    plan(x.len()).forward(x);
213    let s = 1.0 / (x.len() as f64).sqrt();
214    for v in x.iter_mut() {
215        *v *= s;
216    }
217}
218
219/// The unitary inverse DFT in place, `x_n = Σ_k X_k e^{+j2πkn/N} / √N`.
220pub fn ifft_unitary_in_place(x: &mut [C64]) {
221    if x.is_empty() {
222        return;
223    }
224    plan(x.len()).inverse(x);
225    let s = (x.len() as f64).sqrt();
226    for v in x.iter_mut() {
227        *v *= s;
228    }
229}
230
231/// The DFT bin of the `i`-th of `k` contiguous subcarriers centred on bin 0 (indices
232/// `-k/2 … k/2 - 1`, in increasing frequency) on a grid of `n` bins: `(i - ⌊k/2⌋) mod n`.
233///
234/// # Examples
235/// ```
236/// use comnum_math::fft::centred_bin;
237/// // four subcarriers on a grid of eight: bins 6, 7, 0, 1
238/// assert_eq!((0..4).map(|i| centred_bin(i, 4, 8)).collect::<Vec<_>>(), [6, 7, 0, 1]);
239/// ```
240pub fn centred_bin(i: usize, k: usize, n: usize) -> usize {
241    (i as isize - (k / 2) as isize).rem_euclid(n as isize) as usize
242}
243
244/// Swap the two halves of a spectrum so that the zero frequency is in the middle
245/// (`numpy.fft.fftshift`): the bin `(i + ⌊N/2⌋) mod N` moves to `i`.
246///
247/// # Examples
248/// ```
249/// use comnum_math::fft::fftshift;
250/// assert_eq!(fftshift(&[0, 1, 2, -2, -1]), [-2, -1, 0, 1, 2]);
251/// assert_eq!(fftshift(&[0, 1, -2, -1]), [-2, -1, 0, 1]);
252/// ```
253pub fn fftshift<T: Copy>(x: &[T]) -> Vec<T> {
254    let n = x.len();
255    (0..n).map(|i| x[(i + n.div_ceil(2)) % n]).collect()
256}
257
258/// Filter a periodic block by its frequency response: `IFFT(FFT(x) · H)`, `H` one
259/// value per DFT bin in the order of [`fftfreq`] (a circular convolution).
260///
261/// # Panics
262/// If `h` does not yield one value per sample of `x` (the callers build it on the
263/// frequencies of `x`).
264///
265/// # Examples
266/// ```
267/// use comnum_math::{C64, fft};
268/// // a delay of one sample: H_k = e^{-j2πk/N}
269/// let x = vec![C64::new(1.0, 0.0), C64::new(2.0, 0.0), C64::new(3.0, 0.0), C64::new(4.0, 0.0)];
270/// let h = (0..4).map(|k| comnum_math::cis(-std::f64::consts::TAU * k as f64 / 4.0));
271/// let y = fft::filter(&x, h);
272/// assert!((y[1] - x[0]).norm() < 1e-12 && (y[0] - x[3]).norm() < 1e-12);
273/// ```
274pub fn filter(x: &[C64], h: impl IntoIterator<Item = C64>) -> Vec<C64> {
275    let mut y = fft(x);
276    let mut h = h.into_iter();
277    for v in &mut y {
278        match h.next() {
279            Some(hk) => *v *= hk,
280            None => panic!("fft::filter: fewer response values than the {} samples", x.len()),
281        }
282    }
283    assert!(h.next().is_none(), "fft::filter: more response values than the {} samples", x.len());
284    if !y.is_empty() {
285        plan(y.len()).inverse(&mut y);
286    }
287    y
288}
289
290/// Frequencies of the DFT bins for a sample spacing `spacing_s` (`numpy.fft.fftfreq`):
291/// `[0, 1, …, ⌈N/2⌉-1, -⌊N/2⌋, …, -1] / (N d)`.
292pub fn fftfreq(n: usize, spacing_s: f64) -> Vec<f64> {
293    (0..n)
294        .map(|k| {
295            let s = if k < n.div_ceil(2) { k as f64 } else { k as f64 - n as f64 };
296            s / (n as f64 * spacing_s)
297        })
298        .collect()
299}
300
301/// Angular frequencies `2π f` (rad/s) of the bins at the sampling rate `fs`.
302pub fn angular_frequencies(n: usize, fs: f64) -> Vec<f64> {
303    fftfreq(n, 1.0 / fs).into_iter().map(|f| 2.0 * std::f64::consts::PI * f).collect()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // the transforms with the work space of the thread give the bits of rustfft's own
311    // `process` (which allocates its work space), for powers of two, primes (Bluestein,
312    // Rader) and mixed radices, in any order of lengths
313    #[test]
314    fn the_shared_work_space_gives_the_bits_of_rustfft_process() {
315        let mut planner = FftPlanner::<f64>::new();
316        for &n in &[1, 2, 7, 64, 97, 360, 1024, 1031, 12, 4093, 3] {
317            let x: Vec<C64> = (0..n).map(|k| C64::new((k as f64 * 0.37).sin(), (k as f64 * 1.3).cos())).collect();
318            let p = plan(n);
319            let (mut a, mut b) = (x.clone(), x.clone());
320            p.forward(&mut a);
321            planner.plan_fft_forward(n).process(&mut b);
322            assert!(
323                a.iter().zip(&b).all(|(u, v)| u.re.to_bits() == v.re.to_bits() && u.im.to_bits() == v.im.to_bits())
324            );
325            let (mut a, mut b) = (x.clone(), x.clone());
326            p.inverse(&mut a);
327            planner.plan_fft_inverse(n).process(&mut b);
328            let s = 1.0 / n as f64;
329            assert!(a.iter().zip(&b).all(|(u, v)| *u == v * s));
330        }
331    }
332
333    #[test]
334    #[should_panic(expected = "fewer response values")]
335    fn filter_rejects_a_short_response_instead_of_leaving_bins_unfiltered() {
336        let _ = filter(&[C64::new(1.0, 0.0); 4], vec![C64::new(0.0, 0.0); 3]);
337    }
338
339    #[test]
340    #[should_panic(expected = "more response values")]
341    fn filter_rejects_a_long_response() {
342        let _ = filter(&[C64::new(1.0, 0.0); 4], vec![C64::new(0.0, 0.0); 5]);
343    }
344}