Skip to main content

qtty_core/units/angular/
mod.rs

1// SPDX-License-Identifier: BSD-3-Clause
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Angular quantities and utilities.
5//!
6//! This module defines the **`Angular` dimension**, a blanket [`AngularUnit`] trait that extends
7//! [`Unit`] for all angular units, common angular units (degrees, radians, arcseconds, etc.), and a set of
8//! convenience methods on [`Quantity<U>`] where `U: AngularUnit`.
9//!
10//! # Design overview
11//!
12//! * **Canonical unit:** Degrees are taken as the canonical *scaling* unit for this dimension. That is,
13//!   `Degree::RATIO == 1.0`, and all other angular units express how many *degrees* correspond to one of that unit.
14//!   For example, `Radian::RATIO == 180.0 / PI` because 1 radian = 180/π degrees.
15//! * **Associated constants:** The `AngularUnit` trait exposes precomputed constants (`FULL_TURN`, `HALF_TURN`,
16//!   `QUARTER_TURN`) expressed *in the receiving unit* for ergonomic range‑wrapping. These are derived from `τ`
17//!   radians and then converted to the target unit to avoid cumulative error from chained conversions.
18//! * **Trigonometry:** `sin`, `cos`, `tan`, and `sin_cos` methods are provided on angular quantities; they convert to
19//!   radians internally and then call the corresponding `f64` intrinsic.
20//! * **Wrapping helpers:** Utility methods to wrap any angle into common ranges — `[0, 360)` (or unit equivalent),
21//!   `(-180, 180]`, and the latitude‑style quarter fold `[-90, 90]`.
22//!
23//! ## Edge cases
24//!
25//! Wrapping and trig operations follow IEEE‑754 semantics from `f64`: if the underlying numeric is `NaN` or
26//! `±∞`, results will generally be `NaN`.
27//!
28//! ## Unit symbols
29//!
30//! Unit `SYMBOL`s are used for display (e.g., `format!("{}", angle)`) and follow conventional unit symbols.
31//! Unicode symbols are used where standard notation requires them (e.g., `°`, `′`, `″`, `μas`).
32//!
33//! ## Examples
34//!
35//! Convert between degrees and radians and evaluate a trig function:
36//!
37//! ```rust
38//! use qtty_core::angular::{Degrees, Radians};
39//!
40//! let angle: Degrees = Degrees::new(90.0);
41//! let r: Radians = angle.to();
42//! assert!((r.value() - core::f64::consts::FRAC_PI_2).abs() < 1e-12);
43//! assert!((angle.sin() - 1.0).abs() < 1e-12);
44//! ```
45//!
46//! Wrap into the conventional signed range:
47//!
48//! ```rust
49//! use qtty_core::angular::Degrees;
50//! let a = Degrees::new(370.0).wrap_signed();
51//! assert_eq!(a.value(), 10.0);
52//! ```
53
54use crate::scalar::Transcendental;
55use crate::{Quantity, Unit};
56use core::f64::consts::TAU;
57use qtty_derive::Unit;
58
59#[inline]
60fn rem_euclid(x: f64, modulus: f64) -> f64 {
61    #[cfg(feature = "std")]
62    {
63        x.rem_euclid(modulus)
64    }
65    #[cfg(not(feature = "std"))]
66    {
67        let r = crate::libm::fmod(x, modulus);
68        if r < 0.0 {
69            r + modulus
70        } else {
71            r
72        }
73    }
74}
75
76/// Re-export from the dimension module.
77pub use crate::dimension::Angular;
78
79/// Blanket extension trait for any [`Unit`] whose dimension is [`Angular`].
80///
81/// These associated constants provide the size of key turn fractions *expressed in the implementing unit*.
82/// They are computed via a compile-time conversion from `TAU` radians (i.e., a full revolution) and then scaled.
83/// This keeps all fractions derived from the same base value.
84///
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 QUARTER_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_const::<T>().value();
96    /// Half a revolution (180°) expressed in T unit.
97    const HALF_TURN: f64 = Radians::new(TAU).to_const::<T>().value() * 0.5;
98    /// Quarter revolution (90°) expressed in T unit.
99    const QUARTER_TURN: f64 = Radians::new(TAU).to_const::<T>().value() * 0.25;
100}
101
102#[cfg(feature = "astro")]
103mod astro;
104#[cfg(feature = "astro")]
105pub use astro::*;
106#[cfg(feature = "navigation")]
107mod navigation;
108#[cfg(feature = "navigation")]
109pub use navigation::*;
110
111impl<U: AngularUnit + Copy> Quantity<U> {
112    /// Constant representing τ radians (2π rad == 360°).
113    ///
114    /// For angular quantities, `TAU` and [`Self::FULL_TURN`] are identical by construction.
115    pub const TAU: Quantity<U> = Quantity::<U>::new(U::FULL_TURN);
116    /// One full revolution (360°) expressed as `Quantity<U>`.
117    pub const FULL_TURN: Quantity<U> = Quantity::<U>::new(U::FULL_TURN);
118    /// Half a revolution (180°) expressed as `Quantity<U>`.
119    pub const HALF_TURN: Quantity<U> = Quantity::<U>::new(U::HALF_TURN);
120    /// Quarter revolution (90°) expressed as `Quantity<U>`.
121    pub const QUARTER_TURN: Quantity<U> = Quantity::<U>::new(U::QUARTER_TURN);
122
123    /// Sign of the *raw numeric* in this unit (same semantics as `f64::signum()`).
124    #[inline]
125    pub const fn signum_const(self) -> f64 {
126        self.value().signum()
127    }
128
129    /// Normalize into the canonical positive range `[0, FULL_TURN)`.
130    ///
131    /// Shorthand for [`Self::wrap_pos`].
132    #[inline]
133    pub fn normalize(self) -> Self {
134        self.wrap_pos()
135    }
136
137    /// Wrap into the positive range `[0, FULL_TURN)` using Euclidean remainder.
138    ///
139    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
140    #[inline]
141    pub fn wrap_pos(self) -> Self {
142        Self::new(rem_euclid(self.value(), U::FULL_TURN))
143    }
144
145    /// Wrap into the signed range `(-HALF_TURN, HALF_TURN]`.
146    ///
147    /// *Upper bound is inclusive*; lower bound is exclusive. Useful for computing minimal signed angular differences.
148    ///
149    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
150    #[inline]
151    pub fn wrap_signed(self) -> Self {
152        let full = U::FULL_TURN;
153        let half = 0.5 * full;
154        let x = self.value();
155        let y = rem_euclid(x + half, full) - half;
156        let norm = if y <= -half { y + full } else { y };
157        Self::new(norm)
158    }
159
160    /// Wrap into the alternate signed range `[-HALF_TURN, HALF_TURN)`.
161    ///
162    /// Lower bound inclusive; upper bound exclusive. Equivalent to `self.wrap_signed()` with the boundary flipped.
163    ///
164    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
165    #[inline]
166    pub fn wrap_signed_lo(self) -> Self {
167        let mut y = self.wrap_signed().value(); // now in (-half, half]
168        let half = 0.5 * U::FULL_TURN;
169        if y >= half {
170            // move +half to -half
171            y -= U::FULL_TURN;
172        }
173        Self::new(y)
174    }
175
176    /// "Latitude fold": map into `[-QUARTER_TURN, +QUARTER_TURN]`.
177    ///
178    /// Useful for folding polar coordinates (e.g., converting declination‑like angles to a limited range).
179    ///
180    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
181    #[inline]
182    pub fn wrap_quarter_fold(self) -> Self {
183        let full = U::FULL_TURN;
184        let half = 0.5 * full;
185        let quarter = 0.25 * full;
186        let y = rem_euclid(self.value() + quarter, full);
187        // quarter - |y - half| yields [-quarter, quarter]
188        Self::new(quarter - (y - half).abs())
189    }
190
191    /// Signed smallest angular separation in `(-HALF_TURN, HALF_TURN]`.
192    #[inline]
193    pub fn signed_separation(self, other: Self) -> Self {
194        (self - other).wrap_signed()
195    }
196
197    /// Absolute smallest angular separation (magnitude only).
198    #[inline]
199    pub fn abs_separation(self, other: Self) -> Self {
200        let sep = self.signed_separation(other);
201        Self::new(sep.value().abs())
202    }
203
204    /// Normalize into the signed half-open interval `(-HALF_TURN, +HALF_TURN]`.
205    ///
206    /// For radians this is `(-π, π]`; for degrees this is `(-180°, 180°]`. This is the conventional
207    /// "shortest signed difference" range. It is an alias for [`Self::wrap_signed`] provided to
208    /// match the naming used by downstream callers (e.g. NSB).
209    ///
210    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
211    ///
212    /// # Examples
213    ///
214    /// In‑range value:
215    /// ```
216    /// use qtty_core::angular::Radians;
217    /// use core::f64::consts::FRAC_PI_2;
218    /// assert!((Radians::new(FRAC_PI_2).wrap_to_signed_pi().value() - FRAC_PI_2).abs() < 1e-12);
219    /// ```
220    ///
221    /// Slightly out of range (just past π):
222    /// ```
223    /// use qtty_core::angular::Radians;
224    /// use core::f64::consts::PI;
225    /// let v = Radians::new(PI + 0.1).wrap_to_signed_pi().value();
226    /// assert!((v - (-PI + 0.1)).abs() < 1e-12);
227    /// ```
228    ///
229    /// Far out of range (7π wraps to π):
230    /// ```
231    /// use qtty_core::angular::Radians;
232    /// use core::f64::consts::PI;
233    /// let v = Radians::new(7.0 * PI).wrap_to_signed_pi().value();
234    /// assert!((v - PI).abs() < 1e-12);
235    /// ```
236    ///
237    /// Boundary at +π is inclusive:
238    /// ```
239    /// use qtty_core::angular::Radians;
240    /// use core::f64::consts::PI;
241    /// assert!((Radians::new(PI).wrap_to_signed_pi().value() - PI).abs() < 1e-12);
242    /// ```
243    ///
244    /// Negative values:
245    /// ```
246    /// use qtty_core::angular::Radians;
247    /// use core::f64::consts::PI;
248    /// let v = Radians::new(-3.0 * PI / 2.0).wrap_to_signed_pi().value();
249    /// assert!((v - PI / 2.0).abs() < 1e-12);
250    /// ```
251    ///
252    /// `NaN` propagates:
253    /// ```
254    /// use qtty_core::angular::Radians;
255    /// assert!(Radians::new(f64::NAN).wrap_to_signed_pi().value().is_nan());
256    /// ```
257    #[inline]
258    pub fn wrap_to_signed_pi(self) -> Self {
259        self.wrap_signed()
260    }
261
262    /// Normalize into the unsigned range `[0, FULL_TURN)`.
263    ///
264    /// For radians this is `[0, 2π)`; for degrees this is `[0°, 360°)`. Alias for
265    /// [`Self::wrap_pos`] provided to match the naming used by downstream callers (e.g. NSB).
266    ///
267    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
268    ///
269    /// # Examples
270    ///
271    /// In‑range value passes through:
272    /// ```
273    /// use qtty_core::angular::Radians;
274    /// use core::f64::consts::FRAC_PI_2;
275    /// assert!((Radians::new(FRAC_PI_2).wrap_to_unsigned_pi().value() - FRAC_PI_2).abs() < 1e-12);
276    /// ```
277    ///
278    /// Slightly out of range:
279    /// ```
280    /// use qtty_core::angular::Radians;
281    /// use core::f64::consts::TAU;
282    /// let v = Radians::new(TAU + 0.5).wrap_to_unsigned_pi().value();
283    /// assert!((v - 0.5).abs() < 1e-12);
284    /// ```
285    ///
286    /// Far out of range (7π → π):
287    /// ```
288    /// use qtty_core::angular::Radians;
289    /// use core::f64::consts::PI;
290    /// let v = Radians::new(7.0 * PI).wrap_to_unsigned_pi().value();
291    /// assert!((v - PI).abs() < 1e-12);
292    /// ```
293    ///
294    /// Boundary 2π folds back to 0:
295    /// ```
296    /// use qtty_core::angular::Radians;
297    /// use core::f64::consts::TAU;
298    /// assert!(Radians::new(TAU).wrap_to_unsigned_pi().value().abs() < 1e-12);
299    /// ```
300    ///
301    /// Negative values are folded into the positive range:
302    /// ```
303    /// use qtty_core::angular::Radians;
304    /// use core::f64::consts::{PI, TAU};
305    /// let v = Radians::new(-PI / 2.0).wrap_to_unsigned_pi().value();
306    /// assert!((v - (TAU - PI / 2.0)).abs() < 1e-12);
307    /// ```
308    ///
309    /// `NaN` propagates:
310    /// ```
311    /// use qtty_core::angular::Radians;
312    /// assert!(Radians::new(f64::NAN).wrap_to_unsigned_pi().value().is_nan());
313    /// ```
314    #[inline]
315    pub fn wrap_to_unsigned_pi(self) -> Self {
316        self.wrap_pos()
317    }
318
319    /// Fold into the closed interval `[0, HALF_TURN]`.
320    ///
321    /// For radians this is `[0, π]`; for degrees this is `[0°, 180°]`. This is the magnitude of
322    /// the shortest signed angular distance from the origin and matches the semantics used by
323    /// callers such as NSB when reducing ecliptic longitude separations.
324    ///
325    /// Implementation: takes the absolute value of [`Self::wrap_signed`], so the result is in
326    /// `[0, HALF_TURN]` (the upper bound is reachable when the input maps to exactly `+HALF_TURN`).
327    ///
328    /// IEEE‑754 note: `NaN`/`±∞` inputs generally produce `NaN`.
329    ///
330    /// # Examples
331    ///
332    /// In‑range value:
333    /// ```
334    /// use qtty_core::angular::Radians;
335    /// use core::f64::consts::FRAC_PI_3;
336    /// assert!((Radians::new(FRAC_PI_3).fold_to_pi().value() - FRAC_PI_3).abs() < 1e-12);
337    /// ```
338    ///
339    /// Slightly out of range (just past π folds back symmetrically):
340    /// ```
341    /// use qtty_core::angular::Radians;
342    /// use core::f64::consts::PI;
343    /// let v = Radians::new(PI + 0.1).fold_to_pi().value();
344    /// assert!((v - (PI - 0.1)).abs() < 1e-12);
345    /// ```
346    ///
347    /// Far out of range (7π folds to π):
348    /// ```
349    /// use qtty_core::angular::Radians;
350    /// use core::f64::consts::PI;
351    /// let v = Radians::new(7.0 * PI).fold_to_pi().value();
352    /// assert!((v - PI).abs() < 1e-12);
353    /// ```
354    ///
355    /// Boundary at π is reachable:
356    /// ```
357    /// use qtty_core::angular::Radians;
358    /// use core::f64::consts::PI;
359    /// assert!((Radians::new(PI).fold_to_pi().value() - PI).abs() < 1e-12);
360    /// ```
361    ///
362    /// Negative values are folded:
363    /// ```
364    /// use qtty_core::angular::Radians;
365    /// use core::f64::consts::PI;
366    /// let v = Radians::new(-PI / 4.0).fold_to_pi().value();
367    /// assert!((v - PI / 4.0).abs() < 1e-12);
368    /// ```
369    ///
370    /// `NaN` propagates:
371    /// ```
372    /// use qtty_core::angular::Radians;
373    /// assert!(Radians::new(f64::NAN).fold_to_pi().value().is_nan());
374    /// ```
375    #[inline]
376    pub fn fold_to_pi(self) -> Self {
377        Self::new(self.wrap_signed().value().abs())
378    }
379}
380
381// ─────────────────────────────────────────────────────────────────────────────
382// Generic trigonometric implementations for any Transcendental scalar type
383// ─────────────────────────────────────────────────────────────────────────────
384
385impl<U: AngularUnit + Copy, S: Transcendental> Quantity<U, S> {
386    /// Sine of the angle.
387    ///
388    /// Converts the angle to radians and computes the sine.
389    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
390    #[inline]
391    pub fn sin(self) -> S {
392        let x_rad = self.to::<Radian>().value();
393        x_rad.sin()
394    }
395
396    /// Cosine of the angle.
397    ///
398    /// Converts the angle to radians and computes the cosine.
399    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
400    #[inline]
401    pub fn cos(self) -> S {
402        let x_rad = self.to::<Radian>().value();
403        x_rad.cos()
404    }
405
406    /// Tangent of the angle.
407    ///
408    /// Converts the angle to radians and computes the tangent.
409    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
410    #[inline]
411    pub fn tan(self) -> S {
412        let x_rad = self.to::<Radian>().value();
413        x_rad.tan()
414    }
415
416    /// Simultaneously compute sine and cosine.
417    ///
418    /// Converts the angle to radians and computes both sine and cosine.
419    /// Works with any scalar type that implements [`Transcendental`] (e.g., `f32`, `f64`).
420    #[inline]
421    pub fn sin_cos(self) -> (S, S) {
422        let x_rad = self.to::<Radian>().value();
423        x_rad.sin_cos()
424    }
425}
426
427/// Degree.
428#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
429#[unit(symbol = "°", dimension = Angular, ratio = 1.0)]
430pub struct Degree;
431/// Type alias shorthand for [`Degree`].
432pub type Deg = Degree;
433/// Convenience alias for a degree quantity.
434pub type Degrees = Quantity<Deg>;
435/// One degree.
436pub const DEG: Degrees = Degrees::new(1.0);
437
438/// Radian.
439#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
440#[unit(symbol = "rad", dimension = Angular, ratio = 180.0 / core::f64::consts::PI)]
441pub struct Radian;
442/// Type alias shorthand for [`Radian`].
443pub type Rad = Radian;
444/// Convenience alias for a radian quantity.
445pub type Radians = Quantity<Rad>;
446/// One radian.
447pub const RAD: Radians = Radians::new(1.0);
448
449/// Milliradian (`1/1000` radian).
450#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
451#[unit(symbol = "mrad", dimension = Angular, ratio = (180.0 / core::f64::consts::PI) / 1_000.0)]
452pub struct Milliradian;
453/// Type alias shorthand for [`Milliradian`].
454pub type Mrad = Milliradian;
455/// Convenience alias for a milliradian quantity.
456pub type Milliradians = Quantity<Mrad>;
457/// One milliradian.
458pub const MRAD: Milliradians = Milliradians::new(1.0);
459
460/// Turn (full revolution; `360` degrees).
461#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Unit)]
462#[unit(symbol = "tr", dimension = Angular, ratio = 360.0)]
463pub struct Turn;
464/// Convenience alias for a turn quantity.
465pub type Turns = Quantity<Turn>;
466/// One turn.
467pub const TURN: Turns = Turns::new(1.0);
468
469impl Degrees {
470    /// Construct from **DMS** components (`deg`, `min`, `sec`).
471    ///
472    /// Sign is taken from `deg`; the magnitude of `min` and `sec` is always added.
473    /// No range checking is performed. Use one of the wrapping helpers if you need a canonical range.
474    ///
475    /// ```rust
476    /// use qtty_core::angular::Degrees;
477    /// let lat = Degrees::from_dms(-33, 52, 0.0); // −33°52′00″
478    /// assert!(lat.value() < 0.0);
479    /// ```
480    pub const fn from_dms(deg: i32, min: u32, sec: f64) -> Self {
481        let sign = if deg < 0 { -1.0 } else { 1.0 };
482        let d_abs = if deg < 0 { -(deg as f64) } else { deg as f64 };
483        let m = min as f64 / 60.0;
484        let s = sec / 3600.0;
485        let total = sign * (d_abs + m + s);
486        Self::new(total)
487    }
488
489    /// Construct from explicit sign and magnitude components.
490    ///
491    /// `sign < 0` produces a negative result; any other value (including `0`)
492    /// produces a positive result.
493    pub const fn from_dms_sign(sign: i8, deg: u32, min: u32, sec: f64) -> Self {
494        let s = if sign < 0 { -1.0 } else { 1.0 };
495        let total = (deg as f64) + (min as f64) / 60.0 + (sec / 3600.0);
496        Self::new(s * total)
497    }
498}
499
500/// Canonical list of always-available (base) angular units.
501///
502/// Exported (`#[doc(hidden)]`) for use in `qtty`'s scalar alias generation and
503/// compile-time consistency checks.  Feature-gated units (astro, navigation)
504/// are in their sub-modules and registered via `register_builtin_units_extend!`.
505#[macro_export]
506#[doc(hidden)]
507macro_rules! angular_units {
508    ($cb:path) => {
509        $cb!(Degree, Radian, Milliradian, Turn);
510    };
511}
512
513// Generate bidirectional From impls between base angular units.
514angular_units!(crate::impl_unit_from_conversions);
515
516// ─────────────────────────────────────────────────────────────────────────────
517// Cross-unit ops: default units
518// ─────────────────────────────────────────────────────────────────────────────
519#[cfg(feature = "cross-unit-ops")]
520angular_units!(crate::impl_unit_cross_unit_ops);
521
522// ── Cross-feature: astro × navigation ────────────────────────────────────────
523#[cfg(all(feature = "astro", feature = "navigation"))]
524crate::__impl_from_each_extra_to_bases!(
525    {Arcminute, Arcsecond, MilliArcsecond, MicroArcsecond, HourAngle}
526    Gradian
527);
528#[cfg(all(feature = "astro", feature = "navigation", feature = "cross-unit-ops"))]
529crate::__impl_cross_ops_each_extra_to_bases!(
530    {Arcminute, Arcsecond, MilliArcsecond, MicroArcsecond, HourAngle}
531    Gradian
532);
533
534// Compile-time check: every base angular unit is registered as BuiltinUnit.
535#[cfg(test)]
536angular_units!(crate::assert_units_are_builtin);
537
538#[cfg(all(test, feature = "std"))]
539mod tests {
540    use super::*;
541    use approx::{assert_abs_diff_eq, assert_relative_eq};
542    use core::f64::consts::{PI, TAU};
543    use proptest::prelude::*;
544
545    // ─────────────────────────────────────────────────────────────────────────────
546    // Angular unit constants
547    // ─────────────────────────────────────────────────────────────────────────────
548
549    #[test]
550    fn test_full_turn() {
551        assert_abs_diff_eq!(Radian::FULL_TURN, TAU, epsilon = 1e-12);
552        assert_eq!(Degree::FULL_TURN, 360.0);
553        #[cfg(feature = "astro")]
554        assert_eq!(Arcsecond::FULL_TURN, 1_296_000.0);
555    }
556
557    #[test]
558    fn test_half_turn() {
559        assert_abs_diff_eq!(Radian::HALF_TURN, PI, epsilon = 1e-12);
560        assert_eq!(Degree::HALF_TURN, 180.0);
561        #[cfg(feature = "astro")]
562        assert_eq!(Arcsecond::HALF_TURN, 648_000.0);
563    }
564
565    #[test]
566    fn test_quarter_turn() {
567        assert_abs_diff_eq!(Radian::QUARTER_TURN, PI / 2.0, epsilon = 1e-12);
568        assert_eq!(Degree::QUARTER_TURN, 90.0);
569        #[cfg(feature = "astro")]
570        assert_eq!(Arcsecond::QUARTER_TURN, 324_000.0);
571    }
572
573    #[test]
574    fn test_quantity_constants() {
575        assert_eq!(Degrees::FULL_TURN.value(), 360.0);
576        assert_eq!(Degrees::HALF_TURN.value(), 180.0);
577        assert_eq!(Degrees::QUARTER_TURN.value(), 90.0);
578        assert_eq!(Degrees::TAU.value(), 360.0);
579    }
580
581    // ─────────────────────────────────────────────────────────────────────────────
582    // Conversions
583    // ─────────────────────────────────────────────────────────────────────────────
584
585    #[test]
586    fn conversion_degrees_to_radians() {
587        let deg = Degrees::new(180.0);
588        let rad = deg.to::<Radian>();
589        assert_abs_diff_eq!(rad.value(), PI, epsilon = 1e-12);
590    }
591
592    #[test]
593    fn conversion_radians_to_degrees() {
594        let rad = Radians::new(PI);
595        let deg = rad.to::<Degree>();
596        assert_abs_diff_eq!(deg.value(), 180.0, epsilon = 1e-12);
597    }
598
599    #[test]
600    #[cfg(feature = "astro")]
601    fn conversion_degrees_to_arcseconds() {
602        let deg = Degrees::new(1.0);
603        let arcs = deg.to::<Arcsecond>();
604        assert_abs_diff_eq!(arcs.value(), 3600.0, epsilon = 1e-9);
605    }
606
607    #[test]
608    #[cfg(feature = "astro")]
609    fn conversion_arcseconds_to_degrees() {
610        let arcs = Arcseconds::new(3600.0);
611        let deg = arcs.to::<Degree>();
612        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-12);
613    }
614
615    #[test]
616    #[cfg(feature = "astro")]
617    fn conversion_degrees_to_milliarcseconds() {
618        let deg = Degrees::new(1.0);
619        let mas = deg.to::<MilliArcsecond>();
620        assert_abs_diff_eq!(mas.value(), 3_600_000.0, epsilon = 1e-6);
621    }
622
623    #[test]
624    #[cfg(feature = "astro")]
625    fn conversion_hour_angles_to_degrees() {
626        let ha = HourAngles::new(1.0);
627        let deg = ha.to::<Degree>();
628        assert_abs_diff_eq!(deg.value(), 15.0, epsilon = 1e-12);
629    }
630
631    #[test]
632    fn conversion_roundtrip() {
633        let original = Degrees::new(123.456);
634        let rad = original.to::<Radian>();
635        let back = rad.to::<Degree>();
636        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
637    }
638
639    #[test]
640    fn from_impl_degrees_radians() {
641        let deg = Degrees::new(90.0);
642        let rad: Radians = deg.into();
643        assert_abs_diff_eq!(rad.value(), PI / 2.0, epsilon = 1e-12);
644
645        let rad2 = Radians::new(PI);
646        let deg2: Degrees = rad2.into();
647        assert_abs_diff_eq!(deg2.value(), 180.0, epsilon = 1e-12);
648    }
649
650    // ─────────────────────────────────────────────────────────────────────────────
651    // Trig functions
652    // ─────────────────────────────────────────────────────────────────────────────
653
654    #[test]
655    fn test_trig() {
656        let a = Degrees::new(90.0);
657        assert!((a.sin() - 1.0).abs() < 1e-12);
658        assert!(a.cos().abs() < 1e-12);
659    }
660
661    #[test]
662    fn trig_sin_known_values() {
663        assert_abs_diff_eq!(Degrees::new(0.0).sin(), 0.0, epsilon = 1e-12);
664        assert_abs_diff_eq!(Degrees::new(30.0).sin(), 0.5, epsilon = 1e-12);
665        assert_abs_diff_eq!(Degrees::new(90.0).sin(), 1.0, epsilon = 1e-12);
666        assert_abs_diff_eq!(Degrees::new(180.0).sin(), 0.0, epsilon = 1e-12);
667        assert_abs_diff_eq!(Degrees::new(270.0).sin(), -1.0, epsilon = 1e-12);
668    }
669
670    #[test]
671    fn trig_cos_known_values() {
672        assert_abs_diff_eq!(Degrees::new(0.0).cos(), 1.0, epsilon = 1e-12);
673        assert_abs_diff_eq!(Degrees::new(60.0).cos(), 0.5, epsilon = 1e-12);
674        assert_abs_diff_eq!(Degrees::new(90.0).cos(), 0.0, epsilon = 1e-12);
675        assert_abs_diff_eq!(Degrees::new(180.0).cos(), -1.0, epsilon = 1e-12);
676    }
677
678    #[test]
679    fn trig_tan_known_values() {
680        assert_abs_diff_eq!(Degrees::new(0.0).tan(), 0.0, epsilon = 1e-12);
681        assert_abs_diff_eq!(Degrees::new(45.0).tan(), 1.0, epsilon = 1e-12);
682        assert_abs_diff_eq!(Degrees::new(180.0).tan(), 0.0, epsilon = 1e-12);
683    }
684
685    #[test]
686    fn trig_sin_cos_consistency() {
687        let angle = Degrees::new(37.5);
688        let (sin, cos) = angle.sin_cos();
689        assert_abs_diff_eq!(sin, angle.sin(), epsilon = 1e-15);
690        assert_abs_diff_eq!(cos, angle.cos(), epsilon = 1e-15);
691    }
692
693    #[test]
694    fn trig_pythagorean_identity() {
695        let angle = Degrees::new(123.456);
696        let sin = angle.sin();
697        let cos = angle.cos();
698        assert_abs_diff_eq!(sin * sin + cos * cos, 1.0, epsilon = 1e-12);
699    }
700
701    #[test]
702    fn trig_radians() {
703        assert_abs_diff_eq!(Radians::new(0.0).sin(), 0.0, epsilon = 1e-12);
704        assert_abs_diff_eq!(Radians::new(PI / 2.0).sin(), 1.0, epsilon = 1e-12);
705        assert_abs_diff_eq!(Radians::new(PI).cos(), -1.0, epsilon = 1e-12);
706    }
707
708    // ─────────────────────────────────────────────────────────────────────────────
709    // signum
710    // ─────────────────────────────────────────────────────────────────────────────
711
712    #[test]
713    fn signum_positive() {
714        assert_eq!(Degrees::new(45.0).signum(), 1.0);
715    }
716
717    #[test]
718    fn signum_negative() {
719        assert_eq!(Degrees::new(-45.0).signum(), -1.0);
720    }
721
722    #[test]
723    fn signum_zero() {
724        assert_eq!(Degrees::new(0.0).signum(), 1.0);
725    }
726
727    // ─────────────────────────────────────────────────────────────────────────────
728    // wrap_pos (normalize)
729    // ─────────────────────────────────────────────────────────────────────────────
730
731    #[test]
732    fn wrap_pos_basic() {
733        assert_abs_diff_eq!(
734            Degrees::new(370.0).wrap_pos().value(),
735            10.0,
736            epsilon = 1e-12
737        );
738        assert_abs_diff_eq!(Degrees::new(720.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
739        assert_abs_diff_eq!(Degrees::new(0.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
740    }
741
742    #[test]
743    fn wrap_pos_negative() {
744        assert_abs_diff_eq!(
745            Degrees::new(-10.0).wrap_pos().value(),
746            350.0,
747            epsilon = 1e-12
748        );
749        assert_abs_diff_eq!(
750            Degrees::new(-370.0).wrap_pos().value(),
751            350.0,
752            epsilon = 1e-12
753        );
754        assert_abs_diff_eq!(
755            Degrees::new(-720.0).wrap_pos().value(),
756            0.0,
757            epsilon = 1e-12
758        );
759    }
760
761    #[test]
762    fn wrap_pos_boundary() {
763        assert_abs_diff_eq!(Degrees::new(360.0).wrap_pos().value(), 0.0, epsilon = 1e-12);
764        assert_abs_diff_eq!(
765            Degrees::new(-360.0).wrap_pos().value(),
766            0.0,
767            epsilon = 1e-12
768        );
769    }
770
771    #[test]
772    fn normalize_is_wrap_pos() {
773        let angle = Degrees::new(450.0);
774        assert_eq!(angle.normalize().value(), angle.wrap_pos().value());
775    }
776
777    // ─────────────────────────────────────────────────────────────────────────────
778    // wrap_signed: (-180, 180]
779    // ─────────────────────────────────────────────────────────────────────────────
780
781    #[test]
782    fn test_wrap_signed() {
783        let a = Degrees::new(370.0).wrap_signed();
784        assert_eq!(a.value(), 10.0);
785        let b = Degrees::new(-190.0).wrap_signed();
786        assert_eq!(b.value(), 170.0);
787    }
788
789    #[test]
790    fn wrap_signed_basic() {
791        assert_abs_diff_eq!(
792            Degrees::new(10.0).wrap_signed().value(),
793            10.0,
794            epsilon = 1e-12
795        );
796        assert_abs_diff_eq!(
797            Degrees::new(-10.0).wrap_signed().value(),
798            -10.0,
799            epsilon = 1e-12
800        );
801    }
802
803    #[test]
804    fn wrap_signed_over_180() {
805        assert_abs_diff_eq!(
806            Degrees::new(190.0).wrap_signed().value(),
807            -170.0,
808            epsilon = 1e-12
809        );
810        assert_abs_diff_eq!(
811            Degrees::new(270.0).wrap_signed().value(),
812            -90.0,
813            epsilon = 1e-12
814        );
815    }
816
817    #[test]
818    fn wrap_signed_boundary_180() {
819        assert_abs_diff_eq!(
820            Degrees::new(180.0).wrap_signed().value(),
821            180.0,
822            epsilon = 1e-12
823        );
824        assert_abs_diff_eq!(
825            Degrees::new(-180.0).wrap_signed().value(),
826            180.0,
827            epsilon = 1e-12
828        );
829    }
830
831    #[test]
832    fn wrap_signed_large_values() {
833        assert_abs_diff_eq!(
834            Degrees::new(540.0).wrap_signed().value(),
835            180.0,
836            epsilon = 1e-12
837        );
838        assert_abs_diff_eq!(
839            Degrees::new(-540.0).wrap_signed().value(),
840            180.0,
841            epsilon = 1e-12
842        );
843    }
844
845    // ─────────────────────────────────────────────────────────────────────────────
846    // wrap_quarter_fold: [-90, 90]
847    // ─────────────────────────────────────────────────────────────────────────────
848
849    #[test]
850    fn wrap_quarter_fold_basic() {
851        assert_abs_diff_eq!(
852            Degrees::new(0.0).wrap_quarter_fold().value(),
853            0.0,
854            epsilon = 1e-12
855        );
856        assert_abs_diff_eq!(
857            Degrees::new(45.0).wrap_quarter_fold().value(),
858            45.0,
859            epsilon = 1e-12
860        );
861        assert_abs_diff_eq!(
862            Degrees::new(-45.0).wrap_quarter_fold().value(),
863            -45.0,
864            epsilon = 1e-12
865        );
866    }
867
868    #[test]
869    fn wrap_quarter_fold_boundary() {
870        assert_abs_diff_eq!(
871            Degrees::new(90.0).wrap_quarter_fold().value(),
872            90.0,
873            epsilon = 1e-12
874        );
875        assert_abs_diff_eq!(
876            Degrees::new(-90.0).wrap_quarter_fold().value(),
877            -90.0,
878            epsilon = 1e-12
879        );
880    }
881
882    #[test]
883    fn wrap_quarter_fold_over_90() {
884        assert_abs_diff_eq!(
885            Degrees::new(100.0).wrap_quarter_fold().value(),
886            80.0,
887            epsilon = 1e-12
888        );
889        assert_abs_diff_eq!(
890            Degrees::new(135.0).wrap_quarter_fold().value(),
891            45.0,
892            epsilon = 1e-12
893        );
894        assert_abs_diff_eq!(
895            Degrees::new(180.0).wrap_quarter_fold().value(),
896            0.0,
897            epsilon = 1e-12
898        );
899    }
900
901    // ─────────────────────────────────────────────────────────────────────────────
902    // Separation helpers
903    // ─────────────────────────────────────────────────────────────────────────────
904
905    #[test]
906    fn signed_separation_basic() {
907        let a = Degrees::new(30.0);
908        let b = Degrees::new(50.0);
909        assert_abs_diff_eq!(a.signed_separation(b).value(), -20.0, epsilon = 1e-12);
910        assert_abs_diff_eq!(b.signed_separation(a).value(), 20.0, epsilon = 1e-12);
911    }
912
913    #[test]
914    fn signed_separation_wrap() {
915        let a = Degrees::new(10.0);
916        let b = Degrees::new(350.0);
917        assert_abs_diff_eq!(a.signed_separation(b).value(), 20.0, epsilon = 1e-12);
918        assert_abs_diff_eq!(b.signed_separation(a).value(), -20.0, epsilon = 1e-12);
919    }
920
921    #[test]
922    fn abs_separation() {
923        let a = Degrees::new(30.0);
924        let b = Degrees::new(50.0);
925        assert_abs_diff_eq!(a.abs_separation(b).value(), 20.0, epsilon = 1e-12);
926        assert_abs_diff_eq!(b.abs_separation(a).value(), 20.0, epsilon = 1e-12);
927    }
928
929    // ─────────────────────────────────────────────────────────────────────────────
930    // wrap_to_signed_pi / wrap_to_unsigned_pi / fold_to_pi
931    // ─────────────────────────────────────────────────────────────────────────────
932
933    #[test]
934    fn wrap_to_signed_pi_matches_wrap_signed_radians() {
935        for &x in &[
936            -7.0 * PI,
937            -PI - 0.1,
938            -PI,
939            -1.0,
940            0.0,
941            1.0,
942            PI,
943            PI + 0.1,
944            7.0 * PI,
945        ] {
946            let a = Radians::new(x);
947            assert_abs_diff_eq!(
948                a.wrap_to_signed_pi().value(),
949                a.wrap_signed().value(),
950                epsilon = 1e-12
951            );
952        }
953    }
954
955    #[test]
956    fn wrap_to_signed_pi_degree_analog() {
957        // The same generic helper provides the (-180°, 180°] semantics for Degrees.
958        assert_abs_diff_eq!(
959            Degrees::new(190.0).wrap_to_signed_pi().value(),
960            -170.0,
961            epsilon = 1e-12
962        );
963        assert_abs_diff_eq!(
964            Degrees::new(180.0).wrap_to_signed_pi().value(),
965            180.0,
966            epsilon = 1e-12
967        );
968        assert_abs_diff_eq!(
969            Degrees::new(-180.0).wrap_to_signed_pi().value(),
970            180.0,
971            epsilon = 1e-12
972        );
973        assert_abs_diff_eq!(
974            Degrees::new(7.0 * 180.0).wrap_to_signed_pi().value(),
975            180.0,
976            epsilon = 1e-12
977        );
978    }
979
980    #[test]
981    fn wrap_to_unsigned_pi_matches_wrap_pos_radians() {
982        for &x in &[-7.0 * PI, -PI, -1.0, 0.0, 1.0, PI, TAU, 7.0 * PI] {
983            let a = Radians::new(x);
984            assert_abs_diff_eq!(
985                a.wrap_to_unsigned_pi().value(),
986                a.wrap_pos().value(),
987                epsilon = 1e-12
988            );
989        }
990    }
991
992    #[test]
993    fn wrap_to_unsigned_pi_degree_analog() {
994        assert_abs_diff_eq!(
995            Degrees::new(370.0).wrap_to_unsigned_pi().value(),
996            10.0,
997            epsilon = 1e-12
998        );
999        assert_abs_diff_eq!(
1000            Degrees::new(-10.0).wrap_to_unsigned_pi().value(),
1001            350.0,
1002            epsilon = 1e-12
1003        );
1004        assert_abs_diff_eq!(
1005            Degrees::new(360.0).wrap_to_unsigned_pi().value(),
1006            0.0,
1007            epsilon = 1e-12
1008        );
1009    }
1010
1011    #[test]
1012    fn fold_to_pi_radians() {
1013        assert_abs_diff_eq!(Radians::new(0.5).fold_to_pi().value(), 0.5, epsilon = 1e-12);
1014        assert_abs_diff_eq!(
1015            Radians::new(-0.5).fold_to_pi().value(),
1016            0.5,
1017            epsilon = 1e-12
1018        );
1019        assert_abs_diff_eq!(
1020            Radians::new(PI + 0.1).fold_to_pi().value(),
1021            PI - 0.1,
1022            epsilon = 1e-12
1023        );
1024        assert_abs_diff_eq!(
1025            Radians::new(7.0 * PI).fold_to_pi().value(),
1026            PI,
1027            epsilon = 1e-12
1028        );
1029        assert_abs_diff_eq!(Radians::new(PI).fold_to_pi().value(), PI, epsilon = 1e-12);
1030        assert_abs_diff_eq!(Radians::new(0.0).fold_to_pi().value(), 0.0, epsilon = 1e-12);
1031    }
1032
1033    #[test]
1034    fn fold_to_pi_degree_analog() {
1035        assert_abs_diff_eq!(
1036            Degrees::new(190.0).fold_to_pi().value(),
1037            170.0,
1038            epsilon = 1e-12
1039        );
1040        assert_abs_diff_eq!(
1041            Degrees::new(-30.0).fold_to_pi().value(),
1042            30.0,
1043            epsilon = 1e-12
1044        );
1045        assert_abs_diff_eq!(
1046            Degrees::new(360.0 * 7.0 + 50.0).fold_to_pi().value(),
1047            50.0,
1048            epsilon = 1e-12
1049        );
1050    }
1051
1052    #[test]
1053    fn fold_to_pi_nan_propagates() {
1054        assert!(Radians::new(f64::NAN).fold_to_pi().value().is_nan());
1055        assert!(Degrees::new(f64::NAN).fold_to_pi().value().is_nan());
1056    }
1057
1058    /// Parity with NSB's existing fold semantics: `delta_lambda.to_degrees().abs().min(180.0)`
1059    /// applied to the signed difference of two ecliptic longitudes must equal
1060    /// `(a - b).fold_to_pi()` (in degrees).
1061    #[test]
1062    fn fold_to_pi_matches_nsb_legacy_loop() {
1063        // Reference implementation mirroring the historic NSB normalization
1064        // (`while delta > π { delta -= 2π } / while delta < -π { delta += 2π }` then `.abs()`).
1065        fn nsb_legacy_fold(mut delta: f64) -> f64 {
1066            while delta > PI {
1067                delta -= TAU;
1068            }
1069            while delta < -PI {
1070                delta += TAU;
1071            }
1072            delta.abs()
1073        }
1074
1075        let samples = [
1076            -7.5 * PI,
1077            -PI - 1e-9,
1078            -PI,
1079            -PI / 2.0,
1080            -1e-12,
1081            0.0,
1082            1e-12,
1083            PI / 4.0,
1084            PI - 1e-9,
1085            PI,
1086            PI + 1e-9,
1087            3.0 * PI / 2.0,
1088            7.5 * PI,
1089        ];
1090        for &x in &samples {
1091            let helper = Radians::new(x).fold_to_pi().value();
1092            let legacy = nsb_legacy_fold(x);
1093            assert_abs_diff_eq!(helper, legacy, epsilon = 1e-9);
1094        }
1095    }
1096
1097    // ─────────────────────────────────────────────────────────────────────────────
1098    // DMS / HMS construction
1099    // ─────────────────────────────────────────────────────────────────────────────
1100
1101    #[test]
1102    fn degrees_from_dms_positive() {
1103        let d = Degrees::from_dms(12, 30, 0.0);
1104        assert_abs_diff_eq!(d.value(), 12.5, epsilon = 1e-12);
1105    }
1106
1107    #[test]
1108    fn degrees_from_dms_negative() {
1109        let d = Degrees::from_dms(-33, 52, 0.0);
1110        assert!(d.value() < 0.0);
1111        assert_abs_diff_eq!(d.value(), -(33.0 + 52.0 / 60.0), epsilon = 1e-12);
1112    }
1113
1114    #[test]
1115    fn degrees_from_dms_with_seconds() {
1116        let d = Degrees::from_dms(10, 20, 30.0);
1117        assert_abs_diff_eq!(
1118            d.value(),
1119            10.0 + 20.0 / 60.0 + 30.0 / 3600.0,
1120            epsilon = 1e-12
1121        );
1122    }
1123
1124    #[test]
1125    fn degrees_from_dms_sign() {
1126        let pos = Degrees::from_dms_sign(1, 45, 30, 0.0);
1127        let neg = Degrees::from_dms_sign(-1, 45, 30, 0.0);
1128        assert_abs_diff_eq!(pos.value(), 45.5, epsilon = 1e-12);
1129        assert_abs_diff_eq!(neg.value(), -45.5, epsilon = 1e-12);
1130    }
1131
1132    #[test]
1133    fn degrees_from_dms_sign_zero_is_positive() {
1134        let zero_sign = Degrees::from_dms_sign(0, 45, 30, 0.0);
1135        assert_abs_diff_eq!(zero_sign.value(), 45.5, epsilon = 1e-12);
1136    }
1137
1138    #[test]
1139    #[cfg(feature = "astro")]
1140    fn hour_angles_from_hms() {
1141        let ha = HourAngles::from_hms(5, 30, 0.0);
1142        assert_abs_diff_eq!(ha.value(), 5.5, epsilon = 1e-12);
1143    }
1144
1145    #[test]
1146    #[cfg(feature = "astro")]
1147    fn hour_angles_from_hms_negative() {
1148        let ha = HourAngles::from_hms(-3, 15, 0.0);
1149        assert_abs_diff_eq!(ha.value(), -3.25, epsilon = 1e-12);
1150    }
1151
1152    #[test]
1153    #[cfg(feature = "astro")]
1154    fn hour_angles_to_degrees() {
1155        let ha = HourAngles::new(6.0);
1156        let deg = ha.to::<Degree>();
1157        assert_abs_diff_eq!(deg.value(), 90.0, epsilon = 1e-12);
1158    }
1159
1160    // ─────────────────────────────────────────────────────────────────────────────
1161    // Display formatting
1162    // ─────────────────────────────────────────────────────────────────────────────
1163
1164    #[test]
1165    fn display_degrees() {
1166        let d = Degrees::new(45.5);
1167        assert_eq!(format!("{}", d), "45.5 °");
1168    }
1169
1170    #[test]
1171    fn display_radians() {
1172        let r = Radians::new(1.0);
1173        assert_eq!(format!("{}", r), "1 rad");
1174    }
1175
1176    // ─────────────────────────────────────────────────────────────────────────────
1177    // Unit constants
1178    // ─────────────────────────────────────────────────────────────────────────────
1179
1180    #[test]
1181    fn unit_constants() {
1182        assert_eq!(DEG.value(), 1.0);
1183        assert_eq!(RAD.value(), 1.0);
1184        assert_eq!(MRAD.value(), 1.0);
1185        #[cfg(feature = "astro")]
1186        {
1187            assert_eq!(ARCM.value(), 1.0);
1188            assert_eq!(ARCS.value(), 1.0);
1189            assert_eq!(MAS.value(), 1.0);
1190            assert_eq!(UAS.value(), 1.0);
1191            assert_eq!(HOUR_ANGLE.value(), 1.0);
1192        }
1193        #[cfg(feature = "navigation")]
1194        assert_eq!(GON.value(), 1.0);
1195        assert_eq!(TURN.value(), 1.0);
1196    }
1197
1198    // ─────────────────────────────────────────────────────────────────────────────
1199    // wrap_signed_lo: [-180, 180)
1200    // ─────────────────────────────────────────────────────────────────────────────
1201
1202    #[test]
1203    fn wrap_signed_lo_boundary_half_turn() {
1204        // +half turn should map to -half turn to make the upper bound exclusive.
1205        assert_abs_diff_eq!(
1206            Degrees::new(180.0).wrap_signed_lo().value(),
1207            -180.0,
1208            epsilon = 1e-12
1209        );
1210        assert_abs_diff_eq!(
1211            Degrees::new(-180.0).wrap_signed_lo().value(),
1212            -180.0,
1213            epsilon = 1e-12
1214        );
1215    }
1216
1217    // ─────────────────────────────────────────────────────────────────────────────
1218    // New unit conversions and tests
1219    // ─────────────────────────────────────────────────────────────────────────────
1220
1221    #[test]
1222    #[cfg(feature = "astro")]
1223    fn conversion_degrees_to_arcminutes() {
1224        let deg = Degrees::new(1.0);
1225        let arcm = deg.to::<Arcminute>();
1226        assert_abs_diff_eq!(arcm.value(), 60.0, epsilon = 1e-12);
1227    }
1228
1229    #[test]
1230    #[cfg(feature = "astro")]
1231    fn conversion_arcminutes_to_degrees() {
1232        let arcm = Arcminutes::new(60.0);
1233        let deg = arcm.to::<Degree>();
1234        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-12);
1235    }
1236
1237    #[test]
1238    #[cfg(feature = "astro")]
1239    fn conversion_arcminutes_to_arcseconds() {
1240        let arcm = Arcminutes::new(1.0);
1241        let arcs = arcm.to::<Arcsecond>();
1242        assert_abs_diff_eq!(arcs.value(), 60.0, epsilon = 1e-12);
1243    }
1244
1245    #[test]
1246    #[cfg(feature = "astro")]
1247    fn conversion_arcseconds_to_microarcseconds() {
1248        let arcs = Arcseconds::new(1.0);
1249        let uas = arcs.to::<MicroArcsecond>();
1250        assert_abs_diff_eq!(uas.value(), 1_000_000.0, epsilon = 1e-6);
1251    }
1252
1253    #[test]
1254    #[cfg(feature = "astro")]
1255    fn conversion_microarcseconds_to_degrees() {
1256        let uas = MicroArcseconds::new(3_600_000_000.0);
1257        let deg = uas.to::<Degree>();
1258        assert_abs_diff_eq!(deg.value(), 1.0, epsilon = 1e-9);
1259    }
1260
1261    #[test]
1262    #[cfg(feature = "navigation")]
1263    fn conversion_degrees_to_gradians() {
1264        let deg = Degrees::new(90.0);
1265        let gon = deg.to::<Gradian>();
1266        assert_abs_diff_eq!(gon.value(), 100.0, epsilon = 1e-12);
1267    }
1268
1269    #[test]
1270    #[cfg(feature = "navigation")]
1271    fn conversion_gradians_to_degrees() {
1272        let gon = Gradians::new(400.0);
1273        let deg = gon.to::<Degree>();
1274        assert_abs_diff_eq!(deg.value(), 360.0, epsilon = 1e-12);
1275    }
1276
1277    #[test]
1278    #[cfg(feature = "navigation")]
1279    fn conversion_gradians_to_radians() {
1280        let gon = Gradians::new(200.0);
1281        let rad = gon.to::<Radian>();
1282        assert_abs_diff_eq!(rad.value(), PI, epsilon = 1e-12);
1283    }
1284
1285    #[test]
1286    fn conversion_degrees_to_turns() {
1287        let deg = Degrees::new(360.0);
1288        let turn = deg.to::<Turn>();
1289        assert_abs_diff_eq!(turn.value(), 1.0, epsilon = 1e-12);
1290    }
1291
1292    #[test]
1293    fn conversion_milliradians_to_radians() {
1294        let mrad = Milliradians::new(1_000.0);
1295        let rad = mrad.to::<Radian>();
1296        assert_abs_diff_eq!(rad.value(), 1.0, epsilon = 1e-12);
1297    }
1298
1299    #[test]
1300    fn conversion_turns_to_degrees() {
1301        let turn = Turns::new(2.5);
1302        let deg = turn.to::<Degree>();
1303        assert_abs_diff_eq!(deg.value(), 900.0, epsilon = 1e-12);
1304    }
1305
1306    #[test]
1307    fn conversion_turns_to_radians() {
1308        let turn = Turns::new(1.0);
1309        let rad = turn.to::<Radian>();
1310        assert_abs_diff_eq!(rad.value(), TAU, epsilon = 1e-12);
1311    }
1312
1313    #[test]
1314    #[cfg(feature = "astro")]
1315    fn from_impl_new_units() {
1316        // Test From trait implementations for new units
1317        let deg = Degrees::new(1.0);
1318        let arcm: Arcminutes = deg.into();
1319        assert_abs_diff_eq!(arcm.value(), 60.0, epsilon = 1e-12);
1320    }
1321
1322    #[test]
1323    #[cfg(feature = "navigation")]
1324    fn from_impl_gradian_to_deg() {
1325        let gon = Gradians::new(100.0);
1326        let deg: Degrees = gon.into();
1327        assert_abs_diff_eq!(deg.value(), 90.0, epsilon = 1e-12);
1328    }
1329
1330    #[test]
1331    fn from_impl_turn_to_deg() {
1332        let turn = Turns::new(0.25);
1333        let deg: Degrees = turn.into();
1334        assert_abs_diff_eq!(deg.value(), 90.0, epsilon = 1e-12);
1335    }
1336
1337    #[test]
1338    #[cfg(feature = "astro")]
1339    fn roundtrip_arcminute_arcsecond() {
1340        let original = Arcminutes::new(5.0);
1341        let arcs = original.to::<Arcsecond>();
1342        let back = arcs.to::<Arcminute>();
1343        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1344    }
1345
1346    #[test]
1347    #[cfg(feature = "navigation")]
1348    fn roundtrip_gradian_degree() {
1349        let original = Gradians::new(123.456);
1350        let deg = original.to::<Degree>();
1351        let back = deg.to::<Gradian>();
1352        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1353    }
1354
1355    #[test]
1356    fn roundtrip_turn_radian() {
1357        let original = Turns::new(2.717);
1358        let rad = original.to::<Radian>();
1359        let back = rad.to::<Turn>();
1360        assert_abs_diff_eq!(back.value(), original.value(), epsilon = 1e-12);
1361    }
1362
1363    #[test]
1364    #[cfg(feature = "navigation")]
1365    fn gradian_full_turn() {
1366        assert_abs_diff_eq!(Gradian::FULL_TURN, 400.0, epsilon = 1e-12);
1367    }
1368
1369    #[test]
1370    fn turn_full_turn() {
1371        assert_abs_diff_eq!(Turn::FULL_TURN, 1.0, epsilon = 1e-12);
1372    }
1373
1374    #[test]
1375    #[cfg(feature = "astro")]
1376    fn arcminute_full_turn() {
1377        assert_abs_diff_eq!(Arcminute::FULL_TURN, 21_600.0, epsilon = 1e-9);
1378    }
1379
1380    #[test]
1381    #[cfg(feature = "astro")]
1382    fn microarcsecond_conversion_chain() {
1383        // Test a long conversion chain
1384        let uas = MicroArcseconds::new(1e9);
1385        let mas = uas.to::<MilliArcsecond>();
1386        let arcs = mas.to::<Arcsecond>();
1387        let arcm = arcs.to::<Arcminute>();
1388        let deg = arcm.to::<Degree>();
1389
1390        assert_abs_diff_eq!(mas.value(), 1_000_000.0, epsilon = 1e-6);
1391        assert_abs_diff_eq!(arcs.value(), 1_000.0, epsilon = 1e-9);
1392        assert_abs_diff_eq!(arcm.value(), 1_000.0 / 60.0, epsilon = 1e-9);
1393        assert_relative_eq!(deg.value(), 1_000.0 / 3600.0, max_relative = 1e-9);
1394    }
1395
1396    #[test]
1397    fn wrap_pos_with_turns() {
1398        let turn = Turns::new(2.7);
1399        let wrapped = turn.wrap_pos();
1400        assert_abs_diff_eq!(wrapped.value(), 0.7, epsilon = 1e-12);
1401    }
1402
1403    #[test]
1404    #[cfg(feature = "navigation")]
1405    fn wrap_signed_with_gradians() {
1406        let gon = Gradians::new(350.0);
1407        let wrapped = gon.wrap_signed();
1408        assert_abs_diff_eq!(wrapped.value(), -50.0, epsilon = 1e-12);
1409    }
1410
1411    #[test]
1412    #[cfg(feature = "navigation")]
1413    fn trig_with_gradians() {
1414        let gon = Gradians::new(100.0); // 90 degrees
1415        assert_abs_diff_eq!(gon.sin(), 1.0, epsilon = 1e-12);
1416        assert_abs_diff_eq!(gon.cos(), 0.0, epsilon = 1e-12);
1417    }
1418
1419    #[test]
1420    fn trig_with_turns() {
1421        let turn = Turns::new(0.25); // 90 degrees
1422        assert_abs_diff_eq!(turn.sin(), 1.0, epsilon = 1e-12);
1423        assert_abs_diff_eq!(turn.cos(), 0.0, epsilon = 1e-12);
1424    }
1425
1426    #[test]
1427    #[cfg(all(feature = "astro", feature = "navigation"))]
1428    fn all_units_to_degrees() {
1429        // Verify all units convert correctly to degrees
1430        assert_abs_diff_eq!(
1431            Radians::new(PI).to::<Degree>().value(),
1432            180.0,
1433            epsilon = 1e-12
1434        );
1435        assert_abs_diff_eq!(
1436            Arcminutes::new(60.0).to::<Degree>().value(),
1437            1.0,
1438            epsilon = 1e-12
1439        );
1440        assert_abs_diff_eq!(
1441            Arcseconds::new(3600.0).to::<Degree>().value(),
1442            1.0,
1443            epsilon = 1e-12
1444        );
1445        assert_abs_diff_eq!(
1446            MilliArcseconds::new(3_600_000.0).to::<Degree>().value(),
1447            1.0,
1448            epsilon = 1e-9
1449        );
1450        assert_abs_diff_eq!(
1451            MicroArcseconds::new(3_600_000_000.0).to::<Degree>().value(),
1452            1.0,
1453            epsilon = 1e-6
1454        );
1455        assert_abs_diff_eq!(
1456            Gradians::new(100.0).to::<Degree>().value(),
1457            90.0,
1458            epsilon = 1e-12
1459        );
1460        assert_abs_diff_eq!(
1461            Turns::new(1.0).to::<Degree>().value(),
1462            360.0,
1463            epsilon = 1e-12
1464        );
1465        assert_abs_diff_eq!(
1466            HourAngles::new(1.0).to::<Degree>().value(),
1467            15.0,
1468            epsilon = 1e-12
1469        );
1470    }
1471
1472    // ─────────────────────────────────────────────────────────────────────────────
1473    // Property-based tests
1474    // ─────────────────────────────────────────────────────────────────────────────
1475
1476    proptest! {
1477        #[test]
1478        fn prop_wrap_pos_range(angle in -1e6..1e6f64) {
1479            let wrapped = Degrees::new(angle).wrap_pos();
1480            prop_assert!(wrapped.value() >= 0.0);
1481            prop_assert!(wrapped.value() < 360.0);
1482        }
1483
1484        #[test]
1485        fn prop_wrap_signed_range(angle in -1e6..1e6f64) {
1486            let wrapped = Degrees::new(angle).wrap_signed();
1487            prop_assert!(wrapped.value() > -180.0);
1488            prop_assert!(wrapped.value() <= 180.0);
1489        }
1490
1491        #[test]
1492        fn prop_wrap_quarter_fold_range(angle in -1e6..1e6f64) {
1493            let wrapped = Degrees::new(angle).wrap_quarter_fold();
1494            prop_assert!(wrapped.value() >= -90.0);
1495            prop_assert!(wrapped.value() <= 90.0);
1496        }
1497
1498        #[test]
1499        fn prop_pythagorean_identity(angle in -360.0..360.0f64) {
1500            let a = Degrees::new(angle);
1501            let sin = a.sin();
1502            let cos = a.cos();
1503            assert_abs_diff_eq!(sin * sin + cos * cos, 1.0, epsilon = 1e-12);
1504        }
1505
1506        #[test]
1507        fn prop_conversion_roundtrip(angle in -1e6..1e6f64) {
1508            let deg = Degrees::new(angle);
1509            let rad = deg.to::<Radian>();
1510            let back = rad.to::<Degree>();
1511            assert_relative_eq!(back.value(), deg.value(), max_relative = 1e-12);
1512        }
1513
1514        #[test]
1515        fn prop_abs_separation_symmetric(a in -360.0..360.0f64, b in -360.0..360.0f64) {
1516            let da = Degrees::new(a);
1517            let db = Degrees::new(b);
1518            assert_abs_diff_eq!(
1519                da.abs_separation(db).value(),
1520                db.abs_separation(da).value(),
1521                epsilon = 1e-12
1522            );
1523        }
1524    }
1525
1526    /// Invoke derive-generated PartialEq/Clone/Debug on each unit struct to
1527    /// cover the #[derive] attribute lines tracked by llvm-cov.
1528    #[test]
1529    fn derive_coverage_unit_structs() {
1530        // Direct struct value comparisons invoke <T as PartialEq>::eq() which
1531        // is the derive-generated implementation, covering the #[derive] line.
1532        assert!(Degree == Degree);
1533        assert!(Radian == Radian);
1534        assert!(Milliradian == Milliradian);
1535        #[cfg(feature = "astro")]
1536        {
1537            assert!(Arcminute == Arcminute);
1538            assert!(Arcsecond == Arcsecond);
1539            assert!(MilliArcsecond == MilliArcsecond);
1540            assert!(MicroArcsecond == MicroArcsecond);
1541            assert!(HourAngle == HourAngle);
1542        }
1543        #[cfg(feature = "navigation")]
1544        assert!(Gradian == Gradian);
1545        assert!(Turn == Turn);
1546        // signum_const: cover both positive and negative branches
1547        let pos = Degrees::new(90.0);
1548        let neg = Degrees::new(-45.0);
1549        assert_eq!(pos.signum_const(), 1.0);
1550        assert_eq!(neg.signum_const(), -1.0);
1551    }
1552}