geodetic 0.0.1
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 +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +15 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +471 -0
- data/Rakefile +8 -0
- data/docs/coordinate-systems/bng.md +60 -0
- data/docs/coordinate-systems/ecef.md +215 -0
- data/docs/coordinate-systems/enu.md +77 -0
- data/docs/coordinate-systems/gh36.md +192 -0
- data/docs/coordinate-systems/index.md +93 -0
- data/docs/coordinate-systems/lla.md +304 -0
- data/docs/coordinate-systems/mgrs.md +81 -0
- data/docs/coordinate-systems/ned.md +83 -0
- data/docs/coordinate-systems/state-plane.md +60 -0
- data/docs/coordinate-systems/ups.md +53 -0
- data/docs/coordinate-systems/usng.md +74 -0
- data/docs/coordinate-systems/utm.md +257 -0
- data/docs/coordinate-systems/web-mercator.md +67 -0
- data/docs/getting-started/installation.md +65 -0
- data/docs/getting-started/quick-start.md +175 -0
- data/docs/index.md +58 -0
- data/docs/reference/areas.md +195 -0
- data/docs/reference/conversions.md +351 -0
- data/docs/reference/datums.md +134 -0
- data/docs/reference/geoid-height.md +182 -0
- data/docs/reference/serialization.md +252 -0
- data/examples/01_basic_conversions.rb +187 -0
- data/examples/02_all_coordinate_systems.rb +310 -0
- data/examples/03_distance_calculations.rb +224 -0
- data/examples/04_bearing_calculations.rb +236 -0
- data/lib/geodetic/areas/circle.rb +29 -0
- data/lib/geodetic/areas/polygon.rb +57 -0
- data/lib/geodetic/areas/rectangle.rb +55 -0
- data/lib/geodetic/areas.rb +5 -0
- data/lib/geodetic/bearing.rb +94 -0
- data/lib/geodetic/coordinates/bng.rb +366 -0
- data/lib/geodetic/coordinates/ecef.rb +229 -0
- data/lib/geodetic/coordinates/enu.rb +244 -0
- data/lib/geodetic/coordinates/gh36.rb +384 -0
- data/lib/geodetic/coordinates/lla.rb +268 -0
- data/lib/geodetic/coordinates/mgrs.rb +317 -0
- data/lib/geodetic/coordinates/ned.rb +246 -0
- data/lib/geodetic/coordinates/state_plane.rb +451 -0
- data/lib/geodetic/coordinates/ups.rb +325 -0
- data/lib/geodetic/coordinates/usng.rb +274 -0
- data/lib/geodetic/coordinates/utm.rb +261 -0
- data/lib/geodetic/coordinates/web_mercator.rb +242 -0
- data/lib/geodetic/coordinates.rb +260 -0
- data/lib/geodetic/datum.rb +62 -0
- data/lib/geodetic/distance.rb +146 -0
- data/lib/geodetic/geoid_height.rb +299 -0
- data/lib/geodetic/version.rb +5 -0
- data/lib/geodetic.rb +13 -0
- data/mkdocs.yml +140 -0
- data/sig/geodetic.rbs +4 -0
- metadata +104 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Universal Polar Stereographic (UPS) Coordinate System
|
|
4
|
+
# Used for polar regions not covered by UTM (north of 84°N and south of 80°S)
|
|
5
|
+
|
|
6
|
+
module Geodetic
|
|
7
|
+
module Coordinates
|
|
8
|
+
class UPS
|
|
9
|
+
require_relative '../datum'
|
|
10
|
+
|
|
11
|
+
attr_reader :easting, :northing, :hemisphere, :zone
|
|
12
|
+
|
|
13
|
+
# UPS Constants
|
|
14
|
+
FALSE_EASTING = 2000000.0 # meters
|
|
15
|
+
FALSE_NORTHING = 2000000.0 # meters
|
|
16
|
+
SCALE_FACTOR = 0.994 # Central scale factor
|
|
17
|
+
|
|
18
|
+
# UPS covers two zones: North (Y,Z) and South (A,B)
|
|
19
|
+
NORTH_ZONES = ['Y', 'Z']
|
|
20
|
+
SOUTH_ZONES = ['A', 'B']
|
|
21
|
+
|
|
22
|
+
def initialize(easting: 0.0, northing: 0.0, hemisphere: 'N', zone: 'Y')
|
|
23
|
+
@easting = easting.to_f
|
|
24
|
+
@northing = northing.to_f
|
|
25
|
+
@hemisphere = hemisphere.upcase
|
|
26
|
+
@zone = zone.upcase
|
|
27
|
+
|
|
28
|
+
validate_zone
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def easting=(value)
|
|
32
|
+
@easting = value.to_f
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def northing=(value)
|
|
36
|
+
@northing = value.to_f
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def hemisphere=(value)
|
|
40
|
+
value = value.to_s.upcase
|
|
41
|
+
old_hemisphere = @hemisphere
|
|
42
|
+
@hemisphere = value
|
|
43
|
+
unless valid?
|
|
44
|
+
@hemisphere = old_hemisphere
|
|
45
|
+
raise ArgumentError, "Invalid UPS hemisphere '#{value}' for zone '#{@zone}'"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def zone=(value)
|
|
50
|
+
value = value.to_s.upcase
|
|
51
|
+
old_zone = @zone
|
|
52
|
+
@zone = value
|
|
53
|
+
unless valid?
|
|
54
|
+
@zone = old_zone
|
|
55
|
+
raise ArgumentError, "Invalid UPS zone '#{value}' for hemisphere '#{@hemisphere}'"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_s(precision = 2)
|
|
60
|
+
precision = precision.to_i
|
|
61
|
+
if precision == 0
|
|
62
|
+
"#{@easting.round}, #{@northing.round}, #{@hemisphere}, #{@zone}"
|
|
63
|
+
else
|
|
64
|
+
format("%.#{precision}f, %.#{precision}f, %s, %s", @easting, @northing, @hemisphere, @zone)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_a
|
|
69
|
+
[@easting, @northing, @hemisphere, @zone]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.from_array(array)
|
|
73
|
+
new(easting: array[0].to_f, northing: array[1].to_f, hemisphere: array[2].to_s, zone: array[3].to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.from_string(string)
|
|
77
|
+
parts = string.split(',').map(&:strip)
|
|
78
|
+
new(easting: parts[0].to_f, northing: parts[1].to_f, hemisphere: parts[2].to_s, zone: parts[3].to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_lla(datum = WGS84)
|
|
82
|
+
a = datum.a
|
|
83
|
+
e = datum.e
|
|
84
|
+
e2 = datum.e2
|
|
85
|
+
|
|
86
|
+
# Adjust for false origin
|
|
87
|
+
x = @easting - FALSE_EASTING
|
|
88
|
+
y = @northing - FALSE_NORTHING
|
|
89
|
+
|
|
90
|
+
# Calculate polar stereographic parameters
|
|
91
|
+
rho = Math.sqrt(x * x + y * y)
|
|
92
|
+
|
|
93
|
+
if rho < 1e-10 # At pole
|
|
94
|
+
lat = @hemisphere == 'N' ? 90.0 : -90.0
|
|
95
|
+
lng = 0.0
|
|
96
|
+
else
|
|
97
|
+
# Iterative calculation for latitude
|
|
98
|
+
if @hemisphere == 'N'
|
|
99
|
+
c = 2.0 * Math.atan(rho / (2.0 * a * SCALE_FACTOR * ((1.0 - e) / (1.0 + e)) ** (e / 2.0)))
|
|
100
|
+
lat = (Math::PI / 2.0) - c
|
|
101
|
+
else
|
|
102
|
+
c = 2.0 * Math.atan(rho / (2.0 * a * SCALE_FACTOR * ((1.0 + e) / (1.0 - e)) ** (e / 2.0)))
|
|
103
|
+
lat = c - (Math::PI / 2.0)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Iterative refinement for latitude
|
|
107
|
+
5.times do
|
|
108
|
+
sin_lat = Math.sin(lat)
|
|
109
|
+
if @hemisphere == 'N'
|
|
110
|
+
t = Math.tan((Math::PI / 4.0) - (lat / 2.0)) / ((1.0 - e * sin_lat) / (1.0 + e * sin_lat)) ** (e / 2.0)
|
|
111
|
+
lat = (Math::PI / 2.0) - 2.0 * Math.atan(t * rho / (2.0 * a * SCALE_FACTOR))
|
|
112
|
+
else
|
|
113
|
+
t = Math.tan((Math::PI / 4.0) + (lat / 2.0)) / ((1.0 + e * sin_lat) / (1.0 - e * sin_lat)) ** (e / 2.0)
|
|
114
|
+
lat = 2.0 * Math.atan(t * rho / (2.0 * a * SCALE_FACTOR)) - (Math::PI / 2.0)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
lat *= DEG_PER_RAD
|
|
119
|
+
|
|
120
|
+
# Calculate longitude
|
|
121
|
+
if @hemisphere == 'N'
|
|
122
|
+
lng = Math.atan2(x, -y) * DEG_PER_RAD
|
|
123
|
+
else
|
|
124
|
+
lng = Math.atan2(x, y) * DEG_PER_RAD
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Normalize longitude
|
|
128
|
+
lng = lng - 360.0 while lng > 180.0
|
|
129
|
+
lng = lng + 360.0 while lng < -180.0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
LLA.new(lat: lat, lng: lng, alt: 0.0)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.from_lla(lla_coord, datum = WGS84)
|
|
136
|
+
lat = lla_coord.lat
|
|
137
|
+
lng = lla_coord.lng
|
|
138
|
+
|
|
139
|
+
# Determine hemisphere and zone
|
|
140
|
+
hemisphere = lat >= 0 ? 'N' : 'S'
|
|
141
|
+
|
|
142
|
+
# Determine zone based on longitude
|
|
143
|
+
if hemisphere == 'N'
|
|
144
|
+
zone = (lng >= 0) ? 'Z' : 'Y'
|
|
145
|
+
else
|
|
146
|
+
zone = (lng >= 0) ? 'B' : 'A'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
a = datum.a
|
|
150
|
+
e = datum.e
|
|
151
|
+
|
|
152
|
+
lat_rad = lat * RAD_PER_DEG
|
|
153
|
+
lng_rad = lng * RAD_PER_DEG
|
|
154
|
+
|
|
155
|
+
sin_lat = Math.sin(lat_rad)
|
|
156
|
+
|
|
157
|
+
# Calculate polar stereographic projection
|
|
158
|
+
if lat.abs == 90.0 # At pole
|
|
159
|
+
x = 0.0
|
|
160
|
+
y = 0.0
|
|
161
|
+
else
|
|
162
|
+
if hemisphere == 'N'
|
|
163
|
+
t = Math.tan((Math::PI / 4.0) - (lat_rad / 2.0)) * ((1.0 + e * sin_lat) / (1.0 - e * sin_lat)) ** (e / 2.0)
|
|
164
|
+
rho = 2.0 * a * SCALE_FACTOR * t
|
|
165
|
+
x = rho * Math.sin(lng_rad)
|
|
166
|
+
y = -rho * Math.cos(lng_rad)
|
|
167
|
+
else
|
|
168
|
+
t = Math.tan((Math::PI / 4.0) + (lat_rad / 2.0)) * ((1.0 - e * sin_lat) / (1.0 + e * sin_lat)) ** (e / 2.0)
|
|
169
|
+
rho = 2.0 * a * SCALE_FACTOR * t
|
|
170
|
+
x = rho * Math.sin(lng_rad)
|
|
171
|
+
y = rho * Math.cos(lng_rad)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Apply false origin
|
|
176
|
+
easting = x + FALSE_EASTING
|
|
177
|
+
northing = y + FALSE_NORTHING
|
|
178
|
+
|
|
179
|
+
new(easting: easting, northing: northing, hemisphere: hemisphere, zone: zone)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def to_ecef(datum = WGS84)
|
|
183
|
+
to_lla(datum).to_ecef(datum)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.from_ecef(ecef_coord, datum = WGS84)
|
|
187
|
+
lla_coord = ecef_coord.to_lla(datum)
|
|
188
|
+
from_lla(lla_coord, datum)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def to_utm(datum = WGS84)
|
|
192
|
+
to_lla(datum).to_utm(datum)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.from_utm(utm_coord, datum = WGS84)
|
|
196
|
+
lla_coord = utm_coord.to_lla(datum)
|
|
197
|
+
from_lla(lla_coord, datum)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def to_enu(reference_lla, datum = WGS84)
|
|
201
|
+
to_lla(datum).to_enu(reference_lla)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.from_enu(enu_coord, reference_lla, datum = WGS84)
|
|
205
|
+
lla_coord = enu_coord.to_lla(reference_lla)
|
|
206
|
+
from_lla(lla_coord, datum)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def to_ned(reference_lla, datum = WGS84)
|
|
210
|
+
to_lla(datum).to_ned(reference_lla)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.from_ned(ned_coord, reference_lla, datum = WGS84)
|
|
214
|
+
lla_coord = ned_coord.to_lla(reference_lla)
|
|
215
|
+
from_lla(lla_coord, datum)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def to_mgrs(datum = WGS84, precision = 5)
|
|
219
|
+
MGRS.from_lla(to_lla(datum), datum, precision)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.from_mgrs(mgrs_coord, datum = WGS84)
|
|
223
|
+
lla_coord = mgrs_coord.to_lla(datum)
|
|
224
|
+
from_lla(lla_coord, datum)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def to_web_mercator(datum = WGS84)
|
|
228
|
+
WebMercator.from_lla(to_lla(datum), datum)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.from_web_mercator(web_mercator_coord, datum = WGS84)
|
|
232
|
+
lla_coord = web_mercator_coord.to_lla(datum)
|
|
233
|
+
from_lla(lla_coord, datum)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def to_usng(datum = WGS84, precision = 5)
|
|
237
|
+
USNG.from_lla(to_lla(datum), datum, precision)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.from_usng(usng_coord, datum = WGS84)
|
|
241
|
+
lla_coord = usng_coord.to_lla(datum)
|
|
242
|
+
from_lla(lla_coord, datum)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def to_bng(datum = WGS84)
|
|
246
|
+
BNG.from_lla(to_lla(datum), datum)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.from_bng(bng_coord, datum = WGS84)
|
|
250
|
+
lla_coord = bng_coord.to_lla(datum)
|
|
251
|
+
from_lla(lla_coord, datum)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def to_state_plane(zone_code, datum = WGS84)
|
|
255
|
+
StatePlane.from_lla(to_lla(datum), zone_code, datum)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.from_state_plane(sp_coord, datum = WGS84)
|
|
259
|
+
lla_coord = sp_coord.to_lla(datum)
|
|
260
|
+
from_lla(lla_coord, datum)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def to_gh36(datum = WGS84, precision: 10)
|
|
264
|
+
GH36.new(to_lla(datum), precision: precision)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def self.from_gh36(gh36_coord, datum = WGS84)
|
|
268
|
+
lla_coord = gh36_coord.to_lla(datum)
|
|
269
|
+
from_lla(lla_coord, datum)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def ==(other)
|
|
273
|
+
return false unless other.is_a?(UPS)
|
|
274
|
+
|
|
275
|
+
(@easting - other.easting).abs <= 1e-6 &&
|
|
276
|
+
(@northing - other.northing).abs <= 1e-6 &&
|
|
277
|
+
@hemisphere == other.hemisphere && @zone == other.zone
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# Grid convergence calculation
|
|
282
|
+
def grid_convergence(datum = WGS84)
|
|
283
|
+
lla = to_lla(datum)
|
|
284
|
+
lng_rad = lla.lng * RAD_PER_DEG
|
|
285
|
+
|
|
286
|
+
if @hemisphere == 'N'
|
|
287
|
+
convergence = lng_rad * DEG_PER_RAD
|
|
288
|
+
else
|
|
289
|
+
convergence = -lng_rad * DEG_PER_RAD
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
convergence
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Scale factor at point
|
|
296
|
+
def point_scale_factor(datum = WGS84)
|
|
297
|
+
lla = to_lla(datum)
|
|
298
|
+
lat_rad = lla.lat.abs * RAD_PER_DEG
|
|
299
|
+
|
|
300
|
+
e = datum.e
|
|
301
|
+
sin_lat = Math.sin(lat_rad)
|
|
302
|
+
|
|
303
|
+
# Simplified scale factor calculation
|
|
304
|
+
m = Math.cos(lat_rad) / Math.sqrt(1.0 - e * e * sin_lat * sin_lat)
|
|
305
|
+
t = Math.tan((Math::PI / 4.0) - (lat_rad / 2.0)) * ((1.0 + e * sin_lat) / (1.0 - e * sin_lat)) ** (e / 2.0)
|
|
306
|
+
|
|
307
|
+
scale = SCALE_FACTOR * (1.0 + t * t) / (2.0 * m)
|
|
308
|
+
scale
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def valid?
|
|
312
|
+
(@hemisphere == 'N' && NORTH_ZONES.include?(@zone)) ||
|
|
313
|
+
(@hemisphere == 'S' && SOUTH_ZONES.include?(@zone))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
def validate_zone
|
|
319
|
+
unless valid?
|
|
320
|
+
raise ArgumentError, "Invalid UPS zone '#{@zone}' for hemisphere '#{@hemisphere}'"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# US National Grid (USNG) Coordinate System
|
|
4
|
+
# Based on MGRS but uses a slightly different format and always uses meter precision
|
|
5
|
+
# Used primarily within the United States for emergency services and land management
|
|
6
|
+
|
|
7
|
+
module Geodetic
|
|
8
|
+
module Coordinates
|
|
9
|
+
class USNG
|
|
10
|
+
require_relative '../datum'
|
|
11
|
+
require_relative 'mgrs'
|
|
12
|
+
|
|
13
|
+
attr_reader :grid_zone_designator, :square_identifier, :easting, :northing, :precision
|
|
14
|
+
|
|
15
|
+
def initialize(usng_string: nil, grid_zone: nil, square_id: nil, easting: nil, northing: nil, precision: 5)
|
|
16
|
+
if usng_string
|
|
17
|
+
parse_usng_string(usng_string)
|
|
18
|
+
else
|
|
19
|
+
@grid_zone_designator = grid_zone
|
|
20
|
+
@square_identifier = square_id
|
|
21
|
+
@easting = easting.to_f
|
|
22
|
+
@northing = northing.to_f
|
|
23
|
+
@precision = precision
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
if @precision == 1
|
|
29
|
+
"#{@grid_zone_designator} #{@square_identifier}"
|
|
30
|
+
else
|
|
31
|
+
east_str = ("%0#{@precision}d" % (@easting / (10 ** (5 - @precision)))).to_s
|
|
32
|
+
north_str = ("%0#{@precision}d" % (@northing / (10 ** (5 - @precision)))).to_s
|
|
33
|
+
"#{@grid_zone_designator} #{@square_identifier} #{east_str} #{north_str}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.from_string(string)
|
|
38
|
+
new(usng_string: string)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Convert to MGRS (the underlying format)
|
|
42
|
+
def to_mgrs
|
|
43
|
+
mgrs_string = "#{@grid_zone_designator}#{@square_identifier}"
|
|
44
|
+
if @precision > 1
|
|
45
|
+
east_str = ("%0#{@precision}d" % (@easting / (10 ** (5 - @precision)))).to_s
|
|
46
|
+
north_str = ("%0#{@precision}d" % (@northing / (10 ** (5 - @precision)))).to_s
|
|
47
|
+
mgrs_string += "#{east_str}#{north_str}"
|
|
48
|
+
end
|
|
49
|
+
MGRS.new(mgrs_string: mgrs_string)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_mgrs(mgrs_coord)
|
|
53
|
+
# Extract components from MGRS
|
|
54
|
+
grid_zone = mgrs_coord.grid_zone_designator
|
|
55
|
+
square_id = mgrs_coord.square_identifier
|
|
56
|
+
easting = mgrs_coord.easting
|
|
57
|
+
northing = mgrs_coord.northing
|
|
58
|
+
precision = mgrs_coord.precision
|
|
59
|
+
|
|
60
|
+
new(grid_zone: grid_zone, square_id: square_id, easting: easting, northing: northing, precision: precision)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_utm
|
|
64
|
+
to_mgrs.to_utm
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.from_utm(utm_coord, precision = 5)
|
|
68
|
+
mgrs_coord = MGRS.from_utm(utm_coord, precision)
|
|
69
|
+
from_mgrs(mgrs_coord)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_lla(datum = WGS84)
|
|
73
|
+
to_mgrs.to_lla(datum)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.from_lla(lla_coord, datum = WGS84, precision = 5)
|
|
77
|
+
mgrs_coord = MGRS.from_lla(lla_coord, datum, precision)
|
|
78
|
+
from_mgrs(mgrs_coord)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_ecef(datum = WGS84)
|
|
82
|
+
to_lla(datum).to_ecef(datum)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.from_ecef(ecef_coord, datum = WGS84, precision = 5)
|
|
86
|
+
lla_coord = ecef_coord.to_lla(datum)
|
|
87
|
+
from_lla(lla_coord, datum, precision)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_enu(reference_lla, datum = WGS84)
|
|
91
|
+
to_lla(datum).to_enu(reference_lla)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.from_enu(enu_coord, reference_lla, datum = WGS84, precision = 5)
|
|
95
|
+
lla_coord = enu_coord.to_lla(reference_lla)
|
|
96
|
+
from_lla(lla_coord, datum, precision)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_ned(reference_lla, datum = WGS84)
|
|
100
|
+
to_lla(datum).to_ned(reference_lla)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.from_ned(ned_coord, reference_lla, datum = WGS84, precision = 5)
|
|
104
|
+
lla_coord = ned_coord.to_lla(reference_lla)
|
|
105
|
+
from_lla(lla_coord, datum, precision)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_ups(datum = WGS84)
|
|
109
|
+
UPS.from_lla(to_lla(datum), datum)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.from_ups(ups_coord, datum = WGS84, precision = 5)
|
|
113
|
+
lla_coord = ups_coord.to_lla(datum)
|
|
114
|
+
from_lla(lla_coord, datum, precision)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_web_mercator(datum = WGS84)
|
|
118
|
+
WebMercator.from_lla(to_lla(datum), datum)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.from_web_mercator(web_mercator_coord, datum = WGS84, precision = 5)
|
|
122
|
+
lla_coord = web_mercator_coord.to_lla(datum)
|
|
123
|
+
from_lla(lla_coord, datum, precision)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_bng(datum = WGS84)
|
|
127
|
+
BNG.from_lla(to_lla(datum), datum)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.from_bng(bng_coord, datum = WGS84, precision = 5)
|
|
131
|
+
from_lla(bng_coord.to_lla(datum), datum, precision)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_state_plane(zone_code, datum = WGS84)
|
|
135
|
+
StatePlane.from_lla(to_lla(datum), zone_code, datum)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.from_state_plane(sp_coord, datum = WGS84, precision = 5)
|
|
139
|
+
from_lla(sp_coord.to_lla(datum), datum, precision)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_gh36(datum = WGS84, precision: 10)
|
|
143
|
+
GH36.new(to_lla(datum), precision: precision)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.from_gh36(gh36_coord, datum = WGS84, precision = 5)
|
|
147
|
+
from_lla(gh36_coord.to_lla(datum), datum, precision)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ==(other)
|
|
151
|
+
return false unless other.is_a?(USNG)
|
|
152
|
+
|
|
153
|
+
@grid_zone_designator == other.grid_zone_designator &&
|
|
154
|
+
@square_identifier == other.square_identifier &&
|
|
155
|
+
(@easting - other.easting).abs <= 1e-6 &&
|
|
156
|
+
(@northing - other.northing).abs <= 1e-6 &&
|
|
157
|
+
@precision == other.precision
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def to_a
|
|
161
|
+
[@grid_zone_designator, @square_identifier, @easting, @northing, @precision]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.from_array(array)
|
|
165
|
+
new(grid_zone: array[0], square_id: array[1], easting: array[2].to_f, northing: array[3].to_f, precision: (array[4] || 5).to_i)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# USNG-specific formatting methods
|
|
169
|
+
def to_full_format
|
|
170
|
+
"#{@grid_zone_designator} #{@square_identifier} #{@easting.round.to_s.rjust(5, '0')} #{@northing.round.to_s.rjust(5, '0')}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_abbreviated_format
|
|
174
|
+
# Common abbreviation drops leading zeros and trailing precision
|
|
175
|
+
east_str = @easting.round.to_s.gsub(/^0+/, '')
|
|
176
|
+
north_str = @northing.round.to_s.gsub(/^0+/, '')
|
|
177
|
+
east_str = '0' if east_str.empty?
|
|
178
|
+
north_str = '0' if north_str.empty?
|
|
179
|
+
"#{@grid_zone_designator} #{@square_identifier} #{east_str} #{north_str}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Get adjacent grid squares
|
|
184
|
+
def adjacent_squares
|
|
185
|
+
squares = {}
|
|
186
|
+
|
|
187
|
+
# This is a simplified version - real implementation would need
|
|
188
|
+
# to handle zone boundaries and square identifier cycling
|
|
189
|
+
offsets = {
|
|
190
|
+
north: [0, 100000],
|
|
191
|
+
northeast: [100000, 100000],
|
|
192
|
+
east: [100000, 0],
|
|
193
|
+
southeast: [100000, -100000],
|
|
194
|
+
south: [0, -100000],
|
|
195
|
+
southwest: [-100000, -100000],
|
|
196
|
+
west: [-100000, 0],
|
|
197
|
+
northwest: [-100000, 100000]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
offsets.each do |direction, (de, dn)|
|
|
201
|
+
begin
|
|
202
|
+
new_east = @easting + de
|
|
203
|
+
new_north = @northing + dn
|
|
204
|
+
adjacent_usng = USNG.new(grid_zone: @grid_zone_designator, square_id: @square_identifier,
|
|
205
|
+
easting: new_east, northing: new_north, precision: @precision)
|
|
206
|
+
squares[direction] = adjacent_usng
|
|
207
|
+
rescue => e
|
|
208
|
+
# Skip invalid adjacent squares (e.g., crossing zone boundaries)
|
|
209
|
+
squares[direction] = nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
squares
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Validate USNG coordinate
|
|
217
|
+
def valid?
|
|
218
|
+
# Check if this falls within CONUS/Alaska/Hawaii UTM zones
|
|
219
|
+
valid_zones = %w[10S 10T 10U 11S 11T 11U 12S 12T 12U 13S 13T 13U 14S 14T 14U 15S 15T 15U 16S 16T 16U 17S 17T 17U 18S 18T 18U 19S 19T 19U]
|
|
220
|
+
|
|
221
|
+
return false unless valid_zones.include?(@grid_zone_designator)
|
|
222
|
+
return false unless @square_identifier.length == 2
|
|
223
|
+
return false unless @easting >= 0 && @easting < 100000
|
|
224
|
+
return false unless @northing >= 0 && @northing < 100000
|
|
225
|
+
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def parse_usng_string(usng_string)
|
|
232
|
+
usng = usng_string.upcase.strip
|
|
233
|
+
|
|
234
|
+
# USNG format: "18T WL 12345 67890" or "18TWL1234567890"
|
|
235
|
+
# Handle both spaced and non-spaced formats
|
|
236
|
+
|
|
237
|
+
if usng.include?(' ')
|
|
238
|
+
parts = usng.split(/\s+/)
|
|
239
|
+
|
|
240
|
+
if parts.length < 2
|
|
241
|
+
raise ArgumentError, "Invalid USNG format: #{usng_string}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@grid_zone_designator = parts[0]
|
|
245
|
+
@square_identifier = parts[1]
|
|
246
|
+
|
|
247
|
+
if parts.length >= 4
|
|
248
|
+
# Full coordinate format
|
|
249
|
+
east_str = parts[2]
|
|
250
|
+
north_str = parts[3]
|
|
251
|
+
@precision = east_str.length
|
|
252
|
+
|
|
253
|
+
coord_multiplier = 10 ** (5 - @precision)
|
|
254
|
+
@easting = east_str.to_i * coord_multiplier
|
|
255
|
+
@northing = north_str.to_i * coord_multiplier
|
|
256
|
+
else
|
|
257
|
+
# Grid square only
|
|
258
|
+
@precision = 1
|
|
259
|
+
@easting = 0.0
|
|
260
|
+
@northing = 0.0
|
|
261
|
+
end
|
|
262
|
+
else
|
|
263
|
+
# Non-spaced format - delegate to MGRS parser
|
|
264
|
+
mgrs_coord = MGRS.new(mgrs_string: usng_string)
|
|
265
|
+
@grid_zone_designator = mgrs_coord.grid_zone_designator
|
|
266
|
+
@square_identifier = mgrs_coord.square_identifier
|
|
267
|
+
@easting = mgrs_coord.easting
|
|
268
|
+
@northing = mgrs_coord.northing
|
|
269
|
+
@precision = mgrs_coord.precision
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|