1use std::sync::Arc;
15
16use ndarray::Array2;
17use num_traits::{Float, cast};
18use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
19use rustfft::{Fft, FftNum, FftPlanner, num_complex::Complex};
20use thiserror::Error;
21
22use crate::beam::{Beam, gauss_factor};
23
24#[derive(Debug, Error)]
25pub enum ConvolveError {
26 #[error("image is entirely NaN")]
27 AllNaN,
28 #[error("beam larger than cutoff — image blanked")]
29 AboveCutoff,
30}
31
32pub trait FftFloat: FftNum + Float {}
38impl FftFloat for f32 {}
39impl FftFloat for f64 {}
40
41pub(crate) fn cast_saturating<T: FftFloat>(value: f64) -> T {
49 cast::<f64, T>(value).unwrap_or_else(|| {
50 if value.is_sign_negative() {
51 T::neg_infinity()
52 } else {
53 T::infinity()
54 }
55 })
56}
57
58pub struct ConvolutionResult<T = f32> {
59 pub image: Array2<T>,
61 pub scaling_factor: f64,
63}
64
65pub struct FftPlans<T: FftNum = f32> {
74 nrows: usize,
75 ncols: usize,
76 nhalf: usize,
77 r2c: Arc<dyn RealToComplex<T>>,
78 c2r: Arc<dyn ComplexToReal<T>>,
79 col_fwd: Arc<dyn Fft<T>>,
80 col_inv: Arc<dyn Fft<T>>,
81}
82
83impl<T: FftNum> FftPlans<T> {
84 pub fn new(nrows: usize, ncols: usize) -> Self {
87 let mut rplanner = RealFftPlanner::<T>::new();
88 let r2c = rplanner.plan_fft_forward(ncols);
89 let c2r = rplanner.plan_fft_inverse(ncols);
90
91 let mut cplanner = FftPlanner::<T>::new();
92 let col_fwd = cplanner.plan_fft_forward(nrows);
93 let col_inv = cplanner.plan_fft_inverse(nrows);
94
95 Self {
96 nrows,
97 ncols,
98 nhalf: ncols / 2 + 1,
99 r2c,
100 c2r,
101 col_fwd,
102 col_inv,
103 }
104 }
105
106 pub fn dim(&self) -> (usize, usize) {
108 (self.nrows, self.ncols)
109 }
110}
111
112pub fn convolve_uv<T: FftFloat>(
142 image: &Array2<T>,
143 old_beam: &Beam,
144 new_beam: &Beam,
145 dx_deg: f64,
146 dy_deg: f64,
147 cutoff_arcsec: Option<f64>,
148) -> Result<ConvolutionResult<T>, ConvolveError> {
149 let (nrows, ncols) = image.dim();
150 let plans = FftPlans::<T>::new(nrows, ncols);
151 convolve_uv_with_plans(
152 image,
153 old_beam,
154 new_beam,
155 dx_deg,
156 dy_deg,
157 cutoff_arcsec,
158 &plans,
159 )
160}
161
162pub fn convolve_uv_with_plans<T: FftFloat>(
165 image: &Array2<T>,
166 old_beam: &Beam,
167 new_beam: &Beam,
168 dx_deg: f64,
169 dy_deg: f64,
170 cutoff_arcsec: Option<f64>,
171 plans: &FftPlans<T>,
172) -> Result<ConvolutionResult<T>, ConvolveError> {
173 if let Some(cutoff) = cutoff_arcsec
175 && old_beam.major_arcsec() > cutoff
176 {
177 return Err(ConvolveError::AboveCutoff);
178 }
179
180 if old_beam.approx_eq(new_beam) {
182 return Ok(ConvolutionResult {
183 image: image.clone(),
184 scaling_factor: 1.0,
185 });
186 }
187
188 let conv_beam = new_beam.deconvolve_or_zero(old_beam);
190 let (fac, ..) = gauss_factor(
191 &conv_beam,
192 old_beam,
193 dx_deg.abs() * 3600.0,
194 dy_deg.abs() * 3600.0,
195 );
196
197 let (nrows, ncols) = image.dim();
198 assert_eq!(
199 plans.dim(),
200 (nrows, ncols),
201 "FftPlans built for {:?} but image is {:?}",
202 plans.dim(),
203 (nrows, ncols)
204 );
205
206 let mut clean_image: Vec<T> = Vec::with_capacity(nrows * ncols);
209 let mut nan_count = 0usize;
210 for &x in image.iter() {
211 if x.is_nan() {
212 nan_count += 1;
213 clean_image.push(T::zero());
214 } else {
215 clean_image.push(x);
216 }
217 }
218
219 if nan_count == nrows * ncols {
221 return Ok(ConvolutionResult {
222 image: image.clone(),
223 scaling_factor: fac,
224 });
225 }
226
227 let nan_mask: Option<Vec<T>> = if nan_count > 0 {
228 Some(
229 image
230 .iter()
231 .map(|&x| if x.is_nan() { T::one() } else { T::zero() })
232 .collect(),
233 )
234 } else {
235 None
236 };
237
238 let nhalf = ncols / 2 + 1;
245 let dx_rad = dx_deg.to_radians();
246 let dy_rad = dy_deg.to_radians();
247 let u_freqs = fftfreq(nrows, dx_rad); let v_freqs_full = fftfreq(ncols, dy_rad);
249 let v_freqs = &v_freqs_full[..nhalf]; let (g_final, g_ratio) = gaussft(old_beam, new_beam, &u_freqs, v_freqs);
255 let g_t: Vec<T> = g_final.iter().map(|&g| cast_saturating::<T>(g)).collect();
256
257 let mut im_f = rfft2(plans, &clean_image);
259 for (s, &g) in im_f.iter_mut().zip(g_t.iter()) {
260 *s = s.scale(g);
261 }
262 let im_conv_flat = irfft2(plans, im_f);
263
264 let out_flat: Vec<T> = if let Some(mask) = nan_mask {
266 let mut mask_f = rfft2(plans, &mask);
267 for (s, &g) in mask_f.iter_mut().zip(g_t.iter()) {
268 *s = s.scale(g);
269 }
270 let mask_conv = irfft2(plans, mask_f);
271 let blank_threshold = T::one() - cast_saturating::<T>(1e-2);
277 im_conv_flat
278 .iter()
279 .zip(mask_conv.iter())
280 .map(|(&v, &m)| if m >= blank_threshold { T::nan() } else { v })
281 .collect()
282 } else {
283 im_conv_flat
284 };
285
286 let out = Array2::from_shape_vec((nrows, ncols), out_flat)
287 .expect("shape mismatch in convolve_uv output");
288
289 Ok(ConvolutionResult {
290 image: out,
291 scaling_factor: g_ratio,
292 })
293}
294
295pub fn gaussft(
304 old_beam: &Beam,
305 new_beam: &Beam,
306 u_freqs: &[f64],
307 v_freqs: &[f64],
308) -> (Vec<f64>, f64) {
309 let deg2rad = std::f64::consts::PI / 180.0;
310 let two_ln2 = 2.0 * 2_f64.ln();
311 let fwhm_to_sigma = 2.0 * two_ln2.sqrt(); let bmaj_rad = new_beam.major_deg * deg2rad;
315 let bmin_rad = new_beam.minor_deg * deg2rad;
316 let bpa_rad = new_beam.pa_deg * deg2rad;
317 let sx = bmaj_rad / fwhm_to_sigma;
318 let sy = bmin_rad / fwhm_to_sigma;
319
320 let bmaj_in_rad = old_beam.major_deg * deg2rad;
322 let bmin_in_rad = old_beam.minor_deg * deg2rad;
323 let bpa_in_rad = old_beam.pa_deg * deg2rad;
324 let sx_in = bmaj_in_rad / fwhm_to_sigma;
325 let sy_in = bmin_in_rad / fwhm_to_sigma;
326
327 let g_amp = (2.0 * std::f64::consts::PI * sx * sy).sqrt();
329 let dg_amp = (2.0 * std::f64::consts::PI * sx_in * sy_in).sqrt();
330 let g_ratio = g_amp / dg_amp;
331
332 let pi2 = std::f64::consts::PI * std::f64::consts::PI;
333 let nrows = u_freqs.len();
334 let ncols = v_freqs.len();
335 let mut g_final = vec![0.0_f64; nrows * ncols];
336
337 let u_cos = u_freqs
339 .iter()
340 .map(|&u| u * bpa_rad.cos())
341 .collect::<Vec<_>>();
342 let u_sin = u_freqs
343 .iter()
344 .map(|&u| u * bpa_rad.sin())
345 .collect::<Vec<_>>();
346 let v_cos = v_freqs
347 .iter()
348 .map(|&v| v * bpa_rad.cos())
349 .collect::<Vec<_>>();
350 let v_sin = v_freqs
351 .iter()
352 .map(|&v| v * bpa_rad.sin())
353 .collect::<Vec<_>>();
354
355 let u_cos_in = u_freqs
357 .iter()
358 .map(|&u| u * bpa_in_rad.cos())
359 .collect::<Vec<_>>();
360 let u_sin_in = u_freqs
361 .iter()
362 .map(|&u| u * bpa_in_rad.sin())
363 .collect::<Vec<_>>();
364 let v_cos_in = v_freqs
365 .iter()
366 .map(|&v| v * bpa_in_rad.cos())
367 .collect::<Vec<_>>();
368 let v_sin_in = v_freqs
369 .iter()
370 .map(|&v| v * bpa_in_rad.sin())
371 .collect::<Vec<_>>();
372
373 for i in 0..nrows {
374 for j in 0..ncols {
375 let ur = u_cos[i] - v_sin[j];
377 let vr = u_sin[i] + v_cos[j];
378 let ur_in = u_cos_in[i] - v_sin_in[j];
380 let vr_in = u_sin_in[i] + v_cos_in[j];
381
382 let g_arg = -2.0 * pi2 * ((sx * ur).powi(2) + (sy * vr).powi(2));
383 let dg_arg = -2.0 * pi2 * ((sx_in * ur_in).powi(2) + (sy_in * vr_in).powi(2));
384
385 g_final[i * ncols + j] = g_ratio * (g_arg - dg_arg).exp();
386 }
387 }
388
389 (g_final, g_ratio)
390}
391
392pub fn fftfreq(n: usize, d: f64) -> Vec<f64> {
407 let val = 1.0 / (n as f64 * d);
408 let m = n.div_ceil(2); let mut freqs = vec![0.0_f64; n];
410 for (i, freq) in freqs.iter_mut().enumerate().take(m) {
411 *freq = i as f64 * val;
412 }
413 for (i, freq) in freqs.iter_mut().enumerate().take(n).skip(m) {
414 *freq = (i as f64 - n as f64) * val;
415 }
416 freqs
417}
418
419fn rfft2<T: FftFloat>(plans: &FftPlans<T>, data: &[T]) -> Vec<Complex<T>> {
427 let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
428 let zero = Complex::new(T::zero(), T::zero());
429
430 let mut scratch = plans.r2c.make_scratch_vec();
432 let mut inrow = plans.r2c.make_input_vec();
433 let mut spectrum = vec![zero; nrows * nhalf];
434 for (i, chunk) in data.chunks(ncols).enumerate() {
435 inrow.copy_from_slice(chunk);
436 plans
437 .r2c
438 .process_with_scratch(
439 &mut inrow,
440 &mut spectrum[i * nhalf..(i + 1) * nhalf],
441 &mut scratch,
442 )
443 .expect("r2c FFT");
444 }
445
446 let mut col_scratch = vec![zero; plans.col_fwd.get_inplace_scratch_len()];
449 let mut col_buf = vec![zero; nrows];
450 for j in 0..nhalf {
451 for i in 0..nrows {
452 col_buf[i] = spectrum[i * nhalf + j];
453 }
454 plans
455 .col_fwd
456 .process_with_scratch(&mut col_buf, &mut col_scratch);
457 for i in 0..nrows {
458 spectrum[i * nhalf + j] = col_buf[i];
459 }
460 }
461
462 spectrum
463}
464
465fn irfft2<T: FftFloat>(plans: &FftPlans<T>, mut spectrum: Vec<Complex<T>>) -> Vec<T> {
468 let (nrows, ncols, nhalf) = (plans.nrows, plans.ncols, plans.nhalf);
469 let zero = Complex::new(T::zero(), T::zero());
470
471 let mut col_scratch = vec![zero; plans.col_inv.get_inplace_scratch_len()];
473 let mut col_buf = vec![zero; nrows];
474 for j in 0..nhalf {
475 for i in 0..nrows {
476 col_buf[i] = spectrum[i * nhalf + j];
477 }
478 plans
479 .col_inv
480 .process_with_scratch(&mut col_buf, &mut col_scratch);
481 for i in 0..nrows {
482 spectrum[i * nhalf + j] = col_buf[i];
483 }
484 }
485
486 let mut scratch = plans.c2r.make_scratch_vec();
488 let mut inrow = plans.c2r.make_input_vec();
489 let mut out = vec![T::zero(); nrows * ncols];
490 let even = ncols.is_multiple_of(2);
491 for i in 0..nrows {
492 inrow.copy_from_slice(&spectrum[i * nhalf..(i + 1) * nhalf]);
493 inrow[0].im = T::zero();
496 if even {
497 inrow[nhalf - 1].im = T::zero();
498 }
499 plans
500 .c2r
501 .process_with_scratch(
502 &mut inrow,
503 &mut out[i * ncols..(i + 1) * ncols],
504 &mut scratch,
505 )
506 .expect("c2r FFT");
507 }
508
509 let norm = cast::<usize, T>(nrows * ncols).expect("size out of range");
510 for v in out.iter_mut() {
511 *v = *v / norm;
512 }
513 out
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use ndarray::Array2;
520
521 #[test]
522 fn test_fftfreq() {
523 let f = fftfreq(4, 1.0);
525 let expected = [0.0, 0.25, -0.5, -0.25];
526 for (a, b) in f.iter().zip(expected.iter()) {
527 assert!((a - b).abs() < 1e-12, "got {a}, want {b}");
528 }
529 }
530
531 #[test]
532 fn test_rfft2_irfft2_roundtrip() {
533 let data = vec![
535 1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
536 16.0,
537 ];
538 let (nrows, ncols) = (4, 4);
539 let plans = FftPlans::<f64>::new(nrows, ncols);
540 let spectrum = rfft2(&plans, &data);
541 let recovered = irfft2(&plans, spectrum);
542 for (a, b) in data.iter().zip(recovered.iter()) {
543 assert!((a - b).abs() < 1e-10, "roundtrip failed: {a} vs {b}");
544 }
545 }
546
547 #[test]
548 fn test_rfft2_irfft2_roundtrip_f32() {
549 let data: Vec<f32> = (1..=16).map(|x| x as f32).collect();
550 let (nrows, ncols) = (4, 4);
551 let plans = FftPlans::<f32>::new(nrows, ncols);
552 let spectrum = rfft2(&plans, &data);
553 let recovered = irfft2(&plans, spectrum);
554 for (a, b) in data.iter().zip(recovered.iter()) {
555 assert!((a - b).abs() < 1e-3, "f32 roundtrip failed: {a} vs {b}");
556 }
557 }
558
559 #[test]
560 fn test_convolve_uv_no_change_when_beams_equal() {
561 let beam = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
562 let img = Array2::from_elem((16, 16), 1.0_f32);
563 let result = convolve_uv(&img, &beam, &beam, 2.5 / 3600.0, 2.5 / 3600.0, None).unwrap();
564 assert!((result.scaling_factor - 1.0).abs() < 1e-10);
565 }
566
567 #[test]
572 fn test_convolve_uv_point_source_flux_and_peak() {
573 let (n, dx) = (64usize, 2.0 / 3600.0);
574 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
575 let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
576
577 let mut img = Array2::<f64>::zeros((n, n));
578 img[(n / 2, n / 2)] = 1.0;
579
580 let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
581 let total: f64 = res.image.iter().sum();
582
583 assert!(
586 (total - res.scaling_factor).abs() < 1e-6,
587 "integral {total} != DC gain {}",
588 res.scaling_factor
589 );
590
591 let peak = res.image[(n / 2, n / 2)];
593 assert!(peak > 0.0);
594 for &v in res.image.iter() {
595 assert!(v <= peak + 1e-9, "pixel {v} exceeds peak {peak}");
596 }
597 }
598
599 #[test]
602 fn test_convolve_uv_f32_matches_f64() {
603 let (n, dx) = (48usize, 2.5 / 3600.0);
604 let old = Beam::from_arcsec(8.0, 6.0, 20.0).unwrap();
605 let new = Beam::from_arcsec(15.0, 12.0, 20.0).unwrap();
606
607 let img64 =
608 Array2::<f64>::from_shape_fn((n, n), |(i, j)| ((i * 7 + j * 3) % 11) as f64 / 11.0);
609 let img32 = img64.mapv(|x| x as f32);
610
611 let r64 = convolve_uv(&img64, &old, &new, dx, dx, None).unwrap();
612 let r32 = convolve_uv(&img32, &old, &new, dx, dx, None).unwrap();
613
614 for (a, b) in r64.image.iter().zip(r32.image.iter()) {
615 assert!(
616 (*a - *b as f64).abs() < 1e-4,
617 "f32/f64 mismatch: {a} vs {b}"
618 );
619 }
620 }
621
622 #[test]
627 fn test_convolve_uv_propagates_nans() {
628 let (n, dx) = (48usize, 2.5 / 3600.0);
629 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
630 let new = Beam::from_arcsec(12.0, 12.0, 0.0).unwrap();
631
632 let mut img = Array2::<f32>::from_elem((n, n), 1.0);
633 for i in 0..12 {
635 for j in 0..12 {
636 img[(i, j)] = f32::NAN;
637 }
638 }
639
640 let res = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
641 assert!(res.image[(3, 3)].is_nan(), "block interior should stay NaN");
643 assert!(res.image[(n - 1, n - 1)].is_finite());
645 }
646
647 #[test]
650 fn test_with_plans_matches_per_call() {
651 let (n, dx) = (32usize, 2.5 / 3600.0);
652 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
653 let new = Beam::from_arcsec(11.0, 9.0, 15.0).unwrap();
654 let img = Array2::<f32>::from_shape_fn((n, n), |(i, j)| (i + 2 * j) as f32);
655
656 let per_call = convolve_uv(&img, &old, &new, dx, dx, None).unwrap();
657
658 let plans = FftPlans::<f32>::new(n, n);
659 let reused = convolve_uv_with_plans(&img, &old, &new, dx, dx, None, &plans).unwrap();
660
661 for (a, b) in per_call.image.iter().zip(reused.image.iter()) {
662 assert_eq!(a.to_bits(), b.to_bits(), "plan reuse changed output");
663 }
664 }
665
666 #[test]
668 fn test_gaussft_dc_equals_ratio() {
669 let old = Beam::from_arcsec(6.0, 6.0, 0.0).unwrap();
670 let new = Beam::from_arcsec(12.0, 10.0, 30.0).unwrap();
671 let (g, ratio) = gaussft(&old, &new, &[0.0], &[0.0]);
672 assert!(
673 (g[0] - ratio).abs() < 1e-12,
674 "DC {} != ratio {}",
675 g[0],
676 ratio
677 );
678 assert!(ratio > 1.0, "larger target beam should have ratio > 1");
679 }
680}