mgrs-convert 0.1.0

Convert WGS84 latitude-longitude to and from UTM, UPS, and MGRS grid references, round-tripping exactly at every precision from 100 km to 1 um
Documentation
//! The UTM/UPS coordinate type and the standard zone rules.
//
// Bounded floating-point arithmetic only; see the note in `math.rs`.

use crate::error::Error;
use crate::latlon::LatLon;
use crate::mgrs::Mgrs;
use crate::precision::Precision;
use crate::{polar, tm};

/// UTM false easting in metres.
pub(crate) const UTM_FALSE_EASTING: f64 = 500_000.0;
/// UTM false northing in the southern frame, metres.
pub(crate) const UTM_FALSE_NORTHING: f64 = 10_000_000.0;

// Wide transfer ranges. Each is the strict MGRS grid coverage extended by
// 100 km on every side, which is GeographicLib's UTMUPS transfer behavior,
// captured from the oracle at stage 0. The ring between a wide range and the
// strict MGRS coverage is where `to_mgrs` returns `Err(OutsideMgrsExtent)`.
pub(crate) const UTM_EASTING_RANGE: (f64, f64) = (0.0, 1_000_000.0);
/// Northing relative to the equator (the frame's false northing removed).
pub(crate) const UTM_NORTHING_ABS_RANGE: (f64, f64) = (-9_100_000.0, 9_600_000.0);
pub(crate) const UPS_NORTH_RANGE: (f64, f64) = (1_200_000.0, 2_800_000.0);
pub(crate) const UPS_SOUTH_RANGE: (f64, f64) = (700_000.0, 3_300_000.0);

/// The central meridian of a UTM zone in degrees.
pub(crate) fn central_meridian(zone: u8) -> f64 {
    f64::from(zone) * 6.0 - 183.0
}

/// The standard UTM zone (or 0 for UPS) for a validated latitude and a
/// longitude normalized to [-180, 180).
///
/// Latitude in [-80, 84) selects UTM: -80 included, 84 excluded, matching
/// GeographicLib's `UTMUPS::StandardZone`. Latitude 84 and above is UPS north;
/// below -80 is UPS south. The Norway and Svalbard exceptions apply.
pub(crate) fn standard_zone(lat: f64, lon: f64) -> u8 {
    if !(-80.0..84.0).contains(&lat) {
        return 0;
    }
    // Norway: latitude [56, 64), longitude [3, 12) is zone 32.
    if (56.0..64.0).contains(&lat) && (3.0..12.0).contains(&lon) {
        return 32;
    }
    // Svalbard: latitude [72, 84), four widened zones.
    if (72.0..84.0).contains(&lat) {
        if (0.0..9.0).contains(&lon) {
            return 31;
        }
        if (9.0..21.0).contains(&lon) {
            return 33;
        }
        if (21.0..33.0).contains(&lon) {
            return 35;
        }
        if (33.0..42.0).contains(&lon) {
            return 37;
        }
    }
    // Base rule, in integer degrees so a longitude one ulp under a zone edge
    // cannot round across it: floor((lon + 180) / 6) + 1.
    let ilon = lon.floor() as i64; // in [-180, 179]
    let zone = (ilon + 180) / 6 + 1; // in [1, 60]
    zone.clamp(1, 60) as u8
}

/// UTM or UPS coordinate. Zone 0 is UPS, matching GeographicLib's convention.
///
/// Constructor-validated against the wide transfer ranges, which are the strict
/// MGRS grid coverage extended by 100 km per axis. A value inside the wide
/// range but outside the MGRS coverage exists and converts to latitude and
/// longitude, and its [`to_mgrs`](UtmUps::to_mgrs) returns
/// [`Error::OutsideMgrsExtent`] instead of panicking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UtmUps {
    zone: u8,
    north: bool,
    easting: f64,
    northing: f64,
}

impl UtmUps {
    /// Validates a UTM coordinate. `zone` is 1 to 60, `north` names the frame
    /// (which false northing applies), easting accepts [0, 1,000,000] and
    /// northing accepts the equator-relative range [-9,100,000, 9,600,000]
    /// expressed in the frame: [-9,100,000, 9,600,000] northern,
    /// [900,000, 19,600,000] southern.
    ///
    /// ```
    /// use mgrs_convert::UtmUps;
    /// let u = UtmUps::utm(18, true, 585664.121, 4511315.422)?;
    /// assert_eq!(u.zone(), 18);
    /// # Ok::<(), mgrs_convert::Error>(())
    /// ```
    pub fn utm(zone: u8, north: bool, easting: f64, northing: f64) -> Result<UtmUps, Error> {
        if !(1..=60).contains(&zone) {
            return Err(Error::ZoneOutOfRange { found: zone });
        }
        let (emin, emax) = UTM_EASTING_RANGE;
        if !easting.is_finite() || !(emin..=emax).contains(&easting) {
            return Err(Error::EastingOutOfRange { found: easting });
        }
        let fold = if north { 0.0 } else { UTM_FALSE_NORTHING };
        let (nmin, nmax) = UTM_NORTHING_ABS_RANGE;
        if !northing.is_finite() || !(nmin + fold..=nmax + fold).contains(&northing) {
            return Err(Error::NorthingOutOfRange { found: northing });
        }
        Ok(UtmUps {
            zone,
            north,
            easting,
            northing,
        })
    }

    /// Validates a UPS coordinate. Easting and northing accept
    /// [1,200,000, 2,800,000] in the north cap and [700,000, 3,300,000] in the
    /// south cap.
    ///
    /// ```
    /// use mgrs_convert::UtmUps;
    /// let u = UtmUps::ups(true, 2_000_000.0, 1_500_000.0)?;
    /// assert_eq!(u.zone(), 0);
    /// # Ok::<(), mgrs_convert::Error>(())
    /// ```
    pub fn ups(north: bool, easting: f64, northing: f64) -> Result<UtmUps, Error> {
        let (lo, hi) = if north {
            UPS_NORTH_RANGE
        } else {
            UPS_SOUTH_RANGE
        };
        if !easting.is_finite() || !(lo..=hi).contains(&easting) {
            return Err(Error::EastingOutOfRange { found: easting });
        }
        if !northing.is_finite() || !(lo..=hi).contains(&northing) {
            return Err(Error::NorthingOutOfRange { found: northing });
        }
        Ok(UtmUps {
            zone: 0,
            north,
            easting,
            northing,
        })
    }

    /// Internal constructor for values already proven inside the wide ranges:
    /// forward projections of validated latitudes and centers of parsed MGRS
    /// cells.
    pub(crate) fn raw(zone: u8, north: bool, easting: f64, northing: f64) -> UtmUps {
        UtmUps {
            zone,
            north,
            easting,
            northing,
        }
    }

    /// The UTM zone, 1 to 60, or 0 for UPS.
    pub fn zone(self) -> u8 {
        self.zone
    }

    /// Whether the coordinate is in the northern frame. For UTM this names the
    /// false northing, not the sign of the latitude: the transfer ranges allow
    /// a coordinate up to 100 km across the equator.
    pub fn is_north(self) -> bool {
        self.north
    }

    /// Easting in metres.
    pub fn easting(self) -> f64 {
        self.easting
    }

    /// Northing in metres, in the coordinate's frame.
    pub fn northing(self) -> f64 {
        self.northing
    }

    /// The exact geodetic coordinate of this grid position.
    pub fn to_latlon(self) -> LatLon {
        let (lat, lon) = self.latlon_parts();
        LatLon::new_normalized(lat, lon)
    }

    /// Reverse projection to (lat, lon) before normalization.
    pub(crate) fn latlon_parts(self) -> (f64, f64) {
        if self.zone == 0 {
            polar::reverse(self.north, self.easting, self.northing)
        } else {
            let x = self.easting - UTM_FALSE_EASTING;
            let fold = if self.north { 0.0 } else { UTM_FALSE_NORTHING };
            let y = self.northing - fold;
            let (lat, dlon) = tm::reverse(x, y);
            (lat, central_meridian(self.zone) + dlon)
        }
    }

    /// The MGRS cell containing this coordinate, truncated to `precision`.
    ///
    /// Fallible by design: the wide transfer range exceeds the
    /// MGRS grid, and a coordinate in the ring between them gets
    /// [`Error::OutsideMgrsExtent`].
    ///
    /// ```
    /// use mgrs_convert::{Error, Precision, UtmUps};
    /// let inside = UtmUps::utm(18, true, 585664.0, 4511315.0)?;
    /// assert_eq!(inside.to_mgrs(Precision::ONE_METER)?.to_string(), "18TWL8566411315");
    /// let ring = UtmUps::utm(18, true, 50_000.0, 4_511_315.0)?;
    /// assert!(matches!(
    ///     ring.to_mgrs(Precision::ONE_METER),
    ///     Err(Error::OutsideMgrsExtent { .. })
    /// ));
    /// # Ok::<(), mgrs_convert::Error>(())
    /// ```
    pub fn to_mgrs(self, precision: Precision) -> Result<Mgrs, Error> {
        crate::mgrs::from_utmups(self, precision)
    }
}