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,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