1use std::sync::Arc;
20
21use ndarray::{Array2, ArrayView2};
22use num_traits::{Float, cast};
23use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
24use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
25use thiserror::Error;
26
27use crate::beam::{Beam, gauss_factor};
28
29#[derive(Debug, Error)]
30pub enum ConvolveError {
31 #[error("image is entirely NaN")]
32 AllNaN,
33 #[error("beam larger than cutoff — image blanked")]
34 AboveCutoff,
35}
36
37pub trait FftFloat: FftNum + Float {}
43impl FftFloat for f32 {}
44impl FftFloat for f64 {}
45
46pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
54 cast::<f64, T>(value).unwrap_or_else(|| {
55 if value.is_sign_negative() {
56 T::neg_infinity()
57 } else {
58 T::infinity()
59 }
60 })
61}
62
63pub struct ConvolutionResult<T = f32> {
64 pub image: Array2<T>,
66 pub scaling_factor: f64,
68}
69
70pub struct FftPlans<T: FftNum = f32> {
79 nrows: usize,
80 ncols: usize,
81 nhalf: usize,
82 r2c: Arc<dyn RealToComplex<T>>,
83 c2r: Arc<dyn ComplexToReal<T>>,
84 col_fwd: Arc<dyn Fft<T>>,
85 col_inv: Arc<dyn Fft<T>>,
86}
87
88impl<T: FftNum> FftPlans<T> {
89 pub fn new(nrows: usize, ncols: usize) -> Self {
92 let mut rplanner = RealFftPlanner::<T>::new();
93 let r2c = rplanner.plan_fft_forward(ncols);
94 let c2r = rplanner.plan_fft_inverse(ncols);
95
96 let mut cplanner = FftPlanner::<T>::new();
97 let col_fwd = cplanner.plan_fft_forward(nrows);
98 let col_inv = cplanner.plan_fft_inverse(nrows);
99
100 Self {
101 nrows,
102 ncols,
103 nhalf: ncols / 2 + 1,
104 r2c,
105 c2r,
106 col_fwd,
107 col_inv,
108 }
109 }
110
111 pub fn dim(&self) -> (usize, usize) {
113 (self.nrows, self.ncols)
114 }
115}
116
117pub(crate) enum PlaneInput<'a, T> {
122 Borrowed(ArrayView2<'a, T>),
123 Owned(Array2<T>),
124}
125
126impl<T: FftFloat> PlaneInput<'_, T> {
127 fn view(&self) -> ArrayView2<'_, T> {
128 match self {
129 PlaneInput::Borrowed(v) => v.view(),
130 PlaneInput::Owned(a) => a.view(),
131 }
132 }
133
134 fn into_owned(self) -> Array2<T> {
135 match self {
136 PlaneInput::Borrowed(v) => v.to_owned(),
137 PlaneInput::Owned(a) => a,
138 }
139 }
140
141 fn into_zero_filled_vec(self, capacity: usize) -> Vec<T> {
145 let zero_nan = |x: T| if x.is_nan() { T::zero() } else { x };
146 match self {
147 PlaneInput::Owned(a) if a.is_standard_layout() => {
148 let (mut buf, offset) = a.into_raw_vec_and_offset();
149 debug_assert_eq!(offset.unwrap_or(0), 0);
150 for x in buf.iter_mut() {
151 *x = zero_nan(*x);
152 }
153 buf
154 }
155 other => {
156 let mut buf = Vec::with_capacity(capacity);
157 buf.extend(other.view().iter().map(|&x| zero_nan(x)));
158 buf
159 }
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub(crate) enum OutputGain {
168 Unit,
170 Ratio,
172 InverseRatio,
174}
175
176pub fn convolve_uv<T: FftFloat>(
206 image: &Array2<T>,
207 old_beam: &Beam,
208 new_beam: &Beam,
209 dx_deg: f64,
210 dy_deg: f64,
211 cutoff_arcsec: Option<f64>,
212) -> Result<ConvolutionResult<T>, ConvolveError> {
213 let (nrows, ncols) = image.dim();
214 let plans = FftPlans::<T>::new(nrows, ncols);
215 convolve_uv_with_plans(
216 image,
217 old_beam,
218 new_beam,
219 dx_deg,
220 dy_deg,
221 cutoff_arcsec,
222 &plans,
223 )
224}
225
226pub fn convolve_uv_with_plans<T: FftFloat>(
229 image: &Array2<T>,
230 old_beam: &Beam,
231 new_beam: &Beam,
232 dx_deg: f64,
233 dy_deg: f64,
234 cutoff_arcsec: Option<f64>,
235 plans: &FftPlans<T>,
236) -> Result<ConvolutionResult<T>, ConvolveError> {
237 convolve_plane(
238 PlaneInput::Borrowed(image.view()),
239 old_beam,
240 new_beam,
241 dx_deg,
242 dy_deg,
243 cutoff_arcsec,
244 OutputGain::Unit,
245 plans,
246 )
247}
248
249pub fn convolve_uv_owned_with_plans<T: FftFloat>(
254 image: Array2<T>,
255 old_beam: &Beam,
256 new_beam: &Beam,
257 dx_deg: f64,
258 dy_deg: f64,
259 cutoff_arcsec: Option<f64>,
260 plans: &FftPlans<T>,
261) -> Result<ConvolutionResult<T>, ConvolveError> {
262 convolve_plane(
263 PlaneInput::Owned(image),
264 old_beam,
265 new_beam,
266 dx_deg,
267 dy_deg,
268 cutoff_arcsec,
269 OutputGain::Unit,
270 plans,
271 )
272}
273
274#[allow(clippy::too_many_arguments)]
284pub(crate) fn convolve_plane<T: FftFloat>(
285 image: PlaneInput<'_, T>,
286 old_beam: &Beam,
287 new_beam: &Beam,
288 dx_deg: f64,
289 dy_deg: f64,
290 cutoff_arcsec: Option<f64>,
291 gain: OutputGain,
292 plans: &FftPlans<T>,
293) -> Result<ConvolutionResult<T>, ConvolveError> {
294 if let Some(cutoff) = cutoff_arcsec
296 && old_beam.major_arcsec() > cutoff
297 {
298 return Err(ConvolveError::AboveCutoff);
299 }
300
301 if old_beam.approx_eq(new_beam) {
303 return Ok(ConvolutionResult {
304 image: image.into_owned(),
305 scaling_factor: 1.0,
306 });
307 }
308
309 let (nrows, ncols) = image.view().dim();
310 assert_eq!(
311 plans.dim(),
312 (nrows, ncols),
313 "FftPlans built for {:?} but image is {:?}",
314 plans.dim(),
315 (nrows, ncols)
316 );
317
318 let nan_count = image.view().iter().filter(|x| x.is_nan()).count();
319
320 if nan_count == nrows * ncols {
322 let conv_beam = new_beam.deconvolve_or_zero(old_beam);
324 let (fac, ..) = gauss_factor(
325 &conv_beam,
326 old_beam,
327 dx_deg.abs() * 3600.0,
328 dy_deg.abs() * 3600.0,
329 );
330 return Ok(ConvolutionResult {
331 image: image.into_owned(),
332 scaling_factor: fac,
333 });
334 }
335
336 let taper = UvTaper::new(old_beam, new_beam);
343 let grid = UvGrid::new(
344 nrows,
345 ncols,
346 dx_deg.to_radians(),
347 dy_deg.to_radians(),
348 &taper,
349 );
350 let npix = (nrows * ncols) as f64;
351 let spectrum_len = nrows * 2 * (ncols / 2 + 1);
352
353 let blank = (nan_count > 0).then(|| {
357 let mut mask: Vec<T> = Vec::with_capacity(spectrum_len);
358 mask.extend(
359 image
360 .view()
361 .iter()
362 .map(|x| if x.is_nan() { T::one() } else { T::zero() }),
363 );
364 convolve_in_place(&mut mask, &taper, taper.ratio / npix, &grid, plans);
365 let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
371 Bitset::from_predicate(&mask, |m| m >= blank_threshold)
372 });
373
374 let out_gain = match gain {
375 OutputGain::Unit => taper.ratio,
376 OutputGain::Ratio => taper.ratio * taper.ratio,
377 OutputGain::InverseRatio => 1.0,
378 };
379 let mut buf = image.into_zero_filled_vec(spectrum_len);
380 convolve_in_place(&mut buf, &taper, out_gain / npix, &grid, plans);
381
382 if let Some(blank) = blank {
383 blank.for_each_set(|k| buf[k] = T::nan());
384 }
385
386 let out =
387 Array2::from_shape_vec((nrows, ncols), buf).expect("shape mismatch in convolve_uv output");
388
389 Ok(ConvolutionResult {
390 image: out,
391 scaling_factor: taper.ratio,
392 })
393}
394
395#[derive(Clone, Copy, Debug)]
407struct UvTaper {
408 a: f64,
409 b: f64,
410 c: f64,
411 ratio: f64,
413}
414
415impl UvTaper {
416 fn new(old_beam: &Beam, new_beam: &Beam) -> Self {
417 let deg2rad = std::f64::consts::PI / 180.0;
418 let fwhm_to_sigma = 2.0 * (2.0 * 2_f64.ln()).sqrt(); let sx = new_beam.major_deg * deg2rad / fwhm_to_sigma;
422 let sy = new_beam.minor_deg * deg2rad / fwhm_to_sigma;
423 let sx_in = old_beam.major_deg * deg2rad / fwhm_to_sigma;
424 let sy_in = old_beam.minor_deg * deg2rad / fwhm_to_sigma;
425
426 let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
428 let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
429
430 let form = |sx: f64, sy: f64, pa_deg: f64| {
432 let (s, c) = (pa_deg * deg2rad).sin_cos();
433 let (sx2, sy2) = (sx * sx, sy * sy);
434 (
435 sx2 * c * c + sy2 * s * s,
436 2.0 * c * s * (sy2 - sx2),
437 sx2 * s * s + sy2 * c * c,
438 )
439 };
440 let (a, b, c) = form(sx, sy, new_beam.pa_deg);
441 let (a_in, b_in, c_in) = form(sx_in, sy_in, old_beam.pa_deg);
442 let k = -2.0 * std::f64::consts::PI * std::f64::consts::PI;
443
444 Self {
445 a: k * (a - a_in),
446 b: k * (b - b_in),
447 c: k * (c - c_in),
448 ratio: g_amp / dg_amp,
449 }
450 }
451
452 fn at(&self, u: f64, v: f64) -> f64 {
454 self.ratio * (self.a * u * u + self.b * u * v + self.c * v * v).exp()
455 }
456}
457
458pub fn gaussft(
468 old_beam: &Beam,
469 new_beam: &Beam,
470 u_freqs: &[f64],
471 v_freqs: &[f64],
472) -> (Vec<f64>, f64) {
473 let taper = UvTaper::new(old_beam, new_beam);
474 let g_final = u_freqs
475 .iter()
476 .flat_map(|&u| v_freqs.iter().map(move |&v| taper.at(u, v)))
477 .collect();
478 (g_final, taper.ratio)
479}
480
481pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
496 let val = 1.0 / (n as f64 * d);
497 let m = n.div_ceil(2); let mut freqs = vec![0.0_f64; n];
499 for (i, freq) in freqs.iter_mut().enumerate().take(m) {
500 *freq = i as f64 * val;
501 }
502 for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
503 *freq = (i as f64 - n as f64) * val;
504 }
505 freqs
506}
507
508const COL_BLOCK: usize = 16;
513
514struct UvGrid {
517 u: Vec<f64>,
519 au2: Vec<f64>,
521 v: Vec<f64>,
523}
524
525impl UvGrid {
526 fn new(nrows: usize, ncols: usize, dx_rad: f64, dy_rad: f64, taper: &UvTaper) -> Self {
527 let u = fftfreq(nrows, dx_rad);
528 let au2 = u.iter().map(|&u| taper.a * u * u).collect();
529 let mut v = fftfreq(ncols, dy_rad);
530 v.truncate(ncols / 2 + 1);
531 Self { u, au2, v }
532 }
533}
534
535struct Bitset {
537 words: Vec<u64>,
538}
539
540impl Bitset {
541 fn from_predicate<T: Copy>(values: &[T], pred: impl Fn(T) -> bool) -> Self {
542 let words = values
543 .chunks(64)
544 .map(|chunk| {
545 chunk
546 .iter()
547 .enumerate()
548 .fold(0u64, |w, (b, &x)| w | ((pred(x) as u64) << b))
549 })
550 .collect();
551 Self { words }
552 }
553
554 fn for_each_set(&self, mut f: impl FnMut(usize)) {
555 for (w, &word) in self.words.iter().enumerate() {
556 let mut bits = word;
557 while bits != 0 {
558 f(w * 64 + bits.trailing_zeros() as usize);
559 bits &= bits - 1;
560 }
561 }
562 }
563}
564
565fn as_complex<T: FftFloat>(re: &[T]) -> &[Complex<T>] {
567 assert!(re.len().is_multiple_of(2));
568 unsafe { std::slice::from_raw_parts(re.as_ptr().cast(), re.len() / 2) }
572}
573
574fn as_complex_mut<T: FftFloat>(re: &mut [T]) -> &mut [Complex<T>] {
576 assert!(re.len().is_multiple_of(2));
577 unsafe { std::slice::from_raw_parts_mut(re.as_mut_ptr().cast(), re.len() / 2) }
579}
580
581fn convolve_in_place<T: FftFloat>(
591 buf: &mut Vec<T>,
592 taper: &UvTaper,
593 gain: f64,
594 grid: &UvGrid,
595 plans: &FftPlans<T>,
596) {
597 let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
598 debug_assert_eq!(buf.len(), nrows * ncols);
599 buf.resize(nrows * 2 * nhalf, T::zero());
602
603 forward_rows_in_place(plans, buf);
604 filter_columns(plans, as_complex_mut(buf), taper, gain, grid);
605 inverse_rows_in_place(plans, buf);
606
607 buf.truncate(nrows * ncols);
608}
609
610fn forward_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
618 let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
619 let mut scratch = plans.r2c.make_scratch_vec();
620 let mut inrow = plans.r2c.make_input_vec();
621 for i in (0..nrows).rev() {
622 inrow.copy_from_slice(&buf[i * ncols..(i + 1) * ncols]);
623 let out = as_complex_mut(&mut buf[2 * nhalf * i..2 * nhalf * (i + 1)]);
624 plans
625 .r2c
626 .process_with_scratch(&mut inrow, out, &mut scratch)
627 .expect("r2c FFT");
628 }
629}
630
631fn inverse_rows_in_place<T: FftFloat>(plans: &FftPlans<T>, buf: &mut [T]) {
637 let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
638 let mut scratch = plans.c2r.make_scratch_vec();
639 let mut inrow = plans.c2r.make_input_vec();
640 let even = ncols.is_multiple_of(2);
641 for i in 0..nrows {
642 inrow.copy_from_slice(as_complex(&buf[2 * nhalf * i..2 * nhalf * (i + 1)]));
643 inrow[0].im = T::zero();
646 if even {
647 inrow[nhalf - 1].im = T::zero();
648 }
649 plans
650 .c2r
651 .process_with_scratch(
652 &mut inrow,
653 &mut buf[i * ncols..(i + 1) * ncols],
654 &mut scratch,
655 )
656 .expect("c2r FFT");
657 }
658}
659
660fn filter_columns<T: FftFloat>(
667 plans: &FftPlans<T>,
668 spectrum: &mut [Complex<T>],
669 taper: &UvTaper,
670 gain: f64,
671 grid: &UvGrid,
672) {
673 let (nrows, nhalf) = (plans.nrows, plans.nhalf);
674 let zero = Complex::new(T::zero(), T::zero());
675 let scratch_len = plans
676 .col_fwd
677 .get_inplace_scratch_len()
678 .max(plans.col_inv.get_inplace_scratch_len());
679 let mut scratch = vec![zero; scratch_len];
680 let mut tile = vec![zero; COL_BLOCK * nrows];
681
682 for j0 in (0..nhalf).step_by(COL_BLOCK) {
683 let width = COL_BLOCK.min(nhalf - j0);
684 let tile = &mut tile[..width * nrows];
685
686 for (i, row) in spectrum.chunks_exact(nhalf).enumerate() {
688 for (k, &s) in row[j0..j0 + width].iter().enumerate() {
689 tile[k * nrows + i] = s;
690 }
691 }
692
693 plans.col_fwd.process_with_scratch(tile, &mut scratch);
694
695 for (k, col) in tile.chunks_exact_mut(nrows).enumerate() {
696 let v = grid.v[j0 + k];
697 let (bv, cv2) = (taper.b * v, taper.c * v * v);
698 for ((s, &u), &au2) in col.iter_mut().zip(&grid.u).zip(&grid.au2) {
699 let g = gain * (au2 + bv * u + cv2).exp();
700 *s = s.scale(cast_saturating::<T>(g));
701 }
702 }
703
704 plans.col_inv.process_with_scratch(tile, &mut scratch);
705
706 for (i, row) in spectrum.chunks_exact_mut(nhalf).enumerate() {
708 for (k, s) in row[j0..j0 + width].iter_mut().enumerate() {
709 *s = tile[k * nrows + i];
710 }
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use ndarray::Array2;
719
720 #[test]
721 fn test_fftfreq() {
722 let f = fftfreq(4, 1.0);
724 let expected = [0.0, 0.25, -0.5, -0.25];
725 for (a, b) in f.iter().zip(expected.iter()) {
726 assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
727 }
728 }
729
730 fn roundtrip<T: FftFloat + std::fmt::Debug>(nrows: usize, ncols: usize, tol: f64) {
733 let data: Vec<T> = (0..nrows * ncols)
734 .map(|k| cast_saturating::<T>(((k * 7) % 13) as f64 - 6.0))
735 .collect();
736 let plans = FftPlans::<T>::new(nrows, ncols);
737 let mut buf = data.clone();
738 buf.resize(nrows * 2 * plans.nhalf, T::zero());
739 forward_rows_in_place(&plans, &mut buf);
740 let flat = UvTaper {
742 a: 0.0,
743 b: 0.0,
744 c: 0.0,
745 ratio: 1.0,
746 };
747 let grid = UvGrid::new(nrows, ncols, 1.0, 1.0, &flat);
748 let n = (nrows * ncols) as f64;
749 filter_columns(&plans, as_complex_mut(&mut buf), &flat, 1.0 / n, &grid);
750 inverse_rows_in_place(&plans, &mut buf);
751 buf.truncate(nrows * ncols);
752 for (a, b) in data.iter().zip(buf.iter()) {
753 let (a, b) = (a.to_f64().unwrap(), b.to_f64().unwrap());
754 assert!((a - b).abs() < tol, "roundtrip {nrows}x{ncols}: {a} vs {b}");
755 }
756 }
757
758 #[test]
759 fn test_in_place_roundtrip() {
760 for &(r, c) in &[(4, 4), (5, 7), (6, 9), (7, 6), (1, 8), (33, 40)] {
761 roundtrip::<f64>(r, c, 1e-10);
762 roundtrip::<f32>(r, c, 1e-4);
763 }
764 }
765
766 fn reference(image: &Array2<f64>, old: &Beam, new: &Beam, dx: f64) -> Array2<f64> {
771 let (nrows, ncols) = image.dim();
772 let u = fftfreq(nrows, dx.to_radians());
773 let v = fftfreq(ncols, dx.to_radians());
774 let (g, _) = gaussft(old, new, &u, &v);
775 let mut planner = FftPlanner::<f64>::new();
776 let (row_fwd, col_fwd) = (
777 planner.plan_fft_forward(ncols),
778 planner.plan_fft_forward(nrows),
779 );
780 let (row_inv, col_inv) = (
781 planner.plan_fft_inverse(ncols),
782 planner.plan_fft_inverse(nrows),
783 );
784 let fft2 = |z: &mut [Complex<f64>], rows: &dyn Fft<f64>, cols: &dyn Fft<f64>| {
785 rows.process(z);
786 let mut col = vec![Complex::new(0.0, 0.0); nrows];
787 for j in 0..ncols {
788 (0..nrows).for_each(|i| col[i] = z[i * ncols + j]);
789 cols.process(&mut col);
790 (0..nrows).for_each(|i| z[i * ncols + j] = col[i]);
791 }
792 };
793 let conv = |data: &dyn Fn(f64) -> f64| -> Vec<f64> {
794 let mut z: Vec<Complex<f64>> =
795 image.iter().map(|&x| Complex::new(data(x), 0.0)).collect();
796 fft2(&mut z, &*row_fwd, &*col_fwd);
797 z.iter_mut().zip(&g).for_each(|(s, &g)| *s *= g);
798 fft2(&mut z, &*row_inv, &*col_inv);
799 z.iter().map(|s| s.re / (nrows * ncols) as f64).collect()
800 };
801 let img = conv(&|x| if x.is_nan() { 0.0 } else { x });
802 let mask = conv(&|x| if x.is_nan() { 1.0 } else { 0.0 });
803 let out = img
804 .iter()
805 .zip(&mask)
806 .map(|(&x, &m)| if m >= 1.0 - 1e-2 { f64::NAN } else { x })
807 .collect();
808 Array2::from_shape_vec((nrows, ncols), out).unwrap()
809 }
810
811 fn test_image(nrows: usize, ncols: usize, nans: bool) -> Array2<f64> {
812 let mut img = Array2::from_shape_fn((nrows, ncols), |(i, j)| {
813 (((i * 31 + j * 17) % 23) as f64 - 11.0) / 7.0 + if i > nrows / 2 { 3.0 } else { 0.0 }
814 });
815 if nans {
816 for i in 0..nrows / 3 {
817 for j in 0..ncols / 2 {
818 img[(i, j)] = f64::NAN;
819 }
820 }
821 img[(nrows - 2, ncols - 3)] = f64::NAN; }
823 img
824 }
825
826 #[test]
830 fn test_convolve_uv_matches_full_fft_reference() {
831 let dx = 2.5 / 3600.0;
832 let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
833 let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
834 for &(r, c) in &[(64, 48), (65, 49), (40, 71), (37, 36)] {
835 for nans in [false, true] {
836 let img = test_image(r, c, nans);
837 let expected = reference(&img, &old, &new, dx);
838 let got = convolve_uv(&img, &old, &new, dx, dx, None).unwrap().image;
839 let got32 = convolve_uv(&img.mapv(|x| x as f32), &old, &new, dx, dx, None)
840 .unwrap()
841 .image;
842 for ((&e, &g), &g32) in expected.iter().zip(got.iter()).zip(got32.iter()) {
843 assert_eq!(
844 e.is_nan(),
845 g.is_nan(),
846 "{r}x{c} nans={nans}: NaN mask differs"
847 );
848 assert_eq!(
849 e.is_nan(),
850 g32.is_nan(),
851 "{r}x{c} nans={nans}: f32 NaN mask"
852 );
853 if !e.is_nan() {
854 assert!((e - g).abs() < 1e-9, "{r}x{c} nans={nans}: {e} vs {g}");
855 assert!((e - g32 as f64).abs() < 1e-4, "{r}x{c}: f32 {e} vs {g32}");
856 }
857 }
858 }
859 }
860 }
861
862 #[test]
865 fn test_owned_matches_borrowed() {
866 let dx = 2.5 / 3600.0;
867 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
868 let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
869 for nans in [false, true] {
870 let img = test_image(45, 38, nans).mapv(|x| x as f32);
871 let plans = FftPlans::<f32>::new(45, 38);
872 let borrowed = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
873 let owned = convolve_uv_owned_with_plans(img.clone(), &old, &new, dx, dx, None, &plans)
874 .unwrap();
875 assert_eq!(borrowed.scaling_factor, owned.scaling_factor);
876 for (a, b) in borrowed.image.iter().zip(owned.image.iter()) {
877 assert_eq!(a.to_bits(), b.to_bits(), "owned path changed output");
878 }
879 }
880 }
881
882 #[test]
885 fn test_owned_non_standard_layout() {
886 let dx = 2.5 / 3600.0;
887 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
888 let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
889 let img = test_image(30, 41, true).reversed_axes(); let plans = FftPlans::<f64>::new(41, 30);
891 let std_layout = img.as_standard_layout().to_owned();
892 let a = convolve_uv_with_plans(&std_layout, &old, &new, dx, dx, None, &plans).unwrap();
893 let b = convolve_uv_owned_with_plans(img, &old, &new, dx, dx, None, &plans).unwrap();
894 for (x, y) in a.image.iter().zip(b.image.iter()) {
895 assert_eq!(x.to_bits(), y.to_bits());
896 }
897 }
898
899 #[test]
900 fn test_bitset() {
901 let vals: Vec<u8> = (0..200).map(|k| (k % 3 == 0 || k == 199) as u8).collect();
902 let bits = Bitset::from_predicate(&vals, |v| v == 1);
903 let mut got = vec![];
904 bits.for_each_set(|k| got.push(k));
905 let want: Vec<usize> = (0..200).filter(|&k| vals[k] == 1).collect();
906 assert_eq!(got, want);
907 }
908
909 #[test]
912 fn test_gaussft_matches_rotated_form() {
913 let old = Beam::from_arcsec(12.0, 10.0, 20.0).unwrap();
914 let new = Beam::from_arcsec(18.0, 15.0, 35.0).unwrap();
915 let u = fftfreq(33, 1.2e-5);
916 let v = fftfreq(20, 1.2e-5);
917 let (g, ratio) = gaussft(&old, &new, &u, &v);
918 let f = 2.0 * (2.0 * 2f64.ln()).sqrt();
919 let arg = |b: &Beam, u: f64, v: f64| {
920 let (sx, sy) = (b.major_deg.to_radians() / f, b.minor_deg.to_radians() / f);
921 let (s, c) = b.pa_deg.to_radians().sin_cos();
922 let (ur, vr) = (u * c - v * s, u * s + v * c);
923 -2.0 * std::f64::consts::PI.powi(2) * ((sx * ur).powi(2) + (sy * vr).powi(2))
924 };
925 for (i, &uu) in u.iter().enumerate() {
926 for (j, &vv) in v.iter().enumerate() {
927 let want = ratio * (arg(&new, uu, vv) - arg(&old, uu, vv)).exp();
928 let got = g[i * v.len() + j];
929 assert!(
930 (got - want).abs() <= 1e-12 * want.abs().max(1e-300),
931 "{got} vs {want}"
932 );
933 }
934 }
935 }
936
937 #[test]
938 fn test_convolve_uv_no_change_when_beams_equal() {
939 let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
940 let img = Array2::from_elem((16, 16), 1.0_f32);
941 let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
942 assert!((result.scaling_factor - 1.0).abs() < 1e-10);
943 }
944
945 #[test]
950 fn test_convolve_uv_point_source_flux_and_peak() {
951 let (n, dx) = (64usize, 2.0 / 3600.0);
952 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
953 let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
954
955 let mut img = Array2::<f64>::zeros((n, n));
956 img[(n / 2, n / 2)] = 1.0;
957
958 let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
959 let total: f64 = res.image.iter().sum();
960
961 assert!(
964 (total - res.scaling_factor).abs() < 1e-6,
965 "integral {total} != DC gain {}",
966 res.scaling_factor
967 );
968
969 let peak = res.image[(n / 2, n / 2)];
971 assert!(peak > 0.0);
972 for &v in res.image.iter() {
973 assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
974 }
975 }
976
977 #[test]
980 fn test_convolve_uv_f32_matches_f64() {
981 let (n, dx) = (48usize, 2.5 / 3600.0);
982 let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
983 let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
984
985 let img64 =
986 Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
987 let img32 = img64.mapv(|x| x as f32);
988
989 let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
990 let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
991
992 for (a, b) in r64.image.iter().zip(r32.image.iter()) {
993 assert!(
994 (*a - *b as f64).abs() < 1e-4,
995 "f32/f64 mismatch: {a} vs {b}"
996 );
997 }
998 }
999
1000 #[test]
1005 fn test_convolve_uv_propagates_nans() {
1006 let (n, dx) = (48usize, 2.5 / 3600.0);
1007 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1008 let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
1009
1010 let mut img = Array2::<f32>::from_elem((n, n), 1.0);
1011 for i in 0..12 {
1013 for j in 0..12 {
1014 img[(i, j)] = f32::NAN;
1015 }
1016 }
1017
1018 let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1019 assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
1021 assert!(res.image[(n - 1, n - 1)].is_finite());
1023 }
1024
1025 #[test]
1028 fn test_with_plans_matches_per_call() {
1029 let (n, dx) = (32usize, 2.5 / 3600.0);
1030 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1031 let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
1032 let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
1033
1034 let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
1035
1036 let plans = FftPlans::<f32>::new(n, n);
1037 let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
1038
1039 for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
1040 assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
1041 }
1042 }
1043
1044 #[test]
1046 fn test_gaussft_dc_equals_ratio() {
1047 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
1048 let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
1049 let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
1050 assert!(
1051 (g[0] - ratio).abs() < 1e-12,
1052 "DC {} != ratio {}",
1053 g[0],
1054 ratio
1055 );
1056 assert!(ratio > 1.0, "larger target beam should have ratio > 1");
1057 }
1058}