Skip to main content

convolve_rs/
convolve_uv.rs

1//! FFT-based UV-plane convolution of an image from one Gaussian beam to
2//! another, following the "robust" mode of `racs_tools.convolve_uv`.
3//!
4//! The filter is evaluated analytically at each UV point instead of being
5//! stored, and the transforms run in the image's own precision ([`FftFloat`]).
6//! Peak memory is about one image on top of the input, and none for an owned,
7//! NaN-free plane.
8use std::sync::Arc;
9
10use ndarray::{Array2, ArrayView2, CowArray, Ix2};
11use num_traits::{Float, cast};
12use rayon::prelude::*;
13use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
14use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
15use thiserror::Error;
16
17use crate::beam::Beam;
18
19#[derive(Debug, Error)]
20pub enum ConvolveError {
21    #[error("input beam {0} has zero size")]
22    ZeroSizeBeam(Beam),
23    #[error("target beam {new} cannot be reached by convolving {old}")]
24    Unreachable { old: Beam, new: Beam },
25    #[error("FFT plans are for {plans:?} images but the image is {image:?}")]
26    PlanSize {
27        plans: (usize, usize),
28        image: (usize, usize),
29    },
30    #[error("FFT failed: {0}")]
31    Fft(String),
32}
33
34mod sealed {
35    pub trait Sealed {}
36    impl Sealed for f32 {}
37    impl Sealed for f64 {}
38}
39
40/// Element type a convolution runs in: `f32` or `f64`.
41pub trait FftFloat: FftNum + Float + sealed::Sealed {}
42impl FftFloat for f32 {}
43impl FftFloat for f64 {}
44
45/// Cast to `T`, saturating to ±∞ outside its range, so an f64 value too large
46/// for f32 gives an infinite pixel rather than a panic.
47pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
48    cast::<f64, T>(value).unwrap_or_else(|| {
49        if value.is_sign_negative() {
50            T::neg_infinity()
51        } else {
52            T::infinity()
53        }
54    })
55}
56
57pub struct ConvolutionResult<T = f32> {
58    /// Convolved image, with NaNs where the input's NaNs spread to.
59    pub image: Array2<T>,
60    /// `√(Ω_new/Ω_old)`, the DC gain of the filter, already applied to `image`.
61    pub scaling_factor: f64,
62}
63
64/// FFT plans for one image size, shareable across threads.
65///
66/// Planning is the expensive part of a small transform, so build these once
67/// per cube and pass them to every channel.
68pub struct FftPlans<T: FftNum = f32> {
69    nrows: usize,
70    ncols: usize,
71    nhalf: usize,
72    r2c: Arc<dyn RealToComplex<T>>,
73    c2r: Arc<dyn ComplexToReal<T>>,
74    col_fwd: Arc<dyn Fft<T>>,
75    col_inv: Arc<dyn Fft<T>>,
76}
77
78impl<T: FftNum> FftPlans<T> {
79    pub fn new(nrows: usize, ncols: usize) -> Self {
80        let mut rplanner = RealFftPlanner::<T>::new();
81        let mut cplanner = FftPlanner::<T>::new();
82        Self {
83            nrows,
84            ncols,
85            nhalf: ncols / 2 + 1,
86            r2c: rplanner.plan_fft_forward(ncols),
87            c2r: rplanner.plan_fft_inverse(ncols),
88            col_fwd: cplanner.plan_fft_forward(nrows),
89            col_inv: cplanner.plan_fft_inverse(nrows),
90        }
91    }
92
93    /// `(nrows, ncols)` these plans were built for.
94    pub fn dim(&self) -> (usize, usize) {
95        (self.nrows, self.ncols)
96    }
97}
98
99/// Convolve `image` from `old_beam` to `new_beam` in the UV plane.
100///
101/// `dx_deg` and `dy_deg` are the pixel sizes (CDELT1, CDELT2); their sign is
102/// ignored. Pass `plans` to reuse FFT plans across images of the same size.
103/// An owned image is transformed in its own buffer.
104///
105/// # Errors
106///
107/// If `old_beam` has zero size, `new_beam` cannot be reached from it, or
108/// `plans` were built for another image size.
109///
110/// ```
111/// use convolve_rs::{Beam, convolve_uv};
112/// use ndarray::Array2;
113///
114/// let old = Beam::from_arcsec(10.0, 10.0, 0.0)?;
115/// let new = Beam::from_arcsec(20.0, 20.0, 0.0)?;
116/// let image = Array2::<f32>::from_elem((64, 64), 1.0);
117/// let dx = 2.5 / 3600.0;
118///
119/// let result = convolve_uv(&image, &old, &new, dx, dx, None)?;
120/// assert!((result.scaling_factor - 2.0).abs() < 1e-9);
121/// assert_eq!(result.image.dim(), (64, 64));
122/// # Ok::<(), Box<dyn std::error::Error>>(())
123/// ```
124pub fn convolve_uv<'a, T: FftFloat>(
125    image: impl Into<CowArray<'a, T, Ix2>>,
126    old_beam: &Beam,
127    new_beam: &Beam,
128    dx_deg: f64,
129    dy_deg: f64,
130    plans: Option<&FftPlans<T>>,
131) -> Result<ConvolutionResult<T>, ConvolveError> {
132    convolve_with_gain(
133        image.into(),
134        old_beam,
135        new_beam,
136        (dx_deg, dy_deg),
137        plans,
138        |ratio| ratio,
139    )
140}
141
142/// [`convolve_uv`] with the filter's DC gain set to `gain(g_ratio)`, where
143/// `g_ratio = √(Ω_new/Ω_old)`. Folding the flux scaling into the filter saves
144/// a pass over the image.
145pub(crate) fn convolve_with_gain<T: FftFloat>(
146    image: CowArray<'_, T, Ix2>,
147    old_beam: &Beam,
148    new_beam: &Beam,
149    (dx_deg, dy_deg): (f64, f64),
150    plans: Option<&FftPlans<T>>,
151    gain: impl Fn(f64) -> f64,
152) -> Result<ConvolutionResult<T>, ConvolveError> {
153    if old_beam.approx_eq(new_beam) {
154        return Ok(ConvolutionResult {
155            image: image.into_owned(),
156            scaling_factor: 1.0,
157        });
158    }
159    if !old_beam.has_area() {
160        return Err(ConvolveError::ZeroSizeBeam(*old_beam));
161    }
162    if !new_beam.contains(old_beam) {
163        return Err(ConvolveError::Unreachable {
164            old: *old_beam,
165            new: *new_beam,
166        });
167    }
168
169    let dim = image.dim();
170    let local_plans;
171    let plans = match plans {
172        Some(p) => p,
173        None => {
174            local_plans = FftPlans::new(dim.0, dim.1);
175            &local_plans
176        }
177    };
178    if plans.dim() != dim {
179        return Err(ConvolveError::PlanSize {
180            plans: plans.dim(),
181            image: dim,
182        });
183    }
184
185    let taper = UvTaper::new(old_beam, new_beam);
186    let nan_count = image.iter().filter(|x| x.is_nan()).count();
187    if nan_count == image.len() {
188        return Ok(ConvolutionResult {
189            image: image.into_owned(),
190            scaling_factor: taper.ratio,
191        });
192    }
193
194    // Rows run along y (CDELT2), columns along x (CDELT1). Only the magnitudes
195    // matter: passing a negative CDELT1 to fftfreq would mirror the PA.
196    let grid = UvGrid::new(
197        dim,
198        dy_deg.abs().to_radians(),
199        dx_deg.abs().to_radians(),
200        &taper,
201    );
202    // Done before the image is unpacked, so the mask's buffer is freed first.
203    let blank = if nan_count > 0 {
204        Some(nan_spread(image.view(), &taper, &grid, plans)?)
205    } else {
206        None
207    };
208
209    let mut buf = zero_filled(image, plans);
210    let npix = (dim.0 * dim.1) as f64;
211    convolve_in_place(&mut buf, &taper, gain(taper.ratio) / npix, &grid, plans)?;
212    if let Some(blank) = blank {
213        blank.for_each_set(|k| buf[k] = T::nan());
214    }
215
216    Ok(ConvolutionResult {
217        image: Array2::from_shape_vec(dim, buf).map_err(|e| ConvolveError::Fft(e.to_string()))?,
218        scaling_factor: taper.ratio,
219    })
220}
221
222/// Pixels to blank: where the NaN mask, convolved like the image, reaches
223/// 0.99. racs_tools uses 1; with nearly equal beams (g_ratio ≈ 1) a solid NaN
224/// region sits at 1 minus round-off and the NaN pixels on its edge just under
225/// 1, and both must stay blank.
226fn nan_spread<T: FftFloat>(
227    image: ArrayView2<'_, T>,
228    taper: &UvTaper,
229    grid: &UvGrid,
230    plans: &FftPlans<T>,
231) -> Result<Bitset, ConvolveError> {
232    let mut mask = Vec::with_capacity(spectrum_len(plans));
233    mask.extend(
234        image
235            .iter()
236            .map(|x| if x.is_nan() { T::one() } else { T::zero() }),
237    );
238    let npix = (plans.nrows * plans.ncols) as f64;
239    convolve_in_place(&mut mask, taper, taper.ratio / npix, grid, plans)?;
240    let threshold = T::one() - cast_saturating::<T>(1e-2);
241    Ok(Bitset::from_predicate(&mask, |m| m >= threshold))
242}
243
244fn spectrum_len<T: FftNum>(plans: &FftPlans<T>) -> usize {
245    plans.nrows * 2 * plans.nhalf
246}
247
248/// The image row-major with NaNs set to zero. An owned standard-layout image
249/// is reused in place.
250fn zero_filled<T: FftFloat>(image: CowArray<'_, T, Ix2>, plans: &FftPlans<T>) -> Vec<T> {
251    let zero_nan = |x: T| if x.is_nan() { T::zero() } else { x };
252    if image.is_view() || !image.is_standard_layout() {
253        let mut buf = Vec::with_capacity(spectrum_len(plans));
254        buf.extend(image.iter().map(|&x| zero_nan(x)));
255        return buf;
256    }
257    let image = image.into_owned();
258    let len = image.len();
259    let (mut buf, offset) = image.into_raw_vec_and_offset();
260    // A sliced array keeps its parent's buffer; in standard layout its
261    // elements are the `len` values starting at `offset`.
262    buf.drain(..offset.unwrap_or(0));
263    buf.truncate(len);
264    for x in buf.iter_mut() {
265        *x = zero_nan(*x);
266    }
267    buf
268}
269
270/// The UV filter `racs_tools.gaussft` evaluates, `g_ratio · exp(g_arg − dg_arg)`,
271/// reduced to one quadratic form `a u² + b uv + c v²` in the exponent so it
272/// is cheap enough to evaluate on the fly.
273#[derive(Clone, Copy, Debug)]
274struct UvTaper {
275    a: f64,
276    b: f64,
277    c: f64,
278    /// DC gain, `√(Ω_new/Ω_old)`.
279    ratio: f64,
280}
281
282impl UvTaper {
283    fn new(old_beam: &Beam, new_beam: &Beam) -> Self {
284        use std::f64::consts::PI;
285        let fwhm_to_sigma = 2.0 * (2.0 * 2_f64.ln()).sqrt();
286        let sigma = |fwhm_deg: f64| fwhm_deg.to_radians() / fwhm_to_sigma;
287        let (sx, sy) = (sigma(new_beam.major_deg()), sigma(new_beam.minor_deg()));
288        let (sx_in, sy_in) = (sigma(old_beam.major_deg()), sigma(old_beam.minor_deg()));
289
290        // (σx u_r)² + (σy v_r)² with u_r = u cos θ − v sin θ, v_r = u sin θ + v cos θ.
291        let form = |sx: f64, sy: f64, pa_deg: f64| {
292            let (s, c) = pa_deg.to_radians().sin_cos();
293            let (sx2, sy2) = (sx * sx, sy * sy);
294            (
295                sx2 * c * c + sy2 * s * s,
296                2.0 * c * s * (sy2 - sx2),
297                sx2 * s * s + sy2 * c * c,
298            )
299        };
300        let (a, b, c) = form(sx, sy, new_beam.pa_deg());
301        let (a_in, b_in, c_in) = form(sx_in, sy_in, old_beam.pa_deg());
302        let k = -2.0 * PI * PI;
303        Self {
304            a: k * (a - a_in),
305            b: k * (b - b_in),
306            c: k * (c - c_in),
307            ratio: (2.0 * PI * sx * sy).sqrt() / (2.0 * PI * sx_in * sy_in).sqrt(),
308        }
309    }
310
311    fn at(&self, u: f64, v: f64) -> f64 {
312        self.ratio * (self.a * u * u + self.b * u * v + self.c * v * v).exp()
313    }
314}
315
316/// The UV filter from `old_beam` to `new_beam` at every `(u, v)`, row-major,
317/// and its DC gain, as `racs_tools.gaussft` returns them. For inspection and
318/// testing; the convolution evaluates the filter on the fly.
319pub fn gaussft(
320    old_beam: &Beam,
321    new_beam: &Beam,
322    u_freqs: &[f64],
323    v_freqs: &[f64],
324) -> (Vec<f64>, f64) {
325    let taper = UvTaper::new(old_beam, new_beam);
326    let g_final = u_freqs
327        .iter()
328        .flat_map(|&u| v_freqs.iter().map(move |&v| taper.at(u, v)))
329        .collect();
330    (g_final, taper.ratio)
331}
332
333/// numpy's `fftfreq(n, d)`, including its negative Nyquist bin for even `n`.
334///
335/// ```
336/// use convolve_rs::fftfreq;
337///
338/// assert_eq!(fftfreq(4, 1.0), vec![0.0, 0.25, -0.5, -0.25]);
339/// assert_eq!(fftfreq(5, 1.0), vec![0.0, 0.2, 0.4, -0.4, -0.2]);
340/// ```
341pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
342    let val = 1.0 / (n as f64 * d);
343    let m = n.div_ceil(2);
344    (0..n)
345        .map(|i| {
346            if i < m {
347                i as f64 * val
348            } else {
349                (i as f64 - n as f64) * val
350            }
351        })
352        .collect()
353}
354
355/// Columns transformed together in the column pass, so each gather reads
356/// whole cache lines of the row-major spectrum.
357const COL_BLOCK: usize = 16;
358
359/// UV coordinates of the half spectrum, with `a·u²` precomputed per row.
360struct UvGrid {
361    u: Vec<f64>,
362    au2: Vec<f64>,
363    /// `v` for the `ncols/2 + 1` columns of the half spectrum. Taken from the
364    /// full `fftfreq` rather than `rfftfreq`, so the Nyquist bin keeps the
365    /// sign the full transform gives it.
366    v: Vec<f64>,
367}
368
369impl UvGrid {
370    fn new(
371        (nrows, ncols): (usize, usize),
372        row_step_rad: f64,
373        col_step_rad: f64,
374        taper: &UvTaper,
375    ) -> Self {
376        let u = fftfreq(nrows, row_step_rad);
377        let au2 = u.iter().map(|&u| taper.a * u * u).collect();
378        let mut v = fftfreq(ncols, col_step_rad);
379        v.truncate(ncols / 2 + 1);
380        Self { u, au2, v }
381    }
382}
383
384/// One bit per pixel.
385struct Bitset {
386    words: Vec<u64>,
387}
388
389impl Bitset {
390    fn from_predicate<T: Copy>(values: &[T], pred: impl Fn(T) -> bool) -> Self {
391        let words = values
392            .chunks(64)
393            .map(|chunk| {
394                chunk
395                    .iter()
396                    .enumerate()
397                    .fold(0u64, |w, (b, &x)| w | ((pred(x) as u64) << b))
398            })
399            .collect();
400        Self { words }
401    }
402
403    fn for_each_set(&self, mut f: impl FnMut(usize)) {
404        for (w, &word) in self.words.iter().enumerate() {
405            let mut bits = word;
406            while bits != 0 {
407                f(w * 64 + bits.trailing_zeros() as usize);
408                bits &= bits - 1;
409            }
410        }
411    }
412}
413
414fn as_complex<T: FftFloat>(re: &[T]) -> &[Complex<T>] {
415    debug_assert!(re.len().is_multiple_of(2));
416    // SAFETY: `Complex<T>` is `#[repr(C)] { re: T, im: T }`: two `T`s with the
417    // alignment of one. The view covers the first `2 * (len / 2)` values.
418    unsafe { std::slice::from_raw_parts(re.as_ptr().cast(), re.len() / 2) }
419}
420
421fn as_complex_mut<T: FftFloat>(re: &mut [T]) -> &mut [Complex<T>] {
422    debug_assert!(re.len().is_multiple_of(2));
423    // SAFETY: as for `as_complex`, carrying over the exclusive borrow.
424    unsafe { std::slice::from_raw_parts_mut(re.as_mut_ptr().cast(), re.len() / 2) }
425}
426
427fn fft_error(e: impl std::fmt::Display) -> ConvolveError {
428    ConvolveError::Fft(e.to_string())
429}
430
431/// Convolve the `nrows × ncols` row-major image in `buf` with `gain ·` the
432/// unit-gain filter, in place. `gain` must include the inverse FFT's `1/N`.
433///
434/// The buffer grows by two values per row to hold the half spectrum. On Linux
435/// that remaps rather than copies (0.9 ms against 180 ms for a copy at 8192²,
436/// measured).
437fn convolve_in_place<T: FftFloat>(
438    buf: &mut Vec<T>,
439    taper: &UvTaper,
440    gain: f64,
441    grid: &UvGrid,
442    plans: &FftPlans<T>,
443) -> Result<(), ConvolveError> {
444    let (nrows, ncols) = (plans.nrows, plans.ncols);
445    let stride = 2 * plans.nhalf;
446    buf.resize(nrows * stride, T::zero());
447    // Called from a rayon worker, the caller is already parallel over planes;
448    // the in-place row passes then avoid two copies of the image.
449    let parallel_rows = rayon::current_thread_index().is_none();
450
451    if parallel_rows {
452        // Space rows out to the half-spectrum stride so each transforms
453        // independently.
454        for i in (1..nrows).rev() {
455            buf.copy_within(i * ncols..(i + 1) * ncols, i * stride);
456        }
457        forward_rows(plans, buf)?;
458    } else {
459        forward_rows_in_place(plans, buf)?;
460    }
461    filter_columns(plans, as_complex_mut(buf), taper, gain, grid)?;
462    if parallel_rows {
463        inverse_rows(plans, buf)?;
464        for i in 1..nrows {
465            buf.copy_within(i * stride..i * stride + ncols, i * ncols);
466        }
467    } else {
468        inverse_rows_in_place(plans, buf)?;
469    }
470    buf.truncate(nrows * ncols);
471    Ok(())
472}
473
474/// Sequential [`forward_rows`] on the packed image. Spectrum row `i` starts at
475/// `2·nhalf·i ≥ ncols·i`, so it can only overwrite image rows `≥ i`; going from
476/// the last row up, those are already transformed.
477fn forward_rows_in_place<T: FftFloat>(
478    plans: &FftPlans<T>,
479    buf: &mut [T],
480) -> Result<(), ConvolveError> {
481    let (ncols, stride) = (plans.ncols, 2 * plans.nhalf);
482    let mut scratch = plans.r2c.make_scratch_vec();
483    let mut inrow = plans.r2c.make_input_vec();
484    for i in (0..plans.nrows).rev() {
485        inrow.copy_from_slice(&buf[i * ncols..(i + 1) * ncols]);
486        let out = as_complex_mut(&mut buf[stride * i..stride * (i + 1)]);
487        plans
488            .r2c
489            .process_with_scratch(&mut inrow, out, &mut scratch)
490            .map_err(fft_error)?;
491    }
492    Ok(())
493}
494
495/// Sequential [`inverse_rows`] that packs the image as it goes. Image row `i`
496/// ends at `ncols·(i + 1) ≤ 2·nhalf·(i + 1)`, so it can only overwrite
497/// spectrum rows `≤ i`, which are already transformed.
498fn inverse_rows_in_place<T: FftFloat>(
499    plans: &FftPlans<T>,
500    buf: &mut [T],
501) -> Result<(), ConvolveError> {
502    let (ncols, nhalf) = (plans.ncols, plans.nhalf);
503    let stride = 2 * nhalf;
504    let mut scratch = plans.c2r.make_scratch_vec();
505    let mut inrow = plans.c2r.make_input_vec();
506    for i in 0..plans.nrows {
507        inrow.copy_from_slice(as_complex(&buf[stride * i..stride * (i + 1)]));
508        inrow[0].im = T::zero();
509        if ncols.is_multiple_of(2) {
510            inrow[nhalf - 1].im = T::zero();
511        }
512        plans
513            .c2r
514            .process_with_scratch(
515                &mut inrow,
516                &mut buf[i * ncols..(i + 1) * ncols],
517                &mut scratch,
518            )
519            .map_err(fft_error)?;
520    }
521    Ok(())
522}
523
524/// Real-to-complex FFT of every row; row `i` starts at `i * 2 * nhalf`.
525fn forward_rows<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) -> Result<(), ConvolveError> {
526    let ncols = plans.ncols;
527    buf.par_chunks_mut(2 * plans.nhalf).try_for_each_init(
528        || (plans.r2c.make_input_vec(), plans.r2c.make_scratch_vec()),
529        |(inrow, scratch), row| {
530            inrow.copy_from_slice(&row[..ncols]);
531            plans
532                .r2c
533                .process_with_scratch(inrow, as_complex_mut(row), scratch)
534                .map_err(fft_error)
535        },
536    )
537}
538
539/// Complex-to-real FFT of every half-spectrum row, leaving each image row at
540/// the start of its slot.
541fn inverse_rows<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) -> Result<(), ConvolveError> {
542    let (ncols, nhalf) = (plans.ncols, plans.nhalf);
543    let even = ncols.is_multiple_of(2);
544    buf.par_chunks_mut(2 * nhalf).try_for_each_init(
545        || {
546            (
547                plans.c2r.make_input_vec(),
548                plans.c2r.make_output_vec(),
549                plans.c2r.make_scratch_vec(),
550            )
551        },
552        |(inrow, outrow, scratch), row| {
553            inrow.copy_from_slice(as_complex(row));
554            // c2r wants the DC and (even ncols) Nyquist bins exactly real.
555            inrow[0].im = T::zero();
556            if even {
557                inrow[nhalf - 1].im = T::zero();
558            }
559            plans
560                .c2r
561                .process_with_scratch(inrow, outrow, scratch)
562                .map_err(fft_error)?;
563            row[..ncols].copy_from_slice(outrow);
564            Ok(())
565        },
566    )
567}
568
569/// Raw pointer to the spectrum, shared by the column-block tasks.
570#[derive(Clone, Copy)]
571struct SpectrumPtr<T>(*mut Complex<T>);
572// SAFETY: each task only touches the columns of its own block (see
573// `filter_columns`), so no element is accessed by two threads.
574unsafe impl<T: Send> Send for SpectrumPtr<T> {}
575unsafe impl<T: Send> Sync for SpectrumPtr<T> {}
576
577impl<T> SpectrumPtr<T> {
578    /// A method rather than field access, so closures capture the whole
579    /// (`Sync`) wrapper instead of the raw pointer.
580    fn get(self) -> *mut Complex<T> {
581        self.0
582    }
583}
584
585/// Column FFT, filter and inverse column FFT of the `nrows × nhalf` half
586/// spectrum, [`COL_BLOCK`] columns per task.
587fn filter_columns<T: FftFloat>(
588    plans: &FftPlans<T>,
589    spectrum: &mut [Complex<T>],
590    taper: &UvTaper,
591    gain: f64,
592    grid: &UvGrid,
593) -> Result<(), ConvolveError> {
594    let (nrows, nhalf) = (plans.nrows, plans.nhalf);
595    if spectrum.len() != nrows * nhalf {
596        return Err(ConvolveError::Fft("spectrum has the wrong size".into()));
597    }
598    let zero = Complex::new(T::zero(), T::zero());
599    let scratch_len = plans
600        .col_fwd
601        .get_inplace_scratch_len()
602        .max(plans.col_inv.get_inplace_scratch_len());
603    let ptr = SpectrumPtr(spectrum.as_mut_ptr());
604
605    (0..nhalf.div_ceil(COL_BLOCK))
606        .into_par_iter()
607        .for_each_init(
608            || (vec![zero; COL_BLOCK * nrows], vec![zero; scratch_len]),
609            |(tile, scratch), block| {
610                let j0 = block * COL_BLOCK;
611                let width = COL_BLOCK.min(nhalf - j0);
612                let tile = &mut tile[..width * nrows];
613                // SAFETY: `i * nhalf + j0 + k` is in bounds for `i < nrows` and
614                // `j0 + k < nhalf`, and this block is the only one using columns
615                // `j0..j0 + width`. The `&mut` borrow of `spectrum` outlives the loop.
616                let at = |i: usize, k: usize| unsafe { ptr.get().add(i * nhalf + j0 + k) };
617
618                for i in 0..nrows {
619                    for k in 0..width {
620                        tile[k * nrows + i] = unsafe { at(i, k).read() };
621                    }
622                }
623                plans.col_fwd.process_with_scratch(tile, scratch);
624                for (k, col) in tile.chunks_exact_mut(nrows).enumerate() {
625                    let v = grid.v[j0 + k];
626                    let (bv, cv2) = (taper.b * v, taper.c * v * v);
627                    for ((s, &u), &au2) in col.iter_mut().zip(&grid.u).zip(&grid.au2) {
628                        let g = gain * (au2 + bv * u + cv2).exp();
629                        *s = s.scale(cast_saturating::<T>(g));
630                    }
631                }
632                plans.col_inv.process_with_scratch(tile, scratch);
633                for i in 0..nrows {
634                    for k in 0..width {
635                        unsafe { at(i, k).write(tile[k * nrows + i]) };
636                    }
637                }
638            },
639        );
640    Ok(())
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use ndarray::Array2;
647
648    #[test]
649    fn test_fftfreq() {
650        let f = fftfreq(4, 1.0);
651        let expected = [0.0, 0.25, -0.5, -0.25];
652        for (a, b) in f.iter().zip(expected.iter()) {
653            assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
654        }
655    }
656
657    /// A flat filter returns the image, for even and odd widths (the Nyquist
658    /// bin differs) and both precisions.
659    fn roundtrip<T: FftFloat + std::fmt::Debug>(nrows: usize, ncols: usize, tol: f64) {
660        let data: Vec<T> = (0..nrows * ncols)
661            .map(|k| cast_saturating::<T>(((k * 7) % 13) as f64 - 6.0))
662            .collect();
663        let plans = FftPlans::<T>::new(nrows, ncols);
664        let mut buf = data.clone();
665        let flat = UvTaper {
666            a: 0.0,
667            b: 0.0,
668            c: 0.0,
669            ratio: 1.0,
670        };
671        let grid = UvGrid::new((nrows, ncols), 1.0, 1.0, &flat);
672        let n = (nrows * ncols) as f64;
673        convolve_in_place(&mut buf, &flat, 1.0 / n, &grid, &plans).unwrap();
674        for (a, b) in data.iter().zip(buf.iter()) {
675            let (a, b) = (a.to_f64().unwrap(), b.to_f64().unwrap());
676            assert!((a - b).abs() < tol, "roundtrip {nrows}x{ncols}: {a} vs {b}");
677        }
678    }
679
680    /// The sequential row passes, used on rayon workers, give the same result
681    /// as the parallel ones.
682    #[test]
683    fn test_row_paths_agree() {
684        let dx = 2.5 / 3600.0;
685        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
686        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
687        for (r, c) in [(37, 36), (40, 71)] {
688            let img = test_image(r, c, true);
689            let outside = convolve_uv(&img, &old, &new, dx, dx, None).unwrap().image;
690            let pool = rayon::ThreadPoolBuilder::new()
691                .num_threads(2)
692                .build()
693                .unwrap();
694            let inside = pool
695                .install(|| convolve_uv(&img, &old, &new, dx, dx, None))
696                .unwrap()
697                .image;
698            for (a, b) in outside.iter().zip(inside.iter()) {
699                assert_eq!(a.is_nan(), b.is_nan());
700                if !a.is_nan() {
701                    assert!((a - b).abs() < 1e-12, "{a} vs {b}");
702                }
703            }
704        }
705    }
706
707    #[test]
708    fn test_in_place_roundtrip() {
709        for &(r, c) in &[(4, 4), (5, 7), (6, 9), (7, 6), (1, 8), (33, 40)] {
710            roundtrip::<f64>(r, c, 1e-10);
711            roundtrip::<f32>(r, c, 1e-4);
712        }
713    }
714
715    /// Reference: full complex 2D FFTs of the zero-filled image and the NaN
716    /// mask, multiplied by `gaussft` on the full grid.
717    fn reference(image: &Array2<f64>, old: &Beam, new: &Beam, dx: f64) -> Array2<f64> {
718        let (nrows, ncols) = image.dim();
719        let u = fftfreq(nrows, dx.to_radians());
720        let v = fftfreq(ncols, dx.to_radians());
721        let (g, _) = gaussft(old, new, &u, &v);
722        let mut planner = FftPlanner::<f64>::new();
723        let (row_fwd, col_fwd) = (
724            planner.plan_fft_forward(ncols),
725            planner.plan_fft_forward(nrows),
726        );
727        let (row_inv, col_inv) = (
728            planner.plan_fft_inverse(ncols),
729            planner.plan_fft_inverse(nrows),
730        );
731        let fft2 = |z: &mut [Complex<f64>], rows: &dyn Fft<f64>, cols: &dyn Fft<f64>| {
732            rows.process(z);
733            let mut col = vec![Complex::new(0.0, 0.0); nrows];
734            for j in 0..ncols {
735                (0..nrows).for_each(|i| col[i] = z[i * ncols + j]);
736                cols.process(&mut col);
737                (0..nrows).for_each(|i| z[i * ncols + j] = col[i]);
738            }
739        };
740        let conv = |data: &dyn Fn(f64) -> f64| -> Vec<f64> {
741            let mut z: Vec<Complex<f64>> =
742                image.iter().map(|&x| Complex::new(data(x), 0.0)).collect();
743            fft2(&mut z, &*row_fwd, &*col_fwd);
744            z.iter_mut().zip(&g).for_each(|(s, &g)| *s *= g);
745            fft2(&mut z, &*row_inv, &*col_inv);
746            z.iter().map(|s| s.re / (nrows * ncols) as f64).collect()
747        };
748        let img = conv(&|x| if x.is_nan() { 0.0 } else { x });
749        let mask = conv(&|x| if x.is_nan() { 1.0 } else { 0.0 });
750        let out = img
751            .iter()
752            .zip(&mask)
753            .map(|(&x, &m)| if m >= 1.0 - 1e-2 { f64::NAN } else { x })
754            .collect();
755        Array2::from_shape_vec((nrows, ncols), out).unwrap()
756    }
757
758    fn test_image(nrows: usize, ncols: usize, nans: bool) -> Array2<f64> {
759        let mut img = Array2::from_shape_fn((nrows, ncols), |(i, j)| {
760            (((i * 31 + j * 17) % 23) as f64 - 11.0) / 7.0 + if i > nrows / 2 { 3.0 } else { 0.0 }
761        });
762        if nans {
763            for i in 0..nrows / 3 {
764                for j in 0..ncols / 2 {
765                    img[(i, j)] = f64::NAN;
766                }
767            }
768            img[(nrows - 2, ncols - 3)] = f64::NAN; // isolated: interpolated over
769        }
770        img
771    }
772
773    /// Matches the reference for odd sizes, rotated beams, NaNs, and widths
774    /// that leave a partial column block.
775    #[test]
776    fn test_convolve_uv_matches_full_fft_reference() {
777        let dx = 2.5 / 3600.0;
778        let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
779        let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
780        for &(r, c) in &[(64, 48), (65, 49), (40, 71), (37, 36)] {
781            for nans in [false, true] {
782                let img = test_image(r, c, nans);
783                let expected = reference(&img, &old, &new, dx);
784                let got = convolve_uv(&img, &old, &new, dx, dx, None).unwrap().image;
785                let got32 = convolve_uv(img.mapv(|x| x as f32), &old, &new, dx, dx, None)
786                    .unwrap()
787                    .image;
788                for ((&e, &g), &g32) in expected.iter().zip(got.iter()).zip(got32.iter()) {
789                    assert_eq!(
790                        e.is_nan(),
791                        g.is_nan(),
792                        "{r}x{c} nans={nans}: NaN mask differs"
793                    );
794                    assert_eq!(
795                        e.is_nan(),
796                        g32.is_nan(),
797                        "{r}x{c} nans={nans}: f32 NaN mask"
798                    );
799                    if !e.is_nan() {
800                        assert!((e - g).abs() < 1e-9, "{r}x{c} nans={nans}: {e} vs {g}");
801                        assert!((e - g32 as f64).abs() < 1e-4, "{r}x{c}: f32 {e} vs {g32}");
802                    }
803                }
804            }
805        }
806    }
807
808    /// Owned and borrowed inputs give identical output.
809    #[test]
810    fn test_owned_matches_borrowed() {
811        let dx = 2.5 / 3600.0;
812        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
813        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
814        for nans in [false, true] {
815            let img = test_image(45, 38, nans).mapv(|x| x as f32);
816            let plans = FftPlans::<f32>::new(45, 38);
817            let borrowed = convolve_uv(&img, &old, &new, dx, dx, Some(&plans)).unwrap();
818            let owned = convolve_uv(img.clone(), &old, &new, dx, dx, Some(&plans)).unwrap();
819            assert_eq!(borrowed.scaling_factor, owned.scaling_factor);
820            for (a, b) in borrowed.image.iter().zip(owned.image.iter()) {
821                assert_eq!(a.to_bits(), b.to_bits(), "owned path changed output");
822            }
823        }
824    }
825
826    /// A transposed input is convolved as the logical image.
827    #[test]
828    fn test_owned_non_standard_layout() {
829        let dx = 2.5 / 3600.0;
830        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
831        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
832        let img = test_image(30, 41, true).reversed_axes(); // 41 x 30, column-major
833        let plans = FftPlans::<f64>::new(41, 30);
834        let std_layout = img.as_standard_layout().to_owned();
835        let a = convolve_uv(&std_layout, &old, &new, dx, dx, Some(&plans)).unwrap();
836        let b = convolve_uv(img, &old, &new, dx, dx, Some(&plans)).unwrap();
837        for (x, y) in a.image.iter().zip(b.image.iter()) {
838            assert_eq!(x.to_bits(), y.to_bits());
839        }
840    }
841
842    /// A row-sliced owned array keeps its parent's buffer at an offset; the
843    /// owned path must convolve the slice, not the parent's leading rows.
844    #[test]
845    fn test_owned_sliced_rows() {
846        use ndarray::s;
847        let dx = 2.5 / 3600.0;
848        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
849        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
850        for nans in [false, true] {
851            let parent = test_image(48, 30, nans);
852            for sl in [s![5..45, ..], s![..40, ..]] {
853                let owned = parent.clone().slice_move(sl);
854                assert!(owned.is_standard_layout());
855                let plans = FftPlans::<f64>::new(40, 30);
856                let want = convolve_uv(owned.view(), &old, &new, dx, dx, Some(&plans)).unwrap();
857                let got = convolve_uv(owned, &old, &new, dx, dx, Some(&plans)).unwrap();
858                for (a, b) in want.image.iter().zip(got.image.iter()) {
859                    assert_eq!(a.to_bits(), b.to_bits());
860                }
861            }
862        }
863    }
864
865    /// The sign of a pixel size must not change the result (CDELT1 is
866    /// normally negative).
867    #[test]
868    fn test_negative_pixel_size_keeps_pa() {
869        let old = Beam::from_arcsec(8.0, 5.0, 30.0).unwrap();
870        let new = Beam::from_arcsec(16.0, 9.0, 70.0).unwrap();
871        let img = test_image(40, 36, false);
872        let d = 2.0 / 3600.0;
873        let pos = convolve_uv(&img, &old, &new, d, d, None).unwrap().image;
874        for (dx, dy) in [(-d, d), (d, -d), (-d, -d)] {
875            let got = convolve_uv(&img, &old, &new, dx, dy, None).unwrap().image;
876            for (a, b) in pos.iter().zip(got.iter()) {
877                assert_eq!(a.to_bits(), b.to_bits(), "dx={dx} dy={dy}");
878            }
879        }
880    }
881
882    /// Second moments (arcsec²) along columns (x) and rows (y) of `img`.
883    fn moments(img: &Array2<f64>, dx_as: f64, dy_as: f64) -> (f64, f64) {
884        let total: f64 = img.sum();
885        let (mut cy, mut cx) = (0.0, 0.0);
886        for ((i, j), &v) in img.indexed_iter() {
887            cy += v * i as f64;
888            cx += v * j as f64;
889        }
890        let (cy, cx) = (cy / total, cx / total);
891        let (mut vy, mut vx) = (0.0, 0.0);
892        for ((i, j), &v) in img.indexed_iter() {
893            vy += v * (i as f64 - cy).powi(2);
894            vx += v * (j as f64 - cx).powi(2);
895        }
896        (vx / total * dx_as * dx_as, vy / total * dy_as * dy_as)
897    }
898
899    /// With rectangular pixels the kernel has the same angular size along x
900    /// and y; rows use CDELT2 and columns CDELT1.
901    #[test]
902    fn test_non_square_pixels() {
903        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
904        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
905        let (dx_as, dy_as) = (1.5, 3.0);
906        let mut img = Array2::<f64>::zeros((64, 96));
907        img[(32, 48)] = 1.0;
908        let res = convolve_uv(&img, &old, &new, dx_as / 3600.0, dy_as / 3600.0, None).unwrap();
909        let (vx, vy) = moments(&res.image, dx_as, dy_as);
910        let fwhm_to_sigma = 1.0 / (8.0 * 2f64.ln()).sqrt();
911        let want = (144.0 - 36.0) * fwhm_to_sigma * fwhm_to_sigma;
912        assert!((vx / want - 1.0).abs() < 1e-3, "x variance {vx} vs {want}");
913        assert!((vy / want - 1.0).abs() < 1e-3, "y variance {vy} vs {want}");
914    }
915
916    #[test]
917    fn test_unreachable_and_zero_beams_are_errors() {
918        let img = test_image(16, 16, false);
919        let d = 2.0 / 3600.0;
920        let small = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
921        let big = Beam::from_arcsec(20.0, 10.0, 0.0).unwrap();
922        assert!(matches!(
923            convolve_uv(&img, &big, &small, d, d, None),
924            Err(ConvolveError::Unreachable { .. })
925        ));
926        assert!(matches!(
927            convolve_uv(&img, &Beam::zero(), &small, d, d, None),
928            Err(ConvolveError::ZeroSizeBeam(_))
929        ));
930        // A one-dimensional kernel is fine.
931        let round = Beam::from_arcsec(20.0, 20.0, 0.0).unwrap();
932        assert!(convolve_uv(&img, &big, &round, d, d, None).is_ok());
933    }
934
935    /// An all-NaN image comes back as is, with the same scaling factor a
936    /// finite image would get.
937    #[test]
938    fn test_all_nan_scaling_factor() {
939        let d = 2.5 / 3600.0;
940        let old = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
941        let new = Beam::from_arcsec(20.0, 20.0, 0.0).unwrap();
942        let nan = Array2::from_elem((16, 16), f32::NAN);
943        let res = convolve_uv(&nan, &old, &new, d, d, None).unwrap();
944        assert!(res.image.iter().all(|x| x.is_nan()));
945        assert!((res.scaling_factor - 2.0).abs() < 1e-12);
946    }
947
948    /// With nearly equal beams the kernel is narrower than a pixel, so every
949    /// NaN input pixel must still be NaN in the output, including the edge of
950    /// a blanked block, where the mask falls just short of 1.
951    #[test]
952    fn test_near_equal_beams_keep_nan_block() {
953        let d = 2.5 / 3600.0;
954        let old = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
955        let new = Beam::from_arcsec(10.000001, 10.0, 0.0).unwrap();
956        assert!(!old.approx_eq(&new));
957        let n = 256;
958        let mut img = test_image(n, n, false).mapv(|x| x as f32);
959        for i in 64..192 {
960            for j in 64..192 {
961                img[(i, j)] = f32::NAN;
962            }
963        }
964        let out = convolve_uv(&img, &old, &new, d, d, None).unwrap().image;
965        for (x, y) in img.iter().zip(out.iter()) {
966            assert_eq!(x.is_nan(), y.is_nan());
967        }
968    }
969
970    #[test]
971    fn test_wrong_plan_size_is_an_error() {
972        let img = test_image(16, 16, false);
973        let plans = FftPlans::<f64>::new(16, 17);
974        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
975        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
976        let d = 2.0 / 3600.0;
977        assert!(matches!(
978            convolve_uv(&img, &old, &new, d, d, Some(&plans)),
979            Err(ConvolveError::PlanSize { .. })
980        ));
981    }
982
983    #[test]
984    fn test_bitset() {
985        let vals: Vec<u8> = (0..200).map(|k| (k % 3 == 0 || k == 199) as u8).collect();
986        let bits = Bitset::from_predicate(&vals, |v| v == 1);
987        let mut got = vec![];
988        bits.for_each_set(|k| got.push(k));
989        let want: Vec<usize> = (0..200).filter(|&k| vals[k] == 1).collect();
990        assert_eq!(got, want);
991    }
992
993    /// `gaussft`'s collapsed quadratic form agrees with the per-beam rotated
994    /// expression `racs_tools.gaussft` evaluates.
995    #[test]
996    fn test_gaussft_matches_rotated_form() {
997        let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
998        let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
999        let u = fftfreq(33, 1.2e-5);
1000        let v = fftfreq(20, 1.2e-5);
1001        let (g, ratio) = gaussft(&old, &new, &u, &v);
1002        let f = 2.0 * (2.0 * 2f64.ln()).sqrt();
1003        let arg = |b: &Beam, u: f64, v: f64| {
1004            let (sx, sy) = (
1005                b.major_deg().to_radians() / f,
1006                b.minor_deg().to_radians() / f,
1007            );
1008            let (s, c) = b.pa_deg().to_radians().sin_cos();
1009            let (ur, vr) = (u * c - v * s, u * s + v * c);
1010            -2.0 * std::f64::consts::PI.powi(2) * ((sx * ur).powi(2) + (sy * vr).powi(2))
1011        };
1012        for (i, &uu) in u.iter().enumerate() {
1013            for (j, &vv) in v.iter().enumerate() {
1014                let want = ratio * (arg(&new, uu, vv) - arg(&old, uu, vv)).exp();
1015                let got = g[i * v.len() + j];
1016                assert!(
1017                    (got - want).abs() <= 1e-12 * want.abs().max(1e-300),
1018                    "{got} vs {want}"
1019                );
1020            }
1021        }
1022    }
1023
1024    #[test]
1025    fn test_convolve_uv_no_change_when_beams_equal() {
1026        let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
1027        let img = Array2::from_elem((16, 16), 1.0_f32);
1028        let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
1029        assert!((result.scaling_factor - 1.0).abs() < 1e-10);
1030    }
1031
1032    /// A point source convolves to a Gaussian peaked on the source whose sum
1033    /// is the filter's DC gain.
1034    #[test]
1035    fn test_convolve_uv_point_source_flux_and_peak() {
1036        let (n, dx) = (64usize, 2.0 / 3600.0);
1037        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1038        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
1039
1040        let mut img = Array2::<f64>::zeros((n, n));
1041        img[(n / 2, n / 2)] = 1.0;
1042
1043        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1044        let total: f64 = res.image.iter().sum();
1045
1046        assert!(
1047            (total - res.scaling_factor).abs() < 1e-6,
1048            "integral {total} != DC gain {}",
1049            res.scaling_factor
1050        );
1051
1052        let peak = res.image[(n / 2, n / 2)];
1053        assert!(peak > 0.0);
1054        for &v in res.image.iter() {
1055            assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
1056        }
1057    }
1058
1059    /// f32 and f64 agree to f32 precision.
1060    #[test]
1061    fn test_convolve_uv_f32_matches_f64() {
1062        let (n, dx) = (48usize, 2.5 / 3600.0);
1063        let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
1064        let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
1065
1066        let img64 =
1067            Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
1068        let img32 = img64.mapv(|x| x as f32);
1069
1070        let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
1071        let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
1072
1073        for (a, b) in r64.image.iter().zip(r32.image.iter()) {
1074            assert!(
1075                (*a - *b as f64).abs() < 1e-4,
1076                "f32/f64 mismatch: {a} vs {b}"
1077            );
1078        }
1079    }
1080
1081    /// A NaN block wider than the kernel stays blank inside; far pixels stay
1082    /// finite. (Isolated NaNs are interpolated over.)
1083    #[test]
1084    fn test_convolve_uv_propagates_nans() {
1085        let (n, dx) = (48usize, 2.5 / 3600.0);
1086        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1087        let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
1088
1089        let mut img = Array2::<f32>::from_elem((n, n), 1.0);
1090        for i in 0..12 {
1091            for j in 0..12 {
1092                img[(i, j)] = f32::NAN;
1093            }
1094        }
1095
1096        let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1097        assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
1098        assert!(res.image[(n - 1, n - 1)].is_finite());
1099    }
1100
1101    /// Shared plans give the same output as per-call plans.
1102    #[test]
1103    fn test_with_plans_matches_per_call() {
1104        let (n, dx) = (32usize, 2.5 / 3600.0);
1105        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1106        let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
1107        let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
1108
1109        let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1110
1111        let plans = FftPlans::<f32>::new(n, n);
1112        let reused = convolve_uv(&img, &old, &new, dx, dx, Some(&plans)).unwrap();
1113
1114        for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
1115            assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
1116        }
1117    }
1118
1119    /// `gaussft` at DC (u=v=0) equals the amplitude ratio g_ratio.
1120    #[test]
1121    fn test_gaussft_dc_equals_ratio() {
1122        let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1123        let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
1124        let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
1125        assert!(
1126            (g[0] - ratio).abs() < 1e-12,
1127            "DC {} != ratio {}",
1128            g[0],
1129            ratio
1130        );
1131        assert!(ratio > 1.0, "larger target beam should have ratio > 1");
1132    }
1133}