Skip to main content

kosher_rust/zmanim/types/
location.rs

1use jiff::tz::TimeZone;
2
3use crate::zmanim::types::error::ZmanimError;
4
5/// A geographic location (latitude/longitude/elevation) and an optional timezone.
6#[derive(Debug, Clone, PartialEq)]
7pub struct Location {
8    /// Latitude in degrees. Valid range: `[-90.0, 90.0]` (positive = North).
9    pub latitude: f64,
10    /// Longitude in degrees. Valid range: `[-180.0, 180.0]` (positive = East).
11    /// Must be provided with a timezone when `abs(longitude) > 150°`.
12    pub longitude: f64,
13    /// Elevation above sea level in meters. Must be `>= 0.0`.
14    pub elevation: f64,
15    /// Timezone of the location. Required when near the anti-meridian (`abs(longitude) > 150°`).
16    /// Also required for calculating kiddush levena times.
17    pub timezone: Option<TimeZone>,
18}
19
20impl Location {
21    /// Creates a new `Location`, returning a [`ZmanimError`] if any value is out of range.
22    ///
23    /// # Errors
24    /// - [`ZmanimError::InvalidLatitude`] — `latitude` outside `[-90.0, 90.0]`
25    /// - [`ZmanimError::InvalidLongitude`] — `longitude` outside `[-180.0, 180.0]`
26    /// - [`ZmanimError::InvalidElevation`] — `elevation` below `0.0`
27    /// - [`ZmanimError::TimeZoneRequired`] — `timezone` is `None` and `abs(longitude) > 150°`
28    pub fn new(latitude: f64, longitude: f64, elevation: f64, timezone: Option<TimeZone>) -> Result<Self, ZmanimError> {
29        if timezone.is_none() && Self::near_anti_meridian(longitude) {
30            return Err(ZmanimError::TimeZoneRequired);
31        }
32
33        if longitude.abs() > 180.0 || longitude.is_nan() {
34            return Err(ZmanimError::InvalidLongitude);
35        }
36        if latitude.abs() > 90.0 || latitude.is_nan() {
37            return Err(ZmanimError::InvalidLatitude);
38        }
39        if elevation.is_nan() || elevation < 0.0 {
40            return Err(ZmanimError::InvalidElevation);
41        }
42
43        Ok(Self {
44            latitude,
45            longitude,
46            elevation,
47            timezone,
48        })
49    }
50
51    pub(crate) fn near_anti_meridian(longitude: f64) -> bool {
52        const ANTI_MERIDIAN_THRESHOLD: f64 = 150.0;
53        longitude.abs() > ANTI_MERIDIAN_THRESHOLD
54    }
55}
56
57#[cfg(feature = "defmt")]
58impl defmt::Format for Location {
59    fn format(&self, fmt: defmt::Formatter) {
60        defmt::write!(
61            fmt,
62            "Location {{ latitude: {}, longitude: {}, elevation: {}, has_timezone: {} }}",
63            self.latitude,
64            self.longitude,
65            self.elevation,
66            self.timezone.is_some(),
67        )
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use jiff::tz::TimeZone;
75
76    #[test]
77    fn test_location_rejects_anti_meridian_without_timezone() {
78        let location = Location::new(0.0, 150.1, 0.0, None);
79        assert!(location.is_err());
80    }
81
82    #[test]
83    fn test_location_rejects_out_of_range_coords() {
84        let bad_longitude = Location::new(0.0, 181.0, 0.0, Some(TimeZone::UTC));
85        assert!(bad_longitude.is_err());
86
87        let bad_latitude = Location::new(91.0, 0.0, 0.0, Some(TimeZone::UTC));
88        assert!(bad_latitude.is_err());
89    }
90
91    #[test]
92    fn test_location_rejects_negative_elevation() {
93        let location = Location::new(0.0, 0.0, -1.0, Some(TimeZone::UTC));
94        assert!(location.is_err());
95    }
96}