use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, Copy)]
pub struct Beam {
major_deg: f64,
minor_deg: f64,
pa_deg: f64,
}
#[derive(Debug, Error)]
pub enum BeamError {
#[error("beam could not be deconvolved: source beam is smaller than the PSF")]
DeconvolveFailed,
#[error("invalid beam: minor axis ({minor}) > major axis ({major})")]
InvalidAxes { major: f64, minor: f64 },
#[error("invalid beam: negative axis ({minor})")]
NegativeAxis { minor: f64 },
#[error("beam is not finite (NaN or infinite values)")]
NotFinite,
}
#[derive(Debug, Clone, Copy)]
struct Cov {
xx: f64,
yy: f64,
xy: f64,
}
impl Cov {
fn from_axes(major: f64, minor: f64, pa_rad: f64) -> Self {
let (sin, cos) = pa_rad.sin_cos();
let a2 = major * major;
let b2 = minor * minor;
Self {
xx: a2 * sin * sin + b2 * cos * cos,
yy: a2 * cos * cos + b2 * sin * sin,
xy: (a2 - b2) * sin * cos,
}
}
fn add(&self, other: &Cov) -> Cov {
Cov {
xx: self.xx + other.xx,
yy: self.yy + other.yy,
xy: self.xy + other.xy,
}
}
fn sub(&self, other: &Cov) -> Cov {
Cov {
xx: self.xx - other.xx,
yy: self.yy - other.yy,
xy: self.xy - other.xy,
}
}
fn det(&self) -> f64 {
self.xx * self.yy - self.xy * self.xy
}
fn eigenvalues(&self) -> (f64, f64) {
let mean = 0.5 * (self.xx + self.yy);
let radius = 0.5 * (self.xx - self.yy).hypot(2.0 * self.xy);
(mean + radius, mean - radius)
}
fn position_angle(&self) -> f64 {
use std::f64::consts::{FRAC_PI_2, PI};
let diff = self.xx - self.yy;
let two_xy = 2.0 * self.xy;
if diff.hypot(two_xy) <= f64::EPSILON * (self.xx + self.yy).abs() {
return 0.0;
}
let mut pa = FRAC_PI_2 - 0.5 * two_xy.atan2(diff);
if pa > FRAC_PI_2 {
pa -= PI;
}
pa
}
fn to_beam(self) -> Beam {
let (lam_major, lam_minor) = self.eigenvalues();
Beam {
major_deg: lam_major.max(0.0).sqrt(),
minor_deg: lam_minor.max(0.0).sqrt(),
pa_deg: self.position_angle().to_degrees(),
}
}
}
impl Beam {
pub fn new(major_deg: f64, minor_deg: f64, pa_deg: f64) -> Result<Self, BeamError> {
if !major_deg.is_finite() || !minor_deg.is_finite() || !pa_deg.is_finite() {
return Err(BeamError::NotFinite);
}
if minor_deg > major_deg {
return Err(BeamError::InvalidAxes {
major: major_deg,
minor: minor_deg,
});
}
if minor_deg < 0.0 {
return Err(BeamError::NegativeAxis { minor: minor_deg });
}
Ok(Self {
major_deg,
minor_deg,
pa_deg,
})
}
pub fn from_arcsec(
major_arcsec: f64,
minor_arcsec: f64,
pa_deg: f64,
) -> Result<Self, BeamError> {
Self::new(major_arcsec / 3600.0, minor_arcsec / 3600.0, pa_deg)
}
pub fn zero() -> Self {
Self {
major_deg: 0.0,
minor_deg: 0.0,
pa_deg: 0.0,
}
}
pub fn major_deg(&self) -> f64 {
self.major_deg
}
pub fn minor_deg(&self) -> f64 {
self.minor_deg
}
pub fn pa_deg(&self) -> f64 {
self.pa_deg
}
pub fn major_arcsec(&self) -> f64 {
self.major_deg * 3600.0
}
pub fn minor_arcsec(&self) -> f64 {
self.minor_deg * 3600.0
}
pub fn has_area(&self) -> bool {
self.minor_deg > 0.0
}
pub fn is_zero(&self) -> bool {
self.major_deg == 0.0 && self.minor_deg == 0.0
}
pub fn is_circular(&self, rtol: f64) -> bool {
if self.major_deg == 0.0 {
return true;
}
(self.major_deg - self.minor_deg) / self.major_deg <= rtol
}
pub fn area_sr(&self) -> f64 {
let fwhm_to_area = 2.0 * std::f64::consts::PI / (8.0 * 2_f64.ln());
self.major_deg.to_radians() * self.minor_deg.to_radians() * fwhm_to_area
}
fn cov_deg(&self) -> Cov {
Cov::from_axes(self.major_deg, self.minor_deg, self.pa_deg.to_radians())
}
pub fn deconvolve(&self, other: &Beam) -> Result<Beam, BeamError> {
let residual = self.cov_deg().sub(&other.cov_deg());
let (_, lam_minor) = residual.eigenvalues();
let eps = f64::EPSILON;
let lam_floor = eps / (2.0 * 3600.0_f64.powi(2));
if residual.xx + eps < 0.0 || residual.yy + eps < 0.0 || lam_minor < lam_floor {
return Err(BeamError::DeconvolveFailed);
}
Ok(residual.to_beam())
}
pub fn deconvolve_or_zero(&self, other: &Beam) -> Beam {
self.deconvolve(other).unwrap_or(Beam::zero())
}
pub fn contains(&self, other: &Beam) -> bool {
let own = self.cov_deg();
let residual = own.sub(&other.cov_deg());
let tol = 1e-12 * (own.xx + own.yy).abs();
let (_, lam_minor) = residual.eigenvalues();
residual.xx >= -tol && residual.yy >= -tol && lam_minor >= -tol
}
pub fn convolve(&self, other: &Beam) -> Beam {
self.cov_deg().add(&other.cov_deg()).to_beam()
}
pub fn approx_eq(&self, other: &Beam) -> bool {
const ATOL: f64 = 1e-10;
let pa_eq = self.is_circular(1e-6)
|| (self.pa_deg.rem_euclid(180.0) - other.pa_deg.rem_euclid(180.0)).abs() < ATOL;
(self.major_deg - other.major_deg).abs() < ATOL
&& (self.minor_deg - other.minor_deg).abs() < ATOL
&& pa_eq
}
}
impl fmt::Display for Beam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"BMAJ={:.4}\" BMIN={:.4}\" BPA={:.2}°",
self.major_arcsec(),
self.minor_arcsec(),
self.pa_deg,
)
}
}
pub fn gauss_factor(conv_beam: &Beam, orig_beam: &Beam, dx_arcsec: f64, dy_arcsec: f64) -> f64 {
let arcsec_cov =
|b: &Beam| Cov::from_axes(b.major_arcsec(), b.minor_arcsec(), b.pa_deg.to_radians());
let (c_orig, c_conv) = (arcsec_cov(orig_beam), arcsec_cov(conv_beam));
let amp = std::f64::consts::PI / (4.0 * 2_f64.ln())
* (c_orig.det() * c_conv.det() / c_orig.add(&c_conv).det()).sqrt();
dx_arcsec.abs() * dy_arcsec.abs() / amp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convolve_deconvolve_roundtrip() {
let a = Beam::from_arcsec(10.0, 8.0, 30.0).unwrap();
let b = Beam::from_arcsec(6.0, 5.0, 15.0).unwrap();
let recovered = a.convolve(&b).deconvolve(&a).unwrap();
assert!((recovered.major_deg - b.major_deg).abs() < 1e-9);
assert!((recovered.minor_deg - b.minor_deg).abs() < 1e-9);
}
#[test]
fn test_deconvolve_fails_when_psf_larger() {
let small = Beam::from_arcsec(5.0, 5.0, 0.0).unwrap();
let large = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
assert!(small.deconvolve(&large).is_err());
}
#[test]
fn test_gauss_factor_circular() {
let conv = Beam::from_arcsec(5.0, 5.0, 0.0).unwrap();
let orig = Beam::from_arcsec(10.0, 10.0, 0.0).unwrap();
let fac = gauss_factor(&conv, &orig, 2.5, 2.5);
assert!(fac.is_finite() && fac > 0.0);
}
#[test]
fn test_negative_axis_rejected() {
assert!(Beam::new(1e-3, -1e-3, 0.0).is_err());
assert!(Beam::new(-1e-3, -2e-3, 0.0).is_err());
}
}