Skip to main content

convolve_rs/
convolve_uv.rs

1//! FFT-based UV-plane beam convolution.
2//!
3//! This is a port of `racs_tools.convolve_uv.convolve` and `racs_tools.gaussft.gaussft`.
4//! The "robust" mode is implemented: the FT of the convolving Gaussian is computed
5//! analytically at each UV point (no kernel image needed), which handles NaNs gracefully.
6//!
7//! The convolution is generic over the floating-point element type [`FftFloat`]
8//! (`f32` or `f64`): the transforms run in the **same precision as the input
9//! image**, so f32 data (the common radio-astronomy case) is transformed in f32
10//! — roughly half the memory traffic and compute of an f64 transform — while
11//! genuine f64 data is honoured exactly. Plans and scratch buffers are reused
12//! across calls via [`FftPlans`], which matters when convolving every channel of
13//! a cube at the same image size.
14//!
15//! Peak memory is about one image on top of the input (none at all for an owned,
16//! NaN-free plane — see [`convolve_uv_owned_with_plans`]): the real FFT runs in
17//! place, the filter is evaluated on the fly rather than stored, and the NaN mask
18//! is reduced to a bitset before the image is transformed.
19use std::sync::Arc;
20
21use ndarray::{Array2, ArrayView2};
22use num_traits::{Float, cast};
23use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
24use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
25use thiserror::Error;
26
27use crate::beam::{Beam, gauss_factor};
28
29#[derive(Debug, Error)]
30pub enum ConvolveError {
31    #[error("image is entirely NaN")]
32    AllNaN,
33    #[error("beam larger than cutoff — image blanked")]
34    AboveCutoff,
35}
36
37/// Floating-point element type a convolution can run in: `f32` or `f64`.
38///
39/// `FftNum` makes it usable by `rustfft`/`realfft`; `Float` provides the NaN
40/// handling and numeric casts the convolution needs. Sealed in practice to the
41/// two IEEE types `rustfft` supports.
42pub trait FftFloat: FftNum + Float {}
43impl FftFloat for f32 {}
44impl FftFloat for f64 {}
45
46/// Cast an `f64` to `T`, saturating to ±∞ when the value is outside `T`'s finite
47/// range instead of panicking.
48///
49/// [`num_traits::cast`] returns `None` for out-of-range values (e.g. an `f64`
50/// filter coefficient or flux factor exceeding `f32::MAX`); saturating to
51/// infinity matches the old `value as f32` behaviour and lets the convolution
52/// produce an inf/NaN pixel rather than aborting the whole run mid-cube.
53pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
54    cast::<f64, T>(value).unwrap_or_else(|| {
55        if value.is_sign_negative() {
56            T::neg_infinity()
57        } else {
58            T::infinity()
59        }
60    })
61}
62
63pub struct ConvolutionResult<T = f32> {
64    /// Convolved image (NaNs propagated from input).
65    pub image: Array2<T>,
66    /// Flux scaling factor for Jy/beam.
67    pub scaling_factor: f64,
68}
69
70/// Cached FFT plans for a fixed `(nrows, ncols)` image size.
71///
72/// `rustfft`/`realfft` plans are `Arc<dyn …>` and `Send + Sync`, so a single
73/// `FftPlans` can be built once per cube and **shared by reference** across the
74/// rayon workers that convolve channels in parallel — each call brings its own
75/// scratch, so only the (immutable) plans are shared. Building plans is the
76/// expensive part of a transform; reusing them avoids re-planning on every
77/// channel.
78pub struct FftPlans<T: FftNum = f32> {
79    nrows: usize,
80    ncols: usize,
81    nhalf: usize,
82    r2c: Arc<dyn RealToComplex<T>>,
83    c2r: Arc<dyn ComplexToReal<T>>,
84    col_fwd: Arc<dyn Fft<T>>,
85    col_inv: Arc<dyn Fft<T>>,
86}
87
88impl<T: FftNum> FftPlans<T> {
89    /// Plan the forward/inverse real (row) and complex (column) FFTs for an
90    /// `nrows × ncols` image. Reuse this across all channels of a cube.
91    pub fn new(nrows: usize, ncols: usize) -> Self {
92        let mut rplanner = RealFftPlanner::<T>::new();
93        let r2c = rplanner.plan_fft_forward(ncols);
94        let c2r = rplanner.plan_fft_inverse(ncols);
95
96        let mut cplanner = FftPlanner::<T>::new();
97        let col_fwd = cplanner.plan_fft_forward(nrows);
98        let col_inv = cplanner.plan_fft_inverse(nrows);
99
100        Self {
101            nrows,
102            ncols,
103            nhalf: ncols / 2 + 1,
104            r2c,
105            c2r,
106            col_fwd,
107            col_inv,
108        }
109    }
110
111    /// Image dimensions `(nrows, ncols)` these plans were built for.
112    pub fn dim(&self) -> (usize, usize) {
113        (self.nrows, self.ncols)
114    }
115}
116
117/// The image handed to the convolution: either borrowed, in which case one
118/// working buffer is allocated for the output, or owned, in which case the
119/// transform runs in the caller's own buffer and nothing image-sized is
120/// allocated at all (for an image without NaNs).
121pub(crate) enum PlaneInput<'a, T> {
122    Borrowed(ArrayView2<'a, T>),
123    Owned(Array2<T>),
124}
125
126impl<T: FftFloat> PlaneInput<'_, T> {
127    fn view(&self) -> ArrayView2<'_, T> {
128        match self {
129            PlaneInput::Borrowed(v) => v.view(),
130            PlaneInput::Owned(a) => a.view(),
131        }
132    }
133
134    fn into_owned(self) -> Array2<T> {
135        match self {
136            PlaneInput::Borrowed(v) => v.to_owned(),
137            PlaneInput::Owned(a) => a,
138        }
139    }
140
141    /// Row-major working buffer holding the image with NaNs zero-filled. A new
142    /// buffer is allocated with room for `capacity` values, so growing it to
143    /// hold the spectrum never reallocates.
144    fn into_zero_filled_vec(self, capacity: usize) -> Vec<T> {
145        let zero_nan = |x: T| if x.is_nan() { T::zero() } else { x };
146        match self {
147            PlaneInput::Owned(a) if a.is_standard_layout() => {
148                let (mut buf, offset) = a.into_raw_vec_and_offset();
149                debug_assert_eq!(offset.unwrap_or(0), 0);
150                for x in buf.iter_mut() {
151                    *x = zero_nan(*x);
152                }
153                buf
154            }
155            other => {
156                let mut buf = Vec::with_capacity(capacity);
157                buf.extend(other.view().iter().map(|&x| zero_nan(x)));
158                buf
159            }
160        }
161    }
162}
163
164/// Extra flux scaling applied to the output on top of the filter's own DC gain
165/// (`g_ratio`), folded into the filter so it costs no extra pass over the image.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub(crate) enum OutputGain {
168    /// Leave the output as the filter produced it (what [`convolve_uv`] returns).
169    Unit,
170    /// Multiply by `g_ratio` once more (Jy/beam).
171    Ratio,
172    /// Divide the filter's `g_ratio` back out (Kelvin).
173    InverseRatio,
174}
175
176/// Convolve `image` from `old_beam` to `new_beam` in the UV plane.
177///
178/// `dx_deg` / `dy_deg` are the pixel sizes in degrees (FITS |CDELT1|, |CDELT2|).
179/// `cutoff_arcsec` blanks images whose current beam exceeds this size.
180///
181/// The returned [`ConvolutionResult::scaling_factor`] is `√(Ω_new/Ω_old)`; see
182/// [`crate::smooth::smooth`] for how this becomes the Jy/beam or Kelvin factor.
183///
184/// This builds FFT plans for the image size on each call. To convolve many
185/// images of the same size (e.g. cube channels), build an [`FftPlans`] once and
186/// call [`convolve_uv_with_plans`] to reuse it.
187///
188/// # Examples
189///
190/// ```
191/// use convolve_rs::{Beam, convolve_uv};
192/// use ndarray::Array2;
193///
194/// let old = Beam::from_arcsec(10.0, 10.0, 0.0)?;
195/// let new = Beam::from_arcsec(20.0, 20.0, 0.0)?;
196/// let image = Array2::<f32>::from_elem((64, 64), 1.0);
197/// let dx = 2.5 / 3600.0;
198///
199/// let result = convolve_uv(&image, &old, &new, dx, dx, None)?;
200/// // √(Ω_new/Ω_old) = √4 = 2 for a doubling of both axes.
201/// assert!((result.scaling_factor - 2.0).abs() < 1e-9);
202/// assert_eq!(result.image.dim(), (64, 64));
203/// # Ok::<(), Box<dyn std::error::Error>>(())
204/// ```
205pub fn convolve_uv<T: FftFloat>(
206    image: &Array2<T>,
207    old_beam: &Beam,
208    new_beam: &Beam,
209    dx_deg: f64,
210    dy_deg: f64,
211    cutoff_arcsec: Option<f64>,
212) -> Result<ConvolutionResult<T>, ConvolveError> {
213    let (nrows, ncols) = image.dim();
214    let plans = FftPlans::<T>::new(nrows, ncols);
215    convolve_uv_with_plans(
216        image,
217        old_beam,
218        new_beam,
219        dx_deg,
220        dy_deg,
221        cutoff_arcsec,
222        &plans,
223    )
224}
225
226/// Like [`convolve_uv`], but reuses pre-built [`FftPlans`] instead of planning
227/// per call. `plans` must have been built for `image`'s dimensions.
228pub fn convolve_uv_with_plans<T: FftFloat>(
229    image: &Array2<T>,
230    old_beam: &Beam,
231    new_beam: &Beam,
232    dx_deg: f64,
233    dy_deg: f64,
234    cutoff_arcsec: Option<f64>,
235    plans: &FftPlans<T>,
236) -> Result<ConvolutionResult<T>, ConvolveError> {
237    convolve_plane(
238        PlaneInput::Borrowed(image.view()),
239        old_beam,
240        new_beam,
241        dx_deg,
242        dy_deg,
243        cutoff_arcsec,
244        OutputGain::Unit,
245        plans,
246    )
247}
248
249/// Like [`convolve_uv_with_plans`], but takes ownership of `image` and
250/// transforms in its buffer, so the only image-sized allocation is the one the
251/// NaN mask needs (none at all for an image without NaNs). Use this when the
252/// input plane is not needed afterwards, e.g. a cube channel just read from disk.
253pub fn convolve_uv_owned_with_plans<T: FftFloat>(
254    image: Array2<T>,
255    old_beam: &Beam,
256    new_beam: &Beam,
257    dx_deg: f64,
258    dy_deg: f64,
259    cutoff_arcsec: Option<f64>,
260    plans: &FftPlans<T>,
261) -> Result<ConvolutionResult<T>, ConvolveError> {
262    convolve_plane(
263        PlaneInput::Owned(image),
264        old_beam,
265        new_beam,
266        dx_deg,
267        dy_deg,
268        cutoff_arcsec,
269        OutputGain::Unit,
270        plans,
271    )
272}
273
274/// The convolution behind every public entry point.
275///
276/// Peak memory is kept to roughly one image on top of the input: the real FFT
277/// runs in place in a single buffer (the half spectrum of an `nrows × ncols`
278/// real image is `nrows × (ncols/2 + 1)` complex values, which fits in that
279/// buffer with only two extra values per row), the filter is evaluated on the
280/// fly rather than stored, and the NaN mask is convolved *first*, in its own
281/// buffer that is freed before the image's is needed, leaving only a bitset of
282/// the pixels to blank.
283#[allow(clippy::too_many_arguments)]
284pub(crate) fn convolve_plane<T: FftFloat>(
285    image: PlaneInput<'_, T>,
286    old_beam: &Beam,
287    new_beam: &Beam,
288    dx_deg: f64,
289    dy_deg: f64,
290    cutoff_arcsec: Option<f64>,
291    gain: OutputGain,
292    plans: &FftPlans<T>,
293) -> Result<ConvolutionResult<T>, ConvolveError> {
294    // Cutoff check.
295    if let Some(cutoff) = cutoff_arcsec
296        && old_beam.major_arcsec() > cutoff
297    {
298        return Err(ConvolveError::AboveCutoff);
299    }
300
301    // Beams identical → no-op with unit scaling.
302    if old_beam.approx_eq(new_beam) {
303        return Ok(ConvolutionResult {
304            image: image.into_owned(),
305            scaling_factor: 1.0,
306        });
307    }
308
309    let (nrows, ncols) = image.view().dim();
310    assert_eq!(
311        plans.dim(),
312        (nrows, ncols),
313        "FftPlans built for {:?} but image is {:?}",
314        plans.dim(),
315        (nrows, ncols)
316    );
317
318    let nan_count = image.view().iter().filter(|x| x.is_nan()).count();
319
320    // All-NaN fast path.
321    if nan_count == nrows * ncols {
322        // Compute the convolving beam (new² - old² in quadrature) and flux scaling.
323        let conv_beam = new_beam.deconvolve_or_zero(old_beam);
324        let (fac, ..) = gauss_factor(
325            &conv_beam,
326            old_beam,
327            dx_deg.abs() * 3600.0,
328            dy_deg.abs() * 3600.0,
329        );
330        return Ok(ConvolutionResult {
331            image: image.into_owned(),
332            scaling_factor: fac,
333        });
334    }
335
336    // UV coordinates: fftfreq(n, d_rad) where d_rad = pixel_size_in_radians.
337    // The data is real, so we use a real-input FFT: the column (ncols) axis only
338    // needs its non-negative half, `nhalf = ncols/2 + 1` bins. We slice the full
339    // `fftfreq` rather than using `rfftfreq` so the filter is evaluated at exactly
340    // the frequencies the equivalent full FFT assigns to bins 0..nhalf (incl. the
341    // signed Nyquist), keeping results aligned with the full-FFT port.
342    let taper = UvTaper::new(old_beam, new_beam);
343    let grid = UvGrid::new(
344        nrows,
345        ncols,
346        dx_deg.to_radians(),
347        dy_deg.to_radians(),
348        &taper,
349    );
350    let npix = (nrows * ncols) as f64;
351    let spectrum_len = nrows * 2 * (ncols / 2 + 1);
352
353    // Convolve the NaN mask with the same filter to find where the blanking
354    // spreads to. Done before the image so its buffer is gone by the time the
355    // image's is needed.
356    let blank = (nan_count > 0).then(|| {
357        let mut mask: Vec<T> = Vec::with_capacity(spectrum_len);
358        mask.extend(
359            image
360                .view()
361                .iter()
362                .map(|x| if x.is_nan() { T::one() } else { T::zero() }),
363        );
364        convolve_in_place(&mut mask, &taper, taper.ratio / npix, &grid, plans);
365        // A fully-NaN-covered pixel reaches the filter's DC gain (g_ratio ≥ 1) in
366        // the convolved mask; isolated NaNs stay well below 1 and are interpolated
367        // over. The threshold sits just under 1 so that f32 round-off — which can
368        // leave a solid-NaN interior at ~0.999 when the beams are nearly equal
369        // (g_ratio ≈ 1) — does not silently un-blank a masked region.
370        let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
371        Bitset::from_predicate(&mask, |m| m >= blank_threshold)
372    });
373
374    let out_gain = match gain {
375        OutputGain::Unit => taper.ratio,
376        OutputGain::Ratio => taper.ratio * taper.ratio,
377        OutputGain::InverseRatio => 1.0,
378    };
379    let mut buf = image.into_zero_filled_vec(spectrum_len);
380    convolve_in_place(&mut buf, &taper, out_gain / npix, &grid, plans);
381
382    if let Some(blank) = blank {
383        blank.for_each_set(|k| buf[k] = T::nan());
384    }
385
386    let out =
387        Array2::from_shape_vec((nrows, ncols), buf).expect("shape mismatch in convolve_uv output");
388
389    Ok(ConvolutionResult {
390        image: out,
391        scaling_factor: taper.ratio,
392    })
393}
394
395// ── gaussft ───────────────────────────────────────────────────────────────────
396
397/// The UV-plane filter that deconvolves one Gaussian beam and re-convolves with
398/// another, reduced to the coefficients of a single quadratic form.
399///
400/// `racs_tools.gaussft` evaluates `g_ratio · exp(g_arg − dg_arg)`, where each
401/// argument is `−2π² ((σx u_r)² + (σy v_r)²)` in coordinates rotated by that
402/// beam's position angle. Expanding the rotations, each argument is a quadratic
403/// form `a u² + b uv + c v²`, so their difference is too. Collapsing both into
404/// one set of coefficients makes each filter value a few multiply-adds and one
405/// `exp`, cheap enough to evaluate on the fly instead of storing the filter.
406#[derive(Clone, Copy, Debug)]
407struct UvTaper {
408    a: f64,
409    b: f64,
410    c: f64,
411    /// Amplitude ratio (= flux scaling factor = DC gain of the filter).
412    ratio: f64,
413}
414
415impl UvTaper {
416    fn new(old_beam: &Beam, new_beam: &Beam) -> Self {
417        let deg2rad = std::f64::consts::PI / 180.0;
418        let fwhm_to_sigma = 2.0 * (2.0 * 2_f64.ln()).sqrt(); // = 2*sqrt(2*ln2)
419
420        // New beam (target) and old beam (input PSF) as Gaussian sigmas.
421        let sx = new_beam.major_deg * deg2rad / fwhm_to_sigma;
422        let sy = new_beam.minor_deg * deg2rad / fwhm_to_sigma;
423        let sx_in = old_beam.major_deg * deg2rad / fwhm_to_sigma;
424        let sy_in = old_beam.minor_deg * deg2rad / fwhm_to_sigma;
425
426        // Amplitude ratio (= flux scaling factor).
427        let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
428        let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
429
430        // (σx u_r)² + (σy v_r)² with u_r = u cos θ − v sin θ, v_r = u sin θ + v cos θ.
431        let form = |sx: f64, sy: f64, pa_deg: f64| {
432            let (s, c) = (pa_deg * deg2rad).sin_cos();
433            let (sx2, sy2) = (sx * sx, sy * sy);
434            (
435                sx2 * c * c + sy2 * s * s,
436                2.0 * c * s * (sy2 - sx2),
437                sx2 * s * s + sy2 * c * c,
438            )
439        };
440        let (a, b, c) = form(sx, sy, new_beam.pa_deg);
441        let (a_in, b_in, c_in) = form(sx_in, sy_in, old_beam.pa_deg);
442        let k = -2.0 * std::f64::consts::PI * std::f64::consts::PI;
443
444        Self {
445            a: k * (a - a_in),
446            b: k * (b - b_in),
447            c: k * (c - c_in),
448            ratio: g_amp / dg_amp,
449        }
450    }
451
452    /// Filter value at `(u, v)`, including the DC gain `ratio`.
453    fn at(&self, u: f64, v: f64) -> f64 {
454        self.ratio * (self.a * u * u + self.b * u * v + self.c * v * v).exp()
455    }
456}
457
458/// Compute the UV-plane filter that deconvolves `old_beam` and re-convolves with
459/// `new_beam`. Direct port of `racs_tools.gaussft.gaussft`.
460///
461/// `u_freqs` has length `nrows`, `v_freqs` has length `ncols` (or `nhalf` for a
462/// half-spectrum / real-FFT layout). The filter is real-valued, so it is returned
463/// as `Vec<f64>` of length `nrows * v_freqs.len()` in row-major order.
464///
465/// The convolution itself never materialises this array — it evaluates the
466/// same filter on the fly — so this is for inspection and testing.
467pub fn gaussft(
468    old_beam: &Beam,
469    new_beam: &Beam,
470    u_freqs: &[f64],
471    v_freqs: &[f64],
472) -> (Vec<f64>, f64) {
473    let taper = UvTaper::new(old_beam, new_beam);
474    let g_final = u_freqs
475        .iter()
476        .flat_map(|&u| v_freqs.iter().map(move |&v| taper.at(u, v)))
477        .collect();
478    (g_final, taper.ratio)
479}
480
481// ── FFT helpers ───────────────────────────────────────────────────────────────
482
483/// numpy-compatible `fftfreq(n, d)`.
484///
485/// For even n the Nyquist bin (index n/2) is listed as negative, matching numpy.
486///
487/// # Examples
488///
489/// ```
490/// use convolve_rs::fftfreq;
491///
492/// assert_eq!(fftfreq(4, 1.0), vec![0.0, 0.25, -0.5, -0.25]);
493/// assert_eq!(fftfreq(5, 1.0), vec![0.0, 0.2, 0.4, -0.4, -0.2]);
494/// ```
495pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
496    let val = 1.0 / (n as f64 * d);
497    let m = n.div_ceil(2); // ceiling(n/2): positive-frequency count
498    let mut freqs = vec![0.0_f64; n];
499    for (i, freq) in freqs.iter_mut().enumerate().take(m) {
500        *freq = i as f64 * val;
501    }
502    for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
503        *freq = (i as f64 - n as f64) * val;
504    }
505    freqs
506}
507
508/// Columns transformed together in the column pass. The half spectrum is stored
509/// row-major, so a single column is `nrows` values each a full row apart; gathering
510/// a block of adjacent columns at once reads whole cache lines instead of one
511/// value per line, which is what makes the column pass cheap on wide images.
512const COL_BLOCK: usize = 16;
513
514/// The UV coordinates of the half spectrum, with the parts of the filter's
515/// exponent that depend on only one of them precomputed.
516struct UvGrid {
517    /// `u` for each of the `nrows` rows.
518    u: Vec<f64>,
519    /// `a·u²` for each row.
520    au2: Vec<f64>,
521    /// `v` for each of the `nhalf` columns of the half spectrum.
522    v: Vec<f64>,
523}
524
525impl UvGrid {
526    fn new(nrows: usize, ncols: usize, dx_rad: f64, dy_rad: f64, taper: &UvTaper) -> Self {
527        let u = fftfreq(nrows, dx_rad);
528        let au2 = u.iter().map(|&u| taper.a * u * u).collect();
529        let mut v = fftfreq(ncols, dy_rad);
530        v.truncate(ncols / 2 + 1);
531        Self { u, au2, v }
532    }
533}
534
535/// A packed set of pixel indices (one bit per pixel).
536struct Bitset {
537    words: Vec<u64>,
538}
539
540impl Bitset {
541    fn from_predicate<T: Copy>(values: &[T], pred: impl Fn(T) -> bool) -> Self {
542        let words = values
543            .chunks(64)
544            .map(|chunk| {
545                chunk
546                    .iter()
547                    .enumerate()
548                    .fold(0u64, |w, (b, &x)| w | ((pred(x) as u64) << b))
549            })
550            .collect();
551        Self { words }
552    }
553
554    fn for_each_set(&self, mut f: impl FnMut(usize)) {
555        for (w, &word) in self.words.iter().enumerate() {
556            let mut bits = word;
557            while bits != 0 {
558                f(w * 64 + bits.trailing_zeros() as usize);
559                bits &= bits - 1;
560            }
561        }
562    }
563}
564
565/// View a buffer of reals as the complex values it stores in pairs.
566fn as_complex<T: FftFloat>(re: &[T]) -> &[Complex<T>] {
567    assert!(re.len().is_multiple_of(2));
568    // SAFETY: `Complex<T>` is `#[repr(C)] { re: T, im: T }`, so it has the size of
569    // two `T`s and the alignment of one, and any pair of initialised `T`s is a
570    // valid value. The length is halved, so the view covers exactly `re`.
571    unsafe { std::slice::from_raw_parts(re.as_ptr().cast(), re.len() / 2) }
572}
573
574/// Mutable form of [`as_complex`].
575fn as_complex_mut<T: FftFloat>(re: &mut [T]) -> &mut [Complex<T>] {
576    assert!(re.len().is_multiple_of(2));
577    // SAFETY: as for `as_complex`; the exclusive borrow of `re` is carried over.
578    unsafe { std::slice::from_raw_parts_mut(re.as_mut_ptr().cast(), re.len() / 2) }
579}
580
581/// Convolve the real `nrows × ncols` image held row-major in `buf` with the
582/// UV-plane filter, in place. The filter applied is `gain · exp(…)`: the shape of
583/// [`UvTaper`] with unit DC gain, times `gain`.
584///
585/// The buffer is grown by two values per row to hold the half spectrum, the
586/// forward real FFT runs in place, the filter is applied during the column
587/// pass, and the inverse writes the image back over the spectrum before the
588/// buffer is shrunk to the image again. `gain` must include the `1/N`
589/// normalisation of the inverse FFT.
590fn convolve_in_place<T: FftFloat>(
591    buf: &mut Vec<T>,
592    taper: &UvTaper,
593    gain: f64,
594    grid: &UvGrid,
595    plans: &FftPlans<T>,
596) {
597    let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
598    debug_assert_eq!(buf.len(), nrows * ncols);
599    // A large buffer is its own mapping, so growing it this little is a remap
600    // rather than a copy.
601    buf.resize(nrows * 2 * nhalf, T::zero());
602
603    forward_rows_in_place(plans, buf);
604    filter_columns(plans, as_complex_mut(buf), taper, gain, grid);
605    inverse_rows_in_place(plans, buf);
606
607    buf.truncate(nrows * ncols);
608}
609
610/// Row-wise real→complex FFT, in place.
611///
612/// On entry the first `nrows · ncols` values of `buf` are the image, row-major;
613/// on exit `buf` holds the `nrows × nhalf` half spectrum, row-major. Spectrum row
614/// `i` starts at real offset `2·nhalf·i ≥ ncols·i`, so writing it can only
615/// overwrite image rows `≥ i`; going from the last row to the first, those have
616/// all been transformed already (row `i` itself is copied out first).
617fn forward_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
618    let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
619    let mut scratch = plans.r2c.make_scratch_vec();
620    let mut inrow = plans.r2c.make_input_vec();
621    for i in (0..nrows).rev() {
622        inrow.copy_from_slice(&buf[i * ncols..(i + 1) * ncols]);
623        let out = as_complex_mut(&mut buf[2 * nhalf * i..2 * nhalf * (i + 1)]);
624        plans
625            .r2c
626            .process_with_scratch(&mut inrow, out, &mut scratch)
627            .expect("r2c FFT");
628    }
629}
630
631/// Row-wise complex→real FFT, in place: the inverse of [`forward_rows_in_place`].
632///
633/// Image row `i` ends at real offset `ncols·(i + 1) ≤ 2·nhalf·(i + 1)`, so writing
634/// it can only overwrite spectrum rows `≤ i`; going from the first row to the
635/// last, those have all been transformed already.
636fn inverse_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
637    let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
638    let mut scratch = plans.c2r.make_scratch_vec();
639    let mut inrow = plans.c2r.make_input_vec();
640    let even = ncols.is_multiple_of(2);
641    for i in 0..nrows {
642        inrow.copy_from_slice(as_complex(&buf[2 * nhalf * i..2 * nhalf * (i + 1)]));
643        // c2r requires the DC (and, for even ncols, Nyquist) bins to be purely
644        // real; they are up to rounding, so zero the imaginary parts explicitly.
645        inrow[0].im = T::zero();
646        if even {
647            inrow[nhalf - 1].im = T::zero();
648        }
649        plans
650            .c2r
651            .process_with_scratch(
652                &mut inrow,
653                &mut buf[i * ncols..(i + 1) * ncols],
654                &mut scratch,
655            )
656            .expect("c2r FFT");
657    }
658}
659
660/// Column-wise forward FFT, filter, and inverse FFT of the `nrows × nhalf` half
661/// spectrum, a block of [`COL_BLOCK`] columns at a time.
662///
663/// Each block is gathered into a contiguous tile once, transformed, multiplied
664/// by `gain ·` the filter (evaluated on the fly, never stored), transformed back
665/// and scattered, so the spectrum is traversed once rather than three times.
666fn filter_columns<T: FftFloat>(
667    plans: &FftPlans<T>,
668    spectrum: &mut [Complex<T>],
669    taper: &UvTaper,
670    gain: f64,
671    grid: &UvGrid,
672) {
673    let (nrows, nhalf) = (plans.nrows, plans.nhalf);
674    let zero = Complex::new(T::zero(), T::zero());
675    let scratch_len = plans
676        .col_fwd
677        .get_inplace_scratch_len()
678        .max(plans.col_inv.get_inplace_scratch_len());
679    let mut scratch = vec![zero; scratch_len];
680    let mut tile = vec![zero; COL_BLOCK * nrows];
681
682    for j0 in (0..nhalf).step_by(COL_BLOCK) {
683        let width = COL_BLOCK.min(nhalf - j0);
684        let tile = &mut tile[..width * nrows];
685
686        // Gather: tile row k is spectrum column j0 + k.
687        for (i, row) in spectrum.chunks_exact(nhalf).enumerate() {
688            for (k, &s) in row[j0..j0 + width].iter().enumerate() {
689                tile[k * nrows + i] = s;
690            }
691        }
692
693        plans.col_fwd.process_with_scratch(tile, &mut scratch);
694
695        for (k, col) in tile.chunks_exact_mut(nrows).enumerate() {
696            let v = grid.v[j0 + k];
697            let (bv, cv2) = (taper.b * v, taper.c * v * v);
698            for ((s, &u), &au2) in col.iter_mut().zip(&grid.u).zip(&grid.au2) {
699                let g = gain * (au2 + bv * u + cv2).exp();
700                *s = s.scale(cast_saturating::<T>(g));
701            }
702        }
703
704        plans.col_inv.process_with_scratch(tile, &mut scratch);
705
706        // Scatter back.
707        for (i, row) in spectrum.chunks_exact_mut(nhalf).enumerate() {
708            for (k, s) in row[j0..j0 + width].iter_mut().enumerate() {
709                *s = tile[k * nrows + i];
710            }
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use ndarray::Array2;
719
720    #[test]
721    fn test_fftfreq() {
722        // Match numpy: fftfreq(4, 1) = [0, 0.25, -0.5, -0.25]
723        let f = fftfreq(4, 1.0);
724        let expected = [0.0, 0.25, -0.5, -0.25];
725        for (a, b) in f.iter().zip(expected.iter()) {
726            assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
727        }
728    }
729
730    /// Forward then inverse in-place transform (no filter) recovers the image,
731    /// for even and odd widths (the Nyquist handling differs) and both precisions.
732    fn roundtrip<T: FftFloat + std::fmt::Debug>(nrows: usize, ncols: usize, tol: f64) {
733        let data: Vec<T> = (0..nrows * ncols)
734            .map(|k| cast_saturating::<T>(((k * 7) % 13) as f64 - 6.0))
735            .collect();
736        let plans = FftPlans::<T>::new(nrows, ncols);
737        let mut buf = data.clone();
738        buf.resize(nrows * 2 * plans.nhalf, T::zero());
739        forward_rows_in_place(&plans, &mut buf);
740        // Column pass with a flat unit filter: a == b == c == 0.
741        let flat = UvTaper {
742            a: 0.0,
743            b: 0.0,
744            c: 0.0,
745            ratio: 1.0,
746        };
747        let grid = UvGrid::new(nrows, ncols, 1.0, 1.0, &flat);
748        let n = (nrows * ncols) as f64;
749        filter_columns(&plans, as_complex_mut(&mut buf), &flat, 1.0 / n, &grid);
750        inverse_rows_in_place(&plans, &mut buf);
751        buf.truncate(nrows * ncols);
752        for (a, b) in data.iter().zip(buf.iter()) {
753            let (a, b) = (a.to_f64().unwrap(), b.to_f64().unwrap());
754            assert!((a - b).abs() < tol, "roundtrip {nrows}x{ncols}: {a} vs {b}");
755        }
756    }
757
758    #[test]
759    fn test_in_place_roundtrip() {
760        for &(r, c) in &[(4, 4), (5, 7), (6, 9), (7, 6), (1, 8), (33, 40)] {
761            roundtrip::<f64>(r, c, 1e-10);
762            roundtrip::<f32>(r, c, 1e-4);
763        }
764    }
765
766    /// Straightforward reference: full complex 2-D FFT of the NaN-filled image,
767    /// multiply by `gaussft` on the full grid, inverse, take the real part, and
768    /// blank where the convolved NaN mask reaches the threshold. This is the
769    /// algorithm as `racs_tools` first wrote it, before any memory saving.
770    fn reference(image: &Array2<f64>, old: &Beam, new: &Beam, dx: f64) -> Array2<f64> {
771        let (nrows, ncols) = image.dim();
772        let u = fftfreq(nrows, dx.to_radians());
773        let v = fftfreq(ncols, dx.to_radians());
774        let (g, _) = gaussft(old, new, &u, &v);
775        let mut planner = FftPlanner::<f64>::new();
776        let (row_fwd, col_fwd) = (
777            planner.plan_fft_forward(ncols),
778            planner.plan_fft_forward(nrows),
779        );
780        let (row_inv, col_inv) = (
781            planner.plan_fft_inverse(ncols),
782            planner.plan_fft_inverse(nrows),
783        );
784        let fft2 = |z: &mut [Complex<f64>], rows: &dyn Fft<f64>, cols: &dyn Fft<f64>| {
785            rows.process(z);
786            let mut col = vec![Complex::new(0.0, 0.0); nrows];
787            for j in 0..ncols {
788                (0..nrows).for_each(|i| col[i] = z[i * ncols + j]);
789                cols.process(&mut col);
790                (0..nrows).for_each(|i| z[i * ncols + j] = col[i]);
791            }
792        };
793        let conv = |data: &dyn Fn(f64) -> f64| -> Vec<f64> {
794            let mut z: Vec<Complex<f64>> =
795                image.iter().map(|&x| Complex::new(data(x), 0.0)).collect();
796            fft2(&mut z, &*row_fwd, &*col_fwd);
797            z.iter_mut().zip(&g).for_each(|(s, &g)| *s *= g);
798            fft2(&mut z, &*row_inv, &*col_inv);
799            z.iter().map(|s| s.re / (nrows * ncols) as f64).collect()
800        };
801        let img = conv(&|x| if x.is_nan() { 0.0 } else { x });
802        let mask = conv(&|x| if x.is_nan() { 1.0 } else { 0.0 });
803        let out = img
804            .iter()
805            .zip(&mask)
806            .map(|(&x, &m)| if m >= 1.0 - 1e-2 { f64::NAN } else { x })
807            .collect();
808        Array2::from_shape_vec((nrows, ncols), out).unwrap()
809    }
810
811    fn test_image(nrows: usize, ncols: usize, nans: bool) -> Array2<f64> {
812        let mut img = Array2::from_shape_fn((nrows, ncols), |(i, j)| {
813            (((i * 31 + j * 17) % 23) as f64 - 11.0) / 7.0 + if i > nrows / 2 { 3.0 } else { 0.0 }
814        });
815        if nans {
816            for i in 0..nrows / 3 {
817                for j in 0..ncols / 2 {
818                    img[(i, j)] = f64::NAN;
819                }
820            }
821            img[(nrows - 2, ncols - 3)] = f64::NAN; // isolated: interpolated over
822        }
823        img
824    }
825
826    /// The in-place, on-the-fly-filter, blocked-column convolution matches the
827    /// naive full-FFT reference, including odd dimensions, rotated beams, NaN
828    /// blanking, and widths that leave a partial final column block.
829    #[test]
830    fn test_convolve_uv_matches_full_fft_reference() {
831        let dx = 2.5 / 3600.0;
832        let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
833        let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
834        for &(r, c) in &[(64, 48), (65, 49), (40, 71), (37, 36)] {
835            for nans in [false, true] {
836                let img = test_image(r, c, nans);
837                let expected = reference(&img, &old, &new, dx);
838                let got = convolve_uv(&img, &old, &new, dx, dx, None).unwrap().image;
839                let got32 = convolve_uv(&img.mapv(|x| x as f32), &old, &new, dx, dx, None)
840                    .unwrap()
841                    .image;
842                for ((&e, &g), &g32) in expected.iter().zip(got.iter()).zip(got32.iter()) {
843                    assert_eq!(
844                        e.is_nan(),
845                        g.is_nan(),
846                        "{r}x{c} nans={nans}: NaN mask differs"
847                    );
848                    assert_eq!(
849                        e.is_nan(),
850                        g32.is_nan(),
851                        "{r}x{c} nans={nans}: f32 NaN mask"
852                    );
853                    if !e.is_nan() {
854                        assert!((e - g).abs() < 1e-9, "{r}x{c} nans={nans}: {e} vs {g}");
855                        assert!((e - g32 as f64).abs() < 1e-4, "{r}x{c}: f32 {e} vs {g32}");
856                    }
857                }
858            }
859        }
860    }
861
862    /// Convolving an owned plane in its own buffer gives bit-identical output to
863    /// convolving a borrowed one, with and without NaNs.
864    #[test]
865    fn test_owned_matches_borrowed() {
866        let dx = 2.5 / 3600.0;
867        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
868        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
869        for nans in [false, true] {
870            let img = test_image(45, 38, nans).mapv(|x| x as f32);
871            let plans = FftPlans::<f32>::new(45, 38);
872            let borrowed = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
873            let owned = convolve_uv_owned_with_plans(img.clone(), &old, &new, dx, dx, None, &plans)
874                .unwrap();
875            assert_eq!(borrowed.scaling_factor, owned.scaling_factor);
876            for (a, b) in borrowed.image.iter().zip(owned.image.iter()) {
877                assert_eq!(a.to_bits(), b.to_bits(), "owned path changed output");
878            }
879        }
880    }
881
882    /// A non-standard-layout (transposed) input is convolved as the logical
883    /// image, not as its memory order.
884    #[test]
885    fn test_owned_non_standard_layout() {
886        let dx = 2.5 / 3600.0;
887        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
888        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
889        let img = test_image(30, 41, true).reversed_axes(); // 41 x 30, column-major
890        let plans = FftPlans::<f64>::new(41, 30);
891        let std_layout = img.as_standard_layout().to_owned();
892        let a = convolve_uv_with_plans(&std_layout, &old, &new, dx, dx, None, &plans).unwrap();
893        let b = convolve_uv_owned_with_plans(img, &old, &new, dx, dx, None, &plans).unwrap();
894        for (x, y) in a.image.iter().zip(b.image.iter()) {
895            assert_eq!(x.to_bits(), y.to_bits());
896        }
897    }
898
899    #[test]
900    fn test_bitset() {
901        let vals: Vec<u8> = (0..200).map(|k| (k % 3 == 0 || k == 199) as u8).collect();
902        let bits = Bitset::from_predicate(&vals, |v| v == 1);
903        let mut got = vec![];
904        bits.for_each_set(|k| got.push(k));
905        let want: Vec<usize> = (0..200).filter(|&k| vals[k] == 1).collect();
906        assert_eq!(got, want);
907    }
908
909    /// `gaussft`'s collapsed quadratic form agrees with the per-beam rotated
910    /// expression `racs_tools.gaussft` evaluates.
911    #[test]
912    fn test_gaussft_matches_rotated_form() {
913        let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
914        let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
915        let u = fftfreq(33, 1.2e-5);
916        let v = fftfreq(20, 1.2e-5);
917        let (g, ratio) = gaussft(&old, &new, &u, &v);
918        let f = 2.0 * (2.0 * 2f64.ln()).sqrt();
919        let arg = |b: &Beam, u: f64, v: f64| {
920            let (sx, sy) = (b.major_deg.to_radians() / f, b.minor_deg.to_radians() / f);
921            let (s, c) = b.pa_deg.to_radians().sin_cos();
922            let (ur, vr) = (u * c - v * s, u * s + v * c);
923            -2.0 * std::f64::consts::PI.powi(2) * ((sx * ur).powi(2) + (sy * vr).powi(2))
924        };
925        for (i, &uu) in u.iter().enumerate() {
926            for (j, &vv) in v.iter().enumerate() {
927                let want = ratio * (arg(&new, uu, vv) - arg(&old, uu, vv)).exp();
928                let got = g[i * v.len() + j];
929                assert!(
930                    (got - want).abs() <= 1e-12 * want.abs().max(1e-300),
931                    "{got} vs {want}"
932                );
933            }
934        }
935    }
936
937    #[test]
938    fn test_convolve_uv_no_change_when_beams_equal() {
939        let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
940        let img = Array2::from_elem((16, 16), 1.0_f32);
941        let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
942        assert!((result.scaling_factor - 1.0).abs() < 1e-10);
943    }
944
945    /// Convolving a point source yields a Gaussian whose integral equals the
946    /// filter's DC gain (`scaling_factor` = g_ratio, which `convolve_uv` bakes
947    /// into the image) and whose peak sits at the source pixel. Anchors the FFT
948    /// path to a known answer.
949    #[test]
950    fn test_convolve_uv_point_source_flux_and_peak() {
951        let (n, dx) = (64usize, 2.0 / 3600.0);
952        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
953        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
954
955        let mut img = Array2::<f64>::zeros((n, n));
956        img[(n / 2, n / 2)] = 1.0;
957
958        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
959        let total: f64 = res.image.iter().sum();
960
961        // The UV filter has DC gain g_ratio (= scaling_factor), so a unit point
962        // source convolves to a Gaussian whose pixels sum to that gain.
963        assert!(
964            (total - res.scaling_factor).abs() < 1e-6,
965            "integral {total} != DC gain {}",
966            res.scaling_factor
967        );
968
969        // Peak stays at the source pixel and is the image maximum.
970        let peak = res.image[(n / 2, n / 2)];
971        assert!(peak > 0.0);
972        for &v in res.image.iter() {
973            assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
974        }
975    }
976
977    /// f32 and f64 convolutions of the same data must agree to f32 precision —
978    /// confirms the precision-generic path is consistent.
979    #[test]
980    fn test_convolve_uv_f32_matches_f64() {
981        let (n, dx) = (48usize, 2.5 / 3600.0);
982        let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
983        let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
984
985        let img64 =
986            Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
987        let img32 = img64.mapv(|x| x as f32);
988
989        let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
990        let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
991
992        for (a, b) in r64.image.iter().zip(r32.image.iter()) {
993            assert!(
994                (*a - *b as f64).abs() < 1e-4,
995                "f32/f64 mismatch: {a} vs {b}"
996            );
997        }
998    }
999
1000    /// A solid NaN region larger than the kernel must stay blanked in the
1001    /// output (the convolved mask reaches the filter's DC gain ≥ 1 there), while
1002    /// data far from it stays finite. Isolated single NaNs are intentionally
1003    /// interpolated over, so the test uses a block.
1004    #[test]
1005    fn test_convolve_uv_propagates_nans() {
1006        let (n, dx) = (48usize, 2.5 / 3600.0);
1007        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1008        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
1009
1010        let mut img = Array2::<f32>::from_elem((n, n), 1.0);
1011        // Blank a solid block in one corner, several kernel-widths across.
1012        for i in 0..12 {
1013            for j in 0..12 {
1014                img[(i, j)] = f32::NAN;
1015            }
1016        }
1017
1018        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1019        // The interior of the blanked block stays NaN…
1020        assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
1021        // …while a pixel far from the block stays finite.
1022        assert!(res.image[(n - 1, n - 1)].is_finite());
1023    }
1024
1025    /// Reusing one `FftPlans` across calls must give bit-identical output to the
1026    /// per-call planning path. Guards the Tier-0 plan-cache optimisation.
1027    #[test]
1028    fn test_with_plans_matches_per_call() {
1029        let (n, dx) = (32usize, 2.5 / 3600.0);
1030        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1031        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
1032        let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
1033
1034        let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1035
1036        let plans = FftPlans::<f32>::new(n, n);
1037        let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
1038
1039        for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
1040            assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
1041        }
1042    }
1043
1044    /// `gaussft` at DC (u=v=0) equals the amplitude ratio g_ratio.
1045    #[test]
1046    fn test_gaussft_dc_equals_ratio() {
1047        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1048        let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
1049        let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
1050        assert!(
1051            (g[0] - ratio).abs() < 1e-12,
1052            "DC {} != ratio {}",
1053            g[0],
1054            ratio
1055        );
1056        assert!(ratio > 1.0, "larger target beam should have ratio > 1");
1057    }
1058}