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}