use crate::coordinates::centers::Topocentric;
use crate::coordinates::frames::Horizontal;
use affn::spherical;
use qtty::{Degrees, LengthUnit, DEG};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AzimuthOrigin {
North,
South,
East,
West,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AzimuthSense {
Clockwise,
CounterClockwise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HorizontalConvention {
pub origin: AzimuthOrigin,
pub sense: AzimuthSense,
}
impl HorizontalConvention {
pub const fn new(origin: AzimuthOrigin, sense: AzimuthSense) -> Self {
Self { origin, sense }
}
pub const NORTH_CLOCKWISE: Self = Self {
origin: AzimuthOrigin::North,
sense: AzimuthSense::Clockwise,
};
pub const SOUTH_CLOCKWISE: Self = Self {
origin: AzimuthOrigin::South,
sense: AzimuthSense::Clockwise,
};
pub const NORTH_COUNTERCLOCKWISE: Self = Self {
origin: AzimuthOrigin::North,
sense: AzimuthSense::CounterClockwise,
};
pub const EAST_COUNTERCLOCKWISE: Self = Self {
origin: AzimuthOrigin::East,
sense: AzimuthSense::CounterClockwise,
};
}
impl std::fmt::Display for HorizontalConvention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let origin = match self.origin {
AzimuthOrigin::North => "North",
AzimuthOrigin::South => "South",
AzimuthOrigin::East => "East",
AzimuthOrigin::West => "West",
};
let sense = match self.sense {
AzimuthSense::Clockwise => "clockwise",
AzimuthSense::CounterClockwise => "counter-clockwise",
};
write!(f, "{origin}-{sense}")
}
}
const fn origin_offset_cw(origin: AzimuthOrigin) -> f64 {
match origin {
AzimuthOrigin::North => 0.0,
AzimuthOrigin::East => 90.0,
AzimuthOrigin::South => 180.0,
AzimuthOrigin::West => 270.0,
}
}
pub fn convert_azimuth(
azimuth: Degrees,
from: &HorizontalConvention,
to: &HorizontalConvention,
) -> Degrees {
if from == to {
return azimuth.normalize();
}
let az_ncw = match from.sense {
AzimuthSense::Clockwise => azimuth.value() + origin_offset_cw(from.origin),
AzimuthSense::CounterClockwise => -azimuth.value() + origin_offset_cw(from.origin),
};
let az_target = match to.sense {
AzimuthSense::Clockwise => az_ncw - origin_offset_cw(to.origin),
AzimuthSense::CounterClockwise => -(az_ncw - origin_offset_cw(to.origin)),
};
(az_target * DEG).normalize()
}
pub fn direction_to_native(
dir: &spherical::Direction<Horizontal>,
from: &HorizontalConvention,
) -> spherical::Direction<Horizontal> {
let new_az = convert_azimuth(dir.az(), from, &HorizontalConvention::NORTH_CLOCKWISE);
spherical::Direction::<Horizontal>::new(dir.alt(), new_az)
}
pub fn direction_from_native(
dir: &spherical::Direction<Horizontal>,
to: &HorizontalConvention,
) -> spherical::Direction<Horizontal> {
let new_az = convert_azimuth(dir.az(), &HorizontalConvention::NORTH_CLOCKWISE, to);
spherical::Direction::<Horizontal>::new(dir.alt(), new_az)
}
pub fn convert_direction(
dir: &spherical::Direction<Horizontal>,
from: &HorizontalConvention,
to: &HorizontalConvention,
) -> spherical::Direction<Horizontal> {
let new_az = convert_azimuth(dir.az(), from, to);
spherical::Direction::<Horizontal>::new(dir.alt(), new_az)
}
pub fn position_to_native<U: LengthUnit>(
pos: &spherical::Position<Topocentric, Horizontal, U>,
from: &HorizontalConvention,
) -> spherical::Position<Topocentric, Horizontal, U> {
let new_az = convert_azimuth(pos.az(), from, &HorizontalConvention::NORTH_CLOCKWISE);
spherical::Position::<Topocentric, Horizontal, U>::new_raw_with_params(
*pos.center_params(),
pos.alt(),
new_az,
pos.distance,
)
}
pub fn position_from_native<U: LengthUnit>(
pos: &spherical::Position<Topocentric, Horizontal, U>,
to: &HorizontalConvention,
) -> spherical::Position<Topocentric, Horizontal, U> {
let new_az = convert_azimuth(pos.az(), &HorizontalConvention::NORTH_CLOCKWISE, to);
spherical::Position::<Topocentric, Horizontal, U>::new_raw_with_params(
*pos.center_params(),
pos.alt(),
new_az,
pos.distance,
)
}
pub fn convert_position<U: LengthUnit>(
pos: &spherical::Position<Topocentric, Horizontal, U>,
from: &HorizontalConvention,
to: &HorizontalConvention,
) -> spherical::Position<Topocentric, Horizontal, U> {
let new_az = convert_azimuth(pos.az(), from, to);
spherical::Position::<Topocentric, Horizontal, U>::new_raw_with_params(
*pos.center_params(),
pos.alt(),
new_az,
pos.distance,
)
}
pub fn flip_north_south(azimuth: Degrees) -> Degrees {
(azimuth + 180.0 * DEG).normalize()
}
pub fn flip_sense(azimuth: Degrees) -> Degrees {
(-(azimuth.value()) * DEG).normalize()
}
#[cfg(test)]
mod tests {
use super::*;
use qtty::DEG;
const TOL: f64 = 1e-10;
fn assert_az_eq(actual: Degrees, expected: f64) {
let diff = (actual.value() - expected).abs();
assert!(
diff < TOL,
"azimuth mismatch: got {}, expected {}",
actual.value(),
expected
);
}
#[test]
fn identity_north_cw() {
let az = 123.456 * DEG;
let result = convert_azimuth(
az,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::NORTH_CLOCKWISE,
);
assert_az_eq(result, 123.456);
}
#[test]
fn north_cw_to_south_cw() {
assert_az_eq(
convert_azimuth(
0.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::SOUTH_CLOCKWISE,
),
180.0,
);
assert_az_eq(
convert_azimuth(
90.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::SOUTH_CLOCKWISE,
),
270.0,
);
assert_az_eq(
convert_azimuth(
225.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::SOUTH_CLOCKWISE,
),
45.0,
);
}
#[test]
fn south_cw_to_north_cw() {
assert_az_eq(
convert_azimuth(
45.0 * DEG,
&HorizontalConvention::SOUTH_CLOCKWISE,
&HorizontalConvention::NORTH_CLOCKWISE,
),
225.0,
);
}
#[test]
fn north_cw_to_north_ccw() {
assert_az_eq(
convert_azimuth(
90.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::NORTH_COUNTERCLOCKWISE,
),
270.0,
);
assert_az_eq(
convert_azimuth(
0.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::NORTH_COUNTERCLOCKWISE,
),
0.0,
);
}
#[test]
fn north_ccw_to_north_cw() {
assert_az_eq(
convert_azimuth(
90.0 * DEG,
&HorizontalConvention::NORTH_COUNTERCLOCKWISE,
&HorizontalConvention::NORTH_CLOCKWISE,
),
270.0,
);
}
#[test]
fn north_cw_to_east_ccw() {
assert_az_eq(
convert_azimuth(
0.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
),
90.0,
);
assert_az_eq(
convert_azimuth(
90.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
),
0.0,
);
assert_az_eq(
convert_azimuth(
180.0 * DEG,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
),
270.0,
);
}
#[test]
fn east_ccw_to_north_cw() {
assert_az_eq(
convert_azimuth(
0.0 * DEG,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
&HorizontalConvention::NORTH_CLOCKWISE,
),
90.0,
);
}
#[test]
fn roundtrip_all_conventions() {
let conventions = [
HorizontalConvention::NORTH_CLOCKWISE,
HorizontalConvention::SOUTH_CLOCKWISE,
HorizontalConvention::NORTH_COUNTERCLOCKWISE,
HorizontalConvention::EAST_COUNTERCLOCKWISE,
];
let test_azimuths = [0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0, 359.9];
for &from in &conventions {
for &to in &conventions {
for &az_val in &test_azimuths {
let az = az_val * DEG;
let converted = convert_azimuth(az, &from, &to);
let back = convert_azimuth(converted, &to, &from);
assert!(
(back.value() - az.normalize().value()).abs() < TOL,
"Round-trip failed: {az_val}° from {from} → {to} → {from}: got {}",
back.value()
);
}
}
}
}
#[test]
fn flip_north_south_basic() {
assert_az_eq(flip_north_south(0.0 * DEG), 180.0);
assert_az_eq(flip_north_south(45.0 * DEG), 225.0);
assert_az_eq(flip_north_south(180.0 * DEG), 0.0);
assert_az_eq(flip_north_south(270.0 * DEG), 90.0);
}
#[test]
fn flip_sense_basic() {
assert_az_eq(flip_sense(90.0 * DEG), 270.0);
assert_az_eq(flip_sense(0.0 * DEG), 0.0);
assert_az_eq(flip_sense(45.0 * DEG), 315.0);
}
#[test]
fn direction_to_native_south_cw() {
let foreign = spherical::Direction::<Horizontal>::new(30.0 * DEG, 45.0 * DEG);
let native = direction_to_native(&foreign, &HorizontalConvention::SOUTH_CLOCKWISE);
assert_eq!(native.alt(), 30.0 * DEG);
assert_az_eq(native.az(), 225.0);
}
#[test]
fn direction_from_native_south_cw() {
let native = spherical::Direction::<Horizontal>::new(30.0 * DEG, 225.0 * DEG);
let foreign = direction_from_native(&native, &HorizontalConvention::SOUTH_CLOCKWISE);
assert_eq!(foreign.alt(), 30.0 * DEG);
assert_az_eq(foreign.az(), 45.0);
}
#[test]
fn direction_roundtrip() {
let original = spherical::Direction::<Horizontal>::new(60.0 * DEG, 123.0 * DEG);
let converted = convert_direction(
&original,
&HorizontalConvention::NORTH_CLOCKWISE,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
);
let back = convert_direction(
&converted,
&HorizontalConvention::EAST_COUNTERCLOCKWISE,
&HorizontalConvention::NORTH_CLOCKWISE,
);
assert_eq!(back.alt(), original.alt());
assert_az_eq(back.az(), original.az().value());
}
#[test]
fn convention_display() {
assert_eq!(
HorizontalConvention::NORTH_CLOCKWISE.to_string(),
"North-clockwise"
);
assert_eq!(
HorizontalConvention::EAST_COUNTERCLOCKWISE.to_string(),
"East-counter-clockwise"
);
}
}