Skip to main content

celestial_core/location/
core.rs

1//! Observer location on Earth using WGS84 geodetic coordinates.
2//!
3//! This module provides the [`Location`] type for representing geographic positions.
4//! Coordinates are geodetic (latitude/longitude relative to the WGS84 ellipsoid),
5//! not geocentric (relative to Earth's center of mass).
6//!
7//! The distinction matters for precision astronomy: geodetic latitude differs from
8//! geocentric latitude by up to ~11 arcminutes at mid-latitudes due to Earth's
9//! equatorial bulge.
10//!
11//! # Coordinate conventions
12//!
13//! - **Latitude**: North positive, stored in radians, range [-pi/2, pi/2]
14//! - **Longitude**: East positive, stored in radians, range [-pi, pi]
15//! - **Height**: Meters above the WGS84 ellipsoid (not sea level)
16//!
17//! # Example
18//!
19//! ```
20//! use celestial_core::Location;
21//!
22//! // Mauna Kea summit
23//! let obs = Location::from_degrees(19.8207, -155.4681, 4205.0)?;
24//!
25//! // Access coordinates
26//! assert!((obs.latitude_degrees() - 19.8207).abs() < 1e-10);
27//! # Ok::<(), celestial_core::AstroError>(())
28//! ```
29
30use crate::errors::{AstroError, AstroResult, MathErrorKind};
31
32#[cfg(feature = "serde")]
33use serde::{Deserialize, Serialize};
34
35/// A geographic location on Earth in WGS84 geodetic coordinates.
36///
37/// All angular values are stored internally in radians. Use [`Location::from_degrees`]
38/// for convenience when working with degree-based coordinates.
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41pub struct Location {
42    /// Geodetic latitude in radians. North is positive.
43    pub latitude: f64,
44    /// Geodetic longitude in radians. East is positive.
45    pub longitude: f64,
46    /// Height above WGS84 ellipsoid in meters.
47    pub height: f64,
48}
49
50impl Location {
51    /// Creates a new location from coordinates in radians.
52    ///
53    /// # Arguments
54    ///
55    /// * `latitude` - Geodetic latitude in radians, must be in [-pi/2, pi/2]
56    /// * `longitude` - Geodetic longitude in radians, must be in [-pi, pi]
57    /// * `height` - Height above WGS84 ellipsoid in meters, must be in [-12000, 100000]
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if any coordinate is non-finite or outside its valid range.
62    /// The height range covers the Mariana Trench floor to well above aircraft altitude.
63    pub fn new(latitude: f64, longitude: f64, height: f64) -> AstroResult<Self> {
64        if !latitude.is_finite() {
65            return Err(AstroError::math_error(
66                "location_validation",
67                MathErrorKind::InvalidInput,
68                "Latitude must be finite",
69            ));
70        }
71        if !longitude.is_finite() {
72            return Err(AstroError::math_error(
73                "location_validation",
74                MathErrorKind::InvalidInput,
75                "Longitude must be finite",
76            ));
77        }
78        if !height.is_finite() {
79            return Err(AstroError::math_error(
80                "location_validation",
81                MathErrorKind::InvalidInput,
82                "Height must be finite",
83            ));
84        }
85
86        if latitude.abs() > crate::constants::HALF_PI {
87            return Err(AstroError::math_error(
88                "location_validation",
89                MathErrorKind::InvalidInput,
90                "Latitude outside valid range [-π/2, π/2]",
91            ));
92        }
93        if longitude.abs() > crate::constants::PI {
94            return Err(AstroError::math_error(
95                "location_validation",
96                MathErrorKind::InvalidInput,
97                "Longitude outside valid range [-π, π]",
98            ));
99        }
100        if !(-12000.0..=100000.0).contains(&height) {
101            return Err(AstroError::math_error(
102                "location_validation",
103                MathErrorKind::InvalidInput,
104                "Height outside reasonable range [-12000, 100000] meters",
105            ));
106        }
107
108        Ok(Self {
109            latitude,
110            longitude,
111            height,
112        })
113    }
114
115    /// Creates a new location from coordinates in degrees.
116    ///
117    /// This is the typical way to create a Location, since most sources
118    /// provide coordinates in degrees.
119    ///
120    /// # Arguments
121    ///
122    /// * `lat_deg` - Geodetic latitude in degrees, must be in [-90, 90]
123    /// * `lon_deg` - Geodetic longitude in degrees, must be in [-180, 180]
124    /// * `height_m` - Height above WGS84 ellipsoid in meters
125    ///
126    /// # Example
127    ///
128    /// ```
129    /// use celestial_core::Location;
130    ///
131    /// // La Silla Observatory, Chile
132    /// let la_silla = Location::from_degrees(-29.2563, -70.7380, 2400.0)?;
133    /// # Ok::<(), celestial_core::AstroError>(())
134    /// ```
135    pub fn from_degrees(lat_deg: f64, lon_deg: f64, height_m: f64) -> AstroResult<Self> {
136        if !lat_deg.is_finite() {
137            return Err(AstroError::math_error(
138                "location_validation",
139                MathErrorKind::InvalidInput,
140                "Latitude degrees must be finite",
141            ));
142        }
143        if !lon_deg.is_finite() {
144            return Err(AstroError::math_error(
145                "location_validation",
146                MathErrorKind::InvalidInput,
147                "Longitude degrees must be finite",
148            ));
149        }
150        if lat_deg.abs() > 90.0 {
151            return Err(AstroError::math_error(
152                "location_validation",
153                MathErrorKind::InvalidInput,
154                "Latitude outside valid range [-90, 90] degrees",
155            ));
156        }
157        if lon_deg.abs() > 180.0 {
158            return Err(AstroError::math_error(
159                "location_validation",
160                MathErrorKind::InvalidInput,
161                "Longitude outside valid range [-180, 180] degrees",
162            ));
163        }
164
165        Self::new(
166            lat_deg * crate::constants::DEG_TO_RAD,
167            lon_deg * crate::constants::DEG_TO_RAD,
168            height_m,
169        )
170    }
171
172    /// Returns the latitude in degrees.
173    pub fn latitude_degrees(&self) -> f64 {
174        self.latitude * crate::constants::RAD_TO_DEG
175    }
176
177    /// Returns the longitude in degrees.
178    pub fn longitude_degrees(&self) -> f64 {
179        self.longitude * crate::constants::RAD_TO_DEG
180    }
181
182    /// Returns the latitude as an [`Angle`](crate::Angle).
183    pub fn latitude_angle(&self) -> crate::Angle {
184        crate::Angle::from_radians(self.latitude)
185    }
186
187    /// Returns the longitude as an [`Angle`](crate::Angle).
188    pub fn longitude_angle(&self) -> crate::Angle {
189        crate::Angle::from_radians(self.longitude)
190    }
191
192    /// Returns the Royal Observatory, Greenwich (0, 0, 0).
193    ///
194    /// Useful as a default or reference location.
195    pub fn greenwich() -> Self {
196        Self::from_degrees(0.0, 0.0, 0.0).expect("Greenwich coordinates should always be valid")
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_location_creation() {
206        let loc = Location::new(0.5, 1.0, 100.0).unwrap();
207        assert_eq!(loc.latitude, 0.5);
208        assert_eq!(loc.longitude, 1.0);
209        assert_eq!(loc.height, 100.0);
210    }
211
212    #[test]
213    fn test_from_degrees() {
214        let loc = Location::from_degrees(45.0, 90.0, 1000.0).unwrap();
215        assert!((loc.latitude - 45.0_f64.to_radians()).abs() < 1e-15);
216        assert!((loc.longitude - 90.0_f64.to_radians()).abs() < 1e-15);
217        assert_eq!(loc.height, 1000.0);
218    }
219
220    #[test]
221    fn test_longitude_degrees_conversion_returns_degrees() {
222        let loc = Location::from_degrees(0.0, 180.0, 0.0).unwrap();
223        assert_eq!(loc.longitude_degrees(), 180.0);
224    }
225
226    #[test]
227    fn test_longitude_degrees_conversion_handles_negative() {
228        let loc = Location::from_degrees(0.0, -90.0, 0.0).unwrap();
229        assert_eq!(loc.longitude_degrees(), -90.0);
230    }
231
232    #[test]
233    fn test_longitude_angle_returns_angle_object() {
234        let loc = Location::from_degrees(0.0, 45.0, 0.0).unwrap();
235        let angle = loc.longitude_angle();
236        crate::test_helpers::assert_float_eq(angle.degrees(), 45.0, 1);
237    }
238
239    #[test]
240    fn test_longitude_angle_handles_wraparound() {
241        let loc = Location::from_degrees(0.0, -180.0, 0.0).unwrap();
242        let angle = loc.longitude_angle();
243        crate::test_helpers::assert_float_eq(angle.degrees(), -180.0, 1);
244    }
245
246    #[test]
247    fn test_location_validation_errors() {
248        let result = Location::new(f64::NAN, 0.0, 0.0);
249        assert!(result.is_err());
250        assert!(result
251            .unwrap_err()
252            .to_string()
253            .contains("Latitude must be finite"));
254
255        let result = Location::new(0.0, f64::NAN, 0.0);
256        assert!(result.is_err());
257        assert!(result
258            .unwrap_err()
259            .to_string()
260            .contains("Longitude must be finite"));
261
262        let result = Location::new(0.0, 0.0, f64::NAN);
263        assert!(result.is_err());
264        assert!(result
265            .unwrap_err()
266            .to_string()
267            .contains("Height must be finite"));
268
269        let result = Location::new(f64::INFINITY, 0.0, 0.0);
270        assert!(result.is_err());
271        assert!(result
272            .unwrap_err()
273            .to_string()
274            .contains("Latitude must be finite"));
275
276        let result = Location::new(0.0, f64::INFINITY, 0.0);
277        assert!(result.is_err());
278        assert!(result
279            .unwrap_err()
280            .to_string()
281            .contains("Longitude must be finite"));
282
283        let result = Location::new(0.0, 0.0, f64::INFINITY);
284        assert!(result.is_err());
285        assert!(result
286            .unwrap_err()
287            .to_string()
288            .contains("Height must be finite"));
289
290        let result = Location::new(crate::constants::PI, 0.0, 0.0);
291        assert!(result.is_err());
292        assert!(result
293            .unwrap_err()
294            .to_string()
295            .contains("outside valid range"));
296
297        let result = Location::new(-crate::constants::PI, 0.0, 0.0);
298        assert!(result.is_err());
299        assert!(result
300            .unwrap_err()
301            .to_string()
302            .contains("outside valid range"));
303
304        let result = Location::new(0.0, crate::constants::TWOPI, 0.0);
305        assert!(result.is_err());
306        assert!(result
307            .unwrap_err()
308            .to_string()
309            .contains("outside valid range"));
310
311        let result = Location::new(0.0, -crate::constants::TWOPI, 0.0);
312        assert!(result.is_err());
313        assert!(result
314            .unwrap_err()
315            .to_string()
316            .contains("outside valid range"));
317
318        let result = Location::new(0.0, 0.0, 200000.0);
319        assert!(result.is_err());
320        assert!(result
321            .unwrap_err()
322            .to_string()
323            .contains("outside reasonable range"));
324
325        let result = Location::new(0.0, 0.0, -20000.0);
326        assert!(result.is_err());
327        assert!(result
328            .unwrap_err()
329            .to_string()
330            .contains("outside reasonable range"));
331    }
332
333    #[test]
334    fn test_from_degrees_validation_errors() {
335        let result = Location::from_degrees(f64::NAN, 0.0, 0.0);
336        assert!(result.is_err());
337        assert!(result
338            .unwrap_err()
339            .to_string()
340            .contains("Latitude degrees must be finite"));
341
342        let result = Location::from_degrees(0.0, f64::NAN, 0.0);
343        assert!(result.is_err());
344        assert!(result
345            .unwrap_err()
346            .to_string()
347            .contains("Longitude degrees must be finite"));
348
349        let result = Location::from_degrees(95.0, 0.0, 0.0);
350        assert!(result.is_err());
351        assert!(result
352            .unwrap_err()
353            .to_string()
354            .contains("outside valid range [-90, 90]"));
355
356        let result = Location::from_degrees(-95.0, 0.0, 0.0);
357        assert!(result.is_err());
358        assert!(result
359            .unwrap_err()
360            .to_string()
361            .contains("outside valid range [-90, 90]"));
362
363        let result = Location::from_degrees(0.0, 185.0, 0.0);
364        assert!(result.is_err());
365        assert!(result
366            .unwrap_err()
367            .to_string()
368            .contains("outside valid range [-180, 180]"));
369
370        let result = Location::from_degrees(0.0, -185.0, 0.0);
371        assert!(result.is_err());
372        assert!(result
373            .unwrap_err()
374            .to_string()
375            .contains("outside valid range [-180, 180]"));
376    }
377}