packages feed

geodetics-2.0.0: src/Geodetics/MGRS.hs

{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NumericUnderscores #-}

{- | The Military Grid Reference System (MGRS)

In MGRS there are two syntaxes for grid references:

  1. Between 80 South and 84 North a grid reference has a zone number, latitude band letter, a
    2 letter code for the 100km square within the zone, and then northings and eastings within
    that square.

  2. In the polar regions a grid reference has a latitude band letter (A or B for South, Y or Z 
    for North), a 2 letter code for the 100km square within the polar region, and then northings
    and eastings within that square. There is no zone number in the polar regions.
-}
module Geodetics.MGRS (
  -- * MGRS Grid Basis
  MgrsGrid (..),
  mgrsGrid,
  utmToMgrsPoint,
  upsToMgrsPoint,
  fromMgrsPoint,
  -- * Textual Representation
  mgrsBandLetterToLatitude,
  mgrsLatitudeToBandLetter,
  fromMgrsGridReference,
  toMgrsGridReference
) where

import Control.Monad
import Data.Array
import Geodetics.Ellipsoids
import Geodetics.Geodetic
import Geodetics.Grid
import Geodetics.PolarStereographic
import Geodetics.UTM
import Text.Parsec
import Text.Parsec.Error
import Text.Printf


-- | MGRS grid references can be anywhere on Earth. Hence the position can be either on a UTM or
-- a UPS grid.
data MgrsGrid =
  MgrsUtm UtmZone
  | MgrsUps UpsGrid
  deriving (Eq, Show)

instance GridClass MgrsGrid WGS84 where
  fromGrid p = case gridBasis p of
    MgrsUtm zone -> fromGrid $ unsafeGridCoerce zone p
    MgrsUps grid -> fromGrid $ unsafeGridCoerce grid p

  toGrid (MgrsUtm zone) = unsafeGridCoerce (MgrsUtm zone) . toGrid zone
  toGrid (MgrsUps grid) = unsafeGridCoerce (MgrsUps grid) . toGrid grid

  gridEllipsoid _ = WGS84


-- | Find the most appropriate grid for the given geodetic position
mgrsGrid :: Geodetic WGS84 -> MgrsGrid
mgrsGrid geo =
  case utmZone geo of
    Just zone -> MgrsUtm zone
    Nothing -> MgrsUps $ if latitude geo > 0 then upsNorth else upsSouth


-- | Generalise from UTM to MGRS
utmToMgrsPoint :: GridPoint UtmZone -> GridPoint MgrsGrid
utmToMgrsPoint gp = unsafeGridCoerce (MgrsUtm $ gridBasis gp) gp


-- | Generalise from UPS to MGRS
upsToMgrsPoint :: GridPoint UpsGrid -> GridPoint MgrsGrid
upsToMgrsPoint gp = unsafeGridCoerce (MgrsUps $ gridBasis gp) gp


-- | Convert an MGRS grid point to either UTM or UPS, depending on its basis.
fromMgrsPoint :: GridPoint MgrsGrid -> Either (GridPoint UtmZone) (GridPoint UpsGrid)
fromMgrsPoint gp = case gridBasis gp of
  MgrsUtm zone -> Left $ unsafeGridCoerce zone gp
  MgrsUps grid -> Right $ unsafeGridCoerce grid gp


-- | Convert a list of letters into an inverse lookup function.
letterTable :: [Char] -> Char -> Maybe Int
letterTable cs = \c -> if inRange (bounds arr) c then arr ! c else Nothing
  where arr = accumArray mplus Nothing ('A', 'Z') [(c1, Just n) | (c1, n) <- zip cs [0..]]
  -- Second argument in a lambda so that partial application returns a function with a CAF
  -- rather than computing the array for every call.


-- | The MGRS latitude band code letters, excluding A and B used for Antarctica (south of -80 degrees)
-- and Y and Z used for the Arctic (north of 84 degrees).
mgrsBandLetters :: [Char]
mgrsBandLetters = "CDEFGHJKLMNPQRSTUVWX"

mgrsBandIx :: Char -> Maybe Int
mgrsBandIx = letterTable mgrsBandLetters

-- | Polar regions have special bands A, B, Y and Z.
mgrsBandLetterToPole :: Char -> Maybe Pole
mgrsBandLetterToPole 'A' = Just SouthPole
mgrsBandLetterToPole 'B' = Just SouthPole
mgrsBandLetterToPole 'Y' = Just NorthPole
mgrsBandLetterToPole 'Z' = Just NorthPole
mgrsBandLetterToPole _ = Nothing

polarBandLetters :: [Char]
polarBandLetters = "ABYZ"

-- | Polar regions use different northings and eastings letters to the rest of the world.
polarEastingLetters :: [Char]
polarEastingLetters = "ABCFGHJKLPQRSTUXYZ"

polarEastingIx :: Char -> Maybe Int
polarEastingIx = letterTable polarEastingLetters

polarNorthingLetters :: [Char]
polarNorthingLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ"

polarNorthingIx :: Char -> Maybe Int
polarNorthingIx = letterTable polarNorthingLetters


-- | Convert the three letters of an MGRS grid reference to the south-west corner of a 100km square.
--
-- If any of the letters are invalid for the positions they are in then an error message is returned.
mgrsLettersToPolar ::
  Char   -- ^ Latitude band: A, B, Y or Z.
  -> Char  -- ^ Eastings letter.
  -> Char  -- ^ Northings letter.
  -> Parsec s u (Pole, Double, Double)
mgrsLettersToPolar bandC eastC northC =
  case (pole, baseEasting, baseNorthing) of
    (Just pole1, Just easting, Just northing) -> pure (pole1, easting, northing + polarNorthingLetterOrigin pole1)
    _ -> fail "Invalid polar grid letters"
  where
    pole = mgrsBandLetterToPole bandC
    baseEasting = (bandEasting +) . (100_000 *) . fromIntegral <$> polarEastingIx eastC
    baseNorthing = (100_000 *) . fromIntegral <$> polarNorthingIx northC
    bandEasting = if bandC `elem` "AY" then 200*kilometer else 2000*kilometer
      -- A and Y denote the "eastern" halves of the south and north polar regions respectively.


-- | Find the southern boundary of a latitude band letter (excluding poles).
mgrsBandLetterToLatitude :: Char -> Maybe Double
mgrsBandLetterToLatitude band = do
    n1 <- mgrsBandIx band
    return $ degree * fromIntegral (-80 + n1 * 8)


-- | Find the band letter for a latitude, if it is in the range (-80, 84) degrees.
-- (Argument in radians)
mgrsLatitudeToBandLetter :: Double -> Maybe Char
mgrsLatitudeToBandLetter lat = do
    guard $ -80 <= dlat && dlat <= 84
    return $ indexMap ! latIdx
  where
    dlat = lat / degree
    ilat :: Int
    ilat = floor dlat
    latIdx = min 19 $ (ilat + 80) `div` 8  -- Band 19 (X) extends an extra 4 degrees.
    indexMap = listArray (0,19) mgrsBandLetters



-- | Letters A-Z except for I and O.
mgrsEastingsLetters :: [Char]
mgrsEastingsLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ"

mgrsEastingIx :: Char -> Maybe Int
mgrsEastingIx = letterTable mgrsEastingsLetters

-- | If zone number is in range and the letter is one of the valid Eastings letters for that zone
-- then return the UTM easting in meters.
--
-- Zone 1 starts with \'A\'. Each zone is 8 characters wide. Hence the letters repeat every 3 zones.
mgrsLetterToEasting :: UtmZoneNumber -> Char -> Maybe Double
mgrsLetterToEasting zn c = do
    guard $ 1 <= zn && zn <= 60
    n1 <- mgrsEastingIx c
    let n2 = n1 - base
    guard $ 0 <= n2 && n2 <= 7
    return $ fromIntegral (n2+1) * 100_000
  where
    base = ((zn-1) `mod` 3) * 8


-- | If the zone number is in range and the eastings are between 100,000 and 900,000 then
-- return the Eastings letter.
mgrsEastingToLetter :: UtmZoneNumber -> Double -> Maybe Char
mgrsEastingToLetter zn east = do
    guard $ 1 <= zn && zn <= 60
    guard $ 100 * kilometer <= east && east < 900 * kilometer
    return $ indexMap ! ix
  where
    indexMap = listArray (0,23) mgrsEastingsLetters
    base = ((zn-1) `mod` 3) * 8
    square = max 0 $ min 7 $ floor $ (east - 100 * kilometer)/(100 * kilometer)  -- Clamped in range (0,7).
    ix = base + square  -- Must be in range (0,23)


-- | Letters A-V except for I and O.
mgrsNorthingsLetters :: [Char]
mgrsNorthingsLetters = "ABCDEFGHJKLMNPQRSTUV"

mgrsNorthingIx :: Char -> Maybe Int
mgrsNorthingIx = letterTable mgrsEastingsLetters


-- | MGRS Northings letters have rather complex relationship to the latitude bands. The 20 letters
-- repeat every 2,000km going north and south from the equator, so the latitude band is needed
-- to disambiguate which repetition of the Northings letter is meant.

-- Unfortunately this repetition of the letters does not neatly coincide with
-- the latitude band boundaries, which are based on degrees of latitude.

-- The base letter just north of the equator is A in odd-numbered zones and F in even numbered zones.
--
-- This uses the latitude band to estimate the range of northings that would be valid there,
-- and hence determine which possible grid band is meant by the northings letter.
-- The algorithm used is approximate and deliberately very forgiving: it will accept some grid squares
-- which are north or south of the band given.
mgrsLetterToNorthings ::
  UtmZoneNumber
  -> Char  -- ^ Latitude band letter (@C@ - @X@ excluding @I@ and @O@).
  -> Char  -- ^ MGRS Northings letter (@A@ - @V@ excluding @I@ and @O@).
  -> Maybe Double
mgrsLetterToNorthings zone bandC northingsC = do
  guard $ 1 <= zone && zone <= 60
  band <- (/degree) <$> mgrsBandLetterToLatitude bandC
  northings0 <- (baseNorthingsOffset +) <$> mgrsNorthingIx northingsC
  let bandDist = band * metersPerDegree -- Approx dist from equator to southern edge of band.
      bandGridLower = floor $ bandDist / 100_000 - 2  -- Lower limit of band in 100km units
      bandGridUpper = ceiling $ if band > 71 * degree  -- Upper limit of band in 100km units.
        then (bandDist + 12 * metersPerDegree) / 100_000 + 1  -- Band X.
        else (bandDist + 8 * metersPerDegree) / 100_000 + 2  -- Other bands.
      rep = (bandGridLower - northings0 - 1) `div` 20  -- Lower limit in 2,000,000km units.
      grid = (rep+1)*20 + northings0
  guard $ grid >= bandGridLower
  guard $ grid <= bandGridUpper
  return $ fromIntegral $ grid * 100_000
  where
    metersPerDegree = 10_002_000 / 90  -- Equator to north pole.
    baseNorthingsOffset :: Int
    baseNorthingsOffset = if odd zone then 0 else -5


-- | Find the northings letter of the 100km square containing the given Northings.
--
-- The input is not range checked. It just assumes that the northings letters repeat forever.
mgrsNorthingToLetter :: UtmZoneNumber -> Double -> Char
mgrsNorthingToLetter zone northings1 =
  letters ! ((gridNum + baseNorthingsOffset) `mod` 20)
  where
    gridNum :: Int
    gridNum = floor $ northings1 / (100 * kilometer)
    baseNorthingsOffset = if odd zone then 0 else 5
    letters = listArray (0,19) mgrsNorthingsLetters


polarEastingsToLetter :: Double -> Char
polarEastingsToLetter eastings1 =
  letters ! ((floor (eastings1 / (100 * kilometer)) - 20) `mod` 18 :: Int)
  where
    letters = listArray (0,17) polarEastingLetters


-- | The southern edge of the @A@ northings letter differs between north and south poles.
polarNorthingLetterOrigin :: Pole -> Double
polarNorthingLetterOrigin NorthPole = 1_300 * kilometer
polarNorthingLetterOrigin SouthPole =   800 * kilometer


polarNorthingsToLetter :: Pole -> Double -> Maybe Char
polarNorthingsToLetter pole northings1 = do
  let i :: Int
      i = floor ((northings1 - polarNorthingLetterOrigin pole) / (100 * kilometer)) `mod` 24
  guard $ i >= 0 && i < 24
  pure $ letters ! i
  where
    letters = listArray (0,23) polarNorthingLetters

-- | Convert an MGRS grid reference to a UTM @GridPoint@, if the reference is valid.
-- E.g. \"30U XC 99304 10208\" is the grid reference for Nelson's Column in London.
-- 
-- If the input contains spaces then these are used to delimit the fields. Multiple
-- spaces are treated as a single space.
--
-- If the reference is valid this returns the position of the south-west corner of the
-- nominated grid square and an offset to its centre. Altitude is set to zero.
fromMgrsGridReference :: String -> Either [String] (GridPoint MgrsGrid, GridOffset)
fromMgrsGridReference str = case parse parseMgrsGridReference "" str of
    Left err -> Left $ filter (not . null) $ lines $ showErrorMessages
      "or" "unknown parse error" "expecting" "unexpected" "end of input"
      (errorMessages err)
    Right r -> Right r

parseMgrsGridReference :: Parsec String u (GridPoint MgrsGrid, GridOffset)
parseMgrsGridReference = mgrsUtmParser <|> mgrsUpsParser
  where
    mgrsUtmParser = do
      zoneNum <- read <$> many1 digit <?> "UTM zone number"
          -- Safe because we can only read digits
      spaces
      band <- oneOf mgrsBandLetters <?> "latitude band letter"
      let (hemi, falseNorthing) = if band >= 'N'
            then (UtmNorth, 0)
            else (UtmSouth, -10_000_000)
      zone <- maybe (fail "Invalid zone") (pure . MgrsUtm) $ mkUtmZone hemi zoneNum
      spaces
      squareEast <- oneOf mgrsEastingsLetters <?> "eastings letter"
      eastingBase <- maybe (fail "Invalid eastings letter") pure $
        mgrsLetterToEasting zoneNum squareEast
      spaces
      squareNorth <- oneOf mgrsNorthingsLetters <?> "northings letter"
      northingBase <- maybe (fail "Invalid northings letter") pure $
        mgrsLetterToNorthings zoneNum band squareNorth
      spaces
      (eastingChars, northingChars) <- try spaced <|> try unspaced <|> noDigits
      when (length eastingChars /= length northingChars) $
        fail "Northings and Eastings must be the same length."
      if northingChars == "" && eastingChars == ""
        then -- No digits, just return the outer 100km grid square
          pure (GridPoint eastingBase (northingBase - falseNorthing) 0 zone,
                  GridOffset (100 * kilometer / 2) (100 * kilometer / 2) 0)
        else do
          (northing, offset) <- maybe (fail "Invalid northing digits") pure $
            fromGridDigits (100 * kilometer) northingChars
          (easting, _) <- maybe (fail "Invalid easting digits") pure $
            fromGridDigits (100 * kilometer) eastingChars
          pure (GridPoint (eastingBase + easting) (northingBase + northing - falseNorthing) 0 zone,
                  GridOffset (offset/2) (offset/2) 0)

    mgrsUpsParser = do
      spaces
      band <- oneOf polarBandLetters <?> "polar band letter"
      spaces
      squareEast <- oneOf polarEastingLetters <?> "eastings letter"
      spaces
      squareNorth <- oneOf polarNorthingLetters <?> "northings letter"
      spaces
      (pole, eastingBase, northingBase) <- mgrsLettersToPolar band squareEast squareNorth
      let grid = case pole of
            NorthPole -> MgrsUps upsNorth
            SouthPole -> MgrsUps upsSouth
      (eastingChars, northingChars) <- try spaced <|> try unspaced <|> noDigits
      when (length eastingChars /= length northingChars) $
        fail "Northings and Eastings must be the same length."
      if northingChars == "" && eastingChars == ""
        then -- No digits, just return the outer 100km grid square
          pure (GridPoint eastingBase northingBase 0 grid,
                  GridOffset (100 * kilometer / 2) (100 * kilometer / 2) 0)
        else do
          (northing, offset) <- maybe (fail "Invalid northing digits") pure $
            fromGridDigits (100 * kilometer) northingChars
          (easting, _) <- maybe (fail "Invalid easting digits") pure $
            fromGridDigits (100 * kilometer) eastingChars
          pure (GridPoint (eastingBase + easting) (northingBase + northing) 0 grid,
                  GridOffset (offset/2) (offset/2) 0)
    spaced = do
      e <- many1 digit
      skipMany1 space  -- A space is mandatory here.
      n <- many1 digit
      pure (e,n)
    unspaced = do
      digits <- many1 digit
      let c = length digits
      when (odd c) $ fail "Northings and Eastings must be the same length."
      pure (splitAt (c `div` 2) digits)
    noDigits = do
      eof
      pure ("", "")

-- | Convert a UTM or UPS @GridPoint@ to an MGRS grid reference.
toMgrsGridReference ::
  Bool  -- ^ Include spaces in the output. The standard says no spaces, but they make 
        -- the output easier to read.
  -> Int  -- ^ Number of digits of precision in the easting and northing. Must be 0-8.
  -> GridPoint MgrsGrid
  -> Maybe String
toMgrsGridReference withSpaces precision gp = do
  guard $ precision >= 0 && precision <= 8
  case gridBasis gp of
    MgrsUtm zone -> do
      band <- mgrsLatitudeToBandLetter $ latitude $ fromGrid gp
      let
        zoneNum = utmZoneNum zone
        northLetter = mgrsNorthingToLetter zoneNum $ northings gp
      eastLetter <- mgrsEastingToLetter zoneNum $ eastings gp
      (_, northDigits) <- toGridDigits (100 * kilometer) precision $ northings gp
      (_, eastDigits) <- toGridDigits (100 * kilometer) precision $ eastings gp
      let part1 = printf "%02d" zoneNum <> [band]
          part2 = [eastLetter, northLetter]
      pure $ if withSpaces
        then part1 <> " " <> part2 <> " " <> eastDigits <> " " <> northDigits
        else part1 <> part2 <> eastDigits <> northDigits
    MgrsUps grid -> do
      let zoneLetter = case trueOrigin grid of
            NorthPole -> if eastings gp < 2_000 * kilometer then 'Y' else 'Z'
            SouthPole -> if eastings gp < 2_000 * kilometer then 'A' else 'B'
          eastLetter = polarEastingsToLetter $ eastings gp
      northLetter <- polarNorthingsToLetter (trueOrigin grid) $ northings gp
      (_, northDigits) <- toGridDigits (100 * kilometer) precision $ northings gp
      (_, eastDigits) <- toGridDigits (100 * kilometer) precision $ eastings gp
      pure $ if withSpaces
        then zoneLetter : ' ' : eastLetter : northLetter : " " <> eastDigits <> " " <> northDigits
        else zoneLetter : eastLetter : northLetter : eastDigits <> northDigits