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