Skip to main content

lat_long/
latitude.rs

1//! This module provides the [`Latitude`] type, [`crate::lat!`] macro, and associated constants.
2//!
3//! In geography, latitude is a geographic coordinate that specifies the north-south position of
4//! a point on the surface of the Earth or another celestial body. Latitude is given as an angle
5//! that ranges from −90° at the south pole to 90° at the north pole, with 0° at the Equator.
6//!
7//! The latitude denoted by the type [`Latitude`] is not strictly a *Geodetic Latitude* in that it
8//! is not defined in relation to some reference geodetic datum but some abstract center of mass.
9//!
10
11use crate::{
12    Angle, Error,
13    fmt::{FormatOptions, Formatter, formatter_impl},
14    inner,
15    parse::{self, Parsed, Value},
16};
17use core::{
18    fmt::{Debug, Display, Write},
19    str::FromStr,
20};
21use ordered_float::OrderedFloat;
22
23#[cfg(feature = "serde")]
24use serde::{Deserialize, Serialize};
25
26// ---------------------------------------------------------------------------
27// Public Types
28// ---------------------------------------------------------------------------
29
30///
31/// A geographic latitude value, constrained to **−90 ≤ degrees ≤ 90**.
32///
33/// Positive values are north of the equator; negative values are south.
34///
35/// # Construction
36///
37/// Use [`Latitude::new`] to construct from degrees, minutes, and seconds, or
38/// [`TryFrom<inner::Float>`] if you already have a decimal-degree value.
39///
40/// # Examples
41///
42/// ```rust
43/// use lat_long::{Angle, Latitude};
44///
45/// let lat = Latitude::new(45, 30, 0.0).unwrap();
46/// assert!(lat.is_northern());
47///
48/// let equator = Latitude::new(0, 0, 0.0).unwrap();
49/// assert!(equator.is_on_equator());
50/// ```
51///
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
53#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
54pub struct Latitude(OrderedFloat<f64>);
55
56// ---------------------------------------------------------------------------
57// Public Constants
58// ---------------------------------------------------------------------------
59
60///
61/// The geographic North Pole, at 90° N latitude.
62///
63pub const NORTH_POLE: Latitude = Latitude(OrderedFloat(LATITUDE_LIMIT));
64
65///
66/// The Arctic Circle, approximately 66.5° N latitude.
67///
68/// Latitudes at or above this value experience at least one full day of
69/// continuous daylight or darkness per year.
70///
71pub const ARCTIC_CIRCLE: Latitude = Latitude(OrderedFloat(66.5));
72
73///
74/// The Tropic of Cancer, approximately 23.5° N latitude.
75///
76/// The northernmost latitude at which the sun can appear directly overhead
77/// at solar noon (at the June solstice).
78///
79pub const TROPIC_OF_CANCER: Latitude = Latitude(OrderedFloat(23.5));
80
81///
82/// The equator, at 0° latitude.
83///
84/// The circle of latitude equidistant from both poles, dividing the globe
85/// into the northern and southern hemispheres.
86///
87pub const EQUATOR: Latitude = Latitude(inner::ZERO);
88
89///
90/// The Tropic of Capricorn, approximately 23.5° S latitude.
91///
92/// The southernmost latitude at which the sun can appear directly overhead
93/// at solar noon (at the December solstice).
94///
95pub const TROPIC_OF_CAPRICORN: Latitude = Latitude(OrderedFloat(-23.5));
96
97///
98/// The Antarctic Circle, approximately 66.5° S latitude.
99///
100/// Latitudes at or below this value experience at least one full day of
101/// continuous daylight or darkness per year.
102///
103pub const ANTARCTIC_CIRCLE: Latitude = Latitude(OrderedFloat(-66.5));
104
105///
106/// The geographic South Pole, at 90° S latitude.
107///
108pub const SOUTH_POLE: Latitude = Latitude(OrderedFloat(-LATITUDE_LIMIT));
109
110// ---------------------------------------------------------------------------
111// Public Macros
112// ---------------------------------------------------------------------------
113
114///
115/// Ergonomic constructor for [`Latitude`] values.
116///
117/// All forms `.unwrap()` internally — they are intended for compile-time-known
118/// constants and tests where invalid input is a bug. Use [`Latitude::new`]
119/// when you need to handle validation errors.
120///
121/// | Form                                | Example                  | Meaning              |
122/// |-------------------------------------|--------------------------|----------------------|
123/// | `lat!(d)`                           | `lat!(45)`               | 45° N                |
124/// | `lat!(d, m)`                        | `lat!(45, 30)`           | 45° 30′ N            |
125/// | `lat!(d, m, s)`                     | `lat!(45, 30, 0.0)`      | 45° 30′ 0″ N         |
126/// | `lat!(N d, …)` / `lat!(S d, …)`     | `lat!(S 33, 51, 24.0)`   | explicit hemisphere  |
127///
128/// The `N`/`S` prefix forms take the absolute value of the degree argument
129/// and apply the sign matching the direction.
130///
131/// # Examples
132///
133/// ```rust
134/// use lat_long::{Angle, Latitude, lat};
135///
136/// let lat = lat!(45, 30, 0.0);
137/// assert!(lat.is_northern());
138/// assert_eq!(lat.degrees(), 45);
139/// ```
140///
141#[macro_export]
142macro_rules! lat {
143    (N $degrees:expr, $minutes:expr, $seconds:expr) => {
144        lat!($degrees.abs(), $minutes, $seconds).unwrap()
145    };
146    (S $degrees:expr, $minutes:expr, $seconds:expr) => {
147        lat!(-$degrees.abs(), $minutes, $seconds).unwrap()
148    };
149    ($degrees:expr, $minutes:expr, $seconds:expr) => {
150        Latitude::new($degrees, $minutes, $seconds).unwrap()
151    };
152    (N $degrees:expr, $minutes:expr) => {
153        lat!($degrees.abs(), $minutes).unwrap()
154    };
155    (S $degrees:expr, $minutes:expr) => {
156        lat!(-$degrees.abs(), $minutes).unwrap()
157    };
158    ($degrees:expr, $minutes:expr) => {
159        lat!($degrees, $minutes, 0.0).unwrap()
160    };
161    (N $degrees:expr) => {
162        lat!($degrees.abs()).unwrap()
163    };
164    (S $degrees:expr) => {
165        lat!(-$degrees.abs()).unwrap()
166    };
167    ($degrees:expr) => {
168        lat!($degrees, 0, 0.0).unwrap()
169    };
170}
171
172// ---------------------------------------------------------------------------
173// Implementations
174// ---------------------------------------------------------------------------
175
176const LATITUDE_LIMIT: f64 = 90.0;
177
178impl Default for Latitude {
179    fn default() -> Self {
180        EQUATOR
181    }
182}
183
184impl TryFrom<f64> for Latitude {
185    type Error = Error;
186
187    fn try_from(value: f64) -> Result<Self, Self::Error> {
188        Self::try_from(OrderedFloat(value))
189    }
190}
191
192impl TryFrom<OrderedFloat<f64>> for Latitude {
193    type Error = Error;
194
195    fn try_from(value: OrderedFloat<f64>) -> Result<Self, Self::Error> {
196        if value.is_infinite() || value.is_nan() {
197            Err(Error::InvalidNumericValue(value.into()))
198        } else if value.0 < -LATITUDE_LIMIT || value.0 > LATITUDE_LIMIT {
199            Err(Error::InvalidAngle(value.into_inner(), LATITUDE_LIMIT))
200        } else {
201            Ok(Self(value))
202        }
203    }
204}
205
206impl From<Latitude> for OrderedFloat<f64> {
207    fn from(value: Latitude) -> Self {
208        value.0
209    }
210}
211
212impl From<Latitude> for f64 {
213    fn from(value: Latitude) -> Self {
214        value.0.into()
215    }
216}
217
218impl FromStr for Latitude {
219    type Err = Error;
220
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        match parse::parse_str(s)? {
223            Parsed::Angle(Value::Unknown(decimal)) => Self::try_from(decimal),
224            Parsed::Angle(Value::Latitude(lat)) => Ok(lat),
225            _ => Err(Error::InvalidAngle(0.0, 0.0)),
226        }
227    }
228}
229
230impl Display for Latitude {
231    ///
232    /// Formats the latitude as decimal degrees by default, or as
233    /// degrees–minutes–seconds when the alternate flag (`{:#}`) is used.
234    ///
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        if f.alternate() {
237            let mut buf = String::new();
238            self.format(&mut buf, &FormatOptions::dms_signed())?;
239            f.write_str(&buf)
240        } else {
241            Display::fmt(&(self.0), f)
242        }
243    }
244}
245
246impl Formatter for Latitude {
247    fn format<W: Write>(&self, f: &mut W, fmt: &FormatOptions) -> std::fmt::Result {
248        let fmt = (*fmt).with_labels(('N', 'S'));
249        formatter_impl(self.0, f, &fmt)
250    }
251}
252
253impl Angle for Latitude {
254    const MIN: Self = Self(OrderedFloat(-LATITUDE_LIMIT));
255    const MAX: Self = Self(OrderedFloat(LATITUDE_LIMIT));
256
257    fn new(degrees: i32, minutes: u32, seconds: f32) -> Result<Self, Error> {
258        if degrees < Self::MIN.as_float().0 as i32 || degrees > Self::MAX.as_float().0 as i32 {
259            return Err(Error::InvalidLatitudeDegrees(degrees));
260        }
261        // Delegate to inner helper; it verifies minutes/seconds.
262        // The only remaining failure path from try_from is if the decimal
263        // representation exceeds the limit (e.g. 90°0′0.000001″) — still
264        // report as InvalidLatitudeDegrees.
265        let float = inner::from_degrees_minutes_seconds(degrees, minutes, seconds)?;
266        Self::try_from(float).map_err(|_| Error::InvalidLatitudeDegrees(degrees))
267    }
268
269    fn as_float(&self) -> OrderedFloat<f64> {
270        self.0
271    }
272}
273
274impl Latitude {
275    ///
276    /// Returns `true` if this latitude is exactly on the equator (0°).
277    ///
278    #[must_use]
279    pub fn is_on_equator(&self) -> bool {
280        self.is_zero()
281    }
282
283    ///
284    /// Returns `true` if this latitude is in the northern hemisphere (> 0°).
285    ///
286    #[must_use]
287    pub fn is_northern(&self) -> bool {
288        self.is_nonzero_positive()
289    }
290
291    ///
292    /// Returns `true` if this latitude is in the southern hemisphere (< 0°).
293    ///
294    #[must_use]
295    pub fn is_southern(&self) -> bool {
296        self.is_nonzero_negative()
297    }
298
299    ///
300    /// Returns `true` if this latitude is within the Arctic region (≥ [`ARCTIC_CIRCLE`], i.e. ≥ 66.5° N).
301    ///
302    #[must_use]
303    pub fn is_arctic(&self) -> bool {
304        *self >= ARCTIC_CIRCLE
305    }
306
307    ///
308    /// Returns `true` if this latitude is within the Antarctic region (≤ [`ANTARCTIC_CIRCLE`], i.e. ≤ 66.5° S).
309    ///
310    #[must_use]
311    pub fn is_antarctic(&self) -> bool {
312        *self <= ANTARCTIC_CIRCLE
313    }
314
315    ///
316    /// Returns `true` if this latitude is at or north of the [`TROPIC_OF_CANCER`] (≥ 23.5° N).
317    ///
318    /// Together with [`is_tropic_of_capricorn`](Self::is_tropic_of_capricorn) this is used to
319    /// identify locations within the tropical band.
320    ///
321    #[must_use]
322    pub fn is_tropic_of_cancer(&self) -> bool {
323        *self >= TROPIC_OF_CANCER
324    }
325
326    ///
327    /// Returns `true` if this latitude is at or south of the [`TROPIC_OF_CAPRICORN`] (≤ 23.5° S).
328    ///
329    #[must_use]
330    pub fn is_tropic_of_capricorn(&self) -> bool {
331        *self <= TROPIC_OF_CAPRICORN
332    }
333
334    ///
335    /// Returns `true` if this latitude lies within the tropical band (between the
336    /// [`TROPIC_OF_CANCER`] and [`TROPIC_OF_CAPRICORN`], i.e. within ±23.5°).
337    ///
338    /// Note: this returns `true` for latitudes *outside* the tropical band that
339    /// are ≥ [`TROPIC_OF_CANCER`] in the north or ≤ [`TROPIC_OF_CAPRICORN`] in
340    /// the south — see individual methods for precise semantics.
341    ///
342    #[must_use]
343    pub fn is_tropical(&self) -> bool {
344        self.is_tropic_of_cancer() || self.is_tropic_of_capricorn()
345    }
346
347    ///
348    /// Returns `true` if this latitude is within either polar region
349    /// (at or beyond [`ARCTIC_CIRCLE`] north or [`ANTARCTIC_CIRCLE`] south).
350    ///
351    #[must_use]
352    pub fn is_polar(&self) -> bool {
353        self.is_arctic() || self.is_antarctic()
354    }
355
356    ///
357    /// Return the [UTM latitude band letter](https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system#Latitude_bands)
358    /// covering this latitude.
359    ///
360    /// The UTM grid divides Earth's surface into 8°-wide latitude bands
361    /// labeled `C` through `X` (omitting `I` and `O` for clarity), with the
362    /// polar regions covered by the special letters `A`/`B` (south of 80°S)
363    /// and `Y`/`Z` (north of 84°N). For those polar bands, `westing` selects
364    /// the western (`A`/`Y`) versus eastern (`B`/`Z`) half.
365    ///
366    /// # Examples
367    ///
368    /// ```rust
369    /// use lat_long::{Angle, Latitude};
370    ///
371    /// let lat = Latitude::try_from(47.6).unwrap();
372    /// // The band is one of the standard UTM letters C–X (plus polar A/B/Y/Z).
373    /// assert!("ABCDEFGHJKLMNPQRSTUVWXYZ".contains(lat.utm_band(false)));
374    /// ```
375    ///
376    #[must_use]
377    pub fn utm_band(&self, westing: bool) -> char {
378        let latitude = self.0.0;
379        const BAND_WIDTH_DEGREES: f64 = 8.0;
380        const BANDS: &[char] = &[
381            'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U',
382            'V', 'W', 'X',
383        ];
384        match latitude {
385            -90.0..-80.0 => {
386                if westing {
387                    'A'
388                } else {
389                    'B'
390                }
391            }
392            80.0..84.0 => 'X',
393            84.0..=90.0 => {
394                if westing {
395                    'Y'
396                } else {
397                    'Z'
398                }
399            }
400            _ => {
401                let index = ((latitude + LATITUDE_LIMIT) / BAND_WIDTH_DEGREES).floor() as usize;
402                BANDS[index]
403            }
404        }
405    }
406}