Skip to main content

dualis_core/
transform.rs

1//! The discrete Fourier transform, as a kernel utility.
2//!
3//! Numerics rather than physics, which is why it belongs here alongside
4//! [`integrator`](crate::integrator) and [`vector`](crate::vector) rather than in any
5//! domain. It arrived in `dualis-optics` because that is where it was first needed —
6//! twice, for pupil transforms and for angular-spectrum propagation — and it stayed
7//! there one domain too long. An electrostatic solver needs the same transform for
8//! Ewald summation, and a domain crate cannot reach into another one.
9//!
10//! # Accuracy over speed, deliberately
11//!
12//! The twiddle factors are computed from `cos` and `sin` at every butterfly rather than
13//! accumulated by repeated complex multiplication. Accumulating is faster and loses
14//! several digits by the end of a long transform. This is a physics library: a result
15//! wrong in its fourth digit is not worth the cycles saved, and the transforms here are
16//! small.
17//!
18//! It also keeps the answer independent of how the loops were ordered, which is the same
19//! reason every other reduction in this workspace is written in a fixed sequence.
20//!
21//! # Radix two only
22//!
23//! Lengths must be powers of two, and that is enforced rather than worked around. A
24//! mixed-radix implementation would be several times the code for a case nothing here
25//! has needed, and silently padding a caller's array would change the frequency grid
26//! underneath them.
27//!
28//! # Sign convention
29//!
30//! The forward transform carries `exp(-2πi jk/N)` and the inverse carries `exp(+2πi
31//! jk/N)` with a `1/N` in front, so that `ifft(fft(x)) == x`. That is the convention
32//! physics and signal processing agree on; some numerical libraries put the `1/N` on the
33//! forward transform instead, and a field propagated with one and read back with the
34//! other comes out scaled by `N²`.
35
36/// In-place one-dimensional transform on split real and imaginary parts.
37pub fn fft(re: &mut [f64], im: &mut [f64]) {
38    transform_1d(re, im, false);
39}
40
41/// In-place one-dimensional inverse transform, including the `1/N`.
42pub fn ifft(re: &mut [f64], im: &mut [f64]) {
43    transform_1d(re, im, true);
44}
45
46/// In-place two-dimensional transform of an `n`-by-`n` array in row-major order.
47pub fn fft2(re: &mut [f64], im: &mut [f64], n: usize) {
48    transform_2d(re, im, n, false);
49}
50
51/// In-place two-dimensional inverse transform, including the `1/N²`.
52pub fn ifft2(re: &mut [f64], im: &mut [f64], n: usize) {
53    transform_2d(re, im, n, true);
54}
55
56/// Swap quadrants so the zero frequency sits at the centre of the array.
57///
58/// Its own inverse for an even-sized grid, which is why a caller can use it both to
59/// centre a result for display and to un-centre one before transforming it again.
60pub fn fftshift(data: &[f64], n: usize) -> Vec<f64> {
61    let half = n / 2;
62    let mut out = vec![0.0; n * n];
63    for y in 0..n {
64        for x in 0..n {
65            let sx = (x + half) % n;
66            let sy = (y + half) % n;
67            out[y * n + x] = data[sy * n + sx];
68        }
69    }
70    out
71}
72
73fn transform_1d(re: &mut [f64], im: &mut [f64], inverse: bool) {
74    let n = re.len();
75    assert_eq!(
76        n,
77        im.len(),
78        "the real and imaginary parts must be the same length"
79    );
80    assert!(
81        n.is_power_of_two(),
82        "the transform is radix two, so the length must be a power of two, got {n}"
83    );
84    if n < 2 {
85        return;
86    }
87
88    // Bit-reversal permutation.
89    let mut j = 0usize;
90    for i in 1..n {
91        let mut bit = n >> 1;
92        while j & bit != 0 {
93            j ^= bit;
94            bit >>= 1;
95        }
96        j |= bit;
97        if i < j {
98            re.swap(i, j);
99            im.swap(i, j);
100        }
101    }
102
103    let sign = if inverse { 1.0 } else { -1.0 };
104    let mut len = 2;
105    while len <= n {
106        let step = sign * std::f64::consts::TAU / len as f64;
107        let mut base = 0;
108        while base < n {
109            for k in 0..len / 2 {
110                let angle = step * k as f64;
111                let (wr, wi) = (angle.cos(), angle.sin());
112                let (a, b) = (base + k, base + k + len / 2);
113                let (ur, ui) = (re[a], im[a]);
114                let vr = re[b] * wr - im[b] * wi;
115                let vi = re[b] * wi + im[b] * wr;
116                re[a] = ur + vr;
117                im[a] = ui + vi;
118                re[b] = ur - vr;
119                im[b] = ui - vi;
120            }
121            base += len;
122        }
123        len <<= 1;
124    }
125
126    if inverse {
127        let scale = 1.0 / n as f64;
128        for v in re.iter_mut() {
129            *v *= scale;
130        }
131        for v in im.iter_mut() {
132            *v *= scale;
133        }
134    }
135}
136
137/// Rows, then columns. Separable, so the order does not matter mathematically — it is
138/// fixed here anyway, because the floating-point result does depend on it.
139fn transform_2d(re: &mut [f64], im: &mut [f64], n: usize, inverse: bool) {
140    assert_eq!(re.len(), n * n, "the array must be n by n");
141    assert_eq!(im.len(), n * n, "the array must be n by n");
142    let mut row_re = vec![0.0; n];
143    let mut row_im = vec![0.0; n];
144    for y in 0..n {
145        row_re.copy_from_slice(&re[y * n..(y + 1) * n]);
146        row_im.copy_from_slice(&im[y * n..(y + 1) * n]);
147        transform_1d(&mut row_re, &mut row_im, inverse);
148        re[y * n..(y + 1) * n].copy_from_slice(&row_re);
149        im[y * n..(y + 1) * n].copy_from_slice(&row_im);
150    }
151    for x in 0..n {
152        for y in 0..n {
153            row_re[y] = re[y * n + x];
154            row_im[y] = im[y * n + x];
155        }
156        transform_1d(&mut row_re, &mut row_im, inverse);
157        for y in 0..n {
158            re[y * n + x] = row_re[y];
159            im[y * n + x] = row_im[y];
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::f64::consts::TAU;
168
169    /// A single complex exponential lands in exactly one bin, and the rest are empty.
170    ///
171    /// The sharpest available check on a transform: it pins the sign convention, the
172    /// normalisation and the bin ordering at once, and any error in the butterflies
173    /// smears energy into neighbouring bins where there should be none.
174    #[test]
175    fn one_frequency_lands_in_one_bin() {
176        const N: usize = 64;
177        for m in [0usize, 1, 7, 31, 63] {
178            let mut re: Vec<f64> = (0..N)
179                .map(|k| (TAU * m as f64 * k as f64 / N as f64).cos())
180                .collect();
181            let mut im: Vec<f64> = (0..N)
182                .map(|k| (TAU * m as f64 * k as f64 / N as f64).sin())
183                .collect();
184            fft(&mut re, &mut im);
185            for j in 0..N {
186                let magnitude = (re[j] * re[j] + im[j] * im[j]).sqrt();
187                let expected = if j == m { N as f64 } else { 0.0 };
188                assert!(
189                    (magnitude - expected).abs() < 1e-10,
190                    "frequency {m}: bin {j} has {magnitude}, expected {expected}"
191                );
192            }
193        }
194    }
195
196    /// A delta is flat, which is the same statement read the other way round.
197    #[test]
198    fn a_delta_transforms_to_a_flat_spectrum() {
199        const N: usize = 32;
200        let mut re = vec![0.0; N];
201        let mut im = vec![0.0; N];
202        re[0] = 1.0;
203        fft(&mut re, &mut im);
204        for j in 0..N {
205            assert!((re[j] - 1.0).abs() < 1e-12, "bin {j} real part {}", re[j]);
206            assert!(im[j].abs() < 1e-12, "bin {j} imaginary part {}", im[j]);
207        }
208    }
209
210    /// The inverse undoes the forward, in one dimension and in two.
211    ///
212    /// Catches a sign or scaling error that no magnitude-only test would: getting the
213    /// `1/N` onto the wrong transform, or both, still gives plausible spectra.
214    #[test]
215    fn the_inverse_undoes_the_forward() {
216        const N: usize = 64;
217        let re0: Vec<f64> = (0..N).map(|i| ((i * 37) % 101) as f64 / 101.0).collect();
218        let im0: Vec<f64> = (0..N).map(|i| ((i * 53) % 97) as f64 / 97.0).collect();
219
220        let (mut re, mut im) = (re0.clone(), im0.clone());
221        fft(&mut re, &mut im);
222        ifft(&mut re, &mut im);
223        for i in 0..N {
224            assert!((re[i] - re0[i]).abs() < 1e-12, "1D real at {i}");
225            assert!((im[i] - im0[i]).abs() < 1e-12, "1D imaginary at {i}");
226        }
227
228        const M: usize = 32;
229        let re0: Vec<f64> = (0..M * M).map(|i| ((i * 29) % 89) as f64 / 89.0).collect();
230        let im0: Vec<f64> = (0..M * M).map(|i| ((i * 41) % 83) as f64 / 83.0).collect();
231        let (mut re, mut im) = (re0.clone(), im0.clone());
232        fft2(&mut re, &mut im, M);
233        ifft2(&mut re, &mut im, M);
234        for i in 0..M * M {
235            assert!((re[i] - re0[i]).abs() < 1e-12, "2D real at {i}");
236            assert!((im[i] - im0[i]).abs() < 1e-12, "2D imaginary at {i}");
237        }
238    }
239
240    /// Parseval: the transform moves energy around without creating or destroying any.
241    /// `Σ|X|² = N Σ|x|²` in one dimension and `N² Σ|x|²` in two.
242    #[test]
243    fn the_transform_conserves_energy() {
244        const N: usize = 128;
245        let re0: Vec<f64> = (0..N)
246            .map(|i| ((i * 17) % 71) as f64 / 71.0 - 0.5)
247            .collect();
248        let im0: Vec<f64> = (0..N)
249            .map(|i| ((i * 23) % 59) as f64 / 59.0 - 0.5)
250            .collect();
251        let before: f64 = re0.iter().zip(im0.iter()).map(|(a, b)| a * a + b * b).sum();
252
253        let (mut re, mut im) = (re0.clone(), im0.clone());
254        fft(&mut re, &mut im);
255        let after: f64 = re.iter().zip(im.iter()).map(|(a, b)| a * a + b * b).sum();
256        assert!(
257            (after / (N as f64 * before) - 1.0).abs() < 1e-12,
258            "1D: {after} against {} ",
259            N as f64 * before
260        );
261
262        const M: usize = 32;
263        let re0: Vec<f64> = (0..M * M).map(|i| ((i * 13) % 61) as f64 / 61.0).collect();
264        let im0 = vec![0.0; M * M];
265        let before: f64 = re0.iter().map(|a| a * a).sum();
266        let (mut re, mut im) = (re0, im0);
267        fft2(&mut re, &mut im, M);
268        let after: f64 = re.iter().zip(im.iter()).map(|(a, b)| a * a + b * b).sum();
269        let n2 = (M * M) as f64;
270        assert!((after / (n2 * before) - 1.0).abs() < 1e-12, "2D");
271    }
272
273    /// The two-dimensional transform is separable, and this checks that the
274    /// rows-then-columns implementation really is it: the transform of an outer product
275    /// is the outer product of the transforms.
276    ///
277    /// A strong test, because getting the row and column strides confused produces
278    /// something that still looks like a spectrum.
279    #[test]
280    fn the_two_dimensional_transform_is_separable() {
281        const N: usize = 16;
282        let a: Vec<f64> = (0..N).map(|i| ((i * 7) % 13) as f64).collect();
283        let b: Vec<f64> = (0..N).map(|i| ((i * 5) % 11) as f64).collect();
284
285        // Transform the two one-dimensional signals on their own.
286        let (mut ar, mut ai) = (a.clone(), vec![0.0; N]);
287        let (mut br, mut bi) = (b.clone(), vec![0.0; N]);
288        fft(&mut ar, &mut ai);
289        fft(&mut br, &mut bi);
290
291        // And the outer product as a two-dimensional array.
292        let mut re = vec![0.0; N * N];
293        let im0 = vec![0.0; N * N];
294        for y in 0..N {
295            for x in 0..N {
296                re[y * N + x] = a[x] * b[y];
297            }
298        }
299        let mut im = im0;
300        fft2(&mut re, &mut im, N);
301
302        for v in 0..N {
303            for u in 0..N {
304                // (Ar + i Ai)(Br + i Bi)
305                let want_re = ar[u] * br[v] - ai[u] * bi[v];
306                let want_im = ar[u] * bi[v] + ai[u] * br[v];
307                let i = v * N + u;
308                assert!(
309                    (re[i] - want_re).abs() < 1e-9 && (im[i] - want_im).abs() < 1e-9,
310                    "at ({u},{v}): got ({}, {}), expected ({want_re}, {want_im})",
311                    re[i],
312                    im[i]
313                );
314            }
315        }
316    }
317
318    /// A real, even signal has a real spectrum. A symmetry the butterflies must preserve
319    /// exactly, and one that a sign error in the twiddle factors breaks.
320    #[test]
321    fn a_real_even_signal_has_a_real_spectrum() {
322        const N: usize = 64;
323        let mut re: Vec<f64> = (0..N)
324            .map(|i| {
325                let k = if i <= N / 2 { i } else { N - i };
326                (-(k as f64) * 0.3).exp()
327            })
328            .collect();
329        let mut im = vec![0.0; N];
330        fft(&mut re, &mut im);
331        for (j, imaginary) in im.iter().enumerate() {
332            assert!(
333                imaginary.abs() < 1e-12,
334                "bin {j} should be real, imaginary part {imaginary}"
335            );
336        }
337    }
338
339    /// Linearity, which is cheap to check and rules out an accidental nonlinearity in the
340    /// scaling.
341    #[test]
342    fn the_transform_is_linear() {
343        const N: usize = 32;
344        let x: Vec<f64> = (0..N).map(|i| ((i * 11) % 17) as f64).collect();
345        let y: Vec<f64> = (0..N).map(|i| ((i * 3) % 7) as f64).collect();
346
347        let (mut xr, mut xi) = (x.clone(), vec![0.0; N]);
348        let (mut yr, mut yi) = (y.clone(), vec![0.0; N]);
349        fft(&mut xr, &mut xi);
350        fft(&mut yr, &mut yi);
351
352        let mut sr: Vec<f64> = x
353            .iter()
354            .zip(y.iter())
355            .map(|(a, b)| 2.0 * a + 3.0 * b)
356            .collect();
357        let mut si = vec![0.0; N];
358        fft(&mut sr, &mut si);
359
360        for j in 0..N {
361            let want = 2.0 * xr[j] + 3.0 * yr[j];
362            assert!(
363                (sr[j] - want).abs() < 1e-9,
364                "bin {j}: {} against {want}",
365                sr[j]
366            );
367            let want = 2.0 * xi[j] + 3.0 * yi[j];
368            assert!((si[j] - want).abs() < 1e-9);
369        }
370    }
371
372    /// Shifting is its own inverse for an even grid, which is what lets a caller centre a
373    /// result and un-centre it with the same call.
374    #[test]
375    fn shifting_twice_returns_the_original() {
376        const N: usize = 8;
377        let data: Vec<f64> = (0..N * N).map(|i| i as f64).collect();
378        let once = fftshift(&data, N);
379        let twice = fftshift(&once, N);
380        assert_eq!(data, twice);
381        // And it really moved something: the corner becomes the centre.
382        assert_eq!(once[(N / 2) * N + N / 2], data[0]);
383    }
384
385    /// Bit-reproducible, like everything else in this workspace.
386    #[test]
387    fn the_transform_is_bit_reproducible() {
388        const N: usize = 64;
389        let build = || {
390            let mut re: Vec<f64> = (0..N * N).map(|i| ((i * 19) % 43) as f64).collect();
391            let mut im: Vec<f64> = (0..N * N).map(|i| ((i * 31) % 37) as f64).collect();
392            fft2(&mut re, &mut im, N);
393            (re, im)
394        };
395        let (a_re, a_im) = build();
396        let (b_re, b_im) = build();
397        for i in 0..N * N {
398            assert_eq!(a_re[i].to_bits(), b_re[i].to_bits());
399            assert_eq!(a_im[i].to_bits(), b_im[i].to_bits());
400        }
401    }
402
403    /// Lengths the transform cannot handle are refused rather than padded, which would
404    /// change the frequency grid under the caller.
405    #[test]
406    #[should_panic(expected = "power of two")]
407    fn a_non_power_of_two_length_is_refused() {
408        let mut re = vec![0.0; 12];
409        let mut im = vec![0.0; 12];
410        fft(&mut re, &mut im);
411    }
412
413    #[test]
414    #[should_panic(expected = "same length")]
415    fn mismatched_halves_are_refused() {
416        let mut re = vec![0.0; 8];
417        let mut im = vec![0.0; 4];
418        fft(&mut re, &mut im);
419    }
420
421    /// A degenerate length is a no-op rather than an error: the transform of one sample
422    /// is that sample.
423    #[test]
424    fn a_single_sample_transforms_to_itself() {
425        let mut re = vec![3.0];
426        let mut im = vec![-1.0];
427        fft(&mut re, &mut im);
428        assert_eq!(re, vec![3.0]);
429        assert_eq!(im, vec![-1.0]);
430    }
431}