defra_ruby_address 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20ddcc7878b6c76c1e8cc3049ba6b746d630ab58256ce27e673dc6da03e3449c
4
- data.tar.gz: bc3e90c8b26939f24d9d15a4209a8cace6486d97aa504e39adc0e505c709865a
3
+ metadata.gz: a5d4b459711e84d2d0caac1f48600cb928c1cede9b4ca85e5d23e53b12f4a881
4
+ data.tar.gz: 0c6b7e465734d6f4cc4cbea1df20b40a2d93d9e0d38035dc7d2d8ffbe639855e
5
5
  SHA512:
6
- metadata.gz: 49bacebde4493276d5240e5f6b428578fb0b45753e41bc6a484b36f0bc722b9ee8def33bfe99ed06fed0fe2a72294f2fdd95aaf50e129fb66d0bf3072f2dc8c0
7
- data.tar.gz: fe934edffe97f32bf09960e3b62826195bb1bf5046015125dcbd19365ba69519e8dc2512317859930fcc7ea7342e80987722dd5ca5a4ed97f0a97a05b572e890
6
+ metadata.gz: c80f7606cbfffc7dc4f03c06b106c2df3d23ad5c4fd93e29849c3ff035e6b5d53836b8bad16816f40164674de1d9437b07792d13410d99b8df4775cb72c10c2c
7
+ data.tar.gz: 7dc7df096183d0422971325ba71e4f5607b9e279833f10d8bd86249ef808af6c758be28a610e16bfb6f2604489992fbfa3eba0c089543fabbe2d6bec9197029b
data/README.md CHANGED
@@ -270,6 +270,22 @@ The expected format of each result matches the EA Address Facade v1.1 format:
270
270
  }
271
271
  ```
272
272
 
273
+ ### Coordinate conversion
274
+
275
+ The gem also provides a pair of services for converting British National Grid easting and northing coordinates (OSGB36) to WGS84 latitude and longitude, and back again, for example to support geospatial queries.
276
+
277
+ The conversion is pure Ruby, using the 7-parameter Helmert transformation and Transverse Mercator formulae from the OS guide [A guide to coordinate systems in Great Britain](https://www.ordnancesurvey.co.uk/documents/resources/guide-coordinate-systems-great-britain.pdf), and is accurate to around 5 metres.
278
+
279
+ Unlike the address lookup services these do not call an external API, and they return a plain hash rather than a `Response` object. String arguments are coerced to floats.
280
+
281
+ ```ruby
282
+ DefraRuby::Address::EastingNorthingToLatLonService.run(358_130, 172_688)
283
+ # => { latitude: 51.45161..., longitude: -2.60394... }
284
+
285
+ DefraRuby::Address::LatLonToEastingNorthingService.run(51.451616, -2.603943)
286
+ # => { easting: 358129.8..., northing: 172687.8... }
287
+ ```
288
+
273
289
  ## Contributing to this project
274
290
 
275
291
  If you have an idea you'd like to contribute please log an issue.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DefraRuby
4
+ module Address
5
+ # Shifts latitude/longitude between the OSGB36 and WGS84 datums using the
6
+ # 7-parameter Helmert transformation from the OS guide "A guide to
7
+ # coordinate systems in Great Britain". Accurate to around 5 metres.
8
+ #
9
+ # The OSGB36 to WGS84 direction is ported from the breasal gem
10
+ # (https://github.com/theodi/breasal, MIT licence, copyright 2013
11
+ # pezholio), corrected to the published OS guide parameters. The reverse
12
+ # direction just negates them.
13
+ class HelmertTransformation
14
+ AIRY_1830 = { a: 6_377_563.396, b: 6_356_256.909 }.freeze
15
+ WGS84 = { a: 6_378_137.0, b: 6_356_752.3141 }.freeze
16
+
17
+ ARC_SECOND_IN_RADIANS = Math::PI / 648_000
18
+
19
+ # Translations (tx, ty, tz) in metres, scale change (s) in parts per
20
+ # unit and rotations (rx, ry, rz) in radians, for the OSGB36 to WGS84
21
+ # direction; negated they transform in the opposite direction.
22
+ PARAMETERS = {
23
+ tx: 446.448,
24
+ ty: -125.157,
25
+ tz: 542.060,
26
+ s: -0.0000204894,
27
+ rx: 0.1502 * ARC_SECOND_IN_RADIANS,
28
+ ry: 0.2470 * ARC_SECOND_IN_RADIANS,
29
+ rz: 0.8421 * ARC_SECOND_IN_RADIANS
30
+ }.freeze
31
+
32
+ def self.osgb36_to_wgs84(latitude, longitude)
33
+ new(AIRY_1830, WGS84, 1).transform(latitude, longitude)
34
+ end
35
+
36
+ def self.wgs84_to_osgb36(latitude, longitude)
37
+ new(WGS84, AIRY_1830, -1).transform(latitude, longitude)
38
+ end
39
+
40
+ def initialize(source_ellipsoid, target_ellipsoid, direction)
41
+ @source_ellipsoid = source_ellipsoid
42
+ @target_ellipsoid = target_ellipsoid
43
+ @direction = direction
44
+ end
45
+
46
+ def transform(latitude, longitude)
47
+ to_geodetic(apply_helmert(to_cartesian(latitude, longitude)))
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :source_ellipsoid, :target_ellipsoid, :direction
53
+
54
+ def to_cartesian(latitude, longitude)
55
+ e_squared = eccentricity_squared(source_ellipsoid)
56
+ phi = latitude * Math::PI / 180
57
+ lambda = longitude * Math::PI / 180
58
+ nu = source_ellipsoid[:a] / Math.sqrt(1.0 - (e_squared * (Math.sin(phi)**2)))
59
+
60
+ [nu * Math.cos(phi) * Math.cos(lambda),
61
+ nu * Math.cos(phi) * Math.sin(lambda),
62
+ (1.0 - e_squared) * nu * Math.sin(phi)]
63
+ end
64
+
65
+ def apply_helmert(cartesian)
66
+ x, y, z = cartesian
67
+ scale = 1.0 + parameter(:s)
68
+ rx = parameter(:rx)
69
+ ry = parameter(:ry)
70
+ rz = parameter(:rz)
71
+
72
+ [parameter(:tx) + (scale * x) - (rz * y) + (ry * z),
73
+ parameter(:ty) + (rz * x) + (scale * y) - (rx * z),
74
+ parameter(:tz) - (ry * x) + (rx * y) + (scale * z)]
75
+ end
76
+
77
+ def to_geodetic(cartesian)
78
+ x, y, z = cartesian
79
+ e_squared = eccentricity_squared(target_ellipsoid)
80
+ p = Math.hypot(x, y)
81
+ phi = Math.atan(z / (p * (1.0 - e_squared)))
82
+
83
+ 10.times do
84
+ nu = target_ellipsoid[:a] / Math.sqrt(1.0 - (e_squared * (Math.sin(phi)**2)))
85
+ phi = Math.atan((z + (e_squared * nu * Math.sin(phi))) / p)
86
+ end
87
+
88
+ [phi * 180 / Math::PI, Math.atan2(y, x) * 180 / Math::PI]
89
+ end
90
+
91
+ def parameter(key)
92
+ direction * PARAMETERS[key]
93
+ end
94
+
95
+ def eccentricity_squared(ellipsoid)
96
+ a = ellipsoid[:a]
97
+ b = ellipsoid[:b]
98
+ ((a * a) - (b * b)) / (a * a)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -6,8 +6,8 @@ require "rest-client"
6
6
  module DefraRuby
7
7
  module Address
8
8
  class BaseService
9
- def self.run(attrs = nil)
10
- new.run(attrs)
9
+ def self.run(*)
10
+ new.run(*)
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DefraRuby
4
+ module Address
5
+ # Converts a British National Grid easting and northing to a WGS84
6
+ # latitude and longitude. Accurate to around 5 metres.
7
+ class EastingNorthingToLatLonService < BaseService
8
+ def run(easting, northing)
9
+ osgb36_latitude, osgb36_longitude = TransverseMercatorProjection.easting_northing_to_lat_lon(
10
+ Float(easting), Float(northing)
11
+ )
12
+ latitude, longitude = HelmertTransformation.osgb36_to_wgs84(osgb36_latitude, osgb36_longitude)
13
+
14
+ { latitude: latitude, longitude: longitude }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DefraRuby
4
+ module Address
5
+ # Converts a WGS84 latitude and longitude to a British National Grid
6
+ # easting and northing. Accurate to around 5 metres.
7
+ class LatLonToEastingNorthingService < BaseService
8
+ def run(latitude, longitude)
9
+ osgb36_latitude, osgb36_longitude = HelmertTransformation.wgs84_to_osgb36(
10
+ Float(latitude), Float(longitude)
11
+ )
12
+ easting, northing = TransverseMercatorProjection.lat_lon_to_easting_northing(
13
+ osgb36_latitude, osgb36_longitude
14
+ )
15
+
16
+ { easting: easting, northing: northing }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DefraRuby
4
+ module Address
5
+ # Converts between British National Grid easting/northing and OSGB36
6
+ # latitude/longitude, a Transverse Mercator projection on the Airy 1830
7
+ # ellipsoid. Variable names follow the terms in the OS guide "A guide to
8
+ # coordinate systems in Great Britain".
9
+ #
10
+ # The easting/northing to lat/lon direction is ported from the breasal
11
+ # gem (https://github.com/theodi/breasal, MIT licence, copyright 2013
12
+ # pezholio). Breasal has no reverse conversion, so that direction comes
13
+ # straight from the OS guide's forward projection formulae.
14
+ class TransverseMercatorProjection
15
+ A = HelmertTransformation::AIRY_1830[:a]
16
+ B = HelmertTransformation::AIRY_1830[:b]
17
+ E_SQUARED = ((A * A) - (B * B)) / (A * A)
18
+ N = (A - B) / (A + B)
19
+
20
+ F0 = 0.9996012717
21
+ PHI0 = 49.0 * Math::PI / 180
22
+ LAMBDA0 = -2.0 * Math::PI / 180
23
+ E0 = 400_000.0
24
+ N0 = -100_000.0
25
+
26
+ def self.easting_northing_to_lat_lon(easting, northing)
27
+ new.easting_northing_to_lat_lon(easting, northing)
28
+ end
29
+
30
+ def self.lat_lon_to_easting_northing(latitude, longitude)
31
+ new.lat_lon_to_easting_northing(latitude, longitude)
32
+ end
33
+
34
+ def easting_northing_to_lat_lon(easting, northing)
35
+ phi_prime = converged_phi_prime(northing)
36
+ delta_e = easting - E0
37
+
38
+ [latitude_from(phi_prime, delta_e) * 180 / Math::PI,
39
+ longitude_from(phi_prime, delta_e) * 180 / Math::PI]
40
+ end
41
+
42
+ def lat_lon_to_easting_northing(latitude, longitude)
43
+ phi = latitude * Math::PI / 180
44
+ delta_lambda = (longitude * Math::PI / 180) - LAMBDA0
45
+
46
+ [easting_from(phi, delta_lambda), northing_from(phi, delta_lambda)]
47
+ end
48
+
49
+ private
50
+
51
+ # Converges within a few iterations for any real coordinates; the cap
52
+ # guards against non-finite input looping forever
53
+ def converged_phi_prime(northing)
54
+ phi_prime = ((northing - N0) / (A * F0)) + PHI0
55
+
56
+ 100.times do
57
+ delta = northing - N0 - meridional_arc(phi_prime)
58
+ break if delta < 0.001
59
+
60
+ phi_prime += delta / (A * F0)
61
+ end
62
+
63
+ phi_prime
64
+ end
65
+
66
+ # The methods below are a straight port of the OS guide formulae; the
67
+ # ABC metric counts every arithmetic operator, so is disabled for them.
68
+ # rubocop:disable Metrics/AbcSize
69
+
70
+ # M in the OS guide
71
+ def meridional_arc(phi)
72
+ delta_phi = phi - PHI0
73
+ sum_phi = phi + PHI0
74
+
75
+ B * F0 * (((1 + N + ((5.0 / 4) * (N**2)) + ((5.0 / 4) * (N**3))) * delta_phi) -
76
+ (((3 * N) + (3 * (N**2)) + ((21.0 / 8) * (N**3))) * Math.sin(delta_phi) * Math.cos(sum_phi)) +
77
+ ((((15.0 / 8) * (N**2)) + ((15.0 / 8) * (N**3))) * Math.sin(2 * delta_phi) * Math.cos(2 * sum_phi)) -
78
+ (((35.0 / 24) * (N**3)) * Math.sin(3 * delta_phi) * Math.cos(3 * sum_phi)))
79
+ end
80
+
81
+ # Terms VII, VIII and IX in the OS guide
82
+ def latitude_from(phi_prime, delta_e)
83
+ tan = Math.tan(phi_prime)
84
+ tan2 = tan**2
85
+ nu = nu(phi_prime)
86
+ rho = rho(phi_prime)
87
+ eta_squared = (nu / rho) - 1.0
88
+
89
+ vii = tan / (2 * rho * nu)
90
+ viii = (tan / (24 * rho * (nu**3))) * (5 + (3 * tan2) + eta_squared - (9 * tan2 * eta_squared))
91
+ ix = (tan / (720 * rho * (nu**5))) * (61 + (90 * tan2) + (45 * (tan2**2)))
92
+
93
+ phi_prime - (vii * (delta_e**2)) + (viii * (delta_e**4)) - (ix * (delta_e**6))
94
+ end
95
+
96
+ # Terms X, XI, XII and XIIA in the OS guide
97
+ def longitude_from(phi_prime, delta_e)
98
+ sec = 1.0 / Math.cos(phi_prime)
99
+ tan2 = Math.tan(phi_prime)**2
100
+ nu = nu(phi_prime)
101
+
102
+ x = sec / nu
103
+ xi = (sec / (6 * (nu**3))) * ((nu / rho(phi_prime)) + (2 * tan2))
104
+ xii = (sec / (120 * (nu**5))) * (5 + (28 * tan2) + (24 * (tan2**2)))
105
+ xiia = (sec / (5040 * (nu**7))) * (61 + (662 * tan2) + (1320 * (tan2**2)) + (720 * (tan2**3)))
106
+
107
+ LAMBDA0 + (x * delta_e) - (xi * (delta_e**3)) + (xii * (delta_e**5)) - (xiia * (delta_e**7))
108
+ end
109
+
110
+ # Terms IV, V and VI in the OS guide
111
+ def easting_from(phi, delta_lambda)
112
+ nu = nu(phi)
113
+ rho = rho(phi)
114
+ eta_squared = (nu / rho) - 1.0
115
+ cos_phi = Math.cos(phi)
116
+ tan2 = Math.tan(phi)**2
117
+
118
+ iv = nu * cos_phi
119
+ v = (nu / 6) * (cos_phi**3) * ((nu / rho) - tan2)
120
+ vi = (nu / 120) * (cos_phi**5) * (5 - (18 * tan2) + (tan2**2) + (14 * eta_squared) - (58 * tan2 * eta_squared))
121
+
122
+ E0 + (iv * delta_lambda) + (v * (delta_lambda**3)) + (vi * (delta_lambda**5))
123
+ end
124
+
125
+ # Terms I, II, III and IIIA in the OS guide
126
+ def northing_from(phi, delta_lambda)
127
+ nu = nu(phi)
128
+ eta_squared = (nu / rho(phi)) - 1.0
129
+ sin_phi = Math.sin(phi)
130
+ cos_phi = Math.cos(phi)
131
+ tan2 = Math.tan(phi)**2
132
+
133
+ i = meridional_arc(phi) + N0
134
+ ii = (nu / 2) * sin_phi * cos_phi
135
+ iii = (nu / 24) * sin_phi * (cos_phi**3) * (5 - tan2 + (9 * eta_squared))
136
+ iiia = (nu / 720) * sin_phi * (cos_phi**5) * (61 - (58 * tan2) + (tan2**2))
137
+
138
+ i + (ii * (delta_lambda**2)) + (iii * (delta_lambda**4)) + (iiia * (delta_lambda**6))
139
+ end
140
+ # rubocop:enable Metrics/AbcSize
141
+
142
+ def nu(phi)
143
+ A * F0 * ((1.0 - (E_SQUARED * (Math.sin(phi)**2)))**-0.5)
144
+ end
145
+
146
+ def rho(phi)
147
+ A * F0 * (1.0 - E_SQUARED) * ((1.0 - (E_SQUARED * (Math.sin(phi)**2)))**-1.5)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module DefraRuby
4
4
  module Address
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -2,12 +2,16 @@
2
2
 
3
3
  require_relative "address/configuration"
4
4
  require_relative "address/ea_address_facade_request"
5
+ require_relative "address/helmert_transformation"
5
6
  require_relative "address/no_match_error"
6
7
  require_relative "address/response"
8
+ require_relative "address/transverse_mercator_projection"
7
9
 
8
10
  require_relative "address/services/base_service"
9
11
  require_relative "address/services/ea_address_facade_v1_1_service"
10
12
  require_relative "address/services/ea_address_facade_v1_service"
13
+ require_relative "address/services/easting_northing_to_lat_lon_service"
14
+ require_relative "address/services/lat_lon_to_easting_northing_service"
11
15
  require_relative "address/services/os_api_address_lookup_v1_service"
12
16
  require_relative "address/services/os_places_address_lookup_service"
13
17
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: defra_ruby_address
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Defra
@@ -40,13 +40,17 @@ files:
40
40
  - lib/defra_ruby/address.rb
41
41
  - lib/defra_ruby/address/configuration.rb
42
42
  - lib/defra_ruby/address/ea_address_facade_request.rb
43
+ - lib/defra_ruby/address/helmert_transformation.rb
43
44
  - lib/defra_ruby/address/no_match_error.rb
44
45
  - lib/defra_ruby/address/response.rb
45
46
  - lib/defra_ruby/address/services/base_service.rb
46
47
  - lib/defra_ruby/address/services/ea_address_facade_v1_1_service.rb
47
48
  - lib/defra_ruby/address/services/ea_address_facade_v1_service.rb
49
+ - lib/defra_ruby/address/services/easting_northing_to_lat_lon_service.rb
50
+ - lib/defra_ruby/address/services/lat_lon_to_easting_northing_service.rb
48
51
  - lib/defra_ruby/address/services/os_api_address_lookup_v1_service.rb
49
52
  - lib/defra_ruby/address/services/os_places_address_lookup_service.rb
53
+ - lib/defra_ruby/address/transverse_mercator_projection.rb
50
54
  - lib/defra_ruby/address/version.rb
51
55
  homepage: https://github.com/DEFRA/defra-ruby-address
52
56
  licenses:
@@ -61,7 +65,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
61
65
  requirements:
62
66
  - - ">="
63
67
  - !ruby/object:Gem::Version
64
- version: '0'
68
+ version: '3.2'
65
69
  required_rubygems_version: !ruby/object:Gem::Requirement
66
70
  requirements:
67
71
  - - ">="