Ravn::Geo::

UTM class

Converted from MIT licensed: github.com/chrisveness/geodesy/blob/master/mgrs.js Original author: Chris Veness chrisv@movable-type.co.uk

Note: Because this is a conversion, and performing important math operations I frankly don’t personally grok, I’ve retained the original author’s Greek variable naming conventions where possible. This is both to avoid confusion when comparing to the original code (and formulas), and also: not even sure what I’d name these variables otherwise.


The Universal Transverse Mercator (UTM) system is a 2-dimensional Cartesian coordinate system providing locations on the surface of the Earth.

UTM is a set of 60 transverse Mercator projections, normally based on the WGS-84 ellipsoid. Within each zone, coordinates are represented as eastings and northings, measures in metres; e.g. ‘31 N 448251 5411932’.

This method based on Karney 2011 ‘Transverse Mercator with an accuracy of a few nanometers’, building on Krüger 1912 ‘Konforme Abbildung des Erdellipsoids in der Ebene’.

Ref: arxiv.org/pdf/1002.1417.pdf

Constants

FALSE_EASTING

Easting shift value.

FALSE_NORTHING

Northing shift value.

MATCHER

A valid UTM string matcher.

UTM_SCALE

Scaling based on the central meridian

Attributes

convergence R

Meridian convergence (bearing of grid north clockwise from true north), in degrees

easting R

Easting in metres from false easting (-500km from central meridian)

hemisphere R

N for northern hemisphere, S for southern hemisphere

northing R

Northing in metres from equator (N) or from false northing -10,000km (S)

scale R

Grid scale factor

zone R

UTM 6° longitudinal zone (1..60 covering 180°W..180°E)

Public Class Methods

from_position( pos )

Convert a latitude and longitude position to a Universal Transverse Mercator (UTM) object.

# File lib/ravn/geo/utm.rb, line 58
def self::from_position( pos )
        zone = ( (pos.long + 180).to_f / 6 ).floor + 1
        λ0   = ( ( zone - 1 ) * 6 - 180 + 3 ).to_radians
        band = MGRS_LAT_BANDS[ ( pos.lat.to_f / 8 + 10 ).floor ]

        adjust_inc = -> { zone += 1 && λ0 += 6.to_radians }
        adjust_dec = -> { zone -= 1 && λ0 -= 6.to_radians }

        #
        # Zone exceptions / adjustments
        #

        # Norway
        if zone == 31 && band == 'V' && long >= 3
                adjust_inc.call
        end

        # Svalbard
        if band == 'X'
                if zone == 32
                        long >= 9 ? adjust_inc.call : adjust_dec.call
                end
                if zone == 34
                        long >= 21 ? adjust_inc.call : adjust_dec.call
                end
                if zone == 36
                        long >= 33? adjust_inc.call : adjust_dec.call
                end
        end

        φ = pos.lat.to_radians
        λ = pos.long.to_radians - λ0

        a, f = WGS84.values_at( :a, :f )

        e  = Math.sqrt( f * ( 2 - f ) ) # eccentricity
        n  = f / ( 2 - f ) # 3rd flattening
        n2 = n*n
        n3 = n*n2
        n4 = n*n3
        n5 = n*n4
        n6 = n*n5

        cosλ = Math.cos( λ )
        sinλ = Math.sin( λ )
        tanλ = Math.tan( λ )

        τ = Math.tan( φ )
        σ = Math.sinh( e * Math.atanh( e*τ / Math.sqrt( 1+τ*τ ) ) )

        τʹ = τ * Math.sqrt(1 + σ * σ) - σ * Math.sqrt( 1 + τ * τ)

        ξʹ = Math.atan2( τʹ, cosλ )
        ηʹ = Math.asinh( sinλ / Math.sqrt( τʹ * τʹ + cosλ * cosλ) )

        a_ = a / (1 + n) * (1 + 1.0/4*n2 + 1.0/64*n4 + 1.0/256*n6) # 2πa_ is the circumference of a meridian

        α = [ nil, # note α is one-based array (6th order Krüger expressions)
         1.0/2*n - 2.0/3*n2 + 5.0/16*n3 + 41.0/180*n4 - 127.0/288*n5 + 7891.0/37800*n6,
         13.0/48*n2 - 3.0/5*n3 + 557.0/1440*n4 + 281.0/630*n5 - 1983433.0/1935360*n6,
         61.0/240*n3 - 103.0/140*n4 + 15061.0/26880*n5 + 167603.0/181440*n6,
         49561.0/161280*n4 - 179.0/168*n5 + 6601661.0/7257600*n6,
         34729.0/80640*n5 - 3418889.0/1995840*n6,
         212378941.0/319334400*n6
        ]

        ξ = ξʹ
        (1..6).each {|j| ξ += α[j] * Math.sin(2*j*ξʹ) * Math.cosh(2*j*ηʹ) }

        η = ηʹ
        (1..6).each {|j| η += α[j] * Math.cos(2*j*ξʹ) * Math.sinh(2*j*ηʹ) }

        x = UTM_SCALE * a_ * η
        y = UTM_SCALE * a_ * ξ

        #
        # Convergence
        #

         = 1
        (1..6).each {|j|  += 2*j*α[j] * Math.cos(2*j*ξʹ) * Math.cosh(2*j*ηʹ) }
         = 0
        (1..6).each {|j|  += 2*j*α[j] * Math.sin(2*j*ξʹ) * Math.sinh(2*j*ηʹ) }

        γʹ = Math.atan( τʹ / Math.sqrt(1+τʹ*τʹ)*tanλ )
        γʺ = Math.atan2( ,  )
        γ  = γʹ + γʺ

        #
        # Scale
        #

        sinφ = Math.sin( φ )
           = Math.sqrt(1 - e*e*sinφ*sinφ) * Math.sqrt(1 + τ*τ) / Math.sqrt(τʹ*τʹ + cosλ*cosλ)
           = a_ / a * Math.sqrt(* + *)
        k    = UTM_SCALE *  * 

        # shift x/y to false origins
        x = x + FALSE_EASTING
        y = y + FALSE_NORTHING if y < 0

        # round to reasonable precision
        x = x.round # x = x.round( 9 )
        y = y.round # y = y.round( 9 )
        convergence = γ.to_degrees.round( 9 )
        scale = k.round( 12 )

        # hemisphere
        h = pos.lat >= 0 ? 'N' : 'S'

        return new(
                convergence,
                scale,
                zone:       zone,
                hemisphere: h,
                easting:    x,
                northing:   y,
        )
end
new( convergence=nil, scale=UTM_SCALE, zone:, hemisphere:, easting:, northing: )

Instance a new Utm object under the WGS84 datum.

convergence: Meridian convergence (bearing of grid north clockwise from true north), in degrees scale: Grid scale factor zone: UTM 6° longitudinal zone (1..60 covering 180°W..180°E) hemisphere: N for northern hemisphere, S for southern hemisphere easting: Easting in metres from false easting (-500km from central meridian) northing: Northing in metres from equator (N) or from false northing -10,000km (S)

# File lib/ravn/geo/utm.rb, line 201
def initialize( convergence=nil, scale=UTM_SCALE, zone:, hemisphere:, easting:, northing: )
        @convergence = convergence
        @scale       = scale
        @zone        = zone
        @hemisphere  = hemisphere
        @easting     = easting
        @northing    = northing

        raise RangeError, "Invalid zone %p" % [ zone ] if zone <= 1 || zone >= 60
        raise RangeError, "Invalid hemisphere, must be 'N' or 'S'" unless hemisphere =~ /^[NS]$/
end
parse( utm )

Parse a string to a UTM object.

# File lib/ravn/geo/utm.rb, line 181
def self::parse( utm )
        match = utm.match( MATCHER ) or raise ArgumentError, "Invalid UTM: %p" % [ utm ]
        return new(
                zone:       match[ :zone ].to_i,
                hemisphere: match[ :hemisphere ],
                easting:    match[ :easting ].to_i,
                northing:   match[ :northing ].to_i
        )
end

Public Instance Methods

==( other )

Basic equality check.

# File lib/ravn/geo/utm.rb, line 247
def ==( other )
        return self.zone == other.zone &&
                self.hemisphere == other.hemisphere &&
                self.easting == other.easting &&
                self.northing == other.northing
end
position()
Alias for: to_position
to_position()

Converts UTM zone/easting/northing coordinate to a latitude/longitude position.

Implements Karney’s method, using Krüger series to order n⁶, giving results accurate to 5nm for distances up to 3900km from the central meridian.

# File lib/ravn/geo/utm.rb, line 262
def to_position
        # make x ± relative to central meridian
        x = self.easting - FALSE_EASTING
        # make y ± relative to equator
        y = self.hemisphere == 'S' ? self.northing - FALSE_NORTHING : self.northing

        a, f = WGS84.values_at( :a, :f )
        e    = Math.sqrt( f * ( 2 - f ) ) # eccentricity
        n    = f / (2 - f) # 3rd flattening
        n2   = n*n
        n3   = n*n2
        n4   = n*n3
        n5   = n*n4
        n6   = n*n5

        a_ = a/(1+n) * (1 + 1.0/4*n2 + 1.0/64*n4 + 1.0/256*n6) # 2πA is the circumference of a meridian

        η = x / ( UTM_SCALE * a_ )
        ξ = y / ( UTM_SCALE * a_ )

        β = [ nil, # note β is one-based array (6th order Krüger expressions)
                1.0/2*n - 2.0/3*n2 + 37.0/96*n3 - 1.0/360*n4 - 81.0/512*n5 + 96199.0/604800*n6,
                1.0/48*n2 + 1.0/15*n3 - 437.0/1440*n4 + 46.0/105*n5 - 1118711.0/3870720*n6,
                17.0/480*n3 - 37.0/840*n4 - 209.0/4480*n5 + 5569.0/90720*n6,
                4397.0/161280*n4 - 11.0/504*n5 - 830251.0/7257600*n6,
                4583.0/161280*n5 - 108847.0/3991680*n6,
                20648693.0/638668800*n6
        ]

        ξʹ = ξ
        (1..6).each {|j| ξʹ -= β[j] * Math.sin(2*j*ξ) * Math.cosh(2*j*η) }

        ηʹ = η
        (1..6).each {|j| ηʹ -= β[j] * Math.cos(2*j*ξ) * Math.sinh(2*j*η) }

        sinhηʹ = Math.sinh(ηʹ)
        sinξʹ  = Math.sin(ξʹ)
        cosξʹ  = Math.cos(ξʹ)
        τʹ     = sinξʹ / Math.sqrt(sinhηʹ*sinhηʹ + cosξʹ*cosξʹ)
        δτi    = nil
        τi     = τʹ

        while δτi.nil? || δτi.abs > 1e-12 # using IEEE 754 δτi -> 0 after 2-3 iterations
                σi = Math.sinh( e*Math.atanh( e*τi / Math.sqrt(1+τi*τi) ) )
                τiʹ = τi * Math.sqrt(1+σi*σi) - σi * Math.sqrt(1+τi*τi)
                δτi = (τʹ - τiʹ) / Math.sqrt(1+τiʹ*τiʹ) * (1 + (1-e*e)*τi*τi) / ((1-e*e)*Math.sqrt(1+τi*τi))
                τi += δτi;
        end
        τ = τi

        φ = Math.atan(τ)
        λ = Math.atan2(sinhηʹ, cosξʹ)

        #
        # convergence (currently unused)
        #

        # p = 1;
        # (1..6).each {|j| p -= 2*j*β[j] * Math.cos(2*j*ξ) * Math.cosh(2*j*η) }
        # q = 0;
        # (1..6).each {|j| q += 2*j*β[j] * Math.sin(2*j*ξ) * Math.sinh(2*j*η) }

        # γʹ = Math.atan(Math.tan(ξʹ) * Math.tanh(ηʹ))
        # γʺ = Math.atan2(q, p)
        # γ  = γʹ + γʺ

        #
        # scale (currently unused)
        #

        # sinφ = Math.sin(φ)
        # kʹ   = Math.sqrt(1 - e*e*sinφ*sinφ) * Math.sqrt(1 + τ*τ) * Math.sqrt(sinhηʹ*sinhηʹ + cosξʹ*cosξʹ)
        # kʺ   = a_ / a / Math.sqrt(p*p + q*q)
        # k    = UTM_SCALE * kʹ * kʺ

        # --

        λ0 = ( (self.zone - 1) * 6 - 180 + 3 ).to_radians # longitude of central meridian
        λ += λ0 # move λ from zonal to global coordinates

        # round to reasonable precision
        lat         = φ.to_degrees.round(14) # nm precision (1nm = 10^-14°)
        long        = λ.to_degrees.round(14) # (strictly lat rounding should be φ⋅cosφ!)
        # convergence = γ.to_degrees.round(9)
        # scale       = k.round(12)

        return Ravn::Geo::Position.new( lat, long )
end
Also aliased as: position
to_s()

Return the standard UTM representation.

# File lib/ravn/geo/utm.rb, line 235
def to_s
        return "%02d %s %d %d" % [
                self.zone,
                self.hemisphere,
                self.easting,
                self.northing
        ]
end