Skip to main content

convolve_rs/
beam.rs

1//! Radio astronomy beam (PSF) represented as a 2D elliptical Gaussian.
2//!
3//! All stored values use FITS conventions: major/minor FWHM in degrees, PA in
4//! degrees East of North.
5//!
6//! The beam algebra (convolution, deconvolution, and the Jy/beam flux-scaling
7//! factor) is implemented here from the standard second-moment / covariance
8//! formulation of an elliptical Gaussian:
9//!
10//!   * An elliptical Gaussian is described by a symmetric 2×2 covariance matrix
11//!     `C` in (East, North) axes. Its eigenvalues are the squared axis lengths
12//!     and its eigenvectors give the orientation.
13//!   * Convolving two Gaussians **adds** their covariance matrices; deconvolving
14//!     **subtracts** them (valid only while the residual stays positive-definite).
15//!   * The integral of a 2D Gaussian is proportional to `sqrt(det C)`, which
16//!     yields the peak-amplitude / flux-rescaling factor as a ratio of
17//!     determinants.
18//!
19//! These are textbook results for combining Gaussian point-spread functions (see
20//! Wild 1970, *Aust. J. Phys.* 23, 113, for the radio-astronomy form). The same
21//! standard formulae underpin `radio_beam` and RACS-tools; this module is an
22//! independent implementation in terms of the covariance matrix and its
23//! eigendecomposition.
24use std::fmt;
25
26use thiserror::Error;
27
28/// A 2D elliptical Gaussian beam in FITS conventions.
29///
30/// # Examples
31///
32/// ```
33/// use convolve_rs::Beam;
34///
35/// let beam = Beam::from_arcsec(20.0, 10.0, 45.0)?;
36/// assert_eq!(beam.major_arcsec(), 20.0);
37/// assert_eq!(beam.major_deg, 20.0 / 3600.0);
38/// # Ok::<(), convolve_rs::BeamError>(())
39/// ```
40#[derive(Debug, Clone, Copy)]
41pub struct Beam {
42    /// FWHM major axis in degrees (FITS BMAJ)
43    pub major_deg: f64,
44    /// FWHM minor axis in degrees (FITS BMIN)
45    pub minor_deg: f64,
46    /// Position angle in degrees East of North (FITS BPA)
47    pub pa_deg: f64,
48}
49
50#[derive(Debug, Error)]
51pub enum BeamError {
52    #[error("beam could not be deconvolved: source beam is smaller than the PSF")]
53    DeconvolveFailed,
54    #[error("invalid beam: minor axis ({minor}) > major axis ({major})")]
55    InvalidAxes { major: f64, minor: f64 },
56    #[error("beam is not finite (NaN or infinite values)")]
57    NotFinite,
58}
59
60/// Symmetric 2×2 covariance matrix `[[xx, xy], [xy, yy]]` of an elliptical
61/// Gaussian, expressed in (East = x, North = y) axes.
62///
63/// The matrix entries carry units of (axis length)², so a beam given in degrees
64/// produces a covariance in deg² and one given in arcsec produces arcsec². Beam
65/// operations are linear in this representation: convolution is matrix addition,
66/// deconvolution is subtraction, and the axis lengths / position angle are the
67/// eigen-pairs.
68#[derive(Debug, Clone, Copy)]
69struct Cov {
70    xx: f64,
71    yy: f64,
72    xy: f64,
73}
74
75impl Cov {
76    /// Build the covariance of a Gaussian with the given FWHM axes and position
77    /// angle (radians, East of North). The major axis points along
78    /// `(sin θ, cos θ)` and the minor along `(cos θ, −sin θ)`.
79    fn from_axes(major: f64, minor: f64, pa_rad: f64) -> Self {
80        let (sin, cos) = pa_rad.sin_cos();
81        let a2 = major * major;
82        let b2 = minor * minor;
83        Self {
84            xx: a2 * sin * sin + b2 * cos * cos,
85            yy: a2 * cos * cos + b2 * sin * sin,
86            xy: (a2 - b2) * sin * cos,
87        }
88    }
89
90    fn add(&self, other: &Cov) -> Cov {
91        Cov {
92            xx: self.xx + other.xx,
93            yy: self.yy + other.yy,
94            xy: self.xy + other.xy,
95        }
96    }
97
98    fn sub(&self, other: &Cov) -> Cov {
99        Cov {
100            xx: self.xx - other.xx,
101            yy: self.yy - other.yy,
102            xy: self.xy - other.xy,
103        }
104    }
105
106    fn det(&self) -> f64 {
107        self.xx * self.yy - self.xy * self.xy
108    }
109
110    /// Eigenvalues `(larger, smaller)` — the squared major and minor axis lengths.
111    fn eigenvalues(&self) -> (f64, f64) {
112        let mean = 0.5 * (self.xx + self.yy);
113        // Half the spread of the eigenvalues about their mean.
114        let radius = 0.5 * (self.xx - self.yy).hypot(2.0 * self.xy);
115        (mean + radius, mean - radius)
116    }
117
118    /// Position angle of the major axis in radians, East of North, folded into
119    /// `(−π/2, π/2]`. A circular (degenerate) covariance has no defined
120    /// orientation and returns 0.
121    fn position_angle(&self) -> f64 {
122        use std::f64::consts::{FRAC_PI_2, PI};
123        let off_diag = self.xx - self.yy;
124        let two_xy = 2.0 * self.xy;
125        // Circular to machine precision → orientation undefined.
126        if off_diag.hypot(two_xy) <= f64::EPSILON * (self.xx + self.yy).abs() {
127            return 0.0;
128        }
129        // Principal-axis angle. The eigenvector of the larger eigenvalue makes
130        // angle ½·atan2(2·xy, xx−yy) with the East (x) axis; converting to a
131        // North-referenced PA gives π/2 minus that.
132        let mut pa = FRAC_PI_2 - 0.5 * two_xy.atan2(off_diag);
133        if pa > FRAC_PI_2 {
134            pa -= PI;
135        }
136        pa
137    }
138}
139
140impl Beam {
141    /// Create a beam from FWHM axes in degrees and a position angle in degrees
142    /// East of North.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`BeamError::InvalidAxes`] if `minor_deg > major_deg`, and
147    /// [`BeamError::NotFinite`] if any value is NaN or infinite.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use convolve_rs::Beam;
153    ///
154    /// let beam = Beam::new(20.0 / 3600.0, 10.0 / 3600.0, 45.0)?;
155    /// assert_eq!(beam.pa_deg, 45.0);
156    ///
157    /// // Minor axis larger than major is rejected.
158    /// assert!(Beam::new(10.0 / 3600.0, 20.0 / 3600.0, 0.0).is_err());
159    /// # Ok::<(), convolve_rs::BeamError>(())
160    /// ```
161    pub fn new(major_deg: f64, minor_deg: f64, pa_deg: f64) -> Result<Self, BeamError> {
162        if !major_deg.is_finite() || !minor_deg.is_finite() || !pa_deg.is_finite() {
163            return Err(BeamError::NotFinite);
164        }
165        if minor_deg > major_deg {
166            return Err(BeamError::InvalidAxes {
167                major: major_deg,
168                minor: minor_deg,
169            });
170        }
171        Ok(Self {
172            major_deg,
173            minor_deg,
174            pa_deg,
175        })
176    }
177
178    /// Create a beam from FWHM axes in arcseconds and a position angle in
179    /// degrees East of North.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use convolve_rs::Beam;
185    ///
186    /// let beam = Beam::from_arcsec(20.0, 10.0, 45.0)?;
187    /// assert_eq!(beam.minor_deg, 10.0 / 3600.0);
188    /// # Ok::<(), convolve_rs::BeamError>(())
189    /// ```
190    pub fn from_arcsec(
191        major_arcsec: f64,
192        minor_arcsec: f64,
193        pa_deg: f64,
194    ) -> Result<Self, BeamError> {
195        Self::new(major_arcsec / 3600.0, minor_arcsec / 3600.0, pa_deg)
196    }
197
198    pub fn zero() -> Self {
199        Self {
200            major_deg: 0.0,
201            minor_deg: 0.0,
202            pa_deg: 0.0,
203        }
204    }
205
206    pub fn major_arcsec(&self) -> f64 {
207        self.major_deg * 3600.0
208    }
209    pub fn minor_arcsec(&self) -> f64 {
210        self.minor_deg * 3600.0
211    }
212
213    pub fn is_finite(&self) -> bool {
214        self.major_deg.is_finite()
215            && self.minor_deg.is_finite()
216            && self.pa_deg.is_finite()
217            && self.major_deg > 0.0
218    }
219
220    pub fn is_zero(&self) -> bool {
221        self.major_deg == 0.0 && self.minor_deg == 0.0
222    }
223
224    pub fn is_circular(&self, rtol: f64) -> bool {
225        if self.major_deg == 0.0 {
226            return true;
227        }
228        (self.major_deg - self.minor_deg) / self.major_deg <= rtol
229    }
230
231    pub fn area_sr(&self) -> f64 {
232        let fwhm_to_area = 2.0 * std::f64::consts::PI / (8.0 * 2_f64.ln());
233        self.major_deg.to_radians() * self.minor_deg.to_radians() * fwhm_to_area
234    }
235
236    /// Covariance matrix of this beam (entries in deg²).
237    fn cov_deg(&self) -> Cov {
238        Cov::from_axes(self.major_deg, self.minor_deg, self.pa_deg.to_radians())
239    }
240
241    /// Deconvolve `other` from `self` (i.e. `self` = result ⊛ `other`).
242    ///
243    /// Subtracts the covariance of `other` from that of `self` and reads off the
244    /// residual ellipse. Fails if the residual is not positive-definite (the
245    /// source beam is not larger than the PSF). Inputs/outputs in degrees.
246    ///
247    /// # Examples
248    ///
249    /// Deconvolution inverts convolution:
250    ///
251    /// ```
252    /// use convolve_rs::Beam;
253    ///
254    /// let a = Beam::from_arcsec(10.0, 8.0, 30.0)?;
255    /// let b = Beam::from_arcsec(6.0, 5.0, 15.0)?;
256    /// let recovered = a.convolve(&b).deconvolve(&a)?;
257    /// assert!((recovered.major_arcsec() - b.major_arcsec()).abs() < 1e-6);
258    /// assert!((recovered.minor_arcsec() - b.minor_arcsec()).abs() < 1e-6);
259    ///
260    /// // Deconvolving a larger beam from a smaller one fails.
261    /// assert!(b.deconvolve(&a).is_err());
262    /// # Ok::<(), convolve_rs::BeamError>(())
263    /// ```
264    pub fn deconvolve(&self, other: &Beam) -> Result<Beam, BeamError> {
265        let (new_major, new_minor, new_pa_rad) = deconvolve_deg(
266            self.major_deg,
267            self.minor_deg,
268            self.pa_deg,
269            other.major_deg,
270            other.minor_deg,
271            other.pa_deg,
272            false,
273        )?;
274        Ok(Beam {
275            major_deg: new_major,
276            minor_deg: new_minor,
277            pa_deg: new_pa_rad.to_degrees(),
278        })
279    }
280
281    /// Like `deconvolve` but returns a zero beam on failure instead of an error.
282    pub fn deconvolve_or_zero(&self, other: &Beam) -> Beam {
283        match self.deconvolve(other) {
284            Ok(b) => b,
285            Err(_) => Beam::zero(),
286        }
287    }
288
289    /// Convolve `self` with `other`: sum the covariance matrices and read off the
290    /// resulting ellipse.
291    ///
292    /// # Examples
293    ///
294    /// Convolving two circular beams adds their axes in quadrature:
295    ///
296    /// ```
297    /// use convolve_rs::Beam;
298    ///
299    /// let a = Beam::from_arcsec(3.0, 3.0, 0.0)?;
300    /// let b = Beam::from_arcsec(4.0, 4.0, 0.0)?;
301    /// let c = a.convolve(&b);
302    /// assert!((c.major_arcsec() - 5.0).abs() < 1e-9);
303    /// # Ok::<(), convolve_rs::BeamError>(())
304    /// ```
305    pub fn convolve(&self, other: &Beam) -> Beam {
306        let combined = self.cov_deg().add(&other.cov_deg());
307        let (lam_major, lam_minor) = combined.eigenvalues();
308        Beam {
309            major_deg: lam_major.max(0.0).sqrt(),
310            minor_deg: lam_minor.max(0.0).sqrt(),
311            pa_deg: combined.position_angle().to_degrees(),
312        }
313    }
314
315    /// Approximate equality with a tolerance of ~1e-10 degrees (~0.4 nanoarcsec).
316    pub fn approx_eq(&self, other: &Beam) -> bool {
317        const ATOL: f64 = 1e-10;
318        let pa_self = self.pa_deg.rem_euclid(180.0);
319        let pa_other = other.pa_deg.rem_euclid(180.0);
320        let pa_eq = if self.is_circular(1e-6) {
321            true
322        } else {
323            (pa_self - pa_other).abs() < ATOL
324        };
325        (self.major_deg - other.major_deg).abs() < ATOL
326            && (self.minor_deg - other.minor_deg).abs() < ATOL
327            && pa_eq
328    }
329}
330
331impl fmt::Display for Beam {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        write!(
334            f,
335            "BMAJ={:.4}\" BMIN={:.4}\" BPA={:.2}°",
336            self.major_arcsec(),
337            self.minor_arcsec(),
338            self.pa_deg,
339        )
340    }
341}
342
343impl PartialEq for Beam {
344    fn eq(&self, other: &Self) -> bool {
345        self.approx_eq(other)
346    }
347}
348
349/// Deconvolve beam 2 from beam 1 by subtracting covariance matrices.
350///
351/// All axis inputs in degrees, PAs in degrees; the returned PA is in radians.
352/// Returns `(new_major_deg, new_minor_deg, new_pa_rad)`. If the residual
353/// covariance is not positive-definite the beams cannot be deconvolved: this is
354/// either a `DeconvolveFailed` error or, when `failure_returns_zero` is set, a
355/// zero result.
356pub(crate) fn deconvolve_deg(
357    maj1: f64,
358    min1: f64,
359    pa1_deg: f64,
360    maj2: f64,
361    min2: f64,
362    pa2_deg: f64,
363    failure_returns_zero: bool,
364) -> Result<(f64, f64, f64), BeamError> {
365    let residual = Cov::from_axes(maj1, min1, pa1_deg.to_radians()).sub(&Cov::from_axes(
366        maj2,
367        min2,
368        pa2_deg.to_radians(),
369    ));
370    let (lam_major, lam_minor) = residual.eigenvalues();
371
372    // The residual must be positive-(semi)definite to correspond to a real
373    // ellipse: both diagonal variances and the smaller eigenvalue stay ≥ 0. The
374    // floor on the smaller eigenvalue also rejects the point-source limit
375    // (deconvolving a beam from itself).
376    let eps = f64::EPSILON;
377    let lam_floor = eps / (2.0 * 3600.0_f64.powi(2));
378    if residual.xx + eps < 0.0 || residual.yy + eps < 0.0 || lam_minor < lam_floor {
379        if failure_returns_zero {
380            return Ok((0.0, 0.0, 0.0));
381        }
382        return Err(BeamError::DeconvolveFailed);
383    }
384
385    Ok((
386        lam_major.sqrt(),
387        lam_minor.max(0.0).sqrt(),
388        residual.position_angle(),
389    ))
390}
391
392/// Flux-scaling factor for Jy/beam images after convolution to a larger beam.
393///
394/// `conv_beam` is the convolving (difference) beam and `orig_beam` the original
395/// restoring beam; both axes in arcsec, PA in degrees. `dx_arcsec`, `dy_arcsec`
396/// are the pixel sizes in arcsec.
397///
398/// The peak amplitude of the convolving Gaussian needed to preserve Jy/beam
399/// units is `π/(4 ln 2) · √(det C_orig · det C_conv / det(C_orig + C_conv))`,
400/// since the integral of a 2D Gaussian scales as `√det` of its covariance. The
401/// returned factor rescales pixel values by the pixel area over that amplitude.
402///
403/// Returns `(fac, amp, result_bmaj_arcsec, result_bmin_arcsec, result_bpa_deg)`.
404///
405/// # Examples
406///
407/// ```
408/// use convolve_rs::{Beam, gauss_factor};
409///
410/// let orig = Beam::from_arcsec(10.0, 10.0, 0.0)?;
411/// let conv = Beam::from_arcsec(5.0, 5.0, 0.0)?;
412/// let (fac, _amp, bmaj, bmin, _bpa) = gauss_factor(&conv, &orig, 2.5, 2.5);
413/// assert!(fac > 0.0);
414/// // The resulting beam adds axes in quadrature: √(10² + 5²).
415/// assert!((bmaj - 125.0_f64.sqrt()).abs() < 1e-9);
416/// assert!((bmin - bmaj).abs() < 1e-9);
417/// # Ok::<(), convolve_rs::BeamError>(())
418/// ```
419pub fn gauss_factor(
420    conv_beam: &Beam,
421    orig_beam: &Beam,
422    dx_arcsec: f64,
423    dy_arcsec: f64,
424) -> (f64, f64, f64, f64, f64) {
425    let c_orig = Cov::from_axes(
426        orig_beam.major_arcsec(),
427        orig_beam.minor_arcsec(),
428        orig_beam.pa_deg.to_radians(),
429    );
430    let c_conv = Cov::from_axes(
431        conv_beam.major_arcsec(),
432        conv_beam.minor_arcsec(),
433        conv_beam.pa_deg.to_radians(),
434    );
435    let combined = c_orig.add(&c_conv);
436
437    let (lam_major, lam_minor) = combined.eigenvalues();
438    let bmaj_out = lam_major.max(0.0).sqrt();
439    let bmin_out = lam_minor.max(0.0).sqrt();
440    let bpa_out_rad = combined.position_angle();
441
442    let amp = std::f64::consts::PI / (4.0 * 2_f64.ln())
443        * (c_orig.det() * c_conv.det() / combined.det()).sqrt();
444    let fac = dx_arcsec.abs() * dy_arcsec.abs() / amp;
445
446    (fac, amp, bmaj_out, bmin_out, bpa_out_rad.to_degrees())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_convolve_deconvolve_roundtrip() {
455        let beam_a = Beam::new(10.0 / 3600.0, 8.0 / 3600.0, 30.0).unwrap();
456        let beam_b = Beam::new(6.0 / 3600.0, 5.0 / 3600.0, 15.0).unwrap();
457        let convolved = beam_a.convolve(&beam_b);
458        let recovered = convolved.deconvolve(&beam_a).unwrap();
459        assert!(
460            (recovered.major_deg - beam_b.major_deg).abs() < 1e-9,
461            "major mismatch: {} vs {}",
462            recovered.major_deg,
463            beam_b.major_deg
464        );
465        assert!(
466            (recovered.minor_deg - beam_b.minor_deg).abs() < 1e-9,
467            "minor mismatch: {} vs {}",
468            recovered.minor_deg,
469            beam_b.minor_deg
470        );
471    }
472
473    #[test]
474    fn test_deconvolve_fails_when_psf_larger() {
475        let small = Beam::new(5.0 / 3600.0, 5.0 / 3600.0, 0.0).unwrap();
476        let large = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
477        assert!(small.deconvolve(&large).is_err());
478    }
479
480    #[test]
481    fn test_gauss_factor_circular() {
482        let conv = Beam::new(5.0 / 3600.0, 5.0 / 3600.0, 0.0).unwrap();
483        let orig = Beam::new(10.0 / 3600.0, 10.0 / 3600.0, 0.0).unwrap();
484        let dx = 2.5;
485        let (fac, ..) = gauss_factor(&conv, &orig, dx, dx);
486        assert!(fac.is_finite() && fac > 0.0);
487    }
488}