1use 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
40pub trait FftFloat: FftNum + Float + sealed::Sealed {}
42impl FftFloat for f32 {}
43impl FftFloat for f64 {}
44
45pub(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 pub image: Array2<T>,
60 pub scaling_factor: f64,
62}
63
64pub 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 pub fn dim(&self) -> (usize, usize) {
95 (self.nrows, self.ncols)
96 }
97}
98
99pub 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
142pub(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 let grid = UvGrid::new(
197 dim,
198 dy_deg.abs().to_radians(),
199 dx_deg.abs().to_radians(),
200 &taper,
201 );
202 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
222fn 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
248fn 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 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#[derive(Clone, Copy, Debug)]
274struct UvTaper {
275 a: f64,
276 b: f64,
277 c: f64,
278 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 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
316pub 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
333pub 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
355const COL_BLOCK: usize = 16;
358
359struct UvGrid {
361 u: Vec<f64>,
362 au2: Vec<f64>,
363 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
384struct 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 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 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
431fn 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 let parallel_rows = rayon::current_thread_index().is_none();
450
451 if parallel_rows {
452 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
474fn 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
495fn 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
524fn 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
539fn 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 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#[derive(Clone, Copy)]
571struct SpectrumPtr<T>(*mut Complex<T>);
572unsafe impl<T: Send> Send for SpectrumPtr<T> {}
575unsafe impl<T: Send> Sync for SpectrumPtr<T> {}
576
577impl<T> SpectrumPtr<T> {
578 fn get(self) -> *mut Complex<T> {
581 self.0
582 }
583}
584
585fn 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 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 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 #[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 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; }
770 img
771 }
772
773 #[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 #[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 #[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(); 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}