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,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
################################
|
|
4
|
+
## Latitude, Longitude, Altitude
|
|
5
|
+
##
|
|
6
|
+
## A negative longitude is the Western hemisphere.
|
|
7
|
+
## A negative latitude is in the Southern hemisphere.
|
|
8
|
+
## Altitude is in decimal meters
|
|
9
|
+
|
|
10
|
+
require_relative '../datum'
|
|
11
|
+
require_relative '../geoid_height'
|
|
12
|
+
|
|
13
|
+
module Geodetic
|
|
14
|
+
module Coordinates
|
|
15
|
+
class LLA
|
|
16
|
+
include GeoidHeightSupport
|
|
17
|
+
attr_reader :lat, :lng, :alt
|
|
18
|
+
alias_method :latitude, :lat
|
|
19
|
+
alias_method :longitude, :lng
|
|
20
|
+
alias_method :altitude, :alt
|
|
21
|
+
|
|
22
|
+
def initialize(lat: 0.0, lng: 0.0, alt: 0.0)
|
|
23
|
+
@lat = lat.to_f
|
|
24
|
+
@lng = lng.to_f
|
|
25
|
+
@alt = alt.to_f
|
|
26
|
+
|
|
27
|
+
validate_coordinates!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def lat=(value)
|
|
31
|
+
value = value.to_f
|
|
32
|
+
raise ArgumentError, "Latitude must be a finite number" if value.nan? || value.infinite?
|
|
33
|
+
raise ArgumentError, "Latitude must be between -90 and 90 degrees" if value < -90 || value > 90
|
|
34
|
+
@lat = value
|
|
35
|
+
end
|
|
36
|
+
alias_method :latitude=, :lat=
|
|
37
|
+
|
|
38
|
+
def lng=(value)
|
|
39
|
+
value = value.to_f
|
|
40
|
+
raise ArgumentError, "Longitude must be a finite number" if value.nan? || value.infinite?
|
|
41
|
+
raise ArgumentError, "Longitude must be between -180 and 180 degrees" if value < -180 || value > 180
|
|
42
|
+
@lng = value
|
|
43
|
+
end
|
|
44
|
+
alias_method :longitude=, :lng=
|
|
45
|
+
|
|
46
|
+
def alt=(value)
|
|
47
|
+
value = value.to_f
|
|
48
|
+
raise ArgumentError, "Altitude must be a finite number" if value.nan? || value.infinite?
|
|
49
|
+
@alt = value
|
|
50
|
+
end
|
|
51
|
+
alias_method :altitude=, :alt=
|
|
52
|
+
|
|
53
|
+
def to_ecef(datum = WGS84)
|
|
54
|
+
latitude_rad = @lat * RAD_PER_DEG
|
|
55
|
+
longitude_rad = @lng * RAD_PER_DEG
|
|
56
|
+
|
|
57
|
+
a = datum.a
|
|
58
|
+
e2 = datum.e2
|
|
59
|
+
|
|
60
|
+
n = a / Math.sqrt(1 - e2 * (Math.sin(latitude_rad))**2)
|
|
61
|
+
|
|
62
|
+
cos_lat = Math.cos(latitude_rad)
|
|
63
|
+
sin_lat = Math.sin(latitude_rad)
|
|
64
|
+
cos_lon = Math.cos(longitude_rad)
|
|
65
|
+
sin_lon = Math.sin(longitude_rad)
|
|
66
|
+
|
|
67
|
+
x = (n + @alt) * cos_lat * cos_lon
|
|
68
|
+
y = (n + @alt) * cos_lat * sin_lon
|
|
69
|
+
z = (n * (1 - e2) + @alt) * sin_lat
|
|
70
|
+
|
|
71
|
+
ECEF.new(x: x, y: y, z: z)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.from_ecef(ecef, datum = WGS84)
|
|
75
|
+
raise ArgumentError, "Expected ECEF" unless ecef.is_a?(ECEF)
|
|
76
|
+
|
|
77
|
+
ecef.to_lla(datum)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_utm(datum = WGS84)
|
|
81
|
+
lat_rad = @lat * RAD_PER_DEG
|
|
82
|
+
lon_rad = @lng * RAD_PER_DEG
|
|
83
|
+
|
|
84
|
+
zone = (((@lng + 180) / 6).floor + 1).to_i
|
|
85
|
+
zone = 60 if zone > 60
|
|
86
|
+
zone = 1 if zone < 1
|
|
87
|
+
|
|
88
|
+
a = datum.a
|
|
89
|
+
e2 = datum.e2
|
|
90
|
+
e4 = e2 * e2
|
|
91
|
+
e6 = e4 * e2
|
|
92
|
+
|
|
93
|
+
lon0_deg = (zone - 1) * 6 - 180 + 3
|
|
94
|
+
lon0_rad = lon0_deg * RAD_PER_DEG
|
|
95
|
+
|
|
96
|
+
k0 = 0.9996
|
|
97
|
+
|
|
98
|
+
sin_lat = Math.sin(lat_rad)
|
|
99
|
+
cos_lat = Math.cos(lat_rad)
|
|
100
|
+
tan_lat = Math.tan(lat_rad)
|
|
101
|
+
|
|
102
|
+
n = a / Math.sqrt(1 - e2 * sin_lat**2)
|
|
103
|
+
t = tan_lat
|
|
104
|
+
t2 = t * t
|
|
105
|
+
c = e2 * cos_lat**2 / (1 - e2)
|
|
106
|
+
aa = cos_lat * (lon_rad - lon0_rad)
|
|
107
|
+
|
|
108
|
+
# Meridional arc — distance along the meridian from equator to lat_rad
|
|
109
|
+
m = a * ((1 - e2 / 4 - 3 * e4 / 64 - 5 * e6 / 256) * lat_rad -
|
|
110
|
+
(3 * e2 / 8 + 3 * e4 / 32 + 45 * e6 / 1024) * Math.sin(2 * lat_rad) +
|
|
111
|
+
(15 * e4 / 256 + 45 * e6 / 1024) * Math.sin(4 * lat_rad) -
|
|
112
|
+
(35 * e6 / 3072) * Math.sin(6 * lat_rad))
|
|
113
|
+
|
|
114
|
+
x = k0 * n * (aa +
|
|
115
|
+
(1 - t2 + c) * aa**3 / 6 +
|
|
116
|
+
(5 - 18 * t2 + t2**2 + 72 * c - 58 * e2 / (1 - e2)) * aa**5 / 120)
|
|
117
|
+
|
|
118
|
+
y = k0 * (m + n * t * (aa**2 / 2 +
|
|
119
|
+
(5 - t2 + 9 * c + 4 * c**2) * aa**4 / 24 +
|
|
120
|
+
(61 - 58 * t2 + t2**2 + 600 * c - 330 * e2 / (1 - e2)) * aa**6 / 720))
|
|
121
|
+
|
|
122
|
+
x += 500000
|
|
123
|
+
y += 10000000 if @lat < 0
|
|
124
|
+
|
|
125
|
+
hemisphere = @lat >= 0 ? 'N' : 'S'
|
|
126
|
+
|
|
127
|
+
UTM.new(easting: x, northing: y, altitude: @alt, zone: zone, hemisphere: hemisphere)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.from_utm(utm, datum = WGS84)
|
|
131
|
+
raise ArgumentError, "Expected UTM" unless utm.is_a?(UTM)
|
|
132
|
+
|
|
133
|
+
utm.to_lla(datum)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def to_ned(reference_lla)
|
|
137
|
+
raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
|
|
138
|
+
|
|
139
|
+
ecef = self.to_ecef
|
|
140
|
+
ref_ecef = reference_lla.to_ecef
|
|
141
|
+
|
|
142
|
+
ecef.to_ned(ref_ecef, reference_lla)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.from_ned(ned, reference_lla)
|
|
146
|
+
raise ArgumentError, "Expected NED" unless ned.is_a?(NED)
|
|
147
|
+
raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
|
|
148
|
+
|
|
149
|
+
ned.to_lla(reference_lla)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_enu(reference_lla)
|
|
153
|
+
raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
|
|
154
|
+
|
|
155
|
+
ecef = self.to_ecef
|
|
156
|
+
ref_ecef = reference_lla.to_ecef
|
|
157
|
+
|
|
158
|
+
ecef.to_enu(ref_ecef, reference_lla)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.from_enu(enu, reference_lla)
|
|
162
|
+
raise ArgumentError, "Expected ENU" unless enu.is_a?(ENU)
|
|
163
|
+
raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
|
|
164
|
+
|
|
165
|
+
enu.to_lla(reference_lla)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_dms
|
|
169
|
+
lat_abs = @lat.abs
|
|
170
|
+
lat_deg = lat_abs.floor
|
|
171
|
+
lat_min_total = (lat_abs - lat_deg) * 60.0
|
|
172
|
+
lat_min = lat_min_total.floor
|
|
173
|
+
lat_sec = (lat_min_total - lat_min) * 60.0
|
|
174
|
+
lat_hemi = @lat >= 0 ? 'N' : 'S'
|
|
175
|
+
|
|
176
|
+
lon_abs = @lng.abs
|
|
177
|
+
lon_deg = lon_abs.floor
|
|
178
|
+
lon_min_total = (lon_abs - lon_deg) * 60.0
|
|
179
|
+
lon_min = lon_min_total.floor
|
|
180
|
+
lon_sec = (lon_min_total - lon_min) * 60.0
|
|
181
|
+
lon_hemi = @lng >= 0 ? 'E' : 'W'
|
|
182
|
+
|
|
183
|
+
lat_str = format("%d° %d' %.2f\" %s", lat_deg, lat_min, lat_sec, lat_hemi)
|
|
184
|
+
lon_str = format("%d° %d' %.2f\" %s", lon_deg, lon_min, lon_sec, lon_hemi)
|
|
185
|
+
alt_str = format("%.2f m", @alt)
|
|
186
|
+
|
|
187
|
+
"#{lat_str}, #{lon_str}, #{alt_str}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.from_dms(dms_str)
|
|
191
|
+
regex = /^\s*([0-9]+)°\s*([0-9]+)'\s*([0-9]+(?:\.[0-9]+)?)"\s*([NS])\s*,\s*([0-9]+)°\s*([0-9]+)'\s*([0-9]+(?:\.[0-9]+)?)"\s*([EW])\s*(?:,\s*([\-+]?[0-9]+(?:\.[0-9]+)?)\s*m?)?\s*$/i
|
|
192
|
+
m = dms_str.match(regex)
|
|
193
|
+
raise ArgumentError, "Invalid DMS format" unless m
|
|
194
|
+
|
|
195
|
+
lat_deg = m[1].to_i
|
|
196
|
+
lat_min = m[2].to_i
|
|
197
|
+
lat_sec = m[3].to_f
|
|
198
|
+
lat_hemi = m[4].upcase
|
|
199
|
+
|
|
200
|
+
lon_deg = m[5].to_i
|
|
201
|
+
lon_min = m[6].to_i
|
|
202
|
+
lon_sec = m[7].to_f
|
|
203
|
+
lon_hemi = m[8].upcase
|
|
204
|
+
|
|
205
|
+
alt = m[9] ? m[9].to_f : 0.0
|
|
206
|
+
|
|
207
|
+
lat = lat_deg + lat_min / 60.0 + lat_sec / 3600.0
|
|
208
|
+
lat = -lat if lat_hemi == 'S'
|
|
209
|
+
|
|
210
|
+
lng = lon_deg + lon_min / 60.0 + lon_sec / 3600.0
|
|
211
|
+
lng = -lng if lon_hemi == 'W'
|
|
212
|
+
|
|
213
|
+
new(lat: lat, lng: lng, alt: alt)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def to_gh36(precision: 10)
|
|
217
|
+
GH36.new(self, precision: precision)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.from_gh36(gh36_coord, datum = WGS84)
|
|
221
|
+
gh36_coord.to_lla(datum)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def to_s(precision = 6)
|
|
225
|
+
precision = precision.to_i
|
|
226
|
+
if precision == 0
|
|
227
|
+
"#{@lat.round}, #{@lng.round}, #{@alt.round}"
|
|
228
|
+
else
|
|
229
|
+
alt_precision = [precision, 2].min
|
|
230
|
+
format("%.#{precision}f, %.#{precision}f, %.#{alt_precision}f", @lat, @lng, @alt)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def to_a
|
|
235
|
+
[@lat, @lng, @alt]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def self.from_array(array)
|
|
239
|
+
new(lat: array[0].to_f, lng: array[1].to_f, alt: array[2].to_f)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.from_string(string)
|
|
243
|
+
parts = string.split(',').map(&:strip)
|
|
244
|
+
new(lat: parts[0].to_f, lng: parts[1].to_f, alt: parts[2].to_f)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def ==(other)
|
|
248
|
+
return false unless other.is_a?(LLA)
|
|
249
|
+
|
|
250
|
+
delta_lat = (@lat - other.lat).abs
|
|
251
|
+
delta_lng = (@lng - other.lng).abs
|
|
252
|
+
delta_alt = (@alt - other.alt).abs
|
|
253
|
+
|
|
254
|
+
delta_lat <= 1e-6 && delta_lng <= 1e-6 && delta_alt <= 1e-6
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def validate_coordinates!
|
|
260
|
+
raise ArgumentError, "Latitude must be a finite number" if @lat.nan? || @lat.infinite?
|
|
261
|
+
raise ArgumentError, "Longitude must be a finite number" if @lng.nan? || @lng.infinite?
|
|
262
|
+
raise ArgumentError, "Altitude must be a finite number" if @alt.nan? || @alt.infinite?
|
|
263
|
+
raise ArgumentError, "Latitude must be between -90 and 90 degrees" if @lat < -90 || @lat > 90
|
|
264
|
+
raise ArgumentError, "Longitude must be between -180 and 180 degrees" if @lng < -180 || @lng > 180
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Military Grid Reference System (MGRS) Coordinate
|
|
4
|
+
# Converts between MGRS grid references and other coordinate systems
|
|
5
|
+
# MGRS is based on UTM but uses a more compact alphanumeric format
|
|
6
|
+
|
|
7
|
+
module Geodetic
|
|
8
|
+
module Coordinates
|
|
9
|
+
class MGRS
|
|
10
|
+
require_relative '../datum'
|
|
11
|
+
|
|
12
|
+
attr_reader :grid_zone_designator, :square_identifier, :easting, :northing, :precision
|
|
13
|
+
|
|
14
|
+
# MGRS 100km square identification letters
|
|
15
|
+
SET1_E = 'ABCDEFGHJKLMNPQRSTUVWXYZ' # Columns (exclude I and O)
|
|
16
|
+
SET2_E = 'ABCDEFGHJKLMNPQRSTUVWXYZ' # Columns for odd UTM zones
|
|
17
|
+
SET1_N = 'ABCDEFGHJKLMNPQRSTUV' # Rows (exclude I and O, stop at V)
|
|
18
|
+
SET2_N = 'FGHJKLMNPQRSTUVABCDE' # Rows for even-numbered 100km squares
|
|
19
|
+
|
|
20
|
+
def initialize(mgrs_string: nil, grid_zone: nil, square_id: nil, easting: nil, northing: nil, precision: 5)
|
|
21
|
+
if mgrs_string
|
|
22
|
+
parse_mgrs_string(mgrs_string)
|
|
23
|
+
else
|
|
24
|
+
@grid_zone_designator = grid_zone
|
|
25
|
+
@square_identifier = square_id
|
|
26
|
+
@easting = easting.to_f
|
|
27
|
+
@northing = northing.to_f
|
|
28
|
+
@precision = precision
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
if @precision == 1
|
|
34
|
+
"#{@grid_zone_designator}#{@square_identifier}"
|
|
35
|
+
else
|
|
36
|
+
east_str = ("%0#{@precision}d" % (@easting / (10 ** (5 - @precision)))).to_s
|
|
37
|
+
north_str = ("%0#{@precision}d" % (@northing / (10 ** (5 - @precision)))).to_s
|
|
38
|
+
"#{@grid_zone_designator}#{@square_identifier}#{east_str}#{north_str}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.from_string(string)
|
|
43
|
+
new(mgrs_string: string)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_utm
|
|
47
|
+
# Extract zone number and hemisphere from grid zone designator
|
|
48
|
+
zone_number = @grid_zone_designator[0..-2].to_i
|
|
49
|
+
zone_letter = @grid_zone_designator[-1]
|
|
50
|
+
hemisphere = (zone_letter >= 'N') ? 'N' : 'S'
|
|
51
|
+
|
|
52
|
+
# Convert 100km square to UTM coordinates
|
|
53
|
+
utm_easting, utm_northing = square_to_utm(zone_number, @square_identifier, @easting, @northing)
|
|
54
|
+
|
|
55
|
+
UTM.new(easting: utm_easting, northing: utm_northing, zone: zone_number, hemisphere: hemisphere)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.from_utm(utm_coord, precision = 5)
|
|
59
|
+
# Create instance to access instance methods
|
|
60
|
+
temp_instance = new()
|
|
61
|
+
|
|
62
|
+
# Get 100km square identifier
|
|
63
|
+
square_id = temp_instance.utm_to_square(utm_coord.zone, utm_coord.easting, utm_coord.northing)
|
|
64
|
+
|
|
65
|
+
# Calculate position within the 100km square
|
|
66
|
+
square_easting = utm_coord.easting % 100000
|
|
67
|
+
square_northing = utm_coord.northing % 100000
|
|
68
|
+
|
|
69
|
+
# Create grid zone designator using hemisphere-aware band letter
|
|
70
|
+
if utm_coord.hemisphere == 'N'
|
|
71
|
+
zone_letter = get_zone_letter(utm_coord.northing)
|
|
72
|
+
else
|
|
73
|
+
zone_letter = get_zone_letter_south(utm_coord.northing)
|
|
74
|
+
end
|
|
75
|
+
grid_zone = "#{utm_coord.zone}#{zone_letter}"
|
|
76
|
+
|
|
77
|
+
new(grid_zone: grid_zone, square_id: square_id, easting: square_easting, northing: square_northing, precision: precision)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_lla(datum = WGS84)
|
|
81
|
+
utm_coord = to_utm
|
|
82
|
+
utm_coord.to_lla(datum)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.from_lla(lla_coord, datum = WGS84, precision = 5)
|
|
86
|
+
utm_coord = UTM.from_lla(lla_coord, datum)
|
|
87
|
+
from_utm(utm_coord, precision)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_ecef(datum = WGS84)
|
|
91
|
+
to_lla(datum).to_ecef(datum)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.from_ecef(ecef_coord, datum = WGS84, precision = 5)
|
|
95
|
+
lla_coord = ecef_coord.to_lla(datum)
|
|
96
|
+
from_lla(lla_coord, datum, precision)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_enu(reference_lla, datum = WGS84)
|
|
100
|
+
to_lla(datum).to_enu(reference_lla)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.from_enu(enu_coord, reference_lla, datum = WGS84, precision = 5)
|
|
104
|
+
lla_coord = enu_coord.to_lla(reference_lla)
|
|
105
|
+
from_lla(lla_coord, datum, precision)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_ned(reference_lla, datum = WGS84)
|
|
109
|
+
to_lla(datum).to_ned(reference_lla)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.from_ned(ned_coord, reference_lla, datum = WGS84, precision = 5)
|
|
113
|
+
lla_coord = ned_coord.to_lla(reference_lla)
|
|
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(wm_coord, datum = WGS84, precision = 5)
|
|
122
|
+
from_lla(wm_coord.to_lla(datum), datum, precision)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def to_ups(datum = WGS84)
|
|
126
|
+
UPS.from_lla(to_lla(datum), datum)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.from_ups(ups_coord, datum = WGS84, precision = 5)
|
|
130
|
+
from_lla(ups_coord.to_lla(datum), datum, precision)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def to_usng
|
|
134
|
+
USNG.from_mgrs(self)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.from_usng(usng_coord)
|
|
138
|
+
usng_coord.to_mgrs
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def to_state_plane(zone_code, datum = WGS84)
|
|
142
|
+
StatePlane.from_lla(to_lla(datum), zone_code, datum)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.from_state_plane(sp_coord, datum = WGS84, precision = 5)
|
|
146
|
+
from_lla(sp_coord.to_lla(datum), datum, precision)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_bng(datum = WGS84)
|
|
150
|
+
BNG.from_lla(to_lla(datum), datum)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.from_bng(bng_coord, datum = WGS84, precision = 5)
|
|
154
|
+
from_lla(bng_coord.to_lla(datum), datum, precision)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def to_gh36(datum = WGS84, precision: 10)
|
|
158
|
+
GH36.new(to_lla(datum), precision: precision)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.from_gh36(gh36_coord, datum = WGS84, precision = 5)
|
|
162
|
+
from_lla(gh36_coord.to_lla(datum), datum, precision)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ==(other)
|
|
166
|
+
return false unless other.is_a?(MGRS)
|
|
167
|
+
|
|
168
|
+
@grid_zone_designator == other.grid_zone_designator &&
|
|
169
|
+
@square_identifier == other.square_identifier &&
|
|
170
|
+
(@easting - other.easting).abs <= 1e-6 &&
|
|
171
|
+
(@northing - other.northing).abs <= 1e-6 &&
|
|
172
|
+
@precision == other.precision
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def to_a
|
|
176
|
+
[@grid_zone_designator, @square_identifier, @easting, @northing, @precision]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.from_array(array)
|
|
180
|
+
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)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def utm_to_square(zone_number, easting, northing)
|
|
184
|
+
# Calculate 100km square column
|
|
185
|
+
col_index = ((easting - 100000) / 100000).floor
|
|
186
|
+
col_index = (col_index + (zone_number - 1) * 8) % 24
|
|
187
|
+
|
|
188
|
+
if zone_number % 2 == 1 # Odd zones
|
|
189
|
+
col_letter = SET1_E[col_index]
|
|
190
|
+
else # Even zones
|
|
191
|
+
col_letter = SET2_E[col_index]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Calculate 100km square row
|
|
195
|
+
row_index = (northing / 100000).floor % 20
|
|
196
|
+
if ((zone_number - 1) / 6).floor % 2 == 1
|
|
197
|
+
row_letter = SET2_N[row_index]
|
|
198
|
+
else
|
|
199
|
+
row_letter = SET1_N[row_index]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
"#{col_letter}#{row_letter}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def parse_mgrs_string(mgrs_string)
|
|
208
|
+
mgrs = mgrs_string.upcase.gsub(/\s/, '')
|
|
209
|
+
|
|
210
|
+
# Extract grid zone designator (first 2-3 characters: zone number + letter)
|
|
211
|
+
if mgrs.match(/^(\d{1,2}[A-Z])/)
|
|
212
|
+
@grid_zone_designator = $1
|
|
213
|
+
remainder = mgrs[($1.length)..-1]
|
|
214
|
+
else
|
|
215
|
+
raise ArgumentError, "Invalid MGRS format: #{mgrs_string}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Extract 100km square identifier (next 2 characters)
|
|
219
|
+
if remainder.length >= 2
|
|
220
|
+
@square_identifier = remainder[0..1]
|
|
221
|
+
coords = remainder[2..-1]
|
|
222
|
+
else
|
|
223
|
+
raise ArgumentError, "Invalid MGRS format: missing square identifier"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Extract coordinates (remaining characters, split evenly)
|
|
227
|
+
if coords.length == 0
|
|
228
|
+
@precision = 1 # Grid square only
|
|
229
|
+
@easting = 0.0
|
|
230
|
+
@northing = 0.0
|
|
231
|
+
elsif coords.length % 2 == 0
|
|
232
|
+
@precision = coords.length / 2
|
|
233
|
+
coord_multiplier = 10 ** (5 - @precision)
|
|
234
|
+
@easting = coords[0...@precision].to_i * coord_multiplier
|
|
235
|
+
@northing = coords[@precision..-1].to_i * coord_multiplier
|
|
236
|
+
else
|
|
237
|
+
raise ArgumentError, "Invalid MGRS format: uneven coordinate length"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def square_to_utm(zone_number, square_id, easting, northing)
|
|
242
|
+
col_letter = square_id[0]
|
|
243
|
+
row_letter = square_id[1]
|
|
244
|
+
|
|
245
|
+
# Calculate easting from column letter
|
|
246
|
+
# For odd zones, columns start at A=1 (100km), for even zones offset by 8
|
|
247
|
+
set = (zone_number % 2 == 1) ? SET1_E : SET2_E
|
|
248
|
+
col_index = set.index(col_letter) || 0
|
|
249
|
+
|
|
250
|
+
# Column letters cycle with zone: each zone spans 8 columns (A-H for zone 1, J-R for zone 2, etc.)
|
|
251
|
+
# The easting within the zone is: (col_index - zone_offset) * 100000 + 100000
|
|
252
|
+
zone_set = (zone_number - 1) % 3 # 0, 1, or 2
|
|
253
|
+
col_in_zone = col_index - (zone_set * 8)
|
|
254
|
+
col_in_zone += 24 if col_in_zone < 0
|
|
255
|
+
utm_easting = (col_in_zone + 1) * 100000 + easting
|
|
256
|
+
|
|
257
|
+
# Calculate northing from row letter
|
|
258
|
+
# Row letters cycle every 2,000,000m (20 letters × 100km)
|
|
259
|
+
if ((zone_number - 1) / 6).floor % 2 == 1
|
|
260
|
+
row_index = SET2_N.index(row_letter) || 0
|
|
261
|
+
else
|
|
262
|
+
row_index = SET1_N.index(row_letter) || 0
|
|
263
|
+
end
|
|
264
|
+
base_northing = row_index * 100000 + northing
|
|
265
|
+
|
|
266
|
+
# Use the grid zone letter (latitude band) to find the correct 2,000,000m cycle
|
|
267
|
+
zone_letter = @grid_zone_designator[-1]
|
|
268
|
+
min_northing = min_northing_for_band(zone_letter)
|
|
269
|
+
|
|
270
|
+
# Find the cycle that puts northing closest to the band's expected range
|
|
271
|
+
utm_northing = base_northing
|
|
272
|
+
while utm_northing < min_northing
|
|
273
|
+
utm_northing += 2000000
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
[utm_easting, utm_northing]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Approximate minimum northing (in meters) for each UTM latitude band letter
|
|
280
|
+
def min_northing_for_band(letter)
|
|
281
|
+
band_min = {
|
|
282
|
+
'C' => 1100000, 'D' => 2000000, 'E' => 2800000, 'F' => 3700000,
|
|
283
|
+
'G' => 4600000, 'H' => 5500000, 'J' => 6400000, 'K' => 7300000,
|
|
284
|
+
'L' => 8200000, 'M' => 9100000, 'N' => 0, 'P' => 800000,
|
|
285
|
+
'Q' => 1700000, 'R' => 2600000, 'S' => 3500000, 'T' => 4400000,
|
|
286
|
+
'U' => 5300000, 'V' => 6200000, 'W' => 7000000, 'X' => 7900000
|
|
287
|
+
}
|
|
288
|
+
band_min[letter.upcase] || 0
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def self.get_zone_letter(northing)
|
|
292
|
+
# For northern hemisphere, estimate latitude from northing
|
|
293
|
+
# then map to the correct band letter
|
|
294
|
+
# Approximate: latitude ≈ northing / 111320 (meters per degree)
|
|
295
|
+
approx_lat = northing / 111320.0
|
|
296
|
+
latitude_to_band_letter(approx_lat)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.get_zone_letter_south(northing)
|
|
300
|
+
# For southern hemisphere, northing uses false northing of 10,000,000
|
|
301
|
+
approx_lat = (northing - 10000000.0) / 111320.0
|
|
302
|
+
latitude_to_band_letter(approx_lat)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def self.latitude_to_band_letter(lat)
|
|
306
|
+
# MGRS latitude band letters: C through X (excluding I and O)
|
|
307
|
+
# Each band covers 8° except X which covers 12° (72-84°N)
|
|
308
|
+
bands = 'CDEFGHJKLMNPQRSTUVWX'
|
|
309
|
+
return 'C' if lat < -80
|
|
310
|
+
return 'X' if lat >= 72
|
|
311
|
+
index = ((lat + 80) / 8).floor
|
|
312
|
+
index = [0, [index, bands.length - 1].min].max
|
|
313
|
+
bands[index]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|