Skip to main content

qtty_core/units/
angular.rs

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