Skip to main content

convolve_rs/
beam.rs

1//! Elliptical Gaussian beams and their algebra.
2//!
3//! A beam is handled through its 2×2 covariance matrix: convolution adds
4//! matrices, deconvolution subtracts them, and the axes and position angle are
5//! the eigen-pairs (Wild 1970, *Aust. J. Phys.* 23, 113).
6use std::fmt;
7
8use thiserror::Error;
9
10/// A 2D elliptical Gaussian beam in FITS conventions: FWHM axes in degrees
11/// (BMAJ, BMIN) and position angle in degrees East of North (BPA).
12///
13/// ```
14/// use convolve_rs::Beam;
15///
16/// let beam = Beam::from_arcsec(20.0, 10.0, 45.0)?;
17/// assert_eq!(beam.major_arcsec(), 20.0);
18/// assert_eq!(beam.major_deg(), 20.0 / 3600.0);
19/// # Ok::<(), convolve_rs::BeamError>(())
20/// ```
21#[derive(Debug, Clone, Copy)]
22pub struct Beam {
23    major_deg: f64,
24    minor_deg: f64,
25    pa_deg: f64,
26}
27
28#[derive(Debug, Error)]
29pub enum BeamError {
30    #[error("beam could not be deconvolved: source beam is smaller than the PSF")]
31    DeconvolveFailed,
32    #[error("invalid beam: minor axis ({minor}) > major axis ({major})")]
33    InvalidAxes { major: f64, minor: f64 },
34    #[error("invalid beam: negative axis ({minor})")]
35    NegativeAxis { minor: f64 },
36    #[error("beam is not finite (NaN or infinite values)")]
37    NotFinite,
38}
39
40/// Covariance `[[xx, xy], [xy, yy]]` of a Gaussian in (East, North) axes, in
41/// the square of the axis unit.
42#[derive(Debug, Clone, Copy)]
43struct Cov {
44    xx: f64,
45    yy: f64,
46    xy: f64,
47}
48
49impl Cov {
50    /// The major axis points along `(sin θ, cos θ)`.
51    fn from_axes(major: f64, minor: f64, pa_rad: f64) -> Self {
52        let (sin, cos) = pa_rad.sin_cos();
53        let a2 = major * major;
54        let b2 = minor * minor;
55        Self {
56            xx: a2 * sin * sin + b2 * cos * cos,
57            yy: a2 * cos * cos + b2 * sin * sin,
58            xy: (a2 - b2) * sin * cos,
59        }
60    }
61
62    fn add(&self, other: &Cov) -> Cov {
63        Cov {
64            xx: self.xx + other.xx,
65            yy: self.yy + other.yy,
66            xy: self.xy + other.xy,
67        }
68    }
69
70    fn sub(&self, other: &Cov) -> Cov {
71        Cov {
72            xx: self.xx - other.xx,
73            yy: self.yy - other.yy,
74            xy: self.xy - other.xy,
75        }
76    }
77
78    fn det(&self) -> f64 {
79        self.xx * self.yy - self.xy * self.xy
80    }
81
82    /// `(larger, smaller)`: the squared major and minor axes.
83    fn eigenvalues(&self) -> (f64, f64) {
84        let mean = 0.5 * (self.xx + self.yy);
85        let radius = 0.5 * (self.xx - self.yy).hypot(2.0 * self.xy);
86        (mean + radius, mean - radius)
87    }
88
89    /// PA of the major axis in radians, in `(−π/2, π/2]`; 0 when circular.
90    fn position_angle(&self) -> f64 {
91        use std::f64::consts::{FRAC_PI_2, PI};
92        let diff = self.xx - self.yy;
93        let two_xy = 2.0 * self.xy;
94        if diff.hypot(two_xy) <= f64::EPSILON * (self.xx + self.yy).abs() {
95            return 0.0;
96        }
97        // atan2 gives the angle from East; PA is measured from North.
98        let mut pa = FRAC_PI_2 - 0.5 * two_xy.atan2(diff);
99        if pa > FRAC_PI_2 {
100            pa -= PI;
101        }
102        pa
103    }
104
105    fn to_beam(self) -> Beam {
106        let (lam_major, lam_minor) = self.eigenvalues();
107        Beam {
108            major_deg: lam_major.max(0.0).sqrt(),
109            minor_deg: lam_minor.max(0.0).sqrt(),
110            pa_deg: self.position_angle().to_degrees(),
111        }
112    }
113}
114
115impl Beam {
116    /// A beam from FWHM axes in degrees and a PA in degrees East of North.
117    ///
118    /// # Errors
119    ///
120    /// If a value is not finite, an axis is negative, or `minor_deg > major_deg`.
121    ///
122    /// ```
123    /// use convolve_rs::Beam;
124    ///
125    /// let beam = Beam::new(20.0 / 3600.0, 10.0 / 3600.0, 45.0)?;
126    /// assert_eq!(beam.pa_deg(), 45.0);
127    /// assert!(Beam::new(10.0 / 3600.0, 20.0 / 3600.0, 0.0).is_err());
128    /// # Ok::<(), convolve_rs::BeamError>(())
129    /// ```
130    pub fn new(major_deg: f64, minor_deg: f64, pa_deg: f64) -> Result<Self, BeamError> {
131        if !major_deg.is_finite() || !minor_deg.is_finite() || !pa_deg.is_finite() {
132            return Err(BeamError::NotFinite);
133        }
134        if minor_deg > major_deg {
135            return Err(BeamError::InvalidAxes {
136                major: major_deg,
137                minor: minor_deg,
138            });
139        }
140        if minor_deg < 0.0 {
141            return Err(BeamError::NegativeAxis { minor: minor_deg });
142        }
143        Ok(Self {
144            major_deg,
145            minor_deg,
146            pa_deg,
147        })
148    }
149
150    /// A beam from FWHM axes in arcseconds and a PA in degrees East of North.
151    ///
152    /// ```
153    /// use convolve_rs::Beam;
154    ///
155    /// let beam = Beam::from_arcsec(20.0, 10.0, 45.0)?;
156    /// assert_eq!(beam.minor_deg(), 10.0 / 3600.0);
157    /// # Ok::<(), convolve_rs::BeamError>(())
158    /// ```
159    pub fn from_arcsec(
160        major_arcsec: f64,
161        minor_arcsec: f64,
162        pa_deg: f64,
163    ) -> Result<Self, BeamError> {
164        Self::new(major_arcsec / 3600.0, minor_arcsec / 3600.0, pa_deg)
165    }
166
167    /// The zero-size beam, used for masked channels.
168    pub fn zero() -> Self {
169        Self {
170            major_deg: 0.0,
171            minor_deg: 0.0,
172            pa_deg: 0.0,
173        }
174    }
175
176    pub fn major_deg(&self) -> f64 {
177        self.major_deg
178    }
179
180    pub fn minor_deg(&self) -> f64 {
181        self.minor_deg
182    }
183
184    pub fn pa_deg(&self) -> f64 {
185        self.pa_deg
186    }
187
188    pub fn major_arcsec(&self) -> f64 {
189        self.major_deg * 3600.0
190    }
191
192    pub fn minor_arcsec(&self) -> f64 {
193        self.minor_deg * 3600.0
194    }
195
196    /// True if both axes are positive, so the beam can be convolved.
197    pub fn has_area(&self) -> bool {
198        self.minor_deg > 0.0
199    }
200
201    pub fn is_zero(&self) -> bool {
202        self.major_deg == 0.0 && self.minor_deg == 0.0
203    }
204
205    pub fn is_circular(&self, rtol: f64) -> bool {
206        if self.major_deg == 0.0 {
207            return true;
208        }
209        (self.major_deg - self.minor_deg) / self.major_deg <= rtol
210    }
211
212    /// Solid angle in steradians.
213    pub fn area_sr(&self) -> f64 {
214        let fwhm_to_area = 2.0 * std::f64::consts::PI / (8.0 * 2_f64.ln());
215        self.major_deg.to_radians() * self.minor_deg.to_radians() * fwhm_to_area
216    }
217
218    fn cov_deg(&self) -> Cov {
219        Cov::from_axes(self.major_deg, self.minor_deg, self.pa_deg.to_radians())
220    }
221
222    /// The beam that, convolved with `other`, gives `self`.
223    ///
224    /// # Errors
225    ///
226    /// [`BeamError::DeconvolveFailed`] if the result would not be a beam with
227    /// positive area, as in radio_beam.
228    ///
229    /// ```
230    /// use convolve_rs::Beam;
231    ///
232    /// let a = Beam::from_arcsec(10.0, 8.0, 30.0)?;
233    /// let b = Beam::from_arcsec(6.0, 5.0, 15.0)?;
234    /// let recovered = a.convolve(&b).deconvolve(&a)?;
235    /// assert!((recovered.major_arcsec() - b.major_arcsec()).abs() < 1e-6);
236    /// assert!((recovered.minor_arcsec() - b.minor_arcsec()).abs() < 1e-6);
237    /// assert!(b.deconvolve(&a).is_err());
238    /// # Ok::<(), convolve_rs::BeamError>(())
239    /// ```
240    pub fn deconvolve(&self, other: &Beam) -> Result<Beam, BeamError> {
241        let residual = self.cov_deg().sub(&other.cov_deg());
242        let (_, lam_minor) = residual.eigenvalues();
243        // radio_beam's thresholds: an absolute epsilon on the diagonal, and a
244        // floor on the minor axis that rejects a point-like result.
245        let eps = f64::EPSILON;
246        let lam_floor = eps / (2.0 * 3600.0_f64.powi(2));
247        if residual.xx + eps < 0.0 || residual.yy + eps < 0.0 || lam_minor < lam_floor {
248            return Err(BeamError::DeconvolveFailed);
249        }
250        Ok(residual.to_beam())
251    }
252
253    /// Like [`Beam::deconvolve`], but a zero beam on failure.
254    pub fn deconvolve_or_zero(&self, other: &Beam) -> Beam {
255        self.deconvolve(other).unwrap_or(Beam::zero())
256    }
257
258    /// True if `other` can be convolved up to `self`.
259    ///
260    /// Unlike [`Beam::deconvolve`], this accepts a kernel that is a line or a
261    /// point (e.g. 20″×10″ inside 20″×20″), which the UV-plane filter handles.
262    ///
263    /// ```
264    /// use convolve_rs::Beam;
265    ///
266    /// let target = Beam::from_arcsec(20.0, 20.0, 0.0)?;
267    /// assert!(target.contains(&Beam::from_arcsec(20.0, 10.0, 0.0)?));
268    /// assert!(!target.contains(&Beam::from_arcsec(21.0, 10.0, 0.0)?));
269    /// # Ok::<(), convolve_rs::BeamError>(())
270    /// ```
271    pub fn contains(&self, other: &Beam) -> bool {
272        let own = self.cov_deg();
273        let residual = own.sub(&other.cov_deg());
274        let tol = 1e-12 * (own.xx + own.yy).abs();
275        let (_, lam_minor) = residual.eigenvalues();
276        residual.xx >= -tol && residual.yy >= -tol && lam_minor >= -tol
277    }
278
279    /// The convolution of two beams.
280    ///
281    /// ```
282    /// use convolve_rs::Beam;
283    ///
284    /// let a = Beam::from_arcsec(3.0, 3.0, 0.0)?;
285    /// let b = Beam::from_arcsec(4.0, 4.0, 0.0)?;
286    /// assert!((a.convolve(&b).major_arcsec() - 5.0).abs() < 1e-9);
287    /// # Ok::<(), convolve_rs::BeamError>(())
288    /// ```
289    pub fn convolve(&self, other: &Beam) -> Beam {
290        self.cov_deg().add(&other.cov_deg()).to_beam()
291    }
292
293    /// Equal axes to within 1e-10 degrees, and equal PA (mod 180°) unless the
294    /// beam is circular.
295    pub fn approx_eq(&self, other: &Beam) -> bool {
296        const ATOL: f64 = 1e-10;
297        let pa_eq = self.is_circular(1e-6)
298            || (self.pa_deg.rem_euclid(180.0) - other.pa_deg.rem_euclid(180.0)).abs() < ATOL;
299        (self.major_deg - other.major_deg).abs() < ATOL
300            && (self.minor_deg - other.minor_deg).abs() < ATOL
301            && pa_eq
302    }
303}
304
305impl fmt::Display for Beam {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        write!(
308            f,
309            "BMAJ={:.4}\" BMIN={:.4}\" BPA={:.2}°",
310            self.major_arcsec(),
311            self.minor_arcsec(),
312            self.pa_deg,
313        )
314    }
315}
316
317/// Factor that keeps a Jy/beam image in Jy/beam after convolving a map with
318/// beam `orig_beam` by the kernel `conv_beam`. Pixel sizes are in arcsec.
319///
320/// The kernel amplitude that preserves Jy/beam is
321/// `π/(4 ln 2) · √(det C_orig · det C_conv / det(C_orig + C_conv))`, and the
322/// factor is the pixel area over that amplitude.
323///
324/// ```
325/// use convolve_rs::{Beam, gauss_factor};
326///
327/// let orig = Beam::from_arcsec(10.0, 10.0, 0.0)?;
328/// let conv = Beam::from_arcsec(5.0, 5.0, 0.0)?;
329/// assert!(gauss_factor(&conv, &orig, 2.5, 2.5) > 0.0);
330/// # Ok::<(), convolve_rs::BeamError>(())
331/// ```
332pub fn gauss_factor(conv_beam: &Beam, orig_beam: &Beam, dx_arcsec: f64, dy_arcsec: f64) -> f64 {
333    let arcsec_cov =
334        |b: &Beam| Cov::from_axes(b.major_arcsec(), b.minor_arcsec(), b.pa_deg.to_radians());
335    let (c_orig, c_conv) = (arcsec_cov(orig_beam), arcsec_cov(conv_beam));
336    let amp = std::f64::consts::PI / (4.0 * 2_f64.ln())
337        * (c_orig.det() * c_conv.det() / c_orig.add(&c_conv).det()).sqrt();
338    dx_arcsec.abs() * dy_arcsec.abs() / amp
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_convolve_deconvolve_roundtrip() {
347        let a = Beam::from_arcsec(10.0, 8.0, 30.0).unwrap();
348        let b = Beam::from_arcsec(6.0, 5.0, 15.0).unwrap();
349        let recovered = a.convolve(&b).deconvolve(&a).unwrap();
350        assert!((recovered.major_deg - b.major_deg).abs() < 1e-9);
351        assert!((recovered.minor_deg - b.minor_deg).abs() < 1e-9);
352    }
353
354    #[test]
355    fn test_deconvolve_fails_when_psf_larger() {
356        let small = Beam::from_arcsec(5.0, 5.0, 0.0).unwrap();
357        let large = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
358        assert!(small.deconvolve(&large).is_err());
359    }
360
361    #[test]
362    fn test_gauss_factor_circular() {
363        let conv = Beam::from_arcsec(5.0, 5.0, 0.0).unwrap();
364        let orig = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
365        let fac = gauss_factor(&conv, &orig, 2.5, 2.5);
366        assert!(fac.is_finite() && fac > 0.0);
367    }
368
369    #[test]
370    fn test_negative_axis_rejected() {
371        assert!(Beam::new(1e-3, -1e-3, 0.0).is_err());
372        assert!(Beam::new(-1e-3, -2e-3, 0.0).is_err());
373    }
374}