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}