# adapted from Geography::NationalGrid by and (c) P Kent
# with reference to the Ordnance Survey guide to coordinate systems in the UK
# http://www.ordnancesurvey.co.uk/oswebsite/gps/information/coordinatesystemsinfo/guidecontents/
class Ellipsoid
  attr_accessor :a, :b, :e2

  def initialize(a,b)
    @a = a
    @b = b
  end
  
  def ecc
    (a**2 - b**2)/(a**2)
  end
end

class GridRef
  OsTiles = {
  	:a => [0,4], :b => [1,4], :c => [2,4], :d => [3,4], :e => [4,4],
  	:f => [0,3], :g => [1,3], :h => [2,3], :j => [3,3], :k => [4,3],
  	:l => [0,2], :m => [1,2], :n => [2,2], :o => [3,2], :p => [4,2],
  	:q => [0,1], :r => [1,1], :s => [2,1], :t => [3,1], :u => [4,1],
  	:v => [0,0], :w => [1,0], :x => [2,0], :y => [3,0], :z => [4,0],
  }
  FalseOrigin = [2,1]
  SquareSize = [nil, 10000, 1000, 100, 10, 1]    # shorter grid ref = larger square.

  @@iteration_ceiling = 1000
  @@ellipsoids = {
    :osgb36 => Ellipsoid.new(6377563.396, 6356256.910),
    :wgs84 => Ellipsoid.new(6378137.000, 6356752.3141),
    :ie65 => Ellipsoid.new(6377340.189, 6356034.447),
    :utm => Ellipsoid.new(6378388.000, 6356911.946)
  }
  @@projections = {
    :gb => {:scale => 0.9996012717, :Phio => 49.to_radians, :Lambdao => -2.to_radians, :Eo => 400000, :No => -100000, :ellipsoid => :osgb36},
    :ie => {:scale => 1.000035, :Phio => 53.5.to_radians, :Lambdao => -8.to_radians, :Eo => 250000, :No => 250000, :ellipsoid => :ie65},
    :utm29 => {:scale => 0.9996, :Phio => 0, :Lambdao => -9.to_radians, :Eo => 500000, :No => 0, :ellipsoid => :utm},
    :utm30 => {:scale => 0.9996, :Phio => 0, :Lambdao => -3.to_radians, :Eo => 500000, :No => 0, :ellipsoid => :utm},
    :utm31 => {:scale => 0.9996, :Phio => 0, :Lambdao => 3.to_radians, :Eo => 500000, :No => 0, :ellipsoid => :utm}
  }
  @@helmerts = {
    :wgs84 => { :tx => 446.448, :ty => -125.157, :tz => 542.060, :rx => 0.1502, :ry => 0.2470, :rz => 0.8421, :s => -20.4894 }
  }
  
  cattr_accessor :iteration_ceiling
  attr_accessor :gridref, :projection, :ellipsoid, :datum, :options
  
  @@defaults = {
    :projection => :gb,   # mercator projection of input gridref. Can be any projection name: usually :ie or :gb
    :precision => 6       # decimal places in the output lat/long
  }
  
  def initialize(string, options={})
    raise ArgumentError, "invalid grid reference string '#{string}'." unless string.is_gridref?
    @gridref = string.upcase
    @options = @@defaults.merge(options)
    @projection = @@projections[@options[:projection]]
    @ellipsoid = @@ellipsoids[@projection[:ellipsoid]]
    @datum = @options[:datum]
    self
  end
  
  def tile
    @tile ||= gridref[0,2]
  end
  
  def digits
    @digits ||= gridref[2,10]
  end
  
  def resolution
    digits.length / 2
  end
  
  def offsets
    if tile
      major = OsTiles[tile[0,1].downcase.to_sym ]
      minor = OsTiles[tile[1,1].downcase.to_sym]
    	@offset ||= {
        :e => (500000 * (major[0] - FalseOrigin[0])) + (100000 * minor[0]),
      	:n => (500000 * (major[1] - FalseOrigin[1])) + (100000 * minor[1])
    	}
    else
      { :e => 0, :n => 0 }
    end
  end
  
  def easting
    @east ||= offsets[:e] + digits[0, resolution].to_i * SquareSize[resolution]
  end
  
  def northing
    @north ||= offsets[:n] + digits[resolution, resolution].to_i * SquareSize[resolution]
  end
  
  def lat
    coordinates[:lat].to_degrees.round(self.options[:precision])
  end
  
  def lng
    coordinates[:lng].to_degrees.round(self.options[:precision])
  end
  
  def to_s
    gridref.to_s
  end
  
  def to_latlng
    "#{lat}, #{lng}"
  end

  def coordinates
    unless @coordinates
      # variable names correspond roughly to symbols in the OS algorithm, lowercased:
      # n0 = northing of true origin 
      # e0 = easting of true origin 
      # f0 = scale factor on central meridian
      # phi0 = latitude of true origin 
      # lambda0 = longitude of true origin and central meridian.
      # e2 = eccentricity squared
      # a = length of polar axis of ellipsoid
      # b = length of equatorial axis of ellipsoid
      # ning & eing are the northings and eastings of the supplied gridref
      # phi and lambda are the discovered latitude and longitude
      
      ning = northing
      eing = easting

      n0 = projection[:No]
      e0 = projection[:Eo]
      phi0 = projection[:Phio]
      l0 = projection[:Lambdao]
      f0 = projection[:scale]
      
      a = ellipsoid.a
      b = ellipsoid.b
      e2 = ellipsoid.ecc
      
      # the rest is taken from the OS equations with help from CPAN's Geography::NationalGrid
      # and only enough understanding to transliterate it, and sometimes not even that.

      n = (a - b) / (a + b)
    	m = 0
      phi = phi0
    
      # iterate to within acceptable distance of solution
      
    	count = 0
    	while ((ning - n0 - m) >= 0.001) do
        raise RuntimeError "Demercatorising equation has not converged. Discrepancy after #{count} cycles is #{ning - n0 - m}" if count >= @@iteration_ceiling

    		phi = ((ning - n0 - m) / (a * f0)) + phi
        ma = (1 + n + (1.25 * n**2) + (1.25 * n**3)) * (phi - phi0)
        mb = ((3 * n) + (3 * n**2) + (2.625 * n**3)) * Math.sin(phi - phi0) * Math.cos(phi + phi0)
        mc = ((1.875 * n**2) + (1.875 * n**3)) * Math.sin(2 * (phi - phi0)) * Math.cos(2 * (phi + phi0))
        md = (35/24) * (n**3) * Math.sin(3 * (phi - phi0)) * Math.cos(3 * (phi + phi0))
        m = b * f0 * (ma - mb + mc - md)
    		count += 1
  	  end
  	  
  	  # engage alphabet soup
  	  
  	  nu = a * f0 * ((1-(e2) * ((Math.sin(phi)**2))) ** -0.5)
    	rho = a * f0 * (1-(e2)) * ((1-(e2)*((Math.sin(phi)**2))) ** -1.5)
    	eta2 = (nu/rho - 1)
    	
    	# fire
    	
    	vii = Math.tan(phi) / (2 * rho * nu)
    	viii = (Math.tan(phi) / (24 * rho * (nu ** 3))) * (5 + (3 * (Math.tan(phi) ** 2)) + eta2 - 9 * eta2 * (Math.tan(phi) ** 2) )
    	ix = (Math.tan(phi) / (720 * rho * (nu ** 5))) * (61 + (90 * (Math.tan(phi) ** 2)) + (45 * (Math.tan(phi) ** 4)) )
    	x = sec(phi) / nu
    	xi = (sec(phi) / (6 * nu ** 3)) * ((nu/rho) + 2 * (Math.tan(phi) ** 2))
    	xii = (sec(phi) / (120 * nu ** 5)) * (5 + (28 * (Math.tan(phi) ** 2)) + (24 * (Math.tan(phi) ** 4)))
    	xiia = (sec(phi) / (5040 * nu ** 7)) * (61 + (662 * (Math.tan(phi) ** 2)) + (1320 * (Math.tan(phi) ** 4)) + (720 * (Math.tan(phi) ** 6)))

      d = eing-e0

      # all of which was just to populate these last two equations:
      
    	phi = phi - vii*(d**2) + viii*(d**4) - ix*(d**6)
      lambda = l0 + x*d - xi*(d**3) + xii*(d**5) - xiia*(d**7)

      # here coordinates are still in radians and osgb36

      @coordinates = helmerted(phi, lambda)
    end
    
    @coordinates
  end
  
  def helmerted(phi, lambda)
    return {:lat => phi, :lng => lambda} unless @datum && @datum != :osgb36
    target_datum = @@ellipsoids[@datum] 
    t = @@helmerts[@datum]
    
    if t && target_datum

      # convert polar to cartesian coordinates using osgb datum

      a = @@ellipsoids[:osgb36].a
      b = @@ellipsoids[:osgb36].b
      e2 = @@ellipsoids[:osgb36].ecc
      
      nu = a / (Math.sqrt(1 - e2 * Math.sin(phi)**2))
      h = 0

      x1 = (nu + h) * Math.cos(phi) * Math.cos(lambda)
      y1 = (nu + h) * Math.cos(phi) * Math.sin(lambda)
      z1 = ((1 - e2) * nu + h) * Math.sin(phi)
      
      # parameterise helmert transformation

      tx = t[:tx]
      ty = t[:ty]
      tz = t[:tz]
      rx = (t[:rx]/3600).to_radians
      ry = (t[:ry]/3600).to_radians
      rz = (t[:rz]/3600).to_radians
      s1 = t[:s]/1e6 + 1

      # apply helmert transformation
      
      xp = tx + x1*s1 - y1*rz + z1*ry
      yp = ty + x1*rz + y1*s1 - z1*rx
      zp = tz - x1*ry + y1*rx + z1*s1
            
      # convert back to polar coordinates using target datum

      a = target_datum.a
      b = target_datum.b
      e2 = target_datum.ecc
      precision = 4 / a
      
      p = Math.sqrt(xp**2 + yp**2)
      phi = Math.atan2(zp, p*(1-e2));
      phip = 2 * Math::PI

      count = 0
      while (phi-phip).abs > precision do
        raise RuntimeError "Helmert transformation has not converged. Discrepancy after #{count} cycles is #{phi-phip}" if count >= @@iteration_ceiling
        
        nu = a / Math.sqrt(1 - e2 * Math.sin(phi)**2)
        phip = phi
        phi = Math.atan2(zp + e2 * nu * Math.sin(phi), p)
        count += 1
      end 

      lambda = Math.atan2(yp, xp)

      {:lat => phi, :lng => lambda}
      
    else
      raise RuntimeError, "Missing ellipsoid or Helmert transformation for #{@datum}"
    end
  end
  
private

  def sec(radians)
    1 / Math.cos(radians)
  end
  
end

class String
  def is_gridref?
    self.upcase =~ /^(H(P|T|U|Y|Z)|N(A|B|C|D|F|G|H|J|K|L|M|N|O|R|S|T|U|W|X|Y|Z)|OV|S(C|D|E|G|H|J|K|M|N|O|P|R|S|T|U|W|X|Y|Z)|T(A|F|G|L|M|Q|R|V)){1}\d{4}(NE|NW|SE|SW)?$|((H(P|T|U|Y|Z)|N(A|B|C|D|F|G|H|J|K|L|M|N|O|R|S|T|U|W|X|Y|Z)|OV|S(C|D|E|G|H|J|K|M|N|O|P|R|S|T|U|W|X|Y|Z)|T(A|F|G|L|M|Q|R|V)){1}(\d{4}|\d{6}|\d{8}|\d{10}))$/
  end

  def to_latlng(options = {})
    GridRef.new(self, options).to_latlng if self.is_gridref?
  end
  
  def to_wgs84(options = {})
    GridRef.new(self, options.merge(:datum => :wgs84)).to_latlng if self.is_gridref?
  end
end