1use std::fmt;
7
8use thiserror::Error;
9
10#[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#[derive(Debug, Clone, Copy)]
43struct Cov {
44 xx: f64,
45 yy: f64,
46 xy: f64,
47}
48
49impl Cov {
50 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 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 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 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 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 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 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 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 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 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 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 pub fn deconvolve_or_zero(&self, other: &Beam) -> Beam {
255 self.deconvolve(other).unwrap_or(Beam::zero())
256 }
257
258 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 pub fn convolve(&self, other: &Beam) -> Beam {
290 self.cov_deg().add(&other.cov_deg()).to_beam()
291 }
292
293 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
317pub 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}