Skip to main content

mgrs_convert/
mgrs.rs

1//! The MGRS cell type: letter tables, formatting, and parsing.
2//!
3//! Letter tables follow NGA TM 8358.1, the AA scheme, with the UPS tables and
4//! the band row windows fixed against the stage-0 oracle capture and proven by
5//! the exhaustive lattice tests.
6//!
7//! Mechanism M1 lives here: the parser holds no i32. Digit values accumulate
8//! in i64, where the precision-11 maximum, under 10^12, sits far below both
9//! i64::MAX and 2^53. This module denies `clippy::arithmetic_side_effects`, so
10//! every integer operation is a checked, saturating, or Euclidean method.
11
12#![deny(clippy::arithmetic_side_effects)]
13
14use core::fmt;
15use core::str::FromStr;
16
17use crate::error::{Error, ParseErrorKind};
18use crate::grid;
19use crate::latlon::LatLon;
20use crate::precision::Precision;
21use crate::utmups::UtmUps;
22
23/// UTM latitude band letters, index = band index + 10.
24const BAND_LETTERS: &str = "CDEFGHJKLMNPQRSTUVWX";
25/// Row letters, one 2,000 km cycle.
26const ROW_LETTERS: &str = "ABCDEFGHJKLMNPQRSTUV";
27/// Column letter sets by zone number mod 3 (zone 1 uses A to H).
28const COL_SETS: [&str; 3] = ["STUVWXYZ", "ABCDEFGH", "JKLMNPQR"];
29
30/// UPS column letters per polar zone letter: (zone letter, first 100 km column
31/// index, letters). Captured from the oracle lattice at stage 0.
32const UPS_COLS: [(u8, i64, &str); 4] = [
33    (b'Y', 13, "RSTUXYZ"),
34    (b'Z', 20, "ABCFGHJ"),
35    (b'A', 8, "JKLPQRSTUXYZ"),
36    (b'B', 20, "ABCFGHJKLPQR"),
37];
38/// UPS row letters, north cap: rows 13 to 26.
39const UPS_N_ROWS: (i64, &str) = (13, "ABCDEFGHJKLMNP");
40/// UPS row letters, south cap: rows 8 to 31.
41const UPS_S_ROWS: (i64, &str) = (8, "ABCDEFGHJKLMNPQRSTUVWXYZ");
42
43/// Accepted absolute row window per band, index = band index + 10. Rows one
44/// outside a window are accepted only through [`row_exception`]. Fixed against
45/// the stage-0 acceptance matrix and replayed by the rowband golden test.
46const BAND_ROW_WINDOW: [(i64, i64); 20] = [
47    (-90, -81), // C
48    (-80, -72), // D
49    (-71, -63), // E
50    (-63, -54), // F
51    (-54, -45), // G
52    (-45, -36), // H
53    (-36, -27), // J
54    (-27, -18), // K
55    (-18, -9),  // L
56    (-9, -1),   // M
57    (0, 8),     // N
58    (8, 17),    // P
59    (17, 26),   // Q
60    (26, 35),   // R
61    (35, 44),   // S
62    (44, 53),   // T
63    (53, 62),   // U
64    (62, 70),   // V
65    (71, 79),   // W
66    (80, 94),   // X
67];
68
69fn letter_at(table: &str, pos: i64) -> u8 {
70    usize::try_from(pos)
71        .ok()
72        .and_then(|i| table.as_bytes().get(i))
73        .copied()
74        .unwrap_or(b'?')
75}
76
77fn pos_of(table: &str, c: u8) -> Option<i64> {
78    table
79        .bytes()
80        .position(|b| b == c)
81        .and_then(|p| i64::try_from(p).ok())
82}
83
84/// A letter that exists in the MGRS alphabets: ASCII uppercase minus `I` and `O`.
85fn is_mgrs_letter(c: u8) -> bool {
86    c.is_ascii_uppercase() && c != b'I' && c != b'O'
87}
88
89/// Row-letter cycle shift: even zones are offset by half a cycle.
90fn row_shift(zone: u8) -> i64 {
91    if zone.rem_euclid(2) == 0 {
92        5
93    } else {
94        0
95    }
96}
97
98fn band_window(ib: i64) -> (i64, i64) {
99    usize::try_from(ib.saturating_add(10))
100        .ok()
101        .and_then(|i| BAND_ROW_WINDOW.get(i))
102        .copied()
103        .unwrap_or((0, 0))
104}
105
106/// The four column-restricted corner cells one row outside a band window,
107/// symmetric between the hemispheres, captured from the stage-0 acceptance
108/// matrix (they match GeographicLib's published exceptions).
109fn row_exception(cand: i64, ib: i64, col: i64) -> bool {
110    let sband = if ib >= 0 {
111        ib
112    } else {
113        ib.saturating_add(1).saturating_neg()
114    };
115    let srow = if cand >= 0 {
116        cand
117    } else {
118        cand.saturating_add(1).saturating_neg()
119    };
120    let c0 = col.saturating_sub(1); // 0..=7
121    let scol = if c0 <= 3 {
122        c0
123    } else {
124        7_i64.saturating_sub(c0)
125    };
126    match (srow, sband) {
127        (70, 8) => scol >= 2,
128        (71, 7) => scol <= 2,
129        (79, 9) => scol >= 1,
130        (80, 8) => scol <= 1,
131        _ => false,
132    }
133}
134
135/// An MGRS cell, only constructible through the validated paths
136/// [`LatLon::to_mgrs`], [`UtmUps::to_mgrs`], and [`str::parse`], so `Display`
137/// is total.
138///
139/// A cell is discrete: `Eq` and `Hash` allow deduplication, and the string form
140/// through `Display` and `FromStr` is the serialization.
141#[derive(Clone, Debug, PartialEq, Eq, Hash)]
142pub struct Mgrs {
143    /// 0 for UPS.
144    zone: u8,
145    north: bool,
146    /// Band letter C..X for UTM, or polar zone letter A, B, Y, Z for UPS.
147    band: u8,
148    /// 100 km column index: easting / 100 km. UTM 1..=8, UPS 8..=31.
149    col: i64,
150    /// Absolute row index: equator-relative northing / 100 km for UTM
151    /// (-90..=94), plain northing / 100 km for UPS (8..=31).
152    row: i64,
153    /// Easting digits, 0 .. 10^p. i64 by mechanism M1.
154    e_digits: i64,
155    /// Northing digits, 0 .. 10^p.
156    n_digits: i64,
157    precision: Precision,
158}
159
160impl Mgrs {
161    /// The digit-pair count of this reference.
162    pub fn precision(&self) -> Precision {
163        self.precision
164    }
165
166    /// The center of this cell: the southwest corner plus half a cell in each
167    /// axis. A corner seeker subtracts half a cell, 10^(5-p)/2 metres, from
168    /// each axis of the result.
169    pub fn to_utmups(&self) -> UtmUps {
170        let p = self.precision.get();
171        let e = grid::cell_center_axis(self.col, self.e_digits, p);
172        let n = grid::cell_center_axis(self.row, self.n_digits, p);
173        if self.zone == 0 {
174            UtmUps::raw(0, self.north, e, n)
175        } else {
176            UtmUps::raw(
177                self.zone,
178                self.north,
179                e,
180                grid::frame_northing(n, self.north),
181            )
182        }
183    }
184
185    /// The geodetic coordinate of the cell center.
186    ///
187    /// ```
188    /// use mgrs_convert::Mgrs;
189    /// let cell: Mgrs = "18TWL8566411315".parse()?;
190    /// let p = cell.to_latlon();
191    /// assert!((p.lat() - 40.748333).abs() < 1e-5);
192    /// # Ok::<(), mgrs_convert::Error>(())
193    /// ```
194    pub fn to_latlon(&self) -> LatLon {
195        self.to_utmups().to_latlon()
196    }
197
198    fn square_letters(&self) -> (u8, u8) {
199        if self.zone == 0 {
200            let col = UPS_COLS
201                .iter()
202                .find(|(b, _, _)| *b == self.band)
203                .map(|&(_, base, table)| letter_at(table, self.col.saturating_sub(base)))
204                .unwrap_or(b'?');
205            let (rbase, rtable) = if self.north { UPS_N_ROWS } else { UPS_S_ROWS };
206            (col, letter_at(rtable, self.row.saturating_sub(rbase)))
207        } else {
208            let set = COL_SETS
209                .get(usize::from(self.zone.rem_euclid(3)))
210                .copied()
211                .unwrap_or("");
212            let col = letter_at(set, self.col.saturating_sub(1));
213            let row = letter_at(
214                ROW_LETTERS,
215                self.row.saturating_add(row_shift(self.zone)).rem_euclid(20),
216            );
217            (col, row)
218        }
219    }
220}
221
222impl fmt::Display for Mgrs {
223    /// Uppercase, no separators, zone as two digits. Total by construction:
224    /// no `unwrap`, no `expect`, no assertion.
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        if self.zone > 0 {
227            write!(f, "{:02}", self.zone)?;
228        }
229        let (col, row) = self.square_letters();
230        write!(f, "{}{}{}", self.band as char, col as char, row as char)?;
231        let width = usize::from(self.precision.get());
232        if width > 0 {
233            write!(f, "{:0width$}", self.e_digits)?;
234            write!(f, "{:0width$}", self.n_digits)?;
235        }
236        Ok(())
237    }
238}
239
240/// Forward construction from a validated geodetic coordinate. Total: every
241/// valid latitude and longitude is inside the MGRS grid, and the band letter
242/// comes from the input latitude (mechanism M5).
243pub(crate) fn from_latlon(ll: LatLon, precision: Precision) -> Mgrs {
244    let (zone, north, e, n) = ll.utmups_parts();
245    if zone == 0 {
246        let (e, n) = grid::ups_nudge(north, e, n);
247        make_ups(north, e, n, precision)
248    } else {
249        let (e, n) = grid::utm_nudge(north, e, n);
250        make_utm(zone, north, grid::band_index(ll.lat()), e, n, precision)
251    }
252}
253
254/// Fallible construction from a grid coordinate: the transfer ring outside the
255/// strict MGRS coverage gets `Err(OutsideMgrsExtent)` (mechanism M2).
256pub(crate) fn from_utmups(u: UtmUps, precision: Precision) -> Result<Mgrs, Error> {
257    let outside = Error::OutsideMgrsExtent {
258        easting: u.easting(),
259        northing: u.northing(),
260    };
261    if u.zone() == 0 {
262        let (e, n) = grid::ups_extent(u.is_north(), u.easting(), u.northing()).ok_or(outside)?;
263        Ok(make_ups(u.is_north(), e, n, precision))
264    } else {
265        let (e, n) = grid::utm_extent(u.is_north(), u.easting(), u.northing()).ok_or(outside)?;
266        // The band comes from the exact latitude of the nudged coordinate,
267        // never from a northing fold table (mechanism M5).
268        let lat = UtmUps::raw(u.zone(), u.is_north(), e, n).latlon_parts().0;
269        Ok(make_utm(
270            u.zone(),
271            u.is_north(),
272            grid::band_index(lat),
273            e,
274            n,
275            precision,
276        ))
277    }
278}
279
280fn make_utm(zone: u8, north: bool, ib: i64, e: f64, n_frame: f64, precision: Precision) -> Mgrs {
281    let (col, off_e) = grid::axis_fields(e);
282    let (row_frame, off_n) = grid::axis_fields(n_frame);
283    let row = row_frame.saturating_sub(if north { 0 } else { 100 });
284    // The band must agree with the row's hemisphere. The two can only disagree
285    // on the equator seam, where a subnormal latitude underflows the band
286    // computation; the row, computed from the northing, is the ground truth
287    // there.
288    let ib = if row < 0 { ib.min(-1) } else { ib.max(0) };
289    let band = letter_at(BAND_LETTERS, ib.saturating_add(10));
290    Mgrs {
291        zone,
292        // The cell's hemisphere is canonical, from its own row, not from the
293        // input frame: a transfer coordinate 100 km across the equator names
294        // a cell in the other hemisphere, and parse must agree with format.
295        north: row >= 0,
296        band,
297        // The clamps cannot move any value that passed the extent check,
298        // which compares in the same frame `axis_fields` truncates, so the
299        // derived indices are already in range. They keep construction total
300        // without a panic path.
301        col: col.clamp(1, 8),
302        row: row.clamp(-90, 94),
303        e_digits: grid::digits_at(off_e, precision.get()),
304        n_digits: grid::digits_at(off_n, precision.get()),
305        precision,
306    }
307}
308
309fn make_ups(north: bool, e: f64, n: f64, precision: Precision) -> Mgrs {
310    let (col, off_e) = grid::axis_fields(e);
311    let (row, off_n) = grid::axis_fields(n);
312    let (lo, hi) = if north { (13, 26) } else { (8, 31) };
313    let east_half = e >= 2_000_000.0;
314    let band = match (north, east_half) {
315        (true, true) => b'Z',
316        (true, false) => b'Y',
317        (false, true) => b'B',
318        (false, false) => b'A',
319    };
320    Mgrs {
321        zone: 0,
322        north,
323        band,
324        col: col.clamp(lo, hi),
325        row: row.clamp(lo, hi),
326        e_digits: grid::digits_at(off_e, precision.get()),
327        n_digits: grid::digits_at(off_n, precision.get()),
328        precision,
329    }
330}
331
332impl FromStr for Mgrs {
333    type Err = Error;
334
335    /// Parses an MGRS reference: ASCII only, outer whitespace trimmed, case
336    /// folded. Precision is derived from the digit count, and odd counts or
337    /// counts above 22 are rejected before any digit is read.
338    fn from_str(s: &str) -> Result<Mgrs, Error> {
339        parse_mgrs(s)
340    }
341}
342
343fn parse_mgrs(s: &str) -> Result<Mgrs, Error> {
344    use ParseErrorKind as K;
345    let t = s.trim_matches(|c: char| c.is_ascii_whitespace());
346    if t.is_empty() {
347        return Err(Error::Parse(K::Empty));
348    }
349    if !t.is_ascii() {
350        return Err(Error::Parse(K::NonAsciiOrWhitespace));
351    }
352    let bytes = t.as_bytes();
353    if bytes.iter().any(|b| b.is_ascii_whitespace()) {
354        return Err(Error::Parse(K::NonAsciiOrWhitespace));
355    }
356
357    // Zone: up to two leading digits, 1 to 60. "08" is accepted.
358    let zone_len = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
359    if zone_len > 2 {
360        return Err(Error::Parse(K::BadZoneNumber));
361    }
362    let zone: u8 = {
363        let mut z: u8 = 0;
364        for b in bytes.iter().take(zone_len) {
365            z = z.saturating_mul(10).saturating_add(b.saturating_sub(b'0'));
366        }
367        if zone_len > 0 && !(1..=60).contains(&z) {
368            return Err(Error::Parse(K::BadZoneNumber));
369        }
370        z
371    };
372
373    // Band letter for UTM, polar zone letter for UPS.
374    let mut letters = bytes.iter().skip(zone_len);
375    let band = match letters.next() {
376        Some(b) => b.to_ascii_uppercase(),
377        None => return Err(Error::Parse(K::BadBandLetter)),
378    };
379    if zone > 0 {
380        if pos_of(BAND_LETTERS, band).is_none() {
381            return Err(Error::Parse(K::BadBandLetter));
382        }
383    } else if !matches!(band, b'A' | b'B' | b'Y' | b'Z') {
384        return Err(Error::Parse(K::BadBandLetter));
385    }
386
387    // Two 100 km square letters.
388    let (col_c, row_c) = match (letters.next(), letters.next()) {
389        (None, _) => return Err(Error::Parse(K::GridZoneOnly)),
390        (Some(_), None) => return Err(Error::Parse(K::BadSquareLetters)),
391        (Some(c), Some(r)) => (c.to_ascii_uppercase(), r.to_ascii_uppercase()),
392    };
393    if !col_c.is_ascii_alphabetic() || !row_c.is_ascii_alphabetic() {
394        return Err(Error::Parse(K::BadSquareLetters));
395    }
396
397    // Digit block: even length, at most 22, digits only.
398    let digits = bytes.get(zone_len.saturating_add(3)..).unwrap_or(&[]);
399    if let Some(_bad) = digits.iter().find(|b| !b.is_ascii_digit()) {
400        return Err(Error::Parse(K::TrailingInput));
401    }
402    if digits.len() > 22 {
403        return Err(Error::Parse(K::TooManyDigits));
404    }
405    if digits.len().rem_euclid(2) != 0 {
406        return Err(Error::Parse(K::OddDigitCount));
407    }
408    let half = digits.len().div_euclid(2);
409    let precision = Precision::new(half as u8).map_err(|_| Error::Parse(K::TooManyDigits))?;
410    // Mechanism M1: i64 accumulation. Eleven digits stay under 10^12, far
411    // below i64::MAX and 2^53; the checked ops keep the fold total anyway.
412    let accumulate = |ds: &[u8]| -> Result<i64, Error> {
413        ds.iter()
414            .try_fold(0_i64, |acc, b| {
415                acc.checked_mul(10)?
416                    .checked_add(i64::from(b.saturating_sub(b'0')))
417            })
418            .ok_or(Error::Parse(K::TooManyDigits))
419    };
420    let e_digits = accumulate(digits.get(..half).unwrap_or(&[]))?;
421    let n_digits = accumulate(digits.get(half..).unwrap_or(&[]))?;
422
423    if zone > 0 {
424        // Column letter must belong to this zone's set.
425        let set = COL_SETS
426            .get(usize::from(zone.rem_euclid(3)))
427            .copied()
428            .unwrap_or("");
429        let col = match pos_of(set, col_c) {
430            Some(p) => p.saturating_add(1),
431            None if is_mgrs_letter(col_c) => {
432                return Err(Error::Parse(K::SquareZoneMismatch));
433            }
434            None => return Err(Error::Parse(K::BadSquareLetters)),
435        };
436        // Row letter resolves against the band's row window.
437        let row_pos = match pos_of(ROW_LETTERS, row_c) {
438            Some(p) => p,
439            None => return Err(Error::Parse(K::BadSquareLetters)),
440        };
441        let ib = pos_of(BAND_LETTERS, band)
442            .map(|p| p.saturating_sub(10))
443            .unwrap_or(0);
444        let residue = row_pos.saturating_sub(row_shift(zone)).rem_euclid(20);
445        let (minrow, maxrow) = band_window(ib);
446        let start = minrow.saturating_sub(1);
447        let row = start.saturating_add(residue.saturating_sub(start).rem_euclid(20));
448        let in_window = row >= minrow && row <= maxrow;
449        if !in_window && !row_exception(row, ib, col) {
450            return Err(Error::Parse(K::SquareBandMismatch));
451        }
452        Ok(Mgrs {
453            zone,
454            north: ib >= 0,
455            band,
456            col,
457            row,
458            e_digits,
459            n_digits,
460            precision,
461        })
462    } else {
463        let north = matches!(band, b'Y' | b'Z');
464        let col = match UPS_COLS
465            .iter()
466            .find(|(b, _, _)| *b == band)
467            .and_then(|&(_, base, table)| pos_of(table, col_c).map(|p| base.saturating_add(p)))
468        {
469            Some(c) => c,
470            None if is_mgrs_letter(col_c) => {
471                return Err(Error::Parse(K::SquareZoneMismatch));
472            }
473            None => return Err(Error::Parse(K::BadSquareLetters)),
474        };
475        let (rbase, rtable) = if north { UPS_N_ROWS } else { UPS_S_ROWS };
476        let row = match pos_of(rtable, row_c) {
477            Some(p) => rbase.saturating_add(p),
478            None if is_mgrs_letter(row_c) => {
479                return Err(Error::Parse(K::SquareZoneMismatch));
480            }
481            None => return Err(Error::Parse(K::BadSquareLetters)),
482        };
483        Ok(Mgrs {
484            zone: 0,
485            north,
486            band,
487            col,
488            row,
489            e_digits,
490            n_digits,
491            precision,
492        })
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn letter_tables_pinned() {
502        // Invariant 9 adjacent: alphabet shapes.
503        assert_eq!(BAND_LETTERS.len(), 20);
504        assert_eq!(ROW_LETTERS.len(), 20);
505        for set in COL_SETS {
506            assert_eq!(set.len(), 8);
507        }
508        assert_eq!(UPS_N_ROWS.1.len(), 14);
509        assert_eq!(UPS_S_ROWS.1.len(), 24);
510        let widths: Vec<usize> = UPS_COLS.iter().map(|(_, _, t)| t.len()).collect();
511        assert_eq!(widths, vec![7, 7, 12, 12]);
512    }
513
514    #[test]
515    fn band_windows_cover_the_grid_contiguously() {
516        // Adjacent bands abut or overlap by exactly one shared row, and the
517        // whole table spans rows -90 to 94.
518        for pair in BAND_ROW_WINDOW.windows(2) {
519            if let [(_, hi), (lo2, _)] = pair {
520                assert!(*lo2 == *hi || *lo2 == hi.saturating_add(1));
521            }
522        }
523        assert_eq!(BAND_ROW_WINDOW.first(), Some(&(-90, -81)));
524        assert_eq!(BAND_ROW_WINDOW.last(), Some(&(80, 94)));
525    }
526}