use core::{f64::consts::FRAC_PI_2, fmt};
use supernovas_ffi::novas_offset_by;
use super::Spherical;
use crate::{
Angle, Coordinate, Position,
error::{Error, Result},
unit,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Horizontal(Spherical);
impl Horizontal {
#[must_use]
pub fn new(azimuth: Angle, elevation: Angle) -> Self {
Horizontal(Spherical::new(azimuth, elevation))
}
pub fn from_radians(azimuth: f64, elevation: f64) -> Result<Self> {
Ok(Horizontal(Spherical::from_radians(azimuth, elevation)?))
}
pub fn from_degrees(azimuth: f64, elevation: f64) -> Result<Self> {
Ok(Horizontal(Spherical::from_degrees(azimuth, elevation)?))
}
#[must_use]
pub fn azimuth(self) -> Angle {
self.0.longitude()
}
#[must_use]
pub fn elevation(self) -> Angle {
self.0.latitude()
}
#[must_use]
pub fn zenith_angle(self) -> Angle {
Angle::from_radians(FRAC_PI_2 - self.elevation().rad())
.expect("FRAC_PI_2 minus a finite angle is finite")
}
#[must_use]
pub fn as_spherical(self) -> Spherical {
self.0
}
#[must_use]
pub fn distance_to(self, other: Horizontal) -> Angle {
self.0.distance_to(other.0)
}
pub fn offset(self, direction: Angle, distance: Angle) -> Result<Horizontal> {
if distance.rad() == 0.0 {
return Ok(self);
}
let mut out_lon = 0.0f64;
let mut out_lat = 0.0f64;
let rc = unsafe {
novas_offset_by(
self.azimuth().deg(),
self.elevation().deg(),
direction.deg(),
distance.deg(),
&raw mut out_lon,
&raw mut out_lat,
)
};
if rc != 0 {
return Err(Error::ffi(rc));
}
Horizontal::from_degrees(out_lon, out_lat)
}
pub fn offset_by_sky(self, xel: Angle, el: Angle) -> Result<Horizontal> {
let (x, y) = (xel.rad(), el.rad());
let cos_d = (x.cos() * y.cos()).clamp(-1.0, 1.0);
let distance = Angle::from_radians(cos_d.acos())
.expect("acos of a clamped value in [-1, 1] is always finite");
let direction = Angle::from_radians((y.cos() * x.sin()).atan2(y.sin()))
.expect("atan2 of finite values is finite");
self.offset(direction, distance)
}
#[must_use]
pub fn xyz(self, distance: Coordinate) -> Position {
self.0.xyz(distance)
}
}
impl fmt::Display for Horizontal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let decimals = f.precision().unwrap_or(3);
write!(
f,
"az={:.decimals$}° el={:.decimals$}°",
self.azimuth().deg().rem_euclid(360.0),
self.elevation().deg()
)
}
}
impl approx::AbsDiffEq for Horizontal {
type Epsilon = f64;
fn default_epsilon() -> Self::Epsilon {
unit::UAS
}
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
self.0.abs_diff_eq(&other.0, epsilon)
}
}
#[cfg(test)]
mod tests {
use approx::assert_abs_diff_eq;
use super::*;
#[test]
fn round_trip_degrees() {
let h = Horizontal::from_degrees(180.0, 45.0).unwrap();
assert!((h.azimuth().deg() - 180.0).abs() < 1e-12);
assert!((h.elevation().deg() - 45.0).abs() < 1e-12);
}
#[test]
fn zenith_angle_relates_to_elevation() {
let zenith = Horizontal::from_degrees(0.0, 90.0).unwrap();
assert!(zenith.zenith_angle().deg().abs() < 1e-12);
let horizon = Horizontal::from_degrees(0.0, 0.0).unwrap();
assert!((horizon.zenith_angle().deg() - 90.0).abs() < 1e-12);
}
#[test]
fn approx_eq_across_azimuth_wrap() {
let a = Horizontal::from_degrees(359.9999, 30.0).unwrap();
let b = Horizontal::from_degrees(-0.0001, 30.0).unwrap();
assert_abs_diff_eq!(a, b, epsilon = unit::ARCSEC);
}
#[test]
fn from_radians_round_trip() {
use core::f64::consts::PI;
let h = Horizontal::from_radians(PI, PI / 4.0).unwrap();
assert!((h.azimuth().rad() - PI).abs() < 1e-12);
assert!((h.elevation().rad() - PI / 4.0).abs() < 1e-12);
}
#[test]
fn as_spherical_gives_same_coords() {
let h = Horizontal::from_degrees(90.0, 45.0).unwrap();
let s = h.as_spherical();
assert!((s.longitude().deg() - 90.0).abs() < 1e-12);
assert!((s.latitude().deg() - 45.0).abs() < 1e-12);
}
#[test]
fn xyz_has_finite_components() {
let h = Horizontal::from_degrees(0.0, 45.0).unwrap();
let d = crate::Coordinate::from_au(1.0).unwrap();
let p = h.xyz(d);
assert!(p.x().m().is_finite());
assert!(p.y().m().is_finite());
assert!(p.z().m().is_finite());
}
#[test]
fn display_contains_az_el() {
let h = Horizontal::from_degrees(270.0, 30.0).unwrap();
let s = format!("{h}");
assert!(s.contains("az=270"), "got: {s}");
assert!(s.contains("el=30"), "got: {s}");
}
#[test]
fn display_normalizes_azimuth_to_0_360() {
let h = Horizontal::from_degrees(-90.0, 10.0).unwrap(); let s = format!("{h}");
assert!(s.contains("az=270"), "expected az=270, got: {s}");
}
#[test]
fn offset_zero_distance_is_identity() {
let h = Horizontal::from_degrees(45.0, 30.0).unwrap();
let h2 = h
.offset(
Angle::from_degrees(0.0).unwrap(),
Angle::from_degrees(0.0).unwrap(),
)
.unwrap();
assert_abs_diff_eq!(h, h2, epsilon = unit::UAS);
}
#[test]
fn offset_pure_elevation() {
let h = Horizontal::from_degrees(45.0, 30.0).unwrap();
let h2 = h
.offset(
Angle::from_degrees(0.0).unwrap(),
Angle::from_degrees(10.0).unwrap(),
)
.unwrap();
assert_abs_diff_eq!(h2.azimuth().deg(), 45.0, epsilon = unit::UAS);
assert_abs_diff_eq!(h2.elevation().deg(), 40.0, epsilon = unit::UAS);
}
#[test]
fn offset_pure_cross_elevation() {
let h = Horizontal::from_degrees(0.0, 45.0).unwrap();
let h2 = h
.offset(
Angle::from_degrees(90.0).unwrap(),
Angle::from_degrees(5.0).unwrap(),
)
.unwrap();
assert!(h2.azimuth().deg() > 0.0);
assert!(h2.elevation().deg() < 45.0);
assert!(h2.elevation().deg() > 40.0);
}
#[test]
fn offset_by_sky_zero_is_identity() {
let h = Horizontal::from_degrees(45.0, 30.0).unwrap();
let zero = Angle::from_degrees(0.0).unwrap();
let h2 = h.offset_by_sky(zero, zero).unwrap();
assert_abs_diff_eq!(h, h2, epsilon = unit::UAS);
}
#[test]
fn offset_by_sky_pure_el() {
let h = Horizontal::from_degrees(0.0, 30.0).unwrap();
let h2 = h
.offset_by_sky(
Angle::from_degrees(0.0).unwrap(),
Angle::from_degrees(5.0).unwrap(),
)
.unwrap();
assert_abs_diff_eq!(h2.azimuth().deg(), 0.0, epsilon = unit::UAS);
assert_abs_diff_eq!(h2.elevation().deg(), 35.0, epsilon = unit::UAS);
}
#[test]
fn offset_by_sky_pure_xel() {
let h = Horizontal::from_degrees(0.0, 30.0).unwrap();
let h2 = h
.offset_by_sky(
Angle::from_degrees(5.0).unwrap(),
Angle::from_degrees(0.0).unwrap(),
)
.unwrap();
assert!(h2.azimuth().deg() > 0.0);
}
#[test]
fn offset_by_sky_compound_matches_direct() {
let h = Horizontal::from_degrees(0.0, 30.0).unwrap();
let xel = Angle::from_degrees(5.0).unwrap();
let el = Angle::from_degrees(5.0).unwrap();
let via_sky = h.offset_by_sky(xel, el).unwrap();
let (x, y) = (xel.rad(), el.rad());
let cos_d = (x.cos() * y.cos()).clamp(-1.0, 1.0);
let distance = Angle::from_radians(cos_d.acos()).unwrap();
let direction = Angle::from_radians((y.cos() * x.sin()).atan2(y.sin())).unwrap();
let via_manual = h.offset(direction, distance).unwrap();
assert_abs_diff_eq!(via_sky, via_manual, epsilon = unit::UAS);
}
#[test]
fn offset_by_sky_distance_correct() {
let h = Horizontal::from_degrees(123.0, 45.0).unwrap();
let xel = Angle::from_degrees(7.0).unwrap();
let el = Angle::from_degrees(12.0).unwrap();
let fwd = h.offset_by_sky(xel, el).unwrap();
let expected_dist_rad = (xel.rad().cos() * el.rad().cos()).acos();
assert_abs_diff_eq!(
h.distance_to(fwd).rad(),
expected_dist_rad,
epsilon = unit::UAS,
);
}
#[test]
fn offset_by_sky_azimuth_direction() {
let h = Horizontal::from_degrees(0.0, 30.0).unwrap();
let fwd = h
.offset_by_sky(
Angle::from_degrees(10.0).unwrap(),
Angle::from_degrees(0.0).unwrap(),
)
.unwrap();
assert!(fwd.azimuth().deg() > 0.0);
let fwd = h
.offset_by_sky(
Angle::from_degrees(0.0).unwrap(),
Angle::from_degrees(10.0).unwrap(),
)
.unwrap();
assert_abs_diff_eq!(fwd.azimuth().deg(), 0.0, epsilon = unit::UAS);
}
#[test]
fn offset_near_pole_errors() {
let h = Horizontal::from_degrees(0.0, 90.0).unwrap();
let result = h.offset(
Angle::from_degrees(0.0).unwrap(),
Angle::from_degrees(1.0).unwrap(),
);
assert!(result.is_err());
}
}