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(lat_deg.to_radians(), lon_deg.to_radians(), height_m)
166    }
167
168    /// Returns the latitude in degrees.
169    pub fn latitude_degrees(&self) -> f64 {
170        self.latitude.to_degrees()
171    }
172
173    /// Returns the longitude in degrees.
174    pub fn longitude_degrees(&self) -> f64 {
175        self.longitude.to_degrees()
176    }
177
178    /// Returns the latitude as an [`Angle`](crate::Angle).
179    pub fn latitude_angle(&self) -> crate::Angle {
180        crate::Angle::from_radians(self.latitude)
181    }
182
183    /// Returns the longitude as an [`Angle`](crate::Angle).
184    pub fn longitude_angle(&self) -> crate::Angle {
185        crate::Angle::from_radians(self.longitude)
186    }
187
188    /// Returns the Royal Observatory, Greenwich (0, 0, 0).
189    ///
190    /// Useful as a default or reference location.
191    pub fn greenwich() -> Self {
192        Self::from_degrees(0.0, 0.0, 0.0).expect("Greenwich coordinates should always be valid")
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_location_creation() {
202        let loc = Location::new(0.5, 1.0, 100.0).unwrap();
203        assert_eq!(loc.latitude, 0.5);
204        assert_eq!(loc.longitude, 1.0);
205        assert_eq!(loc.height, 100.0);
206    }
207
208    #[test]
209    fn test_from_degrees() {
210        let loc = Location::from_degrees(45.0, 90.0, 1000.0).unwrap();
211        assert!((loc.latitude - 45.0_f64.to_radians()).abs() < 1e-15);
212        assert!((loc.longitude - 90.0_f64.to_radians()).abs() < 1e-15);
213        assert_eq!(loc.height, 1000.0);
214    }
215
216    #[test]
217    fn test_longitude_degrees_conversion_returns_degrees() {
218        let loc = Location::from_degrees(0.0, 180.0, 0.0).unwrap();
219        assert_eq!(loc.longitude_degrees(), 180.0);
220    }
221
222    #[test]
223    fn test_longitude_degrees_conversion_handles_negative() {
224        let loc = Location::from_degrees(0.0, -90.0, 0.0).unwrap();
225        assert_eq!(loc.longitude_degrees(), -90.0);
226    }
227
228    #[test]
229    fn test_longitude_angle_returns_angle_object() {
230        let loc = Location::from_degrees(0.0, 45.0, 0.0).unwrap();
231        let angle = loc.longitude_angle();
232        crate::test_helpers::assert_float_eq(angle.degrees(), 45.0, 1);
233    }
234
235    #[test]
236    fn test_longitude_angle_handles_wraparound() {
237        let loc = Location::from_degrees(0.0, -180.0, 0.0).unwrap();
238        let angle = loc.longitude_angle();
239        crate::test_helpers::assert_float_eq(angle.degrees(), -180.0, 1);
240    }
241
242    #[test]
243    fn test_location_validation_errors() {
244        let result = Location::new(f64::NAN, 0.0, 0.0);
245        assert!(result.is_err());
246        assert!(result
247            .unwrap_err()
248            .to_string()
249            .contains("Latitude must be finite"));
250
251        let result = Location::new(0.0, f64::NAN, 0.0);
252        assert!(result.is_err());
253        assert!(result
254            .unwrap_err()
255            .to_string()
256            .contains("Longitude must be finite"));
257
258        let result = Location::new(0.0, 0.0, f64::NAN);
259        assert!(result.is_err());
260        assert!(result
261            .unwrap_err()
262            .to_string()
263            .contains("Height must be finite"));
264
265        let result = Location::new(f64::INFINITY, 0.0, 0.0);
266        assert!(result.is_err());
267        assert!(result
268            .unwrap_err()
269            .to_string()
270            .contains("Latitude must be finite"));
271
272        let result = Location::new(0.0, f64::INFINITY, 0.0);
273        assert!(result.is_err());
274        assert!(result
275            .unwrap_err()
276            .to_string()
277            .contains("Longitude must be finite"));
278
279        let result = Location::new(0.0, 0.0, f64::INFINITY);
280        assert!(result.is_err());
281        assert!(result
282            .unwrap_err()
283            .to_string()
284            .contains("Height must be finite"));
285
286        let result = Location::new(crate::constants::PI, 0.0, 0.0);
287        assert!(result.is_err());
288        assert!(result
289            .unwrap_err()
290            .to_string()
291            .contains("outside valid range"));
292
293        let result = Location::new(-crate::constants::PI, 0.0, 0.0);
294        assert!(result.is_err());
295        assert!(result
296            .unwrap_err()
297            .to_string()
298            .contains("outside valid range"));
299
300        let result = Location::new(0.0, crate::constants::TWOPI, 0.0);
301        assert!(result.is_err());
302        assert!(result
303            .unwrap_err()
304            .to_string()
305            .contains("outside valid range"));
306
307        let result = Location::new(0.0, -crate::constants::TWOPI, 0.0);
308        assert!(result.is_err());
309        assert!(result
310            .unwrap_err()
311            .to_string()
312            .contains("outside valid range"));
313
314        let result = Location::new(0.0, 0.0, 200000.0);
315        assert!(result.is_err());
316        assert!(result
317            .unwrap_err()
318            .to_string()
319            .contains("outside reasonable range"));
320
321        let result = Location::new(0.0, 0.0, -20000.0);
322        assert!(result.is_err());
323        assert!(result
324            .unwrap_err()
325            .to_string()
326            .contains("outside reasonable range"));
327    }
328
329    #[test]
330    fn test_from_degrees_validation_errors() {
331        let result = Location::from_degrees(f64::NAN, 0.0, 0.0);
332        assert!(result.is_err());
333        assert!(result
334            .unwrap_err()
335            .to_string()
336            .contains("Latitude degrees must be finite"));
337
338        let result = Location::from_degrees(0.0, f64::NAN, 0.0);
339        assert!(result.is_err());
340        assert!(result
341            .unwrap_err()
342            .to_string()
343            .contains("Longitude degrees must be finite"));
344
345        let result = Location::from_degrees(95.0, 0.0, 0.0);
346        assert!(result.is_err());
347        assert!(result
348            .unwrap_err()
349            .to_string()
350            .contains("outside valid range [-90, 90]"));
351
352        let result = Location::from_degrees(-95.0, 0.0, 0.0);
353        assert!(result.is_err());
354        assert!(result
355            .unwrap_err()
356            .to_string()
357            .contains("outside valid range [-90, 90]"));
358
359        let result = Location::from_degrees(0.0, 185.0, 0.0);
360        assert!(result.is_err());
361        assert!(result
362            .unwrap_err()
363            .to_string()
364            .contains("outside valid range [-180, 180]"));
365
366        let result = Location::from_degrees(0.0, -185.0, 0.0);
367        assert!(result.is_err());
368        assert!(result
369            .unwrap_err()
370            .to_string()
371            .contains("outside valid range [-180, 180]"));
372    }
373}