use std::sync::Arc;
use ndarray::{Array2, ArrayView2};
use num_traits::{Float, cast};
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
use thiserror::Error;
use crate::beam::{Beam, gauss_factor};
#[derive(Debug, Error)]
pub enum ConvolveError {
#[error("image is entirely NaN")]
AllNaN,
#[error("beam larger than cutoff — image blanked")]
AboveCutoff,
}
pub trait FftFloat: FftNum + Float {}
impl FftFloat for f32 {}
impl FftFloat for f64 {}
pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
cast::<f64, T>(value).unwrap_or_else(|| {
if value.is_sign_negative() {
T::neg_infinity()
} else {
T::infinity()
}
})
}
pub struct ConvolutionResult<T = f32> {
pub image: Array2<T>,
pub scaling_factor: f64,
}
pub struct FftPlans<T: FftNum = f32> {
nrows: usize,
ncols: usize,
nhalf: usize,
r2c: Arc<dyn RealToComplex<T>>,
c2r: Arc<dyn ComplexToReal<T>>,
col_fwd: Arc<dyn Fft<T>>,
col_inv: Arc<dyn Fft<T>>,
}
impl<T: FftNum> FftPlans<T> {
pub fn new(nrows: usize, ncols: usize) -> Self {
let mut rplanner = RealFftPlanner::<T>::new();
let r2c = rplanner.plan_fft_forward(ncols);
let c2r = rplanner.plan_fft_inverse(ncols);
let mut cplanner = FftPlanner::<T>::new();
let col_fwd = cplanner.plan_fft_forward(nrows);
let col_inv = cplanner.plan_fft_inverse(nrows);
Self {
nrows,
ncols,
nhalf: ncols / 2 + 1,
r2c,
c2r,
col_fwd,
col_inv,
}
}
pub fn dim(&self) -> (usize, usize) {
(self.nrows, self.ncols)
}
}
pub(crate) enum PlaneInput<'a, T> {
Borrowed(ArrayView2<'a, T>),
Owned(Array2<T>),
}
impl<T: FftFloat> PlaneInput<'_, T> {
fn view(&self) -> ArrayView2<'_, T> {
match self {
PlaneInput::Borrowed(v) => v.view(),
PlaneInput::Owned(a) => a.view(),
}
}
fn into_owned(self) -> Array2<T> {
match self {
PlaneInput::Borrowed(v) => v.to_owned(),
PlaneInput::Owned(a) => a,
}
}
fn into_zero_filled_vec(self, capacity: usize) -> Vec<T> {
let zero_nan = |x: T| if x.is_nan() { T::zero() } else { x };
match self {
PlaneInput::Owned(a) if a.is_standard_layout() => {
let (mut buf, offset) = a.into_raw_vec_and_offset();
debug_assert_eq!(offset.unwrap_or(0), 0);
for x in buf.iter_mut() {
*x = zero_nan(*x);
}
buf
}
other => {
let mut buf = Vec::with_capacity(capacity);
buf.extend(other.view().iter().map(|&x| zero_nan(x)));
buf
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OutputGain {
Unit,
Ratio,
InverseRatio,
}
pub fn convolve_uv<T: FftFloat>(
image: &Array2<T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
let (nrows, ncols) = image.dim();
let plans = FftPlans::<T>::new(nrows, ncols);
convolve_uv_with_plans(
image,
old_beam,
new_beam,
dx_deg,
dy_deg,
cutoff_arcsec,
&plans,
)
}
pub fn convolve_uv_with_plans<T: FftFloat>(
image: &Array2<T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
plans: &FftPlans<T>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
convolve_plane(
PlaneInput::Borrowed(image.view()),
old_beam,
new_beam,
dx_deg,
dy_deg,
cutoff_arcsec,
OutputGain::Unit,
plans,
)
}
pub fn convolve_uv_owned_with_plans<T: FftFloat>(
image: Array2<T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
plans: &FftPlans<T>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
convolve_plane(
PlaneInput::Owned(image),
old_beam,
new_beam,
dx_deg,
dy_deg,
cutoff_arcsec,
OutputGain::Unit,
plans,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn convolve_plane<T: FftFloat>(
image: PlaneInput<'_, T>,
old_beam: &Beam,
new_beam: &Beam,
dx_deg: f64,
dy_deg: f64,
cutoff_arcsec: Option<f64>,
gain: OutputGain,
plans: &FftPlans<T>,
) -> Result<ConvolutionResult<T>, ConvolveError> {
if let Some(cutoff) = cutoff_arcsec
&& old_beam.major_arcsec() > cutoff
{
return Err(ConvolveError::AboveCutoff);
}
if old_beam.approx_eq(new_beam) {
return Ok(ConvolutionResult {
image: image.into_owned(),
scaling_factor: 1.0,
});
}
let (nrows, ncols) = image.view().dim();
assert_eq!(
plans.dim(),
(nrows, ncols),
"FftPlans built for {:?} but image is {:?}",
plans.dim(),
(nrows, ncols)
);
let nan_count = image.view().iter().filter(|x| x.is_nan()).count();
if nan_count == nrows * ncols {
let conv_beam = new_beam.deconvolve_or_zero(old_beam);
let (fac, ..) = gauss_factor(
&conv_beam,
old_beam,
dx_deg.abs() * 3600.0,
dy_deg.abs() * 3600.0,
);
return Ok(ConvolutionResult {
image: image.into_owned(),
scaling_factor: fac,
});
}
let taper = UvTaper::new(old_beam, new_beam);
let grid = UvGrid::new(
nrows,
ncols,
dx_deg.to_radians(),
dy_deg.to_radians(),
&taper,
);
let npix = (nrows * ncols) as f64;
let spectrum_len = nrows * 2 * (ncols / 2 + 1);
let blank = (nan_count > 0).then(|| {
let mut mask: Vec<T> = Vec::with_capacity(spectrum_len);
mask.extend(
image
.view()
.iter()
.map(|x| if x.is_nan() { T::one() } else { T::zero() }),
);
convolve_in_place(&mut mask, &taper, taper.ratio / npix, &grid, plans);
let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
Bitset::from_predicate(&mask, |m| m >= blank_threshold)
});
let out_gain = match gain {
OutputGain::Unit => taper.ratio,
OutputGain::Ratio => taper.ratio * taper.ratio,
OutputGain::InverseRatio => 1.0,
};
let mut buf = image.into_zero_filled_vec(spectrum_len);
convolve_in_place(&mut buf, &taper, out_gain / npix, &grid, plans);
if let Some(blank) = blank {
blank.for_each_set(|k| buf[k] = T::nan());
}
let out =
Array2::from_shape_vec((nrows, ncols), buf).expect("shape mismatch in convolve_uv output");
Ok(ConvolutionResult {
image: out,
scaling_factor: taper.ratio,
})
}
#[derive(Clone, Copy, Debug)]
struct UvTaper {
a: f64,
b: f64,
c: f64,
ratio: f64,
}
impl UvTaper {
fn new(old_beam: &Beam, new_beam: &Beam) -> Self {
let deg2rad = std::f64::consts::PI / 180.0;
let fwhm_to_sigma = 2.0 * (2.0 * 2_f64.ln()).sqrt();
let sx = new_beam.major_deg * deg2rad / fwhm_to_sigma;
let sy = new_beam.minor_deg * deg2rad / fwhm_to_sigma;
let sx_in = old_beam.major_deg * deg2rad / fwhm_to_sigma;
let sy_in = old_beam.minor_deg * deg2rad / fwhm_to_sigma;
let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
let form = |sx: f64, sy: f64, pa_deg: f64| {
let (s, c) = (pa_deg * deg2rad).sin_cos();
let (sx2, sy2) = (sx * sx, sy * sy);
(
sx2 * c * c + sy2 * s * s,
2.0 * c * s * (sy2 - sx2),
sx2 * s * s + sy2 * c * c,
)
};
let (a, b, c) = form(sx, sy, new_beam.pa_deg);
let (a_in, b_in, c_in) = form(sx_in, sy_in, old_beam.pa_deg);
let k = -2.0 * std::f64::consts::PI * std::f64::consts::PI;
Self {
a: k * (a - a_in),
b: k * (b - b_in),
c: k * (c - c_in),
ratio: g_amp / dg_amp,
}
}
fn at(&self, u: f64, v: f64) -> f64 {
self.ratio * (self.a * u * u + self.b * u * v + self.c * v * v).exp()
}
}
pub fn gaussft(
old_beam: &Beam,
new_beam: &Beam,
u_freqs: &[f64],
v_freqs: &[f64],
) -> (Vec<f64>, f64) {
let taper = UvTaper::new(old_beam, new_beam);
let g_final = u_freqs
.iter()
.flat_map(|&u| v_freqs.iter().map(move |&v| taper.at(u, v)))
.collect();
(g_final, taper.ratio)
}
pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
let val = 1.0 / (n as f64 * d);
let m = n.div_ceil(2); let mut freqs = vec![0.0_f64; n];
for (i, freq) in freqs.iter_mut().enumerate().take(m) {
*freq = i as f64 * val;
}
for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
*freq = (i as f64 - n as f64) * val;
}
freqs
}
const COL_BLOCK: usize = 16;
struct UvGrid {
u: Vec<f64>,
au2: Vec<f64>,
v: Vec<f64>,
}
impl UvGrid {
fn new(nrows: usize, ncols: usize, dx_rad: f64, dy_rad: f64, taper: &UvTaper) -> Self {
let u = fftfreq(nrows, dx_rad);
let au2 = u.iter().map(|&u| taper.a * u * u).collect();
let mut v = fftfreq(ncols, dy_rad);
v.truncate(ncols / 2 + 1);
Self { u, au2, v }
}
}
struct Bitset {
words: Vec<u64>,
}
impl Bitset {
fn from_predicate<T: Copy>(values: &[T], pred: impl Fn(T) -> bool) -> Self {
let words = values
.chunks(64)
.map(|chunk| {
chunk
.iter()
.enumerate()
.fold(0u64, |w, (b, &x)| w | ((pred(x) as u64) << b))
})
.collect();
Self { words }
}
fn for_each_set(&self, mut f: impl FnMut(usize)) {
for (w, &word) in self.words.iter().enumerate() {
let mut bits = word;
while bits != 0 {
f(w * 64 + bits.trailing_zeros() as usize);
bits &= bits - 1;
}
}
}
}
fn as_complex<T: FftFloat>(re: &[T]) -> &[Complex<T>] {
assert!(re.len().is_multiple_of(2));
unsafe { std::slice::from_raw_parts(re.as_ptr().cast(), re.len() / 2) }
}
fn as_complex_mut<T: FftFloat>(re: &mut [T]) -> &mut [Complex<T>] {
assert!(re.len().is_multiple_of(2));
unsafe { std::slice::from_raw_parts_mut(re.as_mut_ptr().cast(), re.len() / 2) }
}
fn convolve_in_place<T: FftFloat>(
buf: &mut Vec<T>,
taper: &UvTaper,
gain: f64,
grid: &UvGrid,
plans: &FftPlans<T>,
) {
let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
debug_assert_eq!(buf.len(), nrows * ncols);
buf.resize(nrows * 2 * nhalf, T::zero());
forward_rows_in_place(plans, buf);
filter_columns(plans, as_complex_mut(buf), taper, gain, grid);
inverse_rows_in_place(plans, buf);
buf.truncate(nrows * ncols);
}
fn forward_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
let mut scratch = plans.r2c.make_scratch_vec();
let mut inrow = plans.r2c.make_input_vec();
for i in (0..nrows).rev() {
inrow.copy_from_slice(&buf[i * ncols..(i + 1) * ncols]);
let out = as_complex_mut(&mut buf[2 * nhalf * i..2 * nhalf * (i + 1)]);
plans
.r2c
.process_with_scratch(&mut inrow, out, &mut scratch)
.expect("r2c FFT");
}
}
fn inverse_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
let mut scratch = plans.c2r.make_scratch_vec();
let mut inrow = plans.c2r.make_input_vec();
let even = ncols.is_multiple_of(2);
for i in 0..nrows {
inrow.copy_from_slice(as_complex(&buf[2 * nhalf * i..2 * nhalf * (i + 1)]));
inrow[0].im = T::zero();
if even {
inrow[nhalf - 1].im = T::zero();
}
plans
.c2r
.process_with_scratch(
&mut inrow,
&mut buf[i * ncols..(i + 1) * ncols],
&mut scratch,
)
.expect("c2r FFT");
}
}
fn filter_columns<T: FftFloat>(
plans: &FftPlans<T>,
spectrum: &mut [Complex<T>],
taper: &UvTaper,
gain: f64,
grid: &UvGrid,
) {
let (nrows, nhalf) = (plans.nrows, plans.nhalf);
let zero = Complex::new(T::zero(), T::zero());
let scratch_len = plans
.col_fwd
.get_inplace_scratch_len()
.max(plans.col_inv.get_inplace_scratch_len());
let mut scratch = vec![zero; scratch_len];
let mut tile = vec![zero; COL_BLOCK * nrows];
for j0 in (0..nhalf).step_by(COL_BLOCK) {
let width = COL_BLOCK.min(nhalf - j0);
let tile = &mut tile[..width * nrows];
for (i, row) in spectrum.chunks_exact(nhalf).enumerate() {
for (k, &s) in row[j0..j0 + width].iter().enumerate() {
tile[k * nrows + i] = s;
}
}
plans.col_fwd.process_with_scratch(tile, &mut scratch);
for (k, col) in tile.chunks_exact_mut(nrows).enumerate() {
let v = grid.v[j0 + k];
let (bv, cv2) = (taper.b * v, taper.c * v * v);
for ((s, &u), &au2) in col.iter_mut().zip(&grid.u).zip(&grid.au2) {
let g = gain * (au2 + bv * u + cv2).exp();
*s = s.scale(cast_saturating::<T>(g));
}
}
plans.col_inv.process_with_scratch(tile, &mut scratch);
for (i, row) in spectrum.chunks_exact_mut(nhalf).enumerate() {
for (k, s) in row[j0..j0 + width].iter_mut().enumerate() {
*s = tile[k * nrows + i];
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
#[test]
fn test_fftfreq() {
let f = fftfreq(4, 1.0);
let expected = [0.0, 0.25, -0.5, -0.25];
for (a, b) in f.iter().zip(expected.iter()) {
assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
}
}
fn roundtrip<T: FftFloat + std::fmt::Debug>(nrows: usize, ncols: usize, tol: f64) {
let data: Vec<T> = (0..nrows * ncols)
.map(|k| cast_saturating::<T>(((k * 7) % 13) as f64 - 6.0))
.collect();
let plans = FftPlans::<T>::new(nrows, ncols);
let mut buf = data.clone();
buf.resize(nrows * 2 * plans.nhalf, T::zero());
forward_rows_in_place(&plans, &mut buf);
let flat = UvTaper {
a: 0.0,
b: 0.0,
c: 0.0,
ratio: 1.0,
};
let grid = UvGrid::new(nrows, ncols, 1.0, 1.0, &flat);
let n = (nrows * ncols) as f64;
filter_columns(&plans, as_complex_mut(&mut buf), &flat, 1.0 / n, &grid);
inverse_rows_in_place(&plans, &mut buf);
buf.truncate(nrows * ncols);
for (a, b) in data.iter().zip(buf.iter()) {
let (a, b) = (a.to_f64().unwrap(), b.to_f64().unwrap());
assert!((a - b).abs() < tol, "roundtrip {nrows}x{ncols}: {a} vs {b}");
}
}
#[test]
fn test_in_place_roundtrip() {
for &(r, c) in &[(4, 4), (5, 7), (6, 9), (7, 6), (1, 8), (33, 40)] {
roundtrip::<f64>(r, c, 1e-10);
roundtrip::<f32>(r, c, 1e-4);
}
}
fn reference(image: &Array2<f64>, old: &Beam, new: &Beam, dx: f64) -> Array2<f64> {
let (nrows, ncols) = image.dim();
let u = fftfreq(nrows, dx.to_radians());
let v = fftfreq(ncols, dx.to_radians());
let (g, _) = gaussft(old, new, &u, &v);
let mut planner = FftPlanner::<f64>::new();
let (row_fwd, col_fwd) = (
planner.plan_fft_forward(ncols),
planner.plan_fft_forward(nrows),
);
let (row_inv, col_inv) = (
planner.plan_fft_inverse(ncols),
planner.plan_fft_inverse(nrows),
);
let fft2 = |z: &mut [Complex<f64>], rows: &dyn Fft<f64>, cols: &dyn Fft<f64>| {
rows.process(z);
let mut col = vec![Complex::new(0.0, 0.0); nrows];
for j in 0..ncols {
(0..nrows).for_each(|i| col[i] = z[i * ncols + j]);
cols.process(&mut col);
(0..nrows).for_each(|i| z[i * ncols + j] = col[i]);
}
};
let conv = |data: &dyn Fn(f64) -> f64| -> Vec<f64> {
let mut z: Vec<Complex<f64>> =
image.iter().map(|&x| Complex::new(data(x), 0.0)).collect();
fft2(&mut z, &*row_fwd, &*col_fwd);
z.iter_mut().zip(&g).for_each(|(s, &g)| *s *= g);
fft2(&mut z, &*row_inv, &*col_inv);
z.iter().map(|s| s.re / (nrows * ncols) as f64).collect()
};
let img = conv(&|x| if x.is_nan() { 0.0 } else { x });
let mask = conv(&|x| if x.is_nan() { 1.0 } else { 0.0 });
let out = img
.iter()
.zip(&mask)
.map(|(&x, &m)| if m >= 1.0 - 1e-2 { f64::NAN } else { x })
.collect();
Array2::from_shape_vec((nrows, ncols), out).unwrap()
}
fn test_image(nrows: usize, ncols: usize, nans: bool) -> Array2<f64> {
let mut img = Array2::from_shape_fn((nrows, ncols), |(i, j)| {
(((i * 31 + j * 17) % 23) as f64 - 11.0) / 7.0 + if i > nrows / 2 { 3.0 } else { 0.0 }
});
if nans {
for i in 0..nrows / 3 {
for j in 0..ncols / 2 {
img[(i, j)] = f64::NAN;
}
}
img[(nrows - 2, ncols - 3)] = f64::NAN; }
img
}
#[test]
fn test_convolve_uv_matches_full_fft_reference() {
let dx = 2.5 / 3600.0;
let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
for &(r, c) in &[(64, 48), (65, 49), (40, 71), (37, 36)] {
for nans in [false, true] {
let img = test_image(r, c, nans);
let expected = reference(&img, &old, &new, dx);
let got = convolve_uv(&img, &old, &new, dx, dx, None).unwrap().image;
let got32 = convolve_uv(&img.mapv(|x| x as f32), &old, &new, dx, dx, None)
.unwrap()
.image;
for ((&e, &g), &g32) in expected.iter().zip(got.iter()).zip(got32.iter()) {
assert_eq!(
e.is_nan(),
g.is_nan(),
"{r}x{c} nans={nans}: NaN mask differs"
);
assert_eq!(
e.is_nan(),
g32.is_nan(),
"{r}x{c} nans={nans}: f32 NaN mask"
);
if !e.is_nan() {
assert!((e - g).abs() < 1e-9, "{r}x{c} nans={nans}: {e} vs {g}");
assert!((e - g32 as f64).abs() < 1e-4, "{r}x{c}: f32 {e} vs {g32}");
}
}
}
}
}
#[test]
fn test_owned_matches_borrowed() {
let dx = 2.5 / 3600.0;
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
for nans in [false, true] {
let img = test_image(45, 38, nans).mapv(|x| x as f32);
let plans = FftPlans::<f32>::new(45, 38);
let borrowed = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
let owned = convolve_uv_owned_with_plans(img.clone(), &old, &new, dx, dx, None, &plans)
.unwrap();
assert_eq!(borrowed.scaling_factor, owned.scaling_factor);
for (a, b) in borrowed.image.iter().zip(owned.image.iter()) {
assert_eq!(a.to_bits(), b.to_bits(), "owned path changed output");
}
}
}
#[test]
fn test_owned_non_standard_layout() {
let dx = 2.5 / 3600.0;
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
let img = test_image(30, 41, true).reversed_axes(); let plans = FftPlans::<f64>::new(41, 30);
let std_layout = img.as_standard_layout().to_owned();
let a = convolve_uv_with_plans(&std_layout, &old, &new, dx, dx, None, &plans).unwrap();
let b = convolve_uv_owned_with_plans(img, &old, &new, dx, dx, None, &plans).unwrap();
for (x, y) in a.image.iter().zip(b.image.iter()) {
assert_eq!(x.to_bits(), y.to_bits());
}
}
#[test]
fn test_bitset() {
let vals: Vec<u8> = (0..200).map(|k| (k % 3 == 0 || k == 199) as u8).collect();
let bits = Bitset::from_predicate(&vals, |v| v == 1);
let mut got = vec![];
bits.for_each_set(|k| got.push(k));
let want: Vec<usize> = (0..200).filter(|&k| vals[k] == 1).collect();
assert_eq!(got, want);
}
#[test]
fn test_gaussft_matches_rotated_form() {
let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
let u = fftfreq(33, 1.2e-5);
let v = fftfreq(20, 1.2e-5);
let (g, ratio) = gaussft(&old, &new, &u, &v);
let f = 2.0 * (2.0 * 2f64.ln()).sqrt();
let arg = |b: &Beam, u: f64, v: f64| {
let (sx, sy) = (b.major_deg.to_radians() / f, b.minor_deg.to_radians() / f);
let (s, c) = b.pa_deg.to_radians().sin_cos();
let (ur, vr) = (u * c - v * s, u * s + v * c);
-2.0 * std::f64::consts::PI.powi(2) * ((sx * ur).powi(2) + (sy * vr).powi(2))
};
for (i, &uu) in u.iter().enumerate() {
for (j, &vv) in v.iter().enumerate() {
let want = ratio * (arg(&new, uu, vv) - arg(&old, uu, vv)).exp();
let got = g[i * v.len() + j];
assert!(
(got - want).abs() <= 1e-12 * want.abs().max(1e-300),
"{got} vs {want}"
);
}
}
}
#[test]
fn test_convolve_uv_no_change_when_beams_equal() {
let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
let img = Array2::from_elem((16, 16), 1.0_f32);
let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
assert!((result.scaling_factor - 1.0).abs() < 1e-10);
}
#[test]
fn test_convolve_uv_point_source_flux_and_peak() {
let (n, dx) = (64usize, 2.0 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
let mut img = Array2::<f64>::zeros((n, n));
img[(n / 2, n / 2)] = 1.0;
let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
let total: f64 = res.image.iter().sum();
assert!(
(total - res.scaling_factor).abs() < 1e-6,
"integral {total} != DC gain {}",
res.scaling_factor
);
let peak = res.image[(n / 2, n / 2)];
assert!(peak > 0.0);
for &v in res.image.iter() {
assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
}
}
#[test]
fn test_convolve_uv_f32_matches_f64() {
let (n, dx) = (48usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
let img64 =
Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
let img32 = img64.mapv(|x| x as f32);
let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
for (a, b) in r64.image.iter().zip(r32.image.iter()) {
assert!(
(*a - *b as f64).abs() < 1e-4,
"f32/f64 mismatch: {a} vs {b}"
);
}
}
#[test]
fn test_convolve_uv_propagates_nans() {
let (n, dx) = (48usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
let mut img = Array2::<f32>::from_elem((n, n), 1.0);
for i in 0..12 {
for j in 0..12 {
img[(i, j)] = f32::NAN;
}
}
let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
assert!(res.image[(n - 1, n - 1)].is_finite());
}
#[test]
fn test_with_plans_matches_per_call() {
let (n, dx) = (32usize, 2.5 / 3600.0);
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
let plans = FftPlans::<f32>::new(n, n);
let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
}
}
#[test]
fn test_gaussft_dc_equals_ratio() {
let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
assert!(
(g[0] - ratio).abs() < 1e-12,
"DC {} != ratio {}",
g[0],
ratio
);
assert!(ratio > 1.0, "larger target beam should have ratio > 1");
}
}