use crate::constants::RAD2DEG;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkyBox {
pub dec_min_deg: f64,
pub dec_max_deg: f64,
pub ra_start_deg: f64,
pub ra_end_deg: f64,
}
impl SkyBox {
pub fn contains(&self, ra_deg: f64, dec_deg: f64) -> bool {
(self.dec_min_deg..self.dec_max_deg).contains(&dec_deg)
&& ra_in_range(ra_deg, self.ra_start_deg, self.ra_end_deg)
}
pub fn ra_span_deg(&self) -> f64 {
(self.ra_end_deg - self.ra_start_deg).rem_euclid(360.0)
}
pub fn solid_angle_deg2(&self) -> f64 {
let dsin = self.dec_max_deg.to_radians().sin() - self.dec_min_deg.to_radians().sin();
self.ra_span_deg() * dsin * RAD2DEG
}
}
fn ra_in_range(ra: f64, start: f64, end: f64) -> bool {
let ra = ra.rem_euclid(360.0);
if start <= end {
(start..end).contains(&ra)
} else {
ra >= start || ra < end
}
}
#[derive(Debug, Clone)]
pub enum Footprint {
AllSky,
DecRange {
dec_min_deg: f64,
dec_max_deg: f64,
},
BoxUnion(Vec<SkyBox>),
Custom {
contains: fn(f64, f64) -> bool,
solid_angle_deg2: f64,
},
}
pub const FULL_SKY_DEG2: f64 = 4.0 * std::f64::consts::PI * RAD2DEG * RAD2DEG;
impl Footprint {
pub fn contains(&self, ra_deg: f64, dec_deg: f64) -> bool {
match self {
Footprint::AllSky => true,
Footprint::DecRange {
dec_min_deg,
dec_max_deg,
} => (*dec_min_deg..=*dec_max_deg).contains(&dec_deg),
Footprint::BoxUnion(boxes) => boxes.iter().any(|b| b.contains(ra_deg, dec_deg)),
Footprint::Custom { contains, .. } => contains(ra_deg, dec_deg),
}
}
pub fn solid_angle_deg2(&self) -> f64 {
match self {
Footprint::AllSky => FULL_SKY_DEG2,
Footprint::DecRange {
dec_min_deg,
dec_max_deg,
} => {
let dsin = dec_max_deg.to_radians().sin() - dec_min_deg.to_radians().sin();
360.0 * dsin * RAD2DEG
}
Footprint::BoxUnion(boxes) => boxes.iter().map(SkyBox::solid_angle_deg2).sum(),
Footprint::Custom {
solid_angle_deg2, ..
} => *solid_angle_deg2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_sky_contains_everything_and_integrates_to_full_sphere() {
let fp = Footprint::AllSky;
assert!(fp.contains(0.0, 90.0));
assert!(fp.contains(359.9, -90.0));
assert!((fp.solid_angle_deg2() - 41_252.96).abs() < 0.01);
}
#[test]
fn dec_range_solid_angle() {
let north = Footprint::DecRange {
dec_min_deg: 0.0,
dec_max_deg: 90.0,
};
assert!((north.solid_angle_deg2() - FULL_SKY_DEG2 / 2.0).abs() < 1e-9);
let band = Footprint::DecRange {
dec_min_deg: -30.0,
dec_max_deg: 90.0,
};
assert!((band.solid_angle_deg2() - 0.75 * FULL_SKY_DEG2).abs() < 1e-9);
assert!(band.contains(123.0, -29.9));
assert!(!band.contains(123.0, -30.1));
}
#[test]
fn sky_box_ra_wrap() {
let b = SkyBox {
dec_min_deg: -65.0,
dec_max_deg: -40.0,
ra_start_deg: 300.0,
ra_end_deg: 105.0,
};
assert!((b.ra_span_deg() - 165.0).abs() < 1e-12);
assert!(b.contains(350.0, -50.0));
assert!(b.contains(30.0, -55.0));
assert!(b.contains(100.0, -45.0));
assert!(!b.contains(150.0, -50.0));
assert!(b.contains(360.0 + 30.0, -55.0));
assert!(b.contains(30.0, -65.0));
assert!(!b.contains(30.0, -40.0));
}
#[test]
fn box_union_solid_angle_monte_carlo() {
use rand::{Rng, SeedableRng};
let fp = Footprint::BoxUnion(vec![
SkyBox {
dec_min_deg: -65.0,
dec_max_deg: -40.0,
ra_start_deg: 300.0,
ra_end_deg: 105.0,
},
SkyBox {
dec_min_deg: -40.0,
dec_max_deg: -20.0,
ra_start_deg: 335.0,
ra_end_deg: 55.0,
},
SkyBox {
dec_min_deg: -20.0,
dec_max_deg: 5.0,
ra_start_deg: 355.0,
ra_end_deg: 40.0,
},
]);
let analytic = fp.solid_angle_deg2();
let mut rng = rand::rngs::StdRng::seed_from_u64(2026);
let n = 200_000;
let mut hits = 0u32;
for _ in 0..n {
let ra = rng.random::<f64>() * 360.0;
let z: f64 = rng.random::<f64>() * 2.0 - 1.0;
if fp.contains(ra, z.asin().to_degrees()) {
hits += 1;
}
}
let mc = FULL_SKY_DEG2 * hits as f64 / n as f64;
assert!(
(mc - analytic).abs() < 150.0,
"MC = {mc:.0} deg^2 vs analytic {analytic:.0} deg^2"
);
}
#[test]
fn custom_footprint_uses_declared_values() {
fn south_cap(_ra: f64, dec: f64) -> bool {
dec < -60.0
}
let fp = Footprint::Custom {
contains: south_cap,
solid_angle_deg2: 1000.0,
};
assert!(fp.contains(10.0, -70.0));
assert!(!fp.contains(10.0, 0.0));
assert_eq!(fp.solid_angle_deg2(), 1000.0);
}
}