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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/CHANGELOG.md +15 -0
  5. data/COMMITS.md +196 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +471 -0
  8. data/Rakefile +8 -0
  9. data/docs/coordinate-systems/bng.md +60 -0
  10. data/docs/coordinate-systems/ecef.md +215 -0
  11. data/docs/coordinate-systems/enu.md +77 -0
  12. data/docs/coordinate-systems/gh36.md +192 -0
  13. data/docs/coordinate-systems/index.md +93 -0
  14. data/docs/coordinate-systems/lla.md +304 -0
  15. data/docs/coordinate-systems/mgrs.md +81 -0
  16. data/docs/coordinate-systems/ned.md +83 -0
  17. data/docs/coordinate-systems/state-plane.md +60 -0
  18. data/docs/coordinate-systems/ups.md +53 -0
  19. data/docs/coordinate-systems/usng.md +74 -0
  20. data/docs/coordinate-systems/utm.md +257 -0
  21. data/docs/coordinate-systems/web-mercator.md +67 -0
  22. data/docs/getting-started/installation.md +65 -0
  23. data/docs/getting-started/quick-start.md +175 -0
  24. data/docs/index.md +58 -0
  25. data/docs/reference/areas.md +195 -0
  26. data/docs/reference/conversions.md +351 -0
  27. data/docs/reference/datums.md +134 -0
  28. data/docs/reference/geoid-height.md +182 -0
  29. data/docs/reference/serialization.md +252 -0
  30. data/examples/01_basic_conversions.rb +187 -0
  31. data/examples/02_all_coordinate_systems.rb +310 -0
  32. data/examples/03_distance_calculations.rb +224 -0
  33. data/examples/04_bearing_calculations.rb +236 -0
  34. data/lib/geodetic/areas/circle.rb +29 -0
  35. data/lib/geodetic/areas/polygon.rb +57 -0
  36. data/lib/geodetic/areas/rectangle.rb +55 -0
  37. data/lib/geodetic/areas.rb +5 -0
  38. data/lib/geodetic/bearing.rb +94 -0
  39. data/lib/geodetic/coordinates/bng.rb +366 -0
  40. data/lib/geodetic/coordinates/ecef.rb +229 -0
  41. data/lib/geodetic/coordinates/enu.rb +244 -0
  42. data/lib/geodetic/coordinates/gh36.rb +384 -0
  43. data/lib/geodetic/coordinates/lla.rb +268 -0
  44. data/lib/geodetic/coordinates/mgrs.rb +317 -0
  45. data/lib/geodetic/coordinates/ned.rb +246 -0
  46. data/lib/geodetic/coordinates/state_plane.rb +451 -0
  47. data/lib/geodetic/coordinates/ups.rb +325 -0
  48. data/lib/geodetic/coordinates/usng.rb +274 -0
  49. data/lib/geodetic/coordinates/utm.rb +261 -0
  50. data/lib/geodetic/coordinates/web_mercator.rb +242 -0
  51. data/lib/geodetic/coordinates.rb +260 -0
  52. data/lib/geodetic/datum.rb +62 -0
  53. data/lib/geodetic/distance.rb +146 -0
  54. data/lib/geodetic/geoid_height.rb +299 -0
  55. data/lib/geodetic/version.rb +5 -0
  56. data/lib/geodetic.rb +13 -0
  57. data/mkdocs.yml +140 -0
  58. data/sig/geodetic.rbs +4 -0
  59. 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