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.
14use std::sync::Arc;
15
16use ndarray::Array2;
17use num_traits::{Float, cast};
18use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
19use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
20use thiserror::Error;
21
22use crate::beam::{Beam, gauss_factor};
23
24#[derive(Debug, Error)]
25pub enum ConvolveError {
26    #[error("image is entirely NaN")]
27    AllNaN,
28    #[error("beam larger than cutoff — image blanked")]
29    AboveCutoff,
30}
31
32/// Floating-point element type a convolution can run in: `f32` or `f64`.
33///
34/// `FftNum` makes it usable by `rustfft`/`realfft`; `Float` provides the NaN
35/// handling and numeric casts the convolution needs. Sealed in practice to the
36/// two IEEE types `rustfft` supports.
37pub trait FftFloat: FftNum + Float {}
38impl FftFloat for f32 {}
39impl FftFloat for f64 {}
40
41/// Cast an `f64` to `T`, saturating to ±∞ when the value is outside `T`'s finite
42/// range instead of panicking.
43///
44/// [`num_traits::cast`] returns `None` for out-of-range values (e.g. an `f64`
45/// filter coefficient or flux factor exceeding `f32::MAX`); saturating to
46/// infinity matches the old `value as f32` behaviour and lets the convolution
47/// produce an inf/NaN pixel rather than aborting the whole run mid-cube.
48pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
49    cast::<f64, T>(value).unwrap_or_else(|| {
50        if value.is_sign_negative() {
51            T::neg_infinity()
52        } else {
53            T::infinity()
54        }
55    })
56}
57
58pub struct ConvolutionResult<T = f32> {
59    /// Convolved image (NaNs propagated from input).
60    pub image: Array2<T>,
61    /// Flux scaling factor for Jy/beam.
62    pub scaling_factor: f64,
63}
64
65/// Cached FFT plans for a fixed `(nrows, ncols)` image size.
66///
67/// `rustfft`/`realfft` plans are `Arc<dyn …>` and `Send + Sync`, so a single
68/// `FftPlans` can be built once per cube and **shared by reference** across the
69/// rayon workers that convolve channels in parallel — each call brings its own
70/// scratch, so only the (immutable) plans are shared. Building plans is the
71/// expensive part of a transform; reusing them avoids re-planning on every
72/// channel.
73pub struct FftPlans<T: FftNum = f32> {
74    nrows: usize,
75    ncols: usize,
76    nhalf: usize,
77    r2c: Arc<dyn RealToComplex<T>>,
78    c2r: Arc<dyn ComplexToReal<T>>,
79    col_fwd: Arc<dyn Fft<T>>,
80    col_inv: Arc<dyn Fft<T>>,
81}
82
83impl<T: FftNum> FftPlans<T> {
84    /// Plan the forward/inverse real (row) and complex (column) FFTs for an
85    /// `nrows × ncols` image. Reuse this across all channels of a cube.
86    pub fn new(nrows: usize, ncols: usize) -> Self {
87        let mut rplanner = RealFftPlanner::<T>::new();
88        let r2c = rplanner.plan_fft_forward(ncols);
89        let c2r = rplanner.plan_fft_inverse(ncols);
90
91        let mut cplanner = FftPlanner::<T>::new();
92        let col_fwd = cplanner.plan_fft_forward(nrows);
93        let col_inv = cplanner.plan_fft_inverse(nrows);
94
95        Self {
96            nrows,
97            ncols,
98            nhalf: ncols / 2 + 1,
99            r2c,
100            c2r,
101            col_fwd,
102            col_inv,
103        }
104    }
105
106    /// Image dimensions `(nrows, ncols)` these plans were built for.
107    pub fn dim(&self) -> (usize, usize) {
108        (self.nrows, self.ncols)
109    }
110}
111
112/// Convolve `image` from `old_beam` to `new_beam` in the UV plane.
113///
114/// `dx_deg` / `dy_deg` are the pixel sizes in degrees (FITS |CDELT1|, |CDELT2|).
115/// `cutoff_arcsec` blanks images whose current beam exceeds this size.
116///
117/// The returned [`ConvolutionResult::scaling_factor`] is `√(Ω_new/Ω_old)`; see
118/// [`crate::smooth::smooth`] for how this becomes the Jy/beam or Kelvin factor.
119///
120/// This builds FFT plans for the image size on each call. To convolve many
121/// images of the same size (e.g. cube channels), build an [`FftPlans`] once and
122/// call [`convolve_uv_with_plans`] to reuse it.
123///
124/// # Examples
125///
126/// ```
127/// use convolve_rs::{Beam, convolve_uv};
128/// use ndarray::Array2;
129///
130/// let old = Beam::from_arcsec(10.0, 10.0, 0.0)?;
131/// let new = Beam::from_arcsec(20.0, 20.0, 0.0)?;
132/// let image = Array2::<f32>::from_elem((64, 64), 1.0);
133/// let dx = 2.5 / 3600.0;
134///
135/// let result = convolve_uv(&image, &old, &new, dx, dx, None)?;
136/// // √(Ω_new/Ω_old) = √4 = 2 for a doubling of both axes.
137/// assert!((result.scaling_factor - 2.0).abs() < 1e-9);
138/// assert_eq!(result.image.dim(), (64, 64));
139/// # Ok::<(), Box<dyn std::error::Error>>(())
140/// ```
141pub fn convolve_uv<T: FftFloat>(
142    image: &Array2<T>,
143    old_beam: &Beam,
144    new_beam: &Beam,
145    dx_deg: f64,
146    dy_deg: f64,
147    cutoff_arcsec: Option<f64>,
148) -> Result<ConvolutionResult<T>, ConvolveError> {
149    let (nrows, ncols) = image.dim();
150    let plans = FftPlans::<T>::new(nrows, ncols);
151    convolve_uv_with_plans(
152        image,
153        old_beam,
154        new_beam,
155        dx_deg,
156        dy_deg,
157        cutoff_arcsec,
158        &plans,
159    )
160}
161
162/// Like [`convolve_uv`], but reuses pre-built [`FftPlans`] instead of planning
163/// per call. `plans` must have been built for `image`'s dimensions.
164pub fn convolve_uv_with_plans<T: FftFloat>(
165    image: &Array2<T>,
166    old_beam: &Beam,
167    new_beam: &Beam,
168    dx_deg: f64,
169    dy_deg: f64,
170    cutoff_arcsec: Option<f64>,
171    plans: &FftPlans<T>,
172) -> Result<ConvolutionResult<T>, ConvolveError> {
173    // Cutoff check.
174    if let Some(cutoff) = cutoff_arcsec
175        && old_beam.major_arcsec() > cutoff
176    {
177        return Err(ConvolveError::AboveCutoff);
178    }
179
180    // Beams identical → no-op with unit scaling.
181    if old_beam.approx_eq(new_beam) {
182        return Ok(ConvolutionResult {
183            image: image.clone(),
184            scaling_factor: 1.0,
185        });
186    }
187
188    // Compute the convolving beam (new² - old² in quadrature) and flux scaling.
189    let conv_beam = new_beam.deconvolve_or_zero(old_beam);
190    let (fac, ..) = gauss_factor(
191        &conv_beam,
192        old_beam,
193        dx_deg.abs() * 3600.0,
194        dy_deg.abs() * 3600.0,
195    );
196
197    let (nrows, ncols) = image.dim();
198    assert_eq!(
199        plans.dim(),
200        (nrows, ncols),
201        "FftPlans built for {:?} but image is {:?}",
202        plans.dim(),
203        (nrows, ncols)
204    );
205
206    // Single pass over the pixels: zero-fill NaNs into the working buffer, build
207    // a NaN mask only if any NaNs are present, and detect the all-NaN case.
208    let mut clean_image: Vec<T> = Vec::with_capacity(nrows * ncols);
209    let mut nan_count = 0usize;
210    for &x in image.iter() {
211        if x.is_nan() {
212            nan_count += 1;
213            clean_image.push(T::zero());
214        } else {
215            clean_image.push(x);
216        }
217    }
218
219    // All-NaN fast path.
220    if nan_count == nrows * ncols {
221        return Ok(ConvolutionResult {
222            image: image.clone(),
223            scaling_factor: fac,
224        });
225    }
226
227    let nan_mask: Option<Vec<T>> = if nan_count > 0 {
228        Some(
229            image
230                .iter()
231                .map(|&x| if x.is_nan() { T::one() } else { T::zero() })
232                .collect(),
233        )
234    } else {
235        None
236    };
237
238    // UV coordinates: fftfreq(n, d_rad) where d_rad = pixel_size_in_radians.
239    // The data is real, so we use a real-input FFT: the column (ncols) axis only
240    // needs its non-negative half, `nhalf = ncols/2 + 1` bins. We slice the full
241    // `fftfreq` rather than using `rfftfreq` so the filter is evaluated at exactly
242    // the frequencies the equivalent full FFT assigns to bins 0..nhalf (incl. the
243    // signed Nyquist), keeping results bit-for-bit aligned with the full-FFT port.
244    let nhalf = ncols / 2 + 1;
245    let dx_rad = dx_deg.to_radians();
246    let dy_rad = dy_deg.to_radians();
247    let u_freqs = fftfreq(nrows, dx_rad); // shape (nrows,)
248    let v_freqs_full = fftfreq(ncols, dy_rad);
249    let v_freqs = &v_freqs_full[..nhalf]; // half spectrum
250
251    // UV-plane filter on the half spectrum (shape nrows × nhalf), real-valued.
252    // `gaussft` works in f64 (analytic, cheap); cast to the image precision once
253    // for the elementwise multiply against the spectrum.
254    let (g_final, g_ratio) = gaussft(old_beam, new_beam, &u_freqs, v_freqs);
255    let g_t: Vec<T> = g_final.iter().map(|&g| cast_saturating::<T>(g)).collect();
256
257    // Forward real FFT, apply the filter in place, inverse real FFT.
258    let mut im_f = rfft2(plans, &clean_image);
259    for (s, &g) in im_f.iter_mut().zip(g_t.iter()) {
260        *s = s.scale(g);
261    }
262    let im_conv_flat = irfft2(plans, im_f);
263
264    // NaN propagation.
265    let out_flat: Vec<T> = if let Some(mask) = nan_mask {
266        let mut mask_f = rfft2(plans, &mask);
267        for (s, &g) in mask_f.iter_mut().zip(g_t.iter()) {
268            *s = s.scale(g);
269        }
270        let mask_conv = irfft2(plans, mask_f);
271        // A fully-NaN-covered pixel reaches the filter's DC gain (g_ratio ≥ 1) in
272        // the convolved mask; isolated NaNs stay well below 1 and are interpolated
273        // over. The threshold sits just under 1 so that f32 round-off — which can
274        // leave a solid-NaN interior at ~0.999 when the beams are nearly equal
275        // (g_ratio ≈ 1) — does not silently un-blank a masked region.
276        let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
277        im_conv_flat
278            .iter()
279            .zip(mask_conv.iter())
280            .map(|(&v, &m)| if m >= blank_threshold { T::nan() } else { v })
281            .collect()
282    } else {
283        im_conv_flat
284    };
285
286    let out = Array2::from_shape_vec((nrows, ncols), out_flat)
287        .expect("shape mismatch in convolve_uv output");
288
289    Ok(ConvolutionResult {
290        image: out,
291        scaling_factor: g_ratio,
292    })
293}
294
295// ── gaussft ───────────────────────────────────────────────────────────────────
296
297/// Compute the UV-plane filter that deconvolves `old_beam` and re-convolves with
298/// `new_beam`. Direct port of `racs_tools.gaussft.gaussft`.
299///
300/// `u_freqs` has length `nrows`, `v_freqs` has length `ncols` (or `nhalf` for a
301/// half-spectrum / real-FFT layout). The filter is real-valued, so it is returned
302/// as `Vec<f64>` of length `nrows * v_freqs.len()` in row-major order.
303pub fn gaussft(
304    old_beam: &Beam,
305    new_beam: &Beam,
306    u_freqs: &[f64],
307    v_freqs: &[f64],
308) -> (Vec<f64>, f64) {
309    let deg2rad = std::f64::consts::PI / 180.0;
310    let two_ln2 = 2.0 * 2_f64.ln();
311    let fwhm_to_sigma = 2.0 * two_ln2.sqrt(); // = 2*sqrt(2*ln2)
312
313    // New beam (target).
314    let bmaj_rad = new_beam.major_deg * deg2rad;
315    let bmin_rad = new_beam.minor_deg * deg2rad;
316    let bpa_rad = new_beam.pa_deg * deg2rad;
317    let sx = bmaj_rad / fwhm_to_sigma;
318    let sy = bmin_rad / fwhm_to_sigma;
319
320    // Old beam (input PSF).
321    let bmaj_in_rad = old_beam.major_deg * deg2rad;
322    let bmin_in_rad = old_beam.minor_deg * deg2rad;
323    let bpa_in_rad = old_beam.pa_deg * deg2rad;
324    let sx_in = bmaj_in_rad / fwhm_to_sigma;
325    let sy_in = bmin_in_rad / fwhm_to_sigma;
326
327    // Amplitude ratio (= flux scaling factor).
328    let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
329    let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
330    let g_ratio = g_amp / dg_amp;
331
332    let pi2 = std::f64::consts::PI * std::f64::consts::PI;
333    let nrows = u_freqs.len();
334    let ncols = v_freqs.len();
335    let mut g_final = vec![0.0_f64; nrows * ncols];
336
337    // Pre-rotate u and v for new beam.
338    let u_cos = u_freqs
339        .iter()
340        .map(|&u| u * bpa_rad.cos())
341        .collect::<Vec<_>>();
342    let u_sin = u_freqs
343        .iter()
344        .map(|&u| u * bpa_rad.sin())
345        .collect::<Vec<_>>();
346    let v_cos = v_freqs
347        .iter()
348        .map(|&v| v * bpa_rad.cos())
349        .collect::<Vec<_>>();
350    let v_sin = v_freqs
351        .iter()
352        .map(|&v| v * bpa_rad.sin())
353        .collect::<Vec<_>>();
354
355    // Pre-rotate u and v for old beam.
356    let u_cos_in = u_freqs
357        .iter()
358        .map(|&u| u * bpa_in_rad.cos())
359        .collect::<Vec<_>>();
360    let u_sin_in = u_freqs
361        .iter()
362        .map(|&u| u * bpa_in_rad.sin())
363        .collect::<Vec<_>>();
364    let v_cos_in = v_freqs
365        .iter()
366        .map(|&v| v * bpa_in_rad.cos())
367        .collect::<Vec<_>>();
368    let v_sin_in = v_freqs
369        .iter()
370        .map(|&v| v * bpa_in_rad.sin())
371        .collect::<Vec<_>>();
372
373    for i in 0..nrows {
374        for j in 0..ncols {
375            // Rotated UV coordinates for new beam.
376            let ur = u_cos[i] - v_sin[j];
377            let vr = u_sin[i] + v_cos[j];
378            // Rotated UV coordinates for old beam.
379            let ur_in = u_cos_in[i] - v_sin_in[j];
380            let vr_in = u_sin_in[i] + v_cos_in[j];
381
382            let g_arg = -2.0 * pi2 * ((sx * ur).powi(2) + (sy * vr).powi(2));
383            let dg_arg = -2.0 * pi2 * ((sx_in * ur_in).powi(2) + (sy_in * vr_in).powi(2));
384
385            g_final[i * ncols + j] = g_ratio * (g_arg - dg_arg).exp();
386        }
387    }
388
389    (g_final, g_ratio)
390}
391
392// ── FFT helpers ───────────────────────────────────────────────────────────────
393
394/// numpy-compatible `fftfreq(n, d)`.
395///
396/// For even n the Nyquist bin (index n/2) is listed as negative, matching numpy.
397///
398/// # Examples
399///
400/// ```
401/// use convolve_rs::fftfreq;
402///
403/// assert_eq!(fftfreq(4, 1.0), vec![0.0, 0.25, -0.5, -0.25]);
404/// assert_eq!(fftfreq(5, 1.0), vec![0.0, 0.2, 0.4, -0.4, -0.2]);
405/// ```
406pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
407    let val = 1.0 / (n as f64 * d);
408    let m = n.div_ceil(2); // ceiling(n/2): positive-frequency count
409    let mut freqs = vec![0.0_f64; n];
410    for (i, freq) in freqs.iter_mut().enumerate().take(m) {
411        *freq = i as f64 * val;
412    }
413    for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
414        *freq = (i as f64 - n as f64) * val;
415    }
416    freqs
417}
418
419/// 2D forward FFT of real-valued data stored row-major in `data` (shape nrows×ncols).
420///
421/// Uses a real-input FFT along the contiguous (ncols) axis, so only the
422/// non-negative half of that axis is kept: the returned spectrum is
423/// `nrows × nhalf` (`nhalf = ncols/2 + 1`) complex values, row-major. This roughly
424/// halves the spectrum memory versus a full complex FFT — the dominant cost at
425/// large image sizes. Scratch buffers are allocated once here, not per row/column.
426fn rfft2<T: FftFloat>(plans: &FftPlans<T>, data: &[T]) -> Vec<Complex<T>> {
427    let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
428    let zero = Complex::new(T::zero(), T::zero());
429
430    // Row-wise real→complex FFT.
431    let mut scratch = plans.r2c.make_scratch_vec();
432    let mut inrow = plans.r2c.make_input_vec();
433    let mut spectrum = vec![zero; nrows * nhalf];
434    for (i, chunk) in data.chunks(ncols).enumerate() {
435        inrow.copy_from_slice(chunk);
436        plans
437            .r2c
438            .process_with_scratch(
439                &mut inrow,
440                &mut spectrum[i * nhalf..(i + 1) * nhalf],
441                &mut scratch,
442            )
443            .expect("r2c FFT");
444    }
445
446    // Column-wise complex FFT over the `nhalf` columns (gather, process, scatter).
447    // A single scratch buffer is reused across all columns.
448    let mut col_scratch = vec![zero; plans.col_fwd.get_inplace_scratch_len()];
449    let mut col_buf = vec![zero; nrows];
450    for j in 0..nhalf {
451        for i in 0..nrows {
452            col_buf[i] = spectrum[i * nhalf + j];
453        }
454        plans
455            .col_fwd
456            .process_with_scratch(&mut col_buf, &mut col_scratch);
457        for i in 0..nrows {
458            spectrum[i * nhalf + j] = col_buf[i];
459        }
460    }
461
462    spectrum
463}
464
465/// 2D inverse of [`rfft2`] (un-normalised → divide by N = nrows*ncols).
466/// Consumes the half `nrows × nhalf` spectrum and returns the real nrows×ncols image.
467fn irfft2<T: FftFloat>(plans: &FftPlans<T>, mut spectrum: Vec<Complex<T>>) -> Vec<T> {
468    let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
469    let zero = Complex::new(T::zero(), T::zero());
470
471    // Column-wise inverse complex FFT over the `nhalf` columns.
472    let mut col_scratch = vec![zero; plans.col_inv.get_inplace_scratch_len()];
473    let mut col_buf = vec![zero; nrows];
474    for j in 0..nhalf {
475        for i in 0..nrows {
476            col_buf[i] = spectrum[i * nhalf + j];
477        }
478        plans
479            .col_inv
480            .process_with_scratch(&mut col_buf, &mut col_scratch);
481        for i in 0..nrows {
482            spectrum[i * nhalf + j] = col_buf[i];
483        }
484    }
485
486    // Row-wise complex→real FFT.
487    let mut scratch = plans.c2r.make_scratch_vec();
488    let mut inrow = plans.c2r.make_input_vec();
489    let mut out = vec![T::zero(); nrows * ncols];
490    let even = ncols.is_multiple_of(2);
491    for i in 0..nrows {
492        inrow.copy_from_slice(&spectrum[i * nhalf..(i + 1) * nhalf]);
493        // c2r requires the DC (and, for even ncols, Nyquist) bins to be purely
494        // real; they are up to rounding, so zero the imaginary parts explicitly.
495        inrow[0].im = T::zero();
496        if even {
497            inrow[nhalf - 1].im = T::zero();
498        }
499        plans
500            .c2r
501            .process_with_scratch(
502                &mut inrow,
503                &mut out[i * ncols..(i + 1) * ncols],
504                &mut scratch,
505            )
506            .expect("c2r FFT");
507    }
508
509    let norm = cast::<usize, T>(nrows * ncols).expect("size out of range");
510    for v in out.iter_mut() {
511        *v = *v / norm;
512    }
513    out
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use ndarray::Array2;
520
521    #[test]
522    fn test_fftfreq() {
523        // Match numpy: fftfreq(4, 1) = [0, 0.25, -0.5, -0.25]
524        let f = fftfreq(4, 1.0);
525        let expected = [0.0, 0.25, -0.5, -0.25];
526        for (a, b) in f.iter().zip(expected.iter()) {
527            assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
528        }
529    }
530
531    #[test]
532    fn test_rfft2_irfft2_roundtrip() {
533        // Use even dimensions to exercise the Nyquist handling in irfft2.
534        let data = vec![
535            1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
536            16.0,
537        ];
538        let (nrows, ncols) = (4, 4);
539        let plans = FftPlans::<f64>::new(nrows, ncols);
540        let spectrum = rfft2(&plans, &data);
541        let recovered = irfft2(&plans, spectrum);
542        for (a, b) in data.iter().zip(recovered.iter()) {
543            assert!((a - b).abs() < 1e-10, "roundtrip failed: {a} vs {b}");
544        }
545    }
546
547    #[test]
548    fn test_rfft2_irfft2_roundtrip_f32() {
549        let data: Vec<f32> = (1..=16).map(|x| x as f32).collect();
550        let (nrows, ncols) = (4, 4);
551        let plans = FftPlans::<f32>::new(nrows, ncols);
552        let spectrum = rfft2(&plans, &data);
553        let recovered = irfft2(&plans, spectrum);
554        for (a, b) in data.iter().zip(recovered.iter()) {
555            assert!((a - b).abs() < 1e-3, "f32 roundtrip failed: {a} vs {b}");
556        }
557    }
558
559    #[test]
560    fn test_convolve_uv_no_change_when_beams_equal() {
561        let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
562        let img = Array2::from_elem((16, 16), 1.0_f32);
563        let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
564        assert!((result.scaling_factor - 1.0).abs() < 1e-10);
565    }
566
567    /// Convolving a point source yields a Gaussian whose integral equals the
568    /// filter's DC gain (`scaling_factor` = g_ratio, which `convolve_uv` bakes
569    /// into the image) and whose peak sits at the source pixel. Anchors the FFT
570    /// path to a known answer.
571    #[test]
572    fn test_convolve_uv_point_source_flux_and_peak() {
573        let (n, dx) = (64usize, 2.0 / 3600.0);
574        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
575        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
576
577        let mut img = Array2::<f64>::zeros((n, n));
578        img[(n / 2, n / 2)] = 1.0;
579
580        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
581        let total: f64 = res.image.iter().sum();
582
583        // The UV filter has DC gain g_ratio (= scaling_factor), so a unit point
584        // source convolves to a Gaussian whose pixels sum to that gain.
585        assert!(
586            (total - res.scaling_factor).abs() < 1e-6,
587            "integral {total} != DC gain {}",
588            res.scaling_factor
589        );
590
591        // Peak stays at the source pixel and is the image maximum.
592        let peak = res.image[(n / 2, n / 2)];
593        assert!(peak > 0.0);
594        for &v in res.image.iter() {
595            assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
596        }
597    }
598
599    /// f32 and f64 convolutions of the same data must agree to f32 precision —
600    /// confirms the precision-generic path is consistent.
601    #[test]
602    fn test_convolve_uv_f32_matches_f64() {
603        let (n, dx) = (48usize, 2.5 / 3600.0);
604        let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
605        let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
606
607        let img64 =
608            Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
609        let img32 = img64.mapv(|x| x as f32);
610
611        let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
612        let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
613
614        for (a, b) in r64.image.iter().zip(r32.image.iter()) {
615            assert!(
616                (*a - *b as f64).abs() < 1e-4,
617                "f32/f64 mismatch: {a} vs {b}"
618            );
619        }
620    }
621
622    /// A solid NaN region larger than the kernel must stay blanked in the
623    /// output (the convolved mask reaches the filter's DC gain ≥ 1 there), while
624    /// data far from it stays finite. Isolated single NaNs are intentionally
625    /// interpolated over, so the test uses a block.
626    #[test]
627    fn test_convolve_uv_propagates_nans() {
628        let (n, dx) = (48usize, 2.5 / 3600.0);
629        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
630        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
631
632        let mut img = Array2::<f32>::from_elem((n, n), 1.0);
633        // Blank a solid block in one corner, several kernel-widths across.
634        for i in 0..12 {
635            for j in 0..12 {
636                img[(i, j)] = f32::NAN;
637            }
638        }
639
640        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
641        // The interior of the blanked block stays NaN…
642        assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
643        // …while a pixel far from the block stays finite.
644        assert!(res.image[(n - 1, n - 1)].is_finite());
645    }
646
647    /// Reusing one `FftPlans` across calls must give bit-identical output to the
648    /// per-call planning path. Guards the Tier-0 plan-cache optimisation.
649    #[test]
650    fn test_with_plans_matches_per_call() {
651        let (n, dx) = (32usize, 2.5 / 3600.0);
652        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
653        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
654        let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
655
656        let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
657
658        let plans = FftPlans::<f32>::new(n, n);
659        let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
660
661        for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
662            assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
663        }
664    }
665
666    /// `gaussft` at DC (u=v=0) equals the amplitude ratio g_ratio.
667    #[test]
668    fn test_gaussft_dc_equals_ratio() {
669        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
670        let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
671        let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
672        assert!(
673            (g[0] - ratio).abs() < 1e-12,
674            "DC {} != ratio {}",
675            g[0],
676            ratio
677        );
678        assert!(ratio > 1.0, "larger target beam should have ratio > 1");
679    }
680}