Skip to main content

convolve_rs/
smooth.rs

1//! High-level smoothing: convolve + apply Jy/beam flux scaling.
2use ndarray::Array2;
3use thiserror::Error;
4
5use crate::beam::Beam;
6use crate::convolve_uv::{
7    ConvolveError, FftFloat, FftPlans, cast_saturating, convolve_uv_with_plans,
8};
9
10#[derive(Debug, Error)]
11pub enum SmoothError {
12    #[error("convolution failed: {0}")]
13    Convolve(#[from] ConvolveError),
14}
15
16/// Brightness unit of an image, determining whether flux scaling applies after
17/// convolution to a larger beam.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum BrightnessUnit {
20    /// Jy/beam (or any per-beam flux density) — apply the Gaussian flux-scaling
21    /// factor so the output stays in the same units.
22    #[default]
23    JyPerBeam,
24    /// Kelvin (brightness temperature) — surface brightness is conserved under
25    /// convolution, so the scaling factor is 1.
26    Kelvin,
27}
28
29impl BrightnessUnit {
30    /// Classify a FITS `BUNIT` string, returning `None` if the unit is not
31    /// recognised (neither a Kelvin nor a Jy/beam form).
32    ///
33    /// # Examples
34    ///
35    /// ```
36    /// use convolve_rs::BrightnessUnit;
37    ///
38    /// assert_eq!(BrightnessUnit::parse("Jy/beam"), Some(BrightnessUnit::JyPerBeam));
39    /// assert_eq!(BrightnessUnit::parse(" K "), Some(BrightnessUnit::Kelvin));
40    /// assert_eq!(BrightnessUnit::parse("Jy/pixel"), None);
41    /// ```
42    pub fn parse(bunit: &str) -> Option<Self> {
43        let u = bunit.trim().trim_matches('\'').trim().to_ascii_uppercase();
44        match u.as_str() {
45            "K" | "KELVIN" => Some(BrightnessUnit::Kelvin),
46            "JY/BEAM" | "JY BEAM-1" | "JY/BM" | "JYBEAM" => Some(BrightnessUnit::JyPerBeam),
47            _ => None,
48        }
49    }
50
51    /// Classify a FITS `BUNIT` string.  Anything recognised as a brightness
52    /// temperature (Kelvin) skips flux scaling; recognised Jy/beam forms get the
53    /// Gaussian factor.  Unrecognised units cannot be determined automatically:
54    /// a warning is emitted and Jy/beam is assumed.
55    pub fn from_bunit(bunit: &str) -> Self {
56        match Self::parse(bunit) {
57            Some(unit) => unit,
58            None => {
59                tracing::warn!(
60                    "Could not determine brightness unit from BUNIT={bunit:?}; \
61                     assuming Jy/beam (flux scaling applied). Pass a recognised \
62                     unit (e.g. 'Jy/beam' or 'K') to silence this warning."
63                );
64                BrightnessUnit::JyPerBeam
65            }
66        }
67    }
68}
69
70/// Smooth `image` from `old_beam` to `new_beam`.
71///
72/// `dx_deg` / `dy_deg` are pixel sizes in degrees.  `unit` selects the flux
73/// scaling: [`BrightnessUnit::JyPerBeam`] applies the Gaussian factor,
74/// [`BrightnessUnit::Kelvin`] leaves the data unscaled (factor 1).  The
75/// convolution runs in the image's element type `T` (`f32` or `f64`); the
76/// result has the same dtype and pixel shape, ready to write back to FITS.
77///
78/// # Examples
79///
80/// Smoothing a flat image from a 10″ to a 20″ circular beam:
81///
82/// ```
83/// use convolve_rs::{Beam, BrightnessUnit, smooth};
84/// use ndarray::Array2;
85///
86/// let old = Beam::from_arcsec(10.0, 10.0, 0.0)?;
87/// let new = Beam::from_arcsec(20.0, 20.0, 0.0)?;
88/// let image = Array2::<f32>::from_elem((64, 64), 1.0);
89/// let dx = 2.5 / 3600.0;
90///
91/// // Jy/beam: pixel values scale by the beam-area ratio Ω_new/Ω_old = 4.
92/// let jy = smooth(&image, &old, &new, dx, dx, None, BrightnessUnit::JyPerBeam)?;
93/// assert!((jy[(32, 32)] - 4.0).abs() < 1e-3);
94///
95/// // Kelvin: surface brightness is conserved, so a flat image stays at 1.
96/// let k = smooth(&image, &old, &new, dx, dx, None, BrightnessUnit::Kelvin)?;
97/// assert!((k[(32, 32)] - 1.0).abs() < 1e-3);
98/// # Ok::<(), Box<dyn std::error::Error>>(())
99/// ```
100pub fn smooth<T: FftFloat>(
101    image: &Array2<T>,
102    old_beam: &Beam,
103    new_beam: &Beam,
104    dx_deg: f64,
105    dy_deg: f64,
106    cutoff_arcsec: Option<f64>,
107    unit: BrightnessUnit,
108) -> Result<Array2<T>, SmoothError> {
109    let (nrows, ncols) = image.dim();
110    let plans = FftPlans::<T>::new(nrows, ncols);
111    smooth_with_plans(
112        image,
113        old_beam,
114        new_beam,
115        dx_deg,
116        dy_deg,
117        cutoff_arcsec,
118        unit,
119        &plans,
120    )
121}
122
123/// Like [`smooth`], but reuses pre-built [`FftPlans`] across calls (e.g. every
124/// channel of a cube), avoiding per-call FFT planning. `plans` must match the
125/// image dimensions.
126#[allow(clippy::too_many_arguments)]
127pub fn smooth_with_plans<T: FftFloat>(
128    image: &Array2<T>,
129    old_beam: &Beam,
130    new_beam: &Beam,
131    dx_deg: f64,
132    dy_deg: f64,
133    cutoff_arcsec: Option<f64>,
134    unit: BrightnessUnit,
135    plans: &FftPlans<T>,
136) -> Result<Array2<T>, SmoothError> {
137    let result = convolve_uv_with_plans(
138        image,
139        old_beam,
140        new_beam,
141        dx_deg,
142        dy_deg,
143        cutoff_arcsec,
144        plans,
145    )?;
146    // `convolve_uv` already bakes one g_ratio (= √(Ω_new/Ω_old)) into the image.
147    // Jy/beam needs the full beam-area ratio Ω_new/Ω_old = g_ratio², so multiply
148    // by g_ratio once more. Kelvin conserves surface brightness, so the image
149    // must be flux-normalised — divide the baked-in g_ratio back out.
150    let factor = match unit {
151        BrightnessUnit::JyPerBeam => result.scaling_factor,
152        BrightnessUnit::Kelvin => 1.0 / result.scaling_factor,
153    };
154    let factor_t = cast_saturating::<T>(factor);
155    let scaled = result.image.mapv(|x| factor_t * x);
156    Ok(scaled)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use ndarray::Array2;
163
164    #[test]
165    fn test_parse_recognised() {
166        assert_eq!(BrightnessUnit::parse("K"), Some(BrightnessUnit::Kelvin));
167        assert_eq!(BrightnessUnit::parse(" k "), Some(BrightnessUnit::Kelvin));
168        assert_eq!(BrightnessUnit::parse("'K'"), Some(BrightnessUnit::Kelvin));
169        assert_eq!(
170            BrightnessUnit::parse("Kelvin"),
171            Some(BrightnessUnit::Kelvin)
172        );
173        assert_eq!(
174            BrightnessUnit::parse("Jy/beam"),
175            Some(BrightnessUnit::JyPerBeam)
176        );
177        assert_eq!(
178            BrightnessUnit::parse("JY BEAM-1"),
179            Some(BrightnessUnit::JyPerBeam)
180        );
181    }
182
183    #[test]
184    fn test_parse_unrecognised_is_none() {
185        // Unknown / ambiguous units cannot be determined automatically.
186        assert_eq!(BrightnessUnit::parse(""), None);
187        assert_eq!(BrightnessUnit::parse("Jy/pixel"), None);
188        assert_eq!(BrightnessUnit::parse("mJy"), None);
189    }
190
191    #[test]
192    fn test_from_bunit_falls_back_to_jy_per_beam() {
193        // Recognised forms classify directly; unrecognised forms warn and
194        // assume Jy/beam.
195        assert_eq!(BrightnessUnit::from_bunit("K"), BrightnessUnit::Kelvin);
196        assert_eq!(
197            BrightnessUnit::from_bunit("Jy/beam"),
198            BrightnessUnit::JyPerBeam
199        );
200        assert_eq!(BrightnessUnit::from_bunit("wat"), BrightnessUnit::JyPerBeam);
201    }
202
203    #[test]
204    fn test_kelvin_skips_flux_scaling() {
205        let old = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
206        let new = Beam::new(20.0 / 3600.0, 20.0 / 3600.0, 0.0).unwrap();
207        let img = Array2::from_elem((32, 32), 1.0_f32);
208        let dx = 2.5 / 3600.0;
209
210        let jy = smooth(&img, &old, &new, dx, dx, None, BrightnessUnit::JyPerBeam).unwrap();
211        let k = smooth(&img, &old, &new, dx, dx, None, BrightnessUnit::Kelvin).unwrap();
212
213        // Jy/beam scales flux up (Ω_new/Ω_old = 4); Kelvin leaves it unscaled.
214        let center = (16, 16);
215        assert!(jy[center] > k[center] * 1.5, "Jy/beam should be scaled up");
216        // Kelvin output is the pure convolution: a flat image stays ~1.
217        assert!(
218            (k[center] - 1.0).abs() < 1e-3,
219            "Kelvin center = {}",
220            k[center]
221        );
222    }
223}