celestial_core/angle/normalize.rs
1//! Angle normalization for astronomical coordinate systems.
2//!
3//! Different astronomical quantities require different angular ranges:
4//!
5//! | Quantity | Range | Function |
6//! |----------|-------|----------|
7//! | Right Ascension | [0, 2pi) | [`wrap_0_2pi`] |
8//! | Hour Angle | [-pi, +pi) | [`wrap_pm_pi`] |
9//! | Longitude (celestial) | [-pi, +pi) | [`wrap_pm_pi`] |
10//! | Declination | [-pi/2, +pi/2] | [`clamp_dec`] |
11//! | Latitude | [-pi/2, +pi/2] | [`clamp_dec`] |
12//!
13//! # Wrapping vs Clamping
14//!
15//! **Wrapping** preserves the direction on the sphere. An angle of 370 degrees
16//! represents the same direction as 10 degrees, so `wrap_0_2pi` returns 10 degrees.
17//!
18//! **Clamping** enforces physical limits. Declination cannot exceed +/-90 degrees
19//! because you cannot go "past" the pole. [`clamp_dec`] enforces this by saturating
20//! at the limits rather than wrapping.
21//!
22//! # Why Two Wrapping Functions?
23//!
24//! Right ascension and hour angle are both cyclic, but their conventions differ:
25//!
26//! - **Right Ascension** uses [0, 24h) or [0, 360 deg) because negative RA makes no sense.
27//! Stars at RA = 23h 59m are close to RA = 0h 01m on the sky.
28//!
29//! - **Hour Angle** uses [-12h, +12h) because it represents "hours from meridian."
30//! Negative means east of meridian (not yet crossed), positive means west (already crossed).
31//! The discontinuity at +/-180 degrees is at the anti-meridian, far from the observing position.
32//!
33//! - **Celestial Longitude** (e.g., in galactic or ecliptic coordinates) typically uses
34//! [-180, +180) to center the "interesting" region (galactic center, vernal equinox)
35//! at zero, with the discontinuity 180 degrees away.
36//!
37//! # Example
38//!
39//! ```
40//! use celestial_core::angle::{wrap_0_2pi, wrap_pm_pi, clamp_dec};
41//! use std::f64::consts::PI;
42//!
43//! // Right ascension: always positive
44//! let ra = wrap_0_2pi(-0.5); // -0.5 rad -> ~5.78 rad
45//! assert!(ra > 0.0 && ra < 2.0 * PI);
46//!
47//! // Hour angle: centered on zero
48//! let ha = wrap_pm_pi(3.5); // 3.5 rad -> ~-2.78 rad (wrapped)
49//! assert!(ha >= -PI && ha < PI);
50//!
51//! // Declination: cannot exceed poles
52//! let dec = clamp_dec(2.0); // 2.0 rad -> pi/2 (clamped to +90 deg)
53//! assert!((dec - PI / 2.0).abs() < 1e-10);
54//! ```
55//!
56//! # Algorithm Notes
57//!
58//! The wrapping functions use `libm::fmod` (via [`crate::math::fmod`]) rather than
59//! the `%` operator because Rust's `%` is a remainder, not a modulo. For negative
60//! numbers, remainder and modulo differ:
61//!
62//! - `-1.0 % 360.0` = `-1.0` (remainder, keeps sign of dividend)
63//! - `fmod(-1.0, 360.0)` = `-1.0` (same as %, but well-defined for floats)
64//!
65//! After `fmod`, we adjust for the desired range.
66
67use crate::constants::{HALF_PI, PI, TWOPI};
68use crate::math::fmod;
69
70/// Specifies which normalization convention to apply.
71///
72/// Used when a function needs to normalize angles but the appropriate range
73/// depends on what the angle represents.
74#[derive(Copy, Clone, Debug)]
75pub enum NormalizeMode {
76 /// Right ascension: wrap to [0, 2pi).
77 Ra0To2Pi,
78 /// Longitude or hour angle: wrap to [-pi, +pi).
79 LonMinusPiToPi,
80 /// Declination or latitude: clamp to [-pi/2, +pi/2].
81 DecClamped,
82}
83
84/// Wraps an angle to [-pi, +pi) radians.
85///
86/// Use for quantities where the discontinuity should be at +/-180 degrees
87/// (the "back" of the circle), not at 0/360 degrees.
88///
89/// # Arguments
90///
91/// * `x` - Angle in radians (any value, including negative or > 2pi)
92///
93/// # Returns
94///
95/// The equivalent angle in [-pi, +pi).
96///
97/// # When to Use
98///
99/// - **Hour angle**: hours from meridian, negative = east, positive = west
100/// - **Longitude differences**: shortest arc between two longitudes
101/// - **Galactic/ecliptic longitude**: if you want galactic center at l=0
102/// - **Position angle differences**: relative rotation between two frames
103///
104/// # Examples
105///
106/// ```
107/// use celestial_core::angle::wrap_pm_pi;
108/// use std::f64::consts::PI;
109///
110/// // 270 degrees -> -90 degrees
111/// let x = wrap_pm_pi(3.0 * PI / 2.0);
112/// assert!((x - (-PI / 2.0)).abs() < 1e-10);
113///
114/// // -270 degrees -> +90 degrees
115/// let y = wrap_pm_pi(-3.0 * PI / 2.0);
116/// assert!((y - (PI / 2.0)).abs() < 1e-10);
117///
118/// // Already in range: unchanged
119/// let z = wrap_pm_pi(1.0);
120/// assert!((z - 1.0).abs() < 1e-10);
121/// ```
122///
123/// # Algorithm
124///
125/// 1. Reduce to [-2pi, +2pi) via `fmod(x, 2pi)`
126/// 2. If result is >= pi or <= -pi, subtract/add 2pi to bring into range
127#[inline]
128pub fn wrap_pm_pi(x: f64) -> f64 {
129 let w = fmod(x, TWOPI);
130 if w.abs() >= PI {
131 return w - libm::copysign(TWOPI, x);
132 }
133
134 w
135}
136
137/// Wraps an angle to [0, 2pi) radians.
138///
139/// Use for quantities that are conventionally non-negative, with the
140/// discontinuity at 0/360 degrees (midnight/noon for time-like quantities).
141///
142/// # Arguments
143///
144/// * `x` - Angle in radians (any value, including negative or > 2pi)
145///
146/// # Returns
147///
148/// The equivalent angle in [0, 2pi).
149///
150/// # When to Use
151///
152/// - **Right ascension**: 0h to 24h, never negative
153/// - **Azimuth**: 0 to 360 degrees, measured from north through east
154/// - **Sidereal time**: 0h to 24h
155/// - **Mean anomaly, true anomaly**: orbital angles
156///
157/// # Examples
158///
159/// ```
160/// use celestial_core::angle::wrap_0_2pi;
161/// use std::f64::consts::PI;
162///
163/// // Negative angle -> positive equivalent
164/// let x = wrap_0_2pi(-PI / 2.0); // -90 deg -> 270 deg
165/// assert!((x - 3.0 * PI / 2.0).abs() < 1e-10);
166///
167/// // Angle > 2pi -> reduced
168/// let y = wrap_0_2pi(5.0 * PI); // 900 deg -> 180 deg
169/// assert!((y - PI).abs() < 1e-10);
170///
171/// // Already in range: unchanged
172/// let z = wrap_0_2pi(1.0);
173/// assert!((z - 1.0).abs() < 1e-10);
174/// ```
175///
176/// # Algorithm
177///
178/// 1. Reduce to (-2pi, +2pi) via `fmod(x, 2pi)`
179/// 2. If result is negative, add 2pi to make it positive
180#[inline]
181pub fn wrap_0_2pi(x: f64) -> f64 {
182 let w = fmod(x, TWOPI);
183 if w < 0.0 {
184 w + TWOPI
185 } else {
186 w
187 }
188}
189
190/// Clamps an angle to [-pi/2, +pi/2] radians (i.e., [-90, +90] degrees).
191///
192/// Unlike wrapping, clamping saturates at the limits. This is appropriate for
193/// quantities that have physical bounds you cannot exceed.
194///
195/// # Arguments
196///
197/// * `x` - Angle in radians
198///
199/// # Returns
200///
201/// The angle clamped to [-pi/2, +pi/2].
202///
203/// # When to Use
204///
205/// - **Declination**: celestial latitude, poles are at +/-90 degrees
206/// - **Geographic latitude**: -90 (south pole) to +90 (north pole)
207/// - **Altitude**: horizon at 0, zenith at +90 (though altitude can be negative)
208///
209/// # Why Clamp Instead of Wrap?
210///
211/// You cannot go "past" the north pole by walking north. If you try, you end up
212/// walking south on the other side. This is fundamentally different from longitude,
213/// where walking east forever eventually brings you back to where you started.
214///
215/// Clamping is a safety mechanism. If your calculation produces declination = 100 degrees,
216/// something is wrong upstream. Clamping to 90 degrees prevents downstream code from
217/// breaking, but you should investigate why the input was out of range.
218///
219/// # Examples
220///
221/// ```
222/// use celestial_core::angle::clamp_dec;
223/// use std::f64::consts::FRAC_PI_2;
224///
225/// // Within range: unchanged
226/// let x = clamp_dec(0.5);
227/// assert!((x - 0.5).abs() < 1e-10);
228///
229/// // Above +90 degrees: clamped to +90
230/// let y = clamp_dec(2.0);
231/// assert!((y - FRAC_PI_2).abs() < 1e-10);
232///
233/// // Below -90 degrees: clamped to -90
234/// let z = clamp_dec(-2.0);
235/// assert!((z - (-FRAC_PI_2)).abs() < 1e-10);
236/// ```
237///
238/// # See Also
239///
240/// For declinations that might legitimately exceed +/-90 degrees (e.g., pier-flipped
241/// telescope positions), see the validation functions in the [`validate`](super::validate)
242/// module which offer extended range options.
243#[inline]
244pub fn clamp_dec(x: f64) -> f64 {
245 x.clamp(-HALF_PI, HALF_PI)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_wrap_pm_pi() {
254 // In range: unchanged
255 assert_eq!(wrap_pm_pi(1.0), 1.0);
256 // Positive overflow: 270° -> -90°
257 assert!((wrap_pm_pi(3.0 * PI / 2.0) - (-PI / 2.0)).abs() < 1e-15);
258 // Negative overflow: -270° -> +90°
259 assert!((wrap_pm_pi(-3.0 * PI / 2.0) - (PI / 2.0)).abs() < 1e-15);
260 // At boundary: ±π both wrap (abs >= PI triggers adjustment)
261 assert!((wrap_pm_pi(PI) - (-PI)).abs() < 1e-15);
262 }
263
264 #[test]
265 fn test_wrap_0_2pi() {
266 // In range: unchanged
267 assert_eq!(wrap_0_2pi(1.0), 1.0);
268 // Negative becomes positive: -90° -> 270°
269 assert!((wrap_0_2pi(-PI / 2.0) - (3.0 * PI / 2.0)).abs() < 1e-15);
270 // Overflow: 3π -> π
271 assert!((wrap_0_2pi(3.0 * PI) - PI).abs() < 1e-15);
272 // At 2π: wraps to 0
273 assert!(wrap_0_2pi(TWOPI).abs() < 1e-15);
274 }
275
276 #[test]
277 fn test_clamp_dec() {
278 // In range: unchanged
279 assert_eq!(clamp_dec(0.5), 0.5);
280 // At boundary: unchanged
281 assert_eq!(clamp_dec(HALF_PI), HALF_PI);
282 // Overflow: clamped to ±π/2
283 assert_eq!(clamp_dec(2.0), HALF_PI);
284 assert_eq!(clamp_dec(-2.0), -HALF_PI);
285 }
286}