use std::f64::consts::PI;
use crate::coordinates::frames;
use crate::coordinates::spherical;
use crate::qtty::{Degrees, Radian, Steradians};
#[derive(Debug, Clone, Copy)]
pub struct SkyGridCell {
pub direction: spherical::direction::Horizontal,
pub solid_angle: Steradians,
}
#[derive(Debug, Clone)]
pub struct SkyGrid {
alt_min: Degrees,
alt_max: Degrees,
alt_step: Degrees,
az_step: Degrees,
equal_solid_angle: bool,
}
impl SkyGrid {
pub fn uniform(step: Degrees) -> Self {
Self::with_steps(step, step)
}
pub fn with_steps(alt_step: Degrees, az_step: Degrees) -> Self {
Self {
alt_min: Degrees::new(0.0),
alt_max: Degrees::new(90.0),
alt_step,
az_step,
equal_solid_angle: false,
}
}
pub fn equal_area(alt_step: Degrees, az_step_at_horizon: Degrees) -> Self {
Self {
alt_min: Degrees::new(0.0),
alt_max: Degrees::new(90.0),
alt_step,
az_step: az_step_at_horizon,
equal_solid_angle: true,
}
}
pub fn with_alt_range(mut self, lo: Degrees, hi: Degrees) -> Self {
self.alt_min = lo;
self.alt_max = hi;
self
}
}
impl SkyGrid {
fn n_alt(&self) -> usize {
if self.alt_max <= self.alt_min || self.alt_step <= Degrees::new(0.0) {
return 0;
}
((self.alt_max - self.alt_min) / self.alt_step).round() as usize
}
fn n_az_uniform(&self) -> usize {
if self.az_step <= Degrees::new(0.0) {
return 0;
}
(Degrees::new(360.0) / self.az_step).round() as usize
}
fn n_az_equal_area(&self, alt_center: Degrees) -> usize {
let cos_alt = alt_center.to::<Radian>().cos().max(0.0);
let az_horizon_rad = self.az_step.to::<Radian>().value();
if az_horizon_rad <= 0.0 {
return 1;
}
let n = ((2.0 * PI * cos_alt) / az_horizon_rad).round() as usize;
n.max(1)
}
pub fn len(&self) -> usize {
let n_alt = self.n_alt();
if self.equal_solid_angle {
(0..n_alt)
.map(|i| {
let alt = self.alt_min + (i as f64 + 0.5) * self.alt_step;
self.n_az_equal_area(alt)
})
.sum()
} else {
n_alt * self.n_az_uniform()
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn iter(&self) -> impl Iterator<Item = spherical::direction::Horizontal> + '_ {
self.iter_cells().map(|c| c.direction)
}
pub fn with_solid_angle(
&self,
) -> impl Iterator<Item = (spherical::direction::Horizontal, Steradians)> + '_ {
self.iter_cells().map(|c| (c.direction, c.solid_angle))
}
pub fn iter_cells(&self) -> impl Iterator<Item = SkyGridCell> + '_ {
let n_alt = self.n_alt();
let alt_step_rad = self.alt_step.to::<Radian>().value();
let equal_area = self.equal_solid_angle;
let alt_min = self.alt_min;
let alt_step = self.alt_step;
let az_step_uniform = self.az_step;
let n_az_uniform = self.n_az_uniform();
(0..n_alt).flat_map(move |i| {
let alt: Degrees = alt_min + (i as f64 + 0.5) * alt_step;
let alt_rad = alt.to::<Radian>().value();
let cos_alt = alt_rad.cos();
let n_az: usize;
let az_step: Degrees;
if equal_area {
n_az = {
let cos_clamped = cos_alt.max(0.0);
let az_horizon_rad = az_step_uniform.to::<Radian>().value();
let raw = if az_horizon_rad > 0.0 {
((2.0 * PI * cos_clamped) / az_horizon_rad).round() as usize
} else {
1
};
raw.max(1)
};
az_step = Degrees::new(360.0) / n_az as f64;
} else {
n_az = n_az_uniform;
az_step = az_step_uniform;
}
let az_step_rad = az_step.to::<Radian>().value();
let solid_angle = Steradians::new(cos_alt * alt_step_rad * az_step_rad);
(0..n_az).map(move |j| {
let az: Degrees = (j as f64 + 0.5) * az_step;
SkyGridCell {
direction: spherical::Direction::<frames::Horizontal>::new_unchecked(alt, az),
solid_angle,
}
})
})
}
}
impl<'a> IntoIterator for &'a SkyGrid {
type Item = spherical::direction::Horizontal;
type IntoIter = Box<dyn Iterator<Item = spherical::direction::Horizontal> + 'a>;
fn into_iter(self) -> Self::IntoIter {
Box::new(self.iter())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qtty::DEG;
#[test]
fn regular_5deg_hemisphere_count() {
let grid = SkyGrid::uniform(5.0 * DEG);
assert_eq!(grid.len(), 18 * 72);
assert_eq!(grid.iter().count(), 18 * 72);
}
#[test]
fn regular_10deg_count() {
let grid = SkyGrid::uniform(10.0 * DEG);
assert_eq!(grid.len(), 324);
}
#[test]
fn len_matches_iter_count() {
for step in [1.0_f64, 5.0, 10.0, 15.0, 30.0, 45.0] {
let g = SkyGrid::uniform(step * DEG);
assert_eq!(g.len(), g.iter().count(), "uniform step={step}");
}
for step in [5.0_f64, 10.0, 15.0, 30.0] {
let g = SkyGrid::equal_area(step * DEG, step * DEG);
assert_eq!(g.len(), g.iter().count(), "equal_area step={step}");
}
}
#[test]
fn uniform_solid_angle_sum_approx_two_pi() {
let grid = SkyGrid::uniform(1.0 * DEG);
let total: f64 = grid.with_solid_angle().map(|(_, sr)| sr.value()).sum();
let expected = 2.0 * PI;
let rel_err = (total - expected).abs() / expected;
assert!(rel_err < 0.01, "uniform sum={total}, rel_err={rel_err}");
}
#[test]
fn equal_area_solid_angle_sum_approx_two_pi() {
let grid = SkyGrid::equal_area(1.0 * DEG, 1.0 * DEG);
let total: f64 = grid.with_solid_angle().map(|(_, sr)| sr.value()).sum();
let expected = 2.0 * PI;
let rel_err = (total - expected).abs() / expected;
assert!(rel_err < 0.001, "equal-area sum={total}, rel_err={rel_err}");
}
#[test]
fn all_directions_inside_ranges() {
let grid = SkyGrid::uniform(5.0 * DEG);
for dir in grid.iter() {
let alt = dir.polar.value();
let az = dir.azimuth.value();
assert!((0.0..=90.0).contains(&alt), "alt {alt} out of [0, 90]");
assert!((0.0..360.0).contains(&az), "az {az} out of [0, 360)");
}
}
#[test]
fn boundary_cells_no_duplicates() {
let grid = SkyGrid::uniform(10.0 * DEG);
let cells: Vec<_> = grid.iter().collect();
let first = cells.first().expect("non-empty");
assert!((first.polar.value() - 5.0).abs() < 1e-9);
assert!((first.azimuth.value() - 5.0).abs() < 1e-9);
let last = cells.last().expect("non-empty");
assert!((last.polar.value() - 85.0).abs() < 1e-9);
assert!((last.azimuth.value() - 355.0).abs() < 1e-9);
for (i, a) in cells.iter().enumerate() {
for b in &cells[i + 1..] {
let dup = (a.polar.value() - b.polar.value()).abs() < 1e-12
&& (a.azimuth.value() - b.azimuth.value()).abs() < 1e-12;
assert!(
!dup,
"duplicate cell at ({}, {})",
a.polar.value(),
a.azimuth.value()
);
}
}
}
#[test]
fn equal_area_boundary_rings() {
let grid = SkyGrid::equal_area(10.0 * DEG, 10.0 * DEG);
let cells: Vec<_> = grid.iter().collect();
let cos_top = 85.0_f64.to_radians().cos();
let expected_top = (((2.0 * PI * cos_top) / 10.0_f64.to_radians()).round() as usize).max(1);
let top_count = cells
.iter()
.filter(|c| (c.polar.value() - 85.0).abs() < 1e-9)
.count();
assert_eq!(top_count, expected_top);
for c in &cells {
assert!((0.0..=90.0).contains(&c.polar.value()));
assert!((0.0..360.0).contains(&c.azimuth.value()));
}
}
#[test]
fn horizon_mask_via_with_alt_range() {
let grid = SkyGrid::uniform(10.0 * DEG).with_alt_range(30.0 * DEG, 60.0 * DEG);
assert_eq!(grid.len(), 3 * 36);
for dir in grid.iter() {
assert!(dir.polar.value() >= 30.0 && dir.polar.value() <= 60.0);
}
}
#[test]
fn into_iterator_yields_directions() {
use crate::coordinates::frames::Horizontal;
use crate::coordinates::spherical::Direction;
let grid = SkyGrid::uniform(15.0 * DEG);
let mut count = 0usize;
for dir in &grid {
let _: Direction<Horizontal> = dir;
count += 1;
}
assert_eq!(count, grid.len());
}
#[test]
fn solid_angle_is_typed_steradians() {
let grid = SkyGrid::uniform(30.0 * DEG);
let cell = grid.iter_cells().next().unwrap();
let _: Steradians = cell.solid_angle;
assert!(cell.solid_angle.value() > 0.0);
}
}