Skip to main content

celestial_core/angle/
core.rs

1//! Core angle type for astronomical calculations.
2//!
3//! This module provides [`Angle`], the fundamental angular measurement type used throughout
4//! the astronomy library. Angles are stored internally as radians (f64) but can be constructed
5//! from and converted to degrees, hours, arcminutes, and arcseconds.
6//!
7//! # Design Rationale
8//!
9//! **Why radians internally?** All trigonometric functions in Rust (and most languages) operate
10//! on radians. Storing radians avoids repeated conversions during calculations. The degree-based
11//! constructors and accessors provide ergonomic APIs for human-readable values.
12//!
13//! **Why associated constants?** [`Angle::PI`], [`Angle::HALF_PI`], and [`Angle::ZERO`] exist
14//! because angles are not just numbers. While `std::f64::consts::PI` gives you a raw float,
15//! `Angle::PI` gives you a typed angle. This prevents accidentally mixing raw radians with
16//! Angles and catches unit errors at compile time.
17//!
18//! # Quick Start
19//!
20//! ```
21//! use celestial_core::Angle;
22//!
23//! // Construction - pick the unit that matches your data
24//! let from_deg = Angle::from_degrees(45.0);
25//! let from_rad = Angle::from_radians(0.785398);
26//! let from_hrs = Angle::from_hours(3.0);  // 3h = 45 degrees
27//! let from_arcsec = Angle::from_arcseconds(162000.0);  // 45 degrees
28//!
29//! // Conversion - get any unit you need
30//! assert!((from_deg.radians() - 0.785398).abs() < 1e-5);
31//! assert!((from_deg.hours() - 3.0).abs() < 1e-10);
32//!
33//! // Trigonometry - no conversion needed
34//! let (sin, cos) = from_deg.sin_cos();
35//! ```
36//!
37//! # Hour Angles
38//!
39//! Astronomy uses hours (0-24h) for right ascension. One hour equals 15 degrees:
40//!
41//! ```
42//! use celestial_core::Angle;
43//!
44//! let ra = Angle::from_hours(6.0);  // 6h RA
45//! assert!((ra.degrees() - 90.0).abs() < 1e-10);
46//! ```
47//!
48//! # Validation
49//!
50//! Angles can be validated for specific astronomical contexts:
51//!
52//! ```
53//! use celestial_core::Angle;
54//!
55//! let dec = Angle::from_degrees(45.0);
56//! assert!(dec.validate_declination(false).is_ok());  // -90 to +90
57//!
58//! let bad_dec = Angle::from_degrees(100.0);
59//! assert!(bad_dec.validate_declination(false).is_err());  // Out of range
60//!
61//! // Right ascension auto-normalizes to [0, 360)
62//! let ra = Angle::from_degrees(400.0);
63//! let normalized = ra.validate_right_ascension().unwrap();
64//! assert!((normalized.degrees() - 40.0).abs() < 1e-10);
65//! ```
66//!
67//! # Convenience Functions
68//!
69//! For terser code, use the free functions [`deg`], [`rad`], [`hours`], [`arcsec`], [`arcmin`]:
70//!
71//! ```
72//! use celestial_core::angle::{deg, hours, arcsec};
73//!
74//! let a = deg(45.0);
75//! let b = hours(3.0);
76//! let c = arcsec(162000.0);
77//!
78//! assert!((a.degrees() - b.degrees()).abs() < 1e-10);
79//! ```
80//!
81//! # Arithmetic
82//!
83//! Angles support addition, subtraction, negation, and scalar multiplication/division:
84//!
85//! ```
86//! use celestial_core::Angle;
87//!
88//! let a = Angle::from_degrees(30.0);
89//! let b = Angle::from_degrees(15.0);
90//!
91//! let sum = a + b;  // 45 degrees
92//! let diff = a - b;  // 15 degrees
93//! let scaled = a * 2.0;  // 60 degrees
94//! let neg = -a;  // -30 degrees
95//! ```
96
97use crate::constants::{HALF_PI, PI};
98
99/// An angular measurement stored as radians.
100///
101/// `Angle` is the primary type for representing angles throughout this library.
102/// It stores the angle as a 64-bit float in radians and provides conversions to/from
103/// other angular units commonly used in astronomy.
104///
105/// # Internal Representation
106///
107/// Angles are stored as radians (`f64`). This choice optimizes for:
108/// - Direct use with trigonometric functions
109/// - Precision in intermediate calculations
110/// - Consistency with mathematical conventions
111///
112/// # Derives
113///
114/// - `Copy`, `Clone`: Angles are small (8 bytes) and cheap to copy
115/// - `Debug`: Shows internal radian value
116/// - `PartialEq`, `PartialOrd`: Compare angles directly (compares radian values)
117///
118/// Note: `Eq` and `Ord` are not implemented because f64 can be NaN.
119#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
120pub struct Angle {
121    rad: f64,
122}
123
124impl Angle {
125    /// Zero angle (0 radians).
126    pub const ZERO: Self = Self { rad: 0.0 };
127
128    /// Pi radians (180 degrees). Useful for half-circle operations.
129    pub const PI: Self = Self { rad: PI };
130
131    /// Pi/2 radians (90 degrees). Useful for right angles and pole declinations.
132    pub const HALF_PI: Self = Self { rad: HALF_PI };
133
134    /// Creates an angle from radians.
135    ///
136    /// This is the only `const` constructor because radians are the internal representation.
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use celestial_core::Angle;
142    /// use std::f64::consts::FRAC_PI_4;
143    ///
144    /// let angle = Angle::from_radians(FRAC_PI_4);
145    /// assert!((angle.degrees() - 45.0).abs() < 1e-10);
146    /// ```
147    #[inline]
148    pub const fn from_radians(rad: f64) -> Self {
149        Self { rad }
150    }
151
152    /// Creates an angle from degrees.
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// use celestial_core::Angle;
158    ///
159    /// let angle = Angle::from_degrees(180.0);
160    /// assert!((angle.radians() - celestial_core::constants::PI).abs() < 1e-10);
161    /// ```
162    #[inline]
163    pub fn from_degrees(deg: f64) -> Self {
164        Self {
165            rad: deg * crate::constants::DEG_TO_RAD,
166        }
167    }
168
169    /// Creates an angle from hours.
170    ///
171    /// In astronomy, right ascension is measured in hours where 24h = 360 degrees.
172    /// Each hour equals 15 degrees.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use celestial_core::Angle;
178    ///
179    /// let ra = Angle::from_hours(6.0);  // 6h = 90 degrees
180    /// assert!((ra.degrees() - 90.0).abs() < 1e-10);
181    ///
182    /// let ra_24h = Angle::from_hours(24.0);  // Full circle
183    /// assert!((ra_24h.degrees() - 360.0).abs() < 1e-10);
184    /// ```
185    #[inline]
186    pub fn from_hours(h: f64) -> Self {
187        Self {
188            rad: h * 15.0 * crate::constants::DEG_TO_RAD,
189        }
190    }
191
192    /// Creates an angle from arcseconds.
193    ///
194    /// One arcsecond = 1/3600 of a degree. Commonly used for:
195    /// - Parallax measurements
196    /// - Proper motion
197    /// - Small angular separations
198    ///
199    /// # Example
200    ///
201    /// ```
202    /// use celestial_core::Angle;
203    ///
204    /// let angle = Angle::from_arcseconds(3600.0);  // 1 degree
205    /// assert!((angle.degrees() - 1.0).abs() < 1e-10);
206    ///
207    /// // Proxima Centauri's parallax is about 0.77 arcseconds
208    /// let parallax = Angle::from_arcseconds(0.77);
209    /// ```
210    #[inline]
211    pub fn from_arcseconds(arcsec: f64) -> Self {
212        Self {
213            rad: arcsec * crate::constants::ARCSEC_TO_RAD,
214        }
215    }
216
217    /// Creates an angle from arcminutes.
218    ///
219    /// One arcminute = 1/60 of a degree. Commonly used for:
220    /// - Field of view specifications
221    /// - Object sizes (e.g., the Moon is about 31 arcminutes)
222    ///
223    /// # Example
224    ///
225    /// ```
226    /// use celestial_core::Angle;
227    ///
228    /// let angle = Angle::from_arcminutes(60.0);  // 1 degree
229    /// assert!((angle.degrees() - 1.0).abs() < 1e-10);
230    ///
231    /// // Full Moon's apparent diameter
232    /// let moon_diameter = Angle::from_arcminutes(31.0);
233    /// ```
234    #[inline]
235    pub fn from_arcminutes(arcmin: f64) -> Self {
236        Self {
237            rad: arcmin * crate::constants::ARCMIN_TO_RAD,
238        }
239    }
240
241    /// Returns the angle in radians.
242    ///
243    /// This is the internal representation, so no conversion occurs.
244    #[inline]
245    pub fn radians(self) -> f64 {
246        self.rad
247    }
248
249    /// Returns the angle in degrees.
250    #[inline]
251    pub fn degrees(self) -> f64 {
252        self.rad * crate::constants::RAD_TO_DEG
253    }
254
255    /// Returns the angle in hours.
256    ///
257    /// Useful for right ascension where 24h = 360 degrees.
258    #[inline]
259    pub fn hours(self) -> f64 {
260        self.degrees() / 15.0
261    }
262
263    /// Returns the angle in arcseconds.
264    #[inline]
265    pub fn arcseconds(self) -> f64 {
266        self.degrees() * 3600.0
267    }
268
269    /// Returns the angle in arcminutes.
270    #[inline]
271    pub fn arcminutes(self) -> f64 {
272        self.degrees() * 60.0
273    }
274
275    /// Returns the sine of the angle.
276    #[inline]
277    pub fn sin(self) -> f64 {
278        libm::sin(self.rad)
279    }
280
281    /// Returns the cosine of the angle.
282    #[inline]
283    pub fn cos(self) -> f64 {
284        libm::cos(self.rad)
285    }
286
287    /// Returns both sine and cosine of the angle.
288    ///
289    /// Convenience method when you need both values.
290    ///
291    /// # Returns
292    ///
293    /// A tuple `(sin, cos)`.
294    ///
295    /// # Example
296    ///
297    /// ```
298    /// use celestial_core::Angle;
299    ///
300    /// let angle = Angle::from_degrees(30.0);
301    /// let (sin, cos) = angle.sin_cos();
302    /// assert!((sin - 0.5).abs() < 1e-10);
303    /// assert!((cos - 0.866025).abs() < 1e-5);
304    /// ```
305    #[inline]
306    pub fn sin_cos(self) -> (f64, f64) {
307        libm::sincos(self.rad)
308    }
309
310    /// Returns the tangent of the angle.
311    #[inline]
312    pub fn tan(self) -> f64 {
313        libm::tan(self.rad)
314    }
315
316    /// Returns the absolute value of the angle.
317    ///
318    /// # Example
319    ///
320    /// ```
321    /// use celestial_core::Angle;
322    ///
323    /// let negative = Angle::from_degrees(-45.0);
324    /// let absolute = negative.abs();
325    /// assert!((absolute.degrees() - 45.0).abs() < 1e-10);
326    /// ```
327    #[inline]
328    pub fn abs(self) -> Self {
329        Self {
330            rad: self.rad.abs(),
331        }
332    }
333
334    /// Wraps the angle to the range [-pi, +pi) (i.e., [-180, +180) degrees).
335    ///
336    /// Use this for longitude-like quantities or angular differences where
337    /// you want the shortest arc representation.
338    ///
339    /// # Example
340    ///
341    /// ```
342    /// use celestial_core::Angle;
343    ///
344    /// let angle = Angle::from_degrees(270.0);
345    /// let wrapped = angle.wrapped();
346    /// assert!((wrapped.degrees() - (-90.0)).abs() < 1e-10);
347    ///
348    /// let angle2 = Angle::from_degrees(-270.0);
349    /// let wrapped2 = angle2.wrapped();
350    /// assert!((wrapped2.degrees() - 90.0).abs() < 1e-10);
351    /// ```
352    #[inline]
353    pub fn wrapped(self) -> Self {
354        use super::normalize::wrap_pm_pi;
355        Self {
356            rad: wrap_pm_pi(self.rad),
357        }
358    }
359
360    /// Normalizes the angle to the range [0, 2*pi) (i.e., [0, 360) degrees).
361    ///
362    /// Use this for right ascension or any angle that should be non-negative.
363    ///
364    /// # Example
365    ///
366    /// ```
367    /// use celestial_core::Angle;
368    ///
369    /// let angle = Angle::from_degrees(-90.0);
370    /// let normalized = angle.normalized();
371    /// assert!((normalized.degrees() - 270.0).abs() < 1e-10);
372    ///
373    /// let angle2 = Angle::from_degrees(450.0);
374    /// let normalized2 = angle2.normalized();
375    /// assert!((normalized2.degrees() - 90.0).abs() < 1e-10);
376    /// ```
377    #[inline]
378    pub fn normalized(self) -> Self {
379        Self {
380            rad: super::normalize::wrap_0_2pi(self.rad),
381        }
382    }
383
384    /// Validates the angle as a longitude.
385    ///
386    /// If `normalize` is true, wraps to [0, 2*pi) and returns Ok.
387    /// If `normalize` is false, requires the angle to be in [-pi, +pi] or returns Err.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`AstroError`](crate::AstroError) if:
392    /// - The angle is not finite (NaN or infinity)
393    /// - `normalize` is false and the angle is outside [-180, +180] degrees
394    #[inline]
395    pub fn validate_longitude(self, normalize: bool) -> Result<Self, crate::AstroError> {
396        super::validate::validate_longitude(self, normalize)
397    }
398
399    /// Validates the angle as a geographic latitude.
400    ///
401    /// Latitude must be in [-90, +90] degrees ([-pi/2, +pi/2] radians).
402    ///
403    /// # Errors
404    ///
405    /// Returns [`AstroError`](crate::AstroError) if:
406    /// - The angle is not finite (NaN or infinity)
407    /// - The angle is outside [-90, +90] degrees
408    #[inline]
409    pub fn validate_latitude(self) -> Result<Self, crate::AstroError> {
410        super::validate::validate_latitude(self)
411    }
412
413    /// Validates the angle as a declination.
414    ///
415    /// - `beyond_pole = false`: standard range [-90°, +90°]
416    /// - `beyond_pole = true`: extended range [-180°, +180°] for GEM pier-flipped observations
417    ///
418    /// # Errors
419    ///
420    /// Returns [`AstroError`](crate::AstroError) if:
421    /// - The angle is not finite (NaN or infinity)
422    /// - The angle is outside the valid range
423    #[inline]
424    pub fn validate_declination(self, beyond_pole: bool) -> Result<Self, crate::AstroError> {
425        super::validate::validate_declination(self, beyond_pole)
426    }
427
428    /// Validates the angle as a right ascension, normalizing to [0, 360) degrees.
429    ///
430    /// Unlike declination, right ascension is cyclic. This method accepts any finite angle
431    /// and normalizes it to [0, 2*pi).
432    ///
433    /// # Errors
434    ///
435    /// Returns [`AstroError`](crate::AstroError) if the angle is not finite (NaN or infinity).
436    #[inline]
437    pub fn validate_right_ascension(self) -> Result<Self, crate::AstroError> {
438        super::validate::validate_right_ascension(self)
439    }
440}
441
442/// Creates an angle from radians. Shorthand for [`Angle::from_radians`].
443///
444/// # Example
445///
446/// ```
447/// use celestial_core::angle::rad;
448/// use std::f64::consts::PI;
449///
450/// let angle = rad(PI);
451/// assert!((angle.degrees() - 180.0).abs() < 1e-10);
452/// ```
453#[inline]
454pub fn rad(v: f64) -> Angle {
455    Angle::from_radians(v)
456}
457
458/// Creates an angle from degrees. Shorthand for [`Angle::from_degrees`].
459///
460/// # Example
461///
462/// ```
463/// use celestial_core::angle::deg;
464///
465/// let angle = deg(45.0);
466/// assert!((angle.radians() - std::f64::consts::FRAC_PI_4).abs() < 1e-10);
467/// ```
468#[inline]
469pub fn deg(v: f64) -> Angle {
470    Angle::from_degrees(v)
471}
472
473/// Creates an angle from hours. Shorthand for [`Angle::from_hours`].
474///
475/// # Example
476///
477/// ```
478/// use celestial_core::angle::hours;
479///
480/// let ra = hours(6.0);  // 6h = 90 degrees
481/// assert!((ra.degrees() - 90.0).abs() < 1e-10);
482/// ```
483#[inline]
484pub fn hours(v: f64) -> Angle {
485    Angle::from_hours(v)
486}
487
488/// Creates an angle from arcseconds. Shorthand for [`Angle::from_arcseconds`].
489///
490/// # Example
491///
492/// ```
493/// use celestial_core::angle::arcsec;
494///
495/// let angle = arcsec(3600.0);  // 1 degree
496/// assert!((angle.degrees() - 1.0).abs() < 1e-10);
497/// ```
498#[inline]
499pub fn arcsec(v: f64) -> Angle {
500    Angle::from_degrees(v / 3600.0)
501}
502
503/// Creates an angle from arcminutes. Shorthand for [`Angle::from_arcminutes`].
504///
505/// # Example
506///
507/// ```
508/// use celestial_core::angle::arcmin;
509///
510/// let angle = arcmin(60.0);  // 1 degree
511/// assert!((angle.degrees() - 1.0).abs() < 1e-10);
512/// ```
513#[inline]
514pub fn arcmin(v: f64) -> Angle {
515    Angle::from_degrees(v / 60.0)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn test_from_arcseconds() {
524        let angle = Angle::from_arcseconds(3600.0);
525        assert!((angle.degrees() - 1.0).abs() < 1e-20);
526    }
527
528    #[test]
529    fn test_from_arcminutes() {
530        let angle = Angle::from_arcminutes(60.0);
531        assert!((angle.degrees() - 1.0).abs() < 1e-20);
532    }
533
534    #[test]
535    fn test_arcseconds_getter() {
536        let angle = Angle::from_degrees(1.0);
537        assert!((angle.arcseconds() - 3600.0).abs() < 1e-20);
538    }
539
540    #[test]
541    fn test_arcminutes_getter() {
542        let angle = Angle::from_degrees(1.0);
543        assert!((angle.arcminutes() - 60.0).abs() < 1e-20);
544    }
545
546    #[test]
547    fn test_sin() {
548        let angle = Angle::from_degrees(30.0);
549        assert!((angle.sin() - 0.5).abs() < 1e-10);
550    }
551
552    #[test]
553    fn test_tan() {
554        let angle = Angle::from_degrees(45.0);
555        assert!((angle.tan() - 1.0).abs() < 1e-15);
556    }
557
558    #[test]
559    fn test_helper_functions() {
560        let a = rad(crate::constants::PI);
561        assert!((a.degrees() - 180.0).abs() < 1e-20);
562
563        let b = deg(90.0);
564        assert!((b.radians() - crate::constants::HALF_PI).abs() < 1e-20);
565
566        let c = hours(12.0);
567        assert!((c.degrees() - 180.0).abs() < 1e-20);
568
569        let d = arcsec(3600.0);
570        assert!((d.degrees() - 1.0).abs() < 1e-20);
571
572        let e = arcmin(60.0);
573        assert!((e.degrees() - 1.0).abs() < 1e-20);
574    }
575}