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}