Skip to main content

qtty_core/units/
angular.rs

1//! Angular quantities and utilities.
2//!
3//! This module defines the **`Angular` dimension**, a blanket [`AngularUnit`] trait that extends
4//! [`Unit`] for all angular units, common angular units (degrees, radians, arcseconds, etc.), and a set of
5//! convenience methods on [`Quantity<U>`] where `U: AngularUnit`.
6//!
7//! # Design overview
8//!
9//! * **Canonical unit:** Degrees are taken as the canonical *scaling* unit for this dimension. That is,
10//!   `Degree::RATIO == 1.0`, and all other angular units express how many *degrees* correspond to one of that unit.
11//!   For example, `Radian::RATIO == 180.0 / PI` because 1 radian = 180/π degrees.
12//! * **Associated constants:** The `AngularUnit` trait exposes precomputed constants (`FULL_TURN`, `HALF_TURN`,
13//!   `QUARTED_TURN`) expressed *in the receiving unit* for ergonomic range‑wrapping. These are derived from `τ`
14//!   radians and then converted to the target unit to avoid cumulative error from chained conversions.
15//! * **Trigonometry:** `sin`, `cos`, `tan`, and `sin_cos` methods are provided on angular quantities; they convert to
16//!   radians internally and then call the corresponding `f64` intrinsic.
17//! * **Wrapping helpers:** Utility methods to wrap any angle into common ranges — `[0, 360)` (or unit equivalent),
18//!   `(-180, 180]`, and the latitude‑style quarter fold `[-90, 90]`.
19//!
20//! ## Edge cases
21//!
22//! Wrapping and trig operations follow IEEE‑754 semantics from `f64`: if the underlying numeric is `NaN` or
23//! `±∞`, results will generally be `NaN`.
24//!
25//! ## Unit symbols
26//!
27//! Unit `SYMBOL`s are used for display (e.g., `format!("{}", angle)`) and follow conventional unit symbols.
28//! Unicode symbols are used where standard notation requires them (e.g., `°`, `′`, `″`, `μas`).
29//!
30//! ## Examples
31//!
32//! Convert between degrees and radians and evaluate a trig function:
33//!
34//! ```rust
35//! use qtty_core::angular::{Degrees, Radians};
36//!
37//! let angle: Degrees = Degrees::new(90.0);
38//! let r: Radians = angle.to();
39//! assert!((r.value() - core::f64::consts::FRAC_PI_2).abs() < 1e-12);
40//! assert!((angle.sin() - 1.0).abs() < 1e-12);
41//! ```
42//!
43//! Wrap into the conventional signed range:
44//!
45//! ```rust
46//! use qtty_core::angular::Degrees;
47//! let a = Degrees::new(370.0).wrap_signed();
48//! assert_eq!(a.value(), 10.0);
49//! ```
50
51use crate::scalar::Transcendental;
52use crate::{Quantity, Unit};
53use core::f64::consts::TAU;
54use qtty_derive::Unit;
55
56#[inline]
57fn rem_euclid(x: f64, modulus: f64) -> f64 {
58    #[cfg(feature = "std")]
59    {
60        x.rem_euclid(modulus)
61    }
62    #[cfg(not(feature = "std"))]
63    {
64        let r = crate::libm::fmod(x, modulus);
65        if r < 0.0 {
66            r + modulus
67        } else {
68            r
69        }
70    }
71}
72
73/// Re-export from the dimension module.
74pub use crate::dimension::Angular;
75
76/// Blanket extension trait for any [`Unit`] whose dimension is [`Angular`].
77///
78/// These associated constants provide the size of key turn fractions *expressed in the implementing unit*.
79/// They are computed via a compile-time conversion from `TAU` radians (i.e., a full revolution) and then scaled.
80/// This keeps all fractions derived from the same base value.
81///
82/// > **Naming note:** The historical spelling `QUARTED_TURN` is retained for backward compatibility. It represents a
83/// > quarter turn (90°).
84pub trait AngularUnit: Unit<Dim = Angular> {
85    /// One full revolution (τ radians / 360°) expressed in this unit.
86    const FULL_TURN: f64;
87    /// Half a revolution (π radians / 180°) expressed in this unit.
88    const HALF_TURN: f64;
89    /// A quarter revolution (π/2 radians / 90°) expressed in this unit.
90    const QUARTED_TURN: f64;
91}
92impl<T: Unit<Dim = Angular>> AngularUnit for T {
93    /// One full revolution (360°) expressed in T unit.
94    const FULL_TURN: f64 = Radians::new(TAU).to_const::<T>().value();
95    /// Half a revolution (180°) expressed in T unit.
96    const HALF_TURN: f64 = Radians::new(TAU).to_const::<T>().value() * 0.5;
97    /// Quarter revolution (90°) expressed in T unit.
98    const QUARTED_TURN: f64 = Radians::new(TAU).to_const::<T>().value() * 0.25;
99}
100
101impl<U: AngularUnit + Copy> Quantity<U> {
102    /// Constant representing τ radians (2π rad == 360°).
103    ///
104    /// For angular quantities, `TAU` and [`Self::FULL_TURN`] are identical by construction.
105    pub const TAU: Quantity<U> = Quantity::<U>::new(U::FULL_TURN);
106    /// One full revolution (360°) expressed as `Quantity<U>`.
107    pub const FULL_TURN: Quantity<U> = Quantity::<U>::new(U::FULL_TURN);
108    /// Half a revolution (180°) expressed as `Quantity<U>`.
109    pub const HALF_TURN: Quantity<U> = Quantity::<U>::new(U::HALF_TURN);
110    /// Quarter revolution (90°) expressed as `Quantity<U>`.
111    pub const QUARTED_TURN: Quantity<U> = Quantity::<U>::new(U::QUARTED_TURN);
112
113    /// Sign of the *raw numeric* in this unit (same semantics as `f64::signum()`).
114    #[inline]
115    pub const fn signum_const(self) -> f64 {
116        self.value().signum()
117    }
118
119    /// Normalize into the canonical positive range `[0, FULL_TURN)`.
120    ///
121    /// Shorthand for [`Self::wrap_pos`].
122    #[inline]
123    pub fn normalize(self) -> Self {
124        self.wrap_pos()
125    }
126
127    /// Wrap into the positive range `[0, FULL_TURN)` using Euclidean remainder.
128    ///
129    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
130    #[inline]
131    pub fn wrap_pos(self) -> Self {
132        Self::new(rem_euclid(self.value(), U::FULL_TURN))
133    }
134
135    /// Wrap into the signed range `(-HALF_TURN, HALF_TURN]`.
136    ///
137    /// *Upper bound is inclusive*; lower bound is exclusive. Useful for computing minimal signed angular differences.
138    ///
139    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
140    #[inline]
141    pub fn wrap_signed(self) -> Self {
142        let full = U::FULL_TURN;
143        let half = 0.5 * full;
144        let x = self.value();
145        let y = rem_euclid(x + half, full) - half;
146        let norm = if y <= -half { y + full } else { y };
147        Self::new(norm)
148    }
149
150    /// Wrap into the alternate signed range `[-HALF_TURN, HALF_TURN)`.
151    ///
152    /// Lower bound inclusive; upper bound exclusive. Equivalent to `self.wrap_signed()` with the boundary flipped.
153    ///
154    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
155    #[inline]
156    pub fn wrap_signed_lo(self) -> Self {
157        let mut y = self.wrap_signed().value(); // now in (-half, half]
158        let half = 0.5 * U::FULL_TURN;
159        if y >= half {
160            // move +half to -half
161            y -= U::FULL_TURN;
162        }
163        Self::new(y)
164    }
165
166    /// "Latitude fold": map into `[-QUARTER_TURN, +QUARTER_TURN]`.
167    ///
168    /// Useful for folding polar coordinates (e.g., converting declination‑like angles to a limited range).
169    ///
170    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
171    #[inline]
172    pub fn wrap_quarter_fold(self) -> Self {
173        let full = U::FULL_TURN;
174        let half = 0.5 * full;
175        let quarter = 0.25 * full;
176        let y = rem_euclid(self.value() + quarter, full);
177        // quarter - |y - half| yields [-quarter, quarter]
178        Self::new(quarter - (y - half).abs())
179    }
180
181    /// Signed smallest angular separation in `(-HALF_TURN, HALF_TURN]`.
182    #[inline]
183    pub fn signed_separation(self, other: Self) -> Self {
184        (self - other).wrap_signed()
185    }
186
187    /// Absolute smallest angular separation (magnitude only).
188    #[inline]
189    pub fn abs_separation(self, other: Self) -> Self {
190        let sep = self.signed_separation(other);
191        Self::new(sep.value().abs())
192    }
193}
194
195// ─────────────────────────────────────────────────────────────────────────────
196// Generic trigonometric implementations for any Transcendental scalar type
197// ─────────────────────────────────────────────────────────────────────────────
198
199impl<U: AngularUnit + Copy, S: Transcendental> Quantity<U, S> {
200    /// Sine of the angle.
201    ///
202    /// Converts the angle to radians and computes the sine.
203    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
204    #[inline]
205    pub fn sin(self) -> S {
206        let x_rad = self.to::<Radian>().value();
207        x_rad.sin()
208    }
209
210    /// Cosine of the angle.
211    ///
212    /// Converts the angle to radians and computes the cosine.
213    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
214    #[inline]
215    pub fn cos(self) -> S {
216        let x_rad = self.to::<Radian>().value();
217        x_rad.cos()
218    }
219
220    /// Tangent of the angle.
221    ///
222    /// Converts the angle to radians and computes the tangent.
223    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
224    #[inline]
225    pub fn tan(self) -> S {
226        let x_rad = self.to::<Radian>().value();
227        x_rad.tan()
228    }
229
230    /// Simultaneously compute sine and cosine.
231    ///
232    /// Converts the angle to radians and computes both sine and cosine.
233    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
234    #[inline]
235    pub fn sin_cos(self) -> (S, S) {
236        let x_rad = self.to::<Radian>().value();
237        x_rad.sin_cos()
238    }
239}
240
241/// Degree.
242#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
243#[unit(symbol = "°", dimension = Angular, ratio = 1.0)]
244pub struct Degree;
245/// Type alias shorthand for [`Degree`].
246pub type Deg = Degree;
247/// Convenience alias for a degree quantity.
248pub type Degrees = Quantity<Deg>;
249/// One degree.
250pub const DEG: Degrees = Degrees::new(1.0);
251
252/// Radian.
253#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
254#[unit(symbol = "rad", dimension = Angular, ratio = 180.0 / core::f64::consts::PI)]
255pub struct Radian;
256/// Type alias shorthand for [`Radian`].
257pub type Rad = Radian;
258/// Convenience alias for a radian quantity.
259pub type Radians = Quantity<Rad>;
260/// One radian.
261pub const RAD: Radians = Radians::new(1.0);
262
263/// Milliradian (`1/1000` radian).
264#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
265#[unit(symbol = "mrad", dimension = Angular, ratio = (180.0 / core::f64::consts::PI) / 1_000.0)]
266pub struct Milliradian;
267/// Type alias shorthand for [`Milliradian`].
268pub type Mrad = Milliradian;
269/// Convenience alias for a milliradian quantity.
270pub type Milliradians = Quantity<Mrad>;
271/// One milliradian.
272pub const MRAD: Milliradians = Milliradians::new(1.0);
273
274/// Arcminute (`1/60` degree).
275#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
276#[unit(symbol = "′", dimension = Angular, ratio = 1.0 / 60.0)]
277pub struct Arcminute;
278/// Alias for [`Arcminute`] (minute of angle, MOA).
279pub type MOA = Arcminute;
280/// Type alias shorthand for [`Arcminute`].
281pub type Arcm = Arcminute;
282/// Convenience alias for an arcminute quantity.
283pub type Arcminutes = Quantity<Arcm>;
284/// One arcminute.
285pub const ARCM: Arcminutes = Arcminutes::new(1.0);
286
287/// Arcsecond (`1/3600` degree).
288#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
289#[unit(symbol = "″", dimension = Angular, ratio = 1.0 / 3600.0)]
290pub struct Arcsecond;
291/// Type alias shorthand for [`Arcsecond`].
292pub type Arcs = Arcsecond;
293/// Convenience alias for an arcsecond quantity.
294pub type Arcseconds = Quantity<Arcs>;
295/// One arcsecond.
296pub const ARCS: Arcseconds = Arcseconds::new(1.0);
297
298/// Milliarcsecond (`1/3_600_000` degree).
299#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
300#[unit(symbol = "mas", dimension = Angular, ratio = 1.0 / 3_600_000.0)]
301pub struct MilliArcsecond;
302/// Type alias shorthand for [`MilliArcsecond`].
303pub type Mas = MilliArcsecond;
304/// Convenience alias for a milliarcsecond quantity.
305pub type MilliArcseconds = Quantity<Mas>;
306/// One milliarcsecond.
307pub const MAS: MilliArcseconds = MilliArcseconds::new(1.0);
308
309/// Microarcsecond (`1/3_600_000_000` degree).
310#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
311#[unit(symbol = "μas", dimension = Angular, ratio = 1.0 / 3_600_000_000.0)]
312pub struct MicroArcsecond;
313/// Type alias shorthand for [`MicroArcsecond`].
314pub type Uas = MicroArcsecond;
315/// Convenience alias for a microarcsecond quantity.
316pub type MicroArcseconds = Quantity<Uas>;
317/// One microarcsecond.
318pub const UAS: MicroArcseconds = MicroArcseconds::new(1.0);
319
320/// Gradian (also called gon; `1/400` of a full turn = `0.9` degree).
321#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
322#[unit(symbol = "gon", dimension = Angular, ratio = 0.9)]
323pub struct Gradian;
324/// Type alias shorthand for [`Gradian`].
325pub type Gon = Gradian;
326/// Convenience alias for a gradian quantity.
327pub type Gradians = Quantity<Gon>;
328/// One gradian.
329pub const GON: Gradians = Gradians::new(1.0);
330
331/// Turn (full revolution; `360` degrees).
332#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
333#[unit(symbol = "turn", dimension = Angular, ratio = 360.0)]
334pub struct Turn;
335/// Convenience alias for a turn quantity.
336pub type Turns = Quantity<Turn>;
337/// One turn.
338pub const TURN: Turns = Turns::new(1.0);
339
340/// Hour angle hour (`15` degrees).
341#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
342#[unit(symbol = "h", dimension = Angular, ratio = 15.0)]
343pub struct HourAngle;
344/// Type alias shorthand for [`HourAngle`].
345pub type Hms = HourAngle;
346/// Convenience alias for an hour-angle quantity.
347pub type HourAngles = Quantity<Hms>;
348/// One hour angle hour (==15°).
349pub const HOUR_ANGLE: HourAngles = HourAngles::new(1.0);
350
351impl HourAngles {
352    /// Construct from **HMS** components (`hours`, `minutes`, `seconds`).
353    ///
354    /// Sign is taken from `hours`; the `minutes` and `seconds` parameters are treated as magnitudes.
355    ///
356    /// ```rust
357    /// use qtty_core::angular::HourAngles;
358    /// let ra = HourAngles::from_hms(5, 30, 0.0); // 5h30m == 5.5h
359    /// assert_eq!(ra.value(), 5.5);
360    /// ```
361    pub const fn from_hms(hours: i32, minutes: u32, seconds: f64) -> Self {
362        let sign = if hours < 0 { -1.0 } else { 1.0 };
363        let h_abs = if hours < 0 { -hours } else { hours } as f64;
364        let m = minutes as f64 / 60.0;
365        let s = seconds / 3600.0;
366        let total_hours = sign * (h_abs + m + s);
367        Self::new(total_hours)
368    }
369}
370
371impl Degrees {
372    /// Construct from **DMS** components (`deg`, `min`, `sec`).
373    ///
374    /// Sign is taken from `deg`; the magnitude of `min` and `sec` is always added.
375    /// No range checking is performed. Use one of the wrapping helpers if you need a canonical range.
376    ///
377    /// ```rust
378    /// use qtty_core::angular::Degrees;
379    /// let lat = Degrees::from_dms(-33, 52, 0.0); // −33°52′00″
380    /// assert!(lat.value() < 0.0);
381    /// ```
382    pub const fn from_dms(deg: i32, min: u32, sec: f64) -> Self {
383        let sign = if deg < 0 { -1.0 } else { 1.0 };
384        let d_abs = if deg < 0 { -deg } else { deg } as f64;
385        let m = min as f64 / 60.0;
386        let s = sec / 3600.0;
387        let total = sign * (d_abs + m + s);
388        Self::new(total)
389    }
390
391    /// Construct from explicit sign and magnitude components.
392    ///
393    /// `sign` should be −1, 0, or +1 (0 treated as +1 unless all components are zero).
394    pub const fn from_dms_sign(sign: i8, deg: u32, min: u32, sec: f64) -> Self {
395        let s = if sign < 0 { -1.0 } else { 1.0 };
396        let total = (deg as f64) + (min as f64) / 60.0 + (sec / 3600.0);
397        Self::new(s * total)
398    }
399}
400
401// Generate all bidirectional From implementations between angular units.
402crate::impl_unit_from_conversions!(
403    Degree,
404    Radian,
405    Milliradian,
406    Arcminute,
407    Arcsecond,
408    MilliArcsecond,
409    MicroArcsecond,
410    Gradian,
411    Turn,
412    HourAngle
413);
414
415// Optional cross-unit operator support (`==`, `<`, etc.).
416#[cfg(feature = "cross-unit-ops")]
417crate::impl_unit_cross_unit_ops!(
418    Degree,
419    Radian,
420    Milliradian,
421    Arcminute,
422    Arcsecond,
423    MilliArcsecond,
424    MicroArcsecond,
425    Gradian,
426    Turn,
427    HourAngle
428);
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use approx::{assert_abs_diff_eq, assert_relative_eq};
434    use proptest::prelude::*;
435    use std::f64::consts::{PI, TAU};
436
437    // ─────────────────────────────────────────────────────────────────────────────
438    // Angular unit constants
439    // ─────────────────────────────────────────────────────────────────────────────
440
441    #[test]
442    fn test_full_turn() {
443        assert_abs_diff_eq!(Radian::FULL_TURN, TAU, epsilon = 1e-12);
444        assert_eq!(Degree::FULL_TURN, 360.0);
445        assert_eq!(Arcsecond::FULL_TURN, 1_296_000.0);
446    }
447
448    #[test]
449    fn test_half_turn() {
450        assert_abs_diff_eq!(Radian::HALF_TURN, PI, epsilon = 1e-12);
451        assert_eq!(Degree::HALF_TURN, 180.0);
452        assert_eq!(Arcsecond::HALF_TURN, 648_000.0);
453    }
454
455    #[test]
456    fn test_quarter_turn() {
457        assert_abs_diff_eq!(Radian::QUARTED_TURN, PI / 2.0, epsilon = 1e-12);
458        assert_eq!(Degree::QUARTED_TURN, 90.0);
459        assert_eq!(Arcsecond::QUARTED_TURN, 324_000.0);
460    }
461
462    #[test]
463    fn test_quantity_constants() {
464        assert_eq!(Degrees::FULL_TURN.value(), 360.0);
465        assert_eq!(Degrees::HALF_TURN.value(), 180.0);
466        assert_eq!(Degrees::QUARTED_TURN.value(), 90.0);
467        assert_eq!(Degrees::TAU.value(), 360.0);
468    }
469
470    // ─────────────────────────────────────────────────────────────────────────────
471    // Conversions
472    // ─────────────────────────────────────────────────────────────────────────────
473
474    #[test]
475    fn conversion_degrees_to_radians() {
476        let deg = Degrees::new(180.0);
477        let rad = deg.to::<Radian>();
478        assert_abs_diff_eq!(rad.value(), PI, epsilon = 1e-12);
479    }
480
481    #[test]
482    fn conversion_radians_to_degrees() {
483        let rad = Radians::new(PI);
484        let deg = rad.to::<Degree>();
485        assert_abs_diff_eq!(deg.value(), 180.0, epsilon = 1e-12);
486    }
487
488    #[test]
489    fn conversion_degrees_to_arcseconds() {
490        let deg = Degrees::new(1.0);
491        let arcs = deg.to::<Arcsecond>();
492        assert_abs_diff_eq!(arcs.value(), 3600.0, epsilon = 1e-9);
493    }
494
495    #[test]
496    fn conversion_arcseconds_to_degrees() {
497        let arcs = Arcseconds::new(3600.0);
498        let deg = arcs.to::<Degree>();
499        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-12);
500    }
501
502    #[test]
503    fn conversion_degrees_to_milliarcseconds() {
504        let deg = Degrees::new(1.0);
505        let mas = deg.to::<MilliArcsecond>();
506        assert_abs_diff_eq!(mas.value(), 3_600_000.0, epsilon = 1e-6);
507    }
508
509    #[test]
510    fn conversion_hour_angles_to_degrees() {
511        let ha = HourAngles::new(1.0);
512        let deg = ha.to::<Degree>();
513        assert_abs_diff_eq!(deg.value(), 15.0, epsilon = 1e-12);
514    }
515
516    #[test]
517    fn conversion_roundtrip() {
518        let original = Degrees::new(123.456);
519        let rad = original.to::<Radian>();
520        let back = rad.to::<Degree>();
521        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
522    }
523
524    #[test]
525    fn from_impl_degrees_radians() {
526        let deg = Degrees::new(90.0);
527        let rad: Radians = deg.into();
528        assert_abs_diff_eq!(rad.value(), PI / 2.0, epsilon = 1e-12);
529
530        let rad2 = Radians::new(PI);
531        let deg2: Degrees = rad2.into();
532        assert_abs_diff_eq!(deg2.value(), 180.0, epsilon = 1e-12);
533    }
534
535    // ─────────────────────────────────────────────────────────────────────────────
536    // Trig functions
537    // ─────────────────────────────────────────────────────────────────────────────
538
539    #[test]
540    fn test_trig() {
541        let a = Degrees::new(90.0);
542        assert!((a.sin() - 1.0).abs() < 1e-12);
543        assert!(a.cos().abs() < 1e-12);
544    }
545
546    #[test]
547    fn trig_sin_known_values() {
548        assert_abs_diff_eq!(Degrees::new(0.0).sin(), 0.0, epsilon = 1e-12);
549        assert_abs_diff_eq!(Degrees::new(30.0).sin(), 0.5, epsilon = 1e-12);
550        assert_abs_diff_eq!(Degrees::new(90.0).sin(), 1.0, epsilon = 1e-12);
551        assert_abs_diff_eq!(Degrees::new(180.0).sin(), 0.0, epsilon = 1e-12);
552        assert_abs_diff_eq!(Degrees::new(270.0).sin(), -1.0, epsilon = 1e-12);
553    }
554
555    #[test]
556    fn trig_cos_known_values() {
557        assert_abs_diff_eq!(Degrees::new(0.0).cos(), 1.0, epsilon = 1e-12);
558        assert_abs_diff_eq!(Degrees::new(60.0).cos(), 0.5, epsilon = 1e-12);
559        assert_abs_diff_eq!(Degrees::new(90.0).cos(), 0.0, epsilon = 1e-12);
560        assert_abs_diff_eq!(Degrees::new(180.0).cos(), -1.0, epsilon = 1e-12);
561    }
562
563    #[test]
564    fn trig_tan_known_values() {
565        assert_abs_diff_eq!(Degrees::new(0.0).tan(), 0.0, epsilon = 1e-12);
566        assert_abs_diff_eq!(Degrees::new(45.0).tan(), 1.0, epsilon = 1e-12);
567        assert_abs_diff_eq!(Degrees::new(180.0).tan(), 0.0, epsilon = 1e-12);
568    }
569
570    #[test]
571    fn trig_sin_cos_consistency() {
572        let angle = Degrees::new(37.5);
573        let (sin, cos) = angle.sin_cos();
574        assert_abs_diff_eq!(sin, angle.sin(), epsilon = 1e-15);
575        assert_abs_diff_eq!(cos, angle.cos(), epsilon = 1e-15);
576    }
577
578    #[test]
579    fn trig_pythagorean_identity() {
580        let angle = Degrees::new(123.456);
581        let sin = angle.sin();
582        let cos = angle.cos();
583        assert_abs_diff_eq!(sin * sin + cos * cos, 1.0, epsilon = 1e-12);
584    }
585
586    #[test]
587    fn trig_radians() {
588        assert_abs_diff_eq!(Radians::new(0.0).sin(), 0.0, epsilon = 1e-12);
589        assert_abs_diff_eq!(Radians::new(PI / 2.0).sin(), 1.0, epsilon = 1e-12);
590        assert_abs_diff_eq!(Radians::new(PI).cos(), -1.0, epsilon = 1e-12);
591    }
592
593    // ─────────────────────────────────────────────────────────────────────────────
594    // signum
595    // ─────────────────────────────────────────────────────────────────────────────
596
597    #[test]
598    fn signum_positive() {
599        assert_eq!(Degrees::new(45.0).signum(), 1.0);
600    }
601
602    #[test]
603    fn signum_negative() {
604        assert_eq!(Degrees::new(-45.0).signum(), -1.0);
605    }
606
607    #[test]
608    fn signum_zero() {
609        assert_eq!(Degrees::new(0.0).signum(), 1.0);
610    }
611
612    // ─────────────────────────────────────────────────────────────────────────────
613    // wrap_pos (normalize)
614    // ─────────────────────────────────────────────────────────────────────────────
615
616    #[test]
617    fn wrap_pos_basic() {
618        assert_abs_diff_eq!(
619            Degrees::new(370.0).wrap_pos().value(),
620            10.0,
621            epsilon = 1e-12
622        );
623        assert_abs_diff_eq!(Degrees::new(720.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
624        assert_abs_diff_eq!(Degrees::new(0.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
625    }
626
627    #[test]
628    fn wrap_pos_negative() {
629        assert_abs_diff_eq!(
630            Degrees::new(-10.0).wrap_pos().value(),
631            350.0,
632            epsilon = 1e-12
633        );
634        assert_abs_diff_eq!(
635            Degrees::new(-370.0).wrap_pos().value(),
636            350.0,
637            epsilon = 1e-12
638        );
639        assert_abs_diff_eq!(
640            Degrees::new(-720.0).wrap_pos().value(),
641            0.0,
642            epsilon = 1e-12
643        );
644    }
645
646    #[test]
647    fn wrap_pos_boundary() {
648        assert_abs_diff_eq!(Degrees::new(360.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
649        assert_abs_diff_eq!(
650            Degrees::new(-360.0).wrap_pos().value(),
651            0.0,
652            epsilon = 1e-12
653        );
654    }
655
656    #[test]
657    fn normalize_is_wrap_pos() {
658        let angle = Degrees::new(450.0);
659        assert_eq!(angle.normalize().value(), angle.wrap_pos().value());
660    }
661
662    // ─────────────────────────────────────────────────────────────────────────────
663    // wrap_signed: (-180, 180]
664    // ─────────────────────────────────────────────────────────────────────────────
665
666    #[test]
667    fn test_wrap_signed() {
668        let a = Degrees::new(370.0).wrap_signed();
669        assert_eq!(a.value(), 10.0);
670        let b = Degrees::new(-190.0).wrap_signed();
671        assert_eq!(b.value(), 170.0);
672    }
673
674    #[test]
675    fn wrap_signed_basic() {
676        assert_abs_diff_eq!(
677            Degrees::new(10.0).wrap_signed().value(),
678            10.0,
679            epsilon = 1e-12
680        );
681        assert_abs_diff_eq!(
682            Degrees::new(-10.0).wrap_signed().value(),
683            -10.0,
684            epsilon = 1e-12
685        );
686    }
687
688    #[test]
689    fn wrap_signed_over_180() {
690        assert_abs_diff_eq!(
691            Degrees::new(190.0).wrap_signed().value(),
692            -170.0,
693            epsilon = 1e-12
694        );
695        assert_abs_diff_eq!(
696            Degrees::new(270.0).wrap_signed().value(),
697            -90.0,
698            epsilon = 1e-12
699        );
700    }
701
702    #[test]
703    fn wrap_signed_boundary_180() {
704        assert_abs_diff_eq!(
705            Degrees::new(180.0).wrap_signed().value(),
706            180.0,
707            epsilon = 1e-12
708        );
709        assert_abs_diff_eq!(
710            Degrees::new(-180.0).wrap_signed().value(),
711            180.0,
712            epsilon = 1e-12
713        );
714    }
715
716    #[test]
717    fn wrap_signed_large_values() {
718        assert_abs_diff_eq!(
719            Degrees::new(540.0).wrap_signed().value(),
720            180.0,
721            epsilon = 1e-12
722        );
723        assert_abs_diff_eq!(
724            Degrees::new(-540.0).wrap_signed().value(),
725            180.0,
726            epsilon = 1e-12
727        );
728    }
729
730    // ─────────────────────────────────────────────────────────────────────────────
731    // wrap_quarter_fold: [-90, 90]
732    // ─────────────────────────────────────────────────────────────────────────────
733
734    #[test]
735    fn wrap_quarter_fold_basic() {
736        assert_abs_diff_eq!(
737            Degrees::new(0.0).wrap_quarter_fold().value(),
738            0.0,
739            epsilon = 1e-12
740        );
741        assert_abs_diff_eq!(
742            Degrees::new(45.0).wrap_quarter_fold().value(),
743            45.0,
744            epsilon = 1e-12
745        );
746        assert_abs_diff_eq!(
747            Degrees::new(-45.0).wrap_quarter_fold().value(),
748            -45.0,
749            epsilon = 1e-12
750        );
751    }
752
753    #[test]
754    fn wrap_quarter_fold_boundary() {
755        assert_abs_diff_eq!(
756            Degrees::new(90.0).wrap_quarter_fold().value(),
757            90.0,
758            epsilon = 1e-12
759        );
760        assert_abs_diff_eq!(
761            Degrees::new(-90.0).wrap_quarter_fold().value(),
762            -90.0,
763            epsilon = 1e-12
764        );
765    }
766
767    #[test]
768    fn wrap_quarter_fold_over_90() {
769        assert_abs_diff_eq!(
770            Degrees::new(100.0).wrap_quarter_fold().value(),
771            80.0,
772            epsilon = 1e-12
773        );
774        assert_abs_diff_eq!(
775            Degrees::new(135.0).wrap_quarter_fold().value(),
776            45.0,
777            epsilon = 1e-12
778        );
779        assert_abs_diff_eq!(
780            Degrees::new(180.0).wrap_quarter_fold().value(),
781            0.0,
782            epsilon = 1e-12
783        );
784    }
785
786    // ─────────────────────────────────────────────────────────────────────────────
787    // Separation helpers
788    // ─────────────────────────────────────────────────────────────────────────────
789
790    #[test]
791    fn signed_separation_basic() {
792        let a = Degrees::new(30.0);
793        let b = Degrees::new(50.0);
794        assert_abs_diff_eq!(a.signed_separation(b).value(), -20.0, epsilon = 1e-12);
795        assert_abs_diff_eq!(b.signed_separation(a).value(), 20.0, epsilon = 1e-12);
796    }
797
798    #[test]
799    fn signed_separation_wrap() {
800        let a = Degrees::new(10.0);
801        let b = Degrees::new(350.0);
802        assert_abs_diff_eq!(a.signed_separation(b).value(), 20.0, epsilon = 1e-12);
803        assert_abs_diff_eq!(b.signed_separation(a).value(), -20.0, epsilon = 1e-12);
804    }
805
806    #[test]
807    fn abs_separation() {
808        let a = Degrees::new(30.0);
809        let b = Degrees::new(50.0);
810        assert_abs_diff_eq!(a.abs_separation(b).value(), 20.0, epsilon = 1e-12);
811        assert_abs_diff_eq!(b.abs_separation(a).value(), 20.0, epsilon = 1e-12);
812    }
813
814    // ─────────────────────────────────────────────────────────────────────────────
815    // DMS / HMS construction
816    // ─────────────────────────────────────────────────────────────────────────────
817
818    #[test]
819    fn degrees_from_dms_positive() {
820        let d = Degrees::from_dms(12, 30, 0.0);
821        assert_abs_diff_eq!(d.value(), 12.5, epsilon = 1e-12);
822    }
823
824    #[test]
825    fn degrees_from_dms_negative() {
826        let d = Degrees::from_dms(-33, 52, 0.0);
827        assert!(d.value() < 0.0);
828        assert_abs_diff_eq!(d.value(), -(33.0 + 52.0 / 60.0), epsilon = 1e-12);
829    }
830
831    #[test]
832    fn degrees_from_dms_with_seconds() {
833        let d = Degrees::from_dms(10, 20, 30.0);
834        assert_abs_diff_eq!(
835            d.value(),
836            10.0 + 20.0 / 60.0 + 30.0 / 3600.0,
837            epsilon = 1e-12
838        );
839    }
840
841    #[test]
842    fn degrees_from_dms_sign() {
843        let pos = Degrees::from_dms_sign(1, 45, 30, 0.0);
844        let neg = Degrees::from_dms_sign(-1, 45, 30, 0.0);
845        assert_abs_diff_eq!(pos.value(), 45.5, epsilon = 1e-12);
846        assert_abs_diff_eq!(neg.value(), -45.5, epsilon = 1e-12);
847    }
848
849    #[test]
850    fn hour_angles_from_hms() {
851        let ha = HourAngles::from_hms(5, 30, 0.0);
852        assert_abs_diff_eq!(ha.value(), 5.5, epsilon = 1e-12);
853    }
854
855    #[test]
856    fn hour_angles_from_hms_negative() {
857        let ha = HourAngles::from_hms(-3, 15, 0.0);
858        assert_abs_diff_eq!(ha.value(), -3.25, epsilon = 1e-12);
859    }
860
861    #[test]
862    fn hour_angles_to_degrees() {
863        let ha = HourAngles::new(6.0);
864        let deg = ha.to::<Degree>();
865        assert_abs_diff_eq!(deg.value(), 90.0, epsilon = 1e-12);
866    }
867
868    // ─────────────────────────────────────────────────────────────────────────────
869    // Display formatting
870    // ─────────────────────────────────────────────────────────────────────────────
871
872    #[test]
873    fn display_degrees() {
874        let d = Degrees::new(45.5);
875        assert_eq!(format!("{}", d), "45.5 °");
876    }
877
878    #[test]
879    fn display_radians() {
880        let r = Radians::new(1.0);
881        assert_eq!(format!("{}", r), "1 rad");
882    }
883
884    // ─────────────────────────────────────────────────────────────────────────────
885    // Unit constants
886    // ─────────────────────────────────────────────────────────────────────────────
887
888    #[test]
889    fn unit_constants() {
890        assert_eq!(DEG.value(), 1.0);
891        assert_eq!(RAD.value(), 1.0);
892        assert_eq!(MRAD.value(), 1.0);
893        assert_eq!(ARCM.value(), 1.0);
894        assert_eq!(ARCS.value(), 1.0);
895        assert_eq!(MAS.value(), 1.0);
896        assert_eq!(UAS.value(), 1.0);
897        assert_eq!(GON.value(), 1.0);
898        assert_eq!(TURN.value(), 1.0);
899        assert_eq!(HOUR_ANGLE.value(), 1.0);
900    }
901
902    // ─────────────────────────────────────────────────────────────────────────────
903    // wrap_signed_lo: [-180, 180)
904    // ─────────────────────────────────────────────────────────────────────────────
905
906    #[test]
907    fn wrap_signed_lo_boundary_half_turn() {
908        // +half turn should map to -half turn to make the upper bound exclusive.
909        assert_abs_diff_eq!(
910            Degrees::new(180.0).wrap_signed_lo().value(),
911            -180.0,
912            epsilon = 1e-12
913        );
914        assert_abs_diff_eq!(
915            Degrees::new(-180.0).wrap_signed_lo().value(),
916            -180.0,
917            epsilon = 1e-12
918        );
919    }
920
921    // ─────────────────────────────────────────────────────────────────────────────
922    // New unit conversions and tests
923    // ─────────────────────────────────────────────────────────────────────────────
924
925    #[test]
926    fn conversion_degrees_to_arcminutes() {
927        let deg = Degrees::new(1.0);
928        let arcm = deg.to::<Arcminute>();
929        assert_abs_diff_eq!(arcm.value(), 60.0, epsilon = 1e-12);
930    }
931
932    #[test]
933    fn conversion_arcminutes_to_degrees() {
934        let arcm = Arcminutes::new(60.0);
935        let deg = arcm.to::<Degree>();
936        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-12);
937    }
938
939    #[test]
940    fn conversion_arcminutes_to_arcseconds() {
941        let arcm = Arcminutes::new(1.0);
942        let arcs = arcm.to::<Arcsecond>();
943        assert_abs_diff_eq!(arcs.value(), 60.0, epsilon = 1e-12);
944    }
945
946    #[test]
947    fn conversion_arcseconds_to_microarcseconds() {
948        let arcs = Arcseconds::new(1.0);
949        let uas = arcs.to::<MicroArcsecond>();
950        assert_abs_diff_eq!(uas.value(), 1_000_000.0, epsilon = 1e-6);
951    }
952
953    #[test]
954    fn conversion_microarcseconds_to_degrees() {
955        let uas = MicroArcseconds::new(3_600_000_000.0);
956        let deg = uas.to::<Degree>();
957        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-9);
958    }
959
960    #[test]
961    fn conversion_degrees_to_gradians() {
962        let deg = Degrees::new(90.0);
963        let gon = deg.to::<Gradian>();
964        assert_abs_diff_eq!(gon.value(), 100.0, epsilon = 1e-12);
965    }
966
967    #[test]
968    fn conversion_gradians_to_degrees() {
969        let gon = Gradians::new(400.0);
970        let deg = gon.to::<Degree>();
971        assert_abs_diff_eq!(deg.value(), 360.0, epsilon = 1e-12);
972    }
973
974    #[test]
975    fn conversion_gradians_to_radians() {
976        let gon = Gradians::new(200.0);
977        let rad = gon.to::<Radian>();
978        assert_abs_diff_eq!(rad.value(), PI, epsilon = 1e-12);
979    }
980
981    #[test]
982    fn conversion_degrees_to_turns() {
983        let deg = Degrees::new(360.0);
984        let turn = deg.to::<Turn>();
985        assert_abs_diff_eq!(turn.value(), 1.0, epsilon = 1e-12);
986    }
987
988    #[test]
989    fn conversion_milliradians_to_radians() {
990        let mrad = Milliradians::new(1_000.0);
991        let rad = mrad.to::<Radian>();
992        assert_abs_diff_eq!(rad.value(), 1.0, epsilon = 1e-12);
993    }
994
995    #[test]
996    fn conversion_turns_to_degrees() {
997        let turn = Turns::new(2.5);
998        let deg = turn.to::<Degree>();
999        assert_abs_diff_eq!(deg.value(), 900.0, epsilon = 1e-12);
1000    }
1001
1002    #[test]
1003    fn conversion_turns_to_radians() {
1004        let turn = Turns::new(1.0);
1005        let rad = turn.to::<Radian>();
1006        assert_abs_diff_eq!(rad.value(), TAU, epsilon = 1e-12);
1007    }
1008
1009    #[test]
1010    fn from_impl_new_units() {
1011        // Test From trait implementations for new units
1012        let deg = Degrees::new(1.0);
1013        let arcm: Arcminutes = deg.into();
1014        assert_abs_diff_eq!(arcm.value(), 60.0, epsilon = 1e-12);
1015
1016        let gon = Gradians::new(100.0);
1017        let deg2: Degrees = gon.into();
1018        assert_abs_diff_eq!(deg2.value(), 90.0, epsilon = 1e-12);
1019
1020        let turn = Turns::new(0.25);
1021        let deg3: Degrees = turn.into();
1022        assert_abs_diff_eq!(deg3.value(), 90.0, epsilon = 1e-12);
1023    }
1024
1025    #[test]
1026    fn roundtrip_arcminute_arcsecond() {
1027        let original = Arcminutes::new(5.0);
1028        let arcs = original.to::<Arcsecond>();
1029        let back = arcs.to::<Arcminute>();
1030        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1031    }
1032
1033    #[test]
1034    fn roundtrip_gradian_degree() {
1035        let original = Gradians::new(123.456);
1036        let deg = original.to::<Degree>();
1037        let back = deg.to::<Gradian>();
1038        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1039    }
1040
1041    #[test]
1042    fn roundtrip_turn_radian() {
1043        let original = Turns::new(2.717);
1044        let rad = original.to::<Radian>();
1045        let back = rad.to::<Turn>();
1046        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1047    }
1048
1049    #[test]
1050    fn gradian_full_turn() {
1051        assert_abs_diff_eq!(Gradian::FULL_TURN, 400.0, epsilon = 1e-12);
1052    }
1053
1054    #[test]
1055    fn turn_full_turn() {
1056        assert_abs_diff_eq!(Turn::FULL_TURN, 1.0, epsilon = 1e-12);
1057    }
1058
1059    #[test]
1060    fn arcminute_full_turn() {
1061        assert_abs_diff_eq!(Arcminute::FULL_TURN, 21_600.0, epsilon = 1e-9);
1062    }
1063
1064    #[test]
1065    fn microarcsecond_conversion_chain() {
1066        // Test a long conversion chain
1067        let uas = MicroArcseconds::new(1e9);
1068        let mas = uas.to::<MilliArcsecond>();
1069        let arcs = mas.to::<Arcsecond>();
1070        let arcm = arcs.to::<Arcminute>();
1071        let deg = arcm.to::<Degree>();
1072
1073        assert_abs_diff_eq!(mas.value(), 1_000_000.0, epsilon = 1e-6);
1074        assert_abs_diff_eq!(arcs.value(), 1_000.0, epsilon = 1e-9);
1075        assert_abs_diff_eq!(arcm.value(), 1_000.0 / 60.0, epsilon = 1e-9);
1076        assert_relative_eq!(deg.value(), 1_000.0 / 3600.0, max_relative = 1e-9);
1077    }
1078
1079    #[test]
1080    fn wrap_pos_with_turns() {
1081        let turn = Turns::new(2.7);
1082        let wrapped = turn.wrap_pos();
1083        assert_abs_diff_eq!(wrapped.value(), 0.7, epsilon = 1e-12);
1084    }
1085
1086    #[test]
1087    fn wrap_signed_with_gradians() {
1088        let gon = Gradians::new(350.0);
1089        let wrapped = gon.wrap_signed();
1090        assert_abs_diff_eq!(wrapped.value(), -50.0, epsilon = 1e-12);
1091    }
1092
1093    #[test]
1094    fn trig_with_gradians() {
1095        let gon = Gradians::new(100.0); // 90 degrees
1096        assert_abs_diff_eq!(gon.sin(), 1.0, epsilon = 1e-12);
1097        assert_abs_diff_eq!(gon.cos(), 0.0, epsilon = 1e-12);
1098    }
1099
1100    #[test]
1101    fn trig_with_turns() {
1102        let turn = Turns::new(0.25); // 90 degrees
1103        assert_abs_diff_eq!(turn.sin(), 1.0, epsilon = 1e-12);
1104        assert_abs_diff_eq!(turn.cos(), 0.0, epsilon = 1e-12);
1105    }
1106
1107    #[test]
1108    fn all_units_to_degrees() {
1109        // Verify all units convert correctly to degrees
1110        assert_abs_diff_eq!(
1111            Radians::new(PI).to::<Degree>().value(),
1112            180.0,
1113            epsilon = 1e-12
1114        );
1115        assert_abs_diff_eq!(
1116            Arcminutes::new(60.0).to::<Degree>().value(),
1117            1.0,
1118            epsilon = 1e-12
1119        );
1120        assert_abs_diff_eq!(
1121            Arcseconds::new(3600.0).to::<Degree>().value(),
1122            1.0,
1123            epsilon = 1e-12
1124        );
1125        assert_abs_diff_eq!(
1126            MilliArcseconds::new(3_600_000.0).to::<Degree>().value(),
1127            1.0,
1128            epsilon = 1e-9
1129        );
1130        assert_abs_diff_eq!(
1131            MicroArcseconds::new(3_600_000_000.0).to::<Degree>().value(),
1132            1.0,
1133            epsilon = 1e-6
1134        );
1135        assert_abs_diff_eq!(
1136            Gradians::new(100.0).to::<Degree>().value(),
1137            90.0,
1138            epsilon = 1e-12
1139        );
1140        assert_abs_diff_eq!(
1141            Turns::new(1.0).to::<Degree>().value(),
1142            360.0,
1143            epsilon = 1e-12
1144        );
1145        assert_abs_diff_eq!(
1146            HourAngles::new(1.0).to::<Degree>().value(),
1147            15.0,
1148            epsilon = 1e-12
1149        );
1150    }
1151
1152    // ─────────────────────────────────────────────────────────────────────────────
1153    // Property-based tests
1154    // ─────────────────────────────────────────────────────────────────────────────
1155
1156    proptest! {
1157        #[test]
1158        fn prop_wrap_pos_range(angle in -1e6..1e6f64) {
1159            let wrapped = Degrees::new(angle).wrap_pos();
1160            prop_assert!(wrapped.value() >= 0.0);
1161            prop_assert!(wrapped.value() < 360.0);
1162        }
1163
1164        #[test]
1165        fn prop_wrap_signed_range(angle in -1e6..1e6f64) {
1166            let wrapped = Degrees::new(angle).wrap_signed();
1167            prop_assert!(wrapped.value() > -180.0);
1168            prop_assert!(wrapped.value() <= 180.0);
1169        }
1170
1171        #[test]
1172        fn prop_wrap_quarter_fold_range(angle in -1e6..1e6f64) {
1173            let wrapped = Degrees::new(angle).wrap_quarter_fold();
1174            prop_assert!(wrapped.value() >= -90.0);
1175            prop_assert!(wrapped.value() <= 90.0);
1176        }
1177
1178        #[test]
1179        fn prop_pythagorean_identity(angle in -360.0..360.0f64) {
1180            let a = Degrees::new(angle);
1181            let sin = a.sin();
1182            let cos = a.cos();
1183            assert_abs_diff_eq!(sin * sin + cos * cos, 1.0, epsilon = 1e-12);
1184        }
1185
1186        #[test]
1187        fn prop_conversion_roundtrip(angle in -1e6..1e6f64) {
1188            let deg = Degrees::new(angle);
1189            let rad = deg.to::<Radian>();
1190            let back = rad.to::<Degree>();
1191            assert_relative_eq!(back.value(), deg.value(), max_relative = 1e-12);
1192        }
1193
1194        #[test]
1195        fn prop_abs_separation_symmetric(a in -360.0..360.0f64, b in -360.0..360.0f64) {
1196            let da = Degrees::new(a);
1197            let db = Degrees::new(b);
1198            assert_abs_diff_eq!(
1199                da.abs_separation(db).value(),
1200                db.abs_separation(da).value(),
1201                epsilon = 1e-12
1202            );
1203        }
1204    }
1205}