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