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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../datum'
4
+
5
+ module Geodetic
6
+ module Coordinates
7
+ class NED
8
+ attr_reader :n, :e, :d
9
+ alias_method :north, :n
10
+ alias_method :east, :e
11
+ alias_method :down, :d
12
+
13
+ def initialize(n: 0.0, e: 0.0, d: 0.0)
14
+ @n = n.to_f
15
+ @e = e.to_f
16
+ @d = d.to_f
17
+ end
18
+
19
+ def n=(value)
20
+ @n = value.to_f
21
+ end
22
+ alias_method :north=, :n=
23
+
24
+ def e=(value)
25
+ @e = value.to_f
26
+ end
27
+ alias_method :east=, :e=
28
+
29
+ def d=(value)
30
+ @d = value.to_f
31
+ end
32
+ alias_method :down=, :d=
33
+
34
+ def to_enu
35
+ ENU.new(e: @e, n: @n, u: -@d)
36
+ end
37
+
38
+ def self.from_enu(enu)
39
+ raise ArgumentError, "Expected ENU" unless enu.is_a?(ENU)
40
+
41
+ enu.to_ned
42
+ end
43
+
44
+ def to_ecef(reference_ecef, reference_lla = nil)
45
+ raise ArgumentError, "Expected ECEF" unless reference_ecef.is_a?(ECEF)
46
+
47
+ enu = self.to_enu
48
+ enu.to_ecef(reference_ecef, reference_lla)
49
+ end
50
+
51
+ def self.from_ecef(ecef, reference_ecef, reference_lla = nil)
52
+ raise ArgumentError, "Expected ECEF" unless ecef.is_a?(ECEF)
53
+ raise ArgumentError, "Expected ECEF" unless reference_ecef.is_a?(ECEF)
54
+
55
+ enu = ecef.to_enu(reference_ecef, reference_lla)
56
+ enu.to_ned
57
+ end
58
+
59
+ def to_lla(reference_lla)
60
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
61
+
62
+ enu = self.to_enu
63
+ enu.to_lla(reference_lla)
64
+ end
65
+
66
+ def self.from_lla(lla, reference_lla)
67
+ raise ArgumentError, "Expected LLA" unless lla.is_a?(LLA)
68
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
69
+
70
+ lla.to_ned(reference_lla)
71
+ end
72
+
73
+ def to_utm(reference_lla, datum = WGS84)
74
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
75
+
76
+ lla = self.to_lla(reference_lla)
77
+ lla.to_utm(datum)
78
+ end
79
+
80
+ def self.from_utm(utm, reference_lla, datum = WGS84)
81
+ raise ArgumentError, "Expected UTM" unless utm.is_a?(UTM)
82
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
83
+
84
+ lla = utm.to_lla(datum)
85
+ lla.to_ned(reference_lla)
86
+ end
87
+
88
+ def to_mgrs(reference_lla, datum = WGS84, precision = 5)
89
+ MGRS.from_lla(to_lla(reference_lla), datum, precision)
90
+ end
91
+
92
+ def self.from_mgrs(mgrs_coord, reference_lla, datum = WGS84)
93
+ lla = mgrs_coord.to_lla(datum)
94
+ from_lla(lla, reference_lla)
95
+ end
96
+
97
+ def to_usng(reference_lla, datum = WGS84, precision = 5)
98
+ USNG.from_lla(to_lla(reference_lla), datum, precision)
99
+ end
100
+
101
+ def self.from_usng(usng_coord, reference_lla, datum = WGS84)
102
+ lla = usng_coord.to_lla(datum)
103
+ from_lla(lla, reference_lla)
104
+ end
105
+
106
+ def to_web_mercator(reference_lla, datum = WGS84)
107
+ WebMercator.from_lla(to_lla(reference_lla), datum)
108
+ end
109
+
110
+ def self.from_web_mercator(wm_coord, reference_lla, datum = WGS84)
111
+ lla = wm_coord.to_lla(datum)
112
+ from_lla(lla, reference_lla)
113
+ end
114
+
115
+ def to_ups(reference_lla, datum = WGS84)
116
+ UPS.from_lla(to_lla(reference_lla), datum)
117
+ end
118
+
119
+ def self.from_ups(ups_coord, reference_lla, datum = WGS84)
120
+ lla = ups_coord.to_lla(datum)
121
+ from_lla(lla, reference_lla)
122
+ end
123
+
124
+ def to_state_plane(reference_lla, zone_code, datum = WGS84)
125
+ StatePlane.from_lla(to_lla(reference_lla), zone_code, datum)
126
+ end
127
+
128
+ def self.from_state_plane(sp_coord, reference_lla, datum = WGS84)
129
+ lla = sp_coord.to_lla(datum)
130
+ from_lla(lla, reference_lla)
131
+ end
132
+
133
+ def to_bng(reference_lla)
134
+ BNG.from_lla(to_lla(reference_lla))
135
+ end
136
+
137
+ def self.from_bng(bng_coord, reference_lla)
138
+ lla = bng_coord.to_lla
139
+ from_lla(lla, reference_lla)
140
+ end
141
+
142
+ def to_gh36(reference_lla, precision: 10)
143
+ GH36.new(to_lla(reference_lla), precision: precision)
144
+ end
145
+
146
+ def self.from_gh36(gh36_coord, reference_lla)
147
+ lla = gh36_coord.to_lla
148
+ from_lla(lla, reference_lla)
149
+ end
150
+
151
+ def to_s(precision = 2)
152
+ precision = precision.to_i
153
+ if precision == 0
154
+ "#{@n.round}, #{@e.round}, #{@d.round}"
155
+ else
156
+ format("%.#{precision}f, %.#{precision}f, %.#{precision}f", @n, @e, @d)
157
+ end
158
+ end
159
+
160
+ def to_a
161
+ [@n, @e, @d]
162
+ end
163
+
164
+ def self.from_array(array)
165
+ new(n: array[0].to_f, e: array[1].to_f, d: array[2].to_f)
166
+ end
167
+
168
+ def self.from_string(string)
169
+ parts = string.split(',').map(&:strip)
170
+ new(n: parts[0].to_f, e: parts[1].to_f, d: parts[2].to_f)
171
+ end
172
+
173
+ def ==(other)
174
+ return false unless other.is_a?(NED)
175
+
176
+ delta_n = (@n - other.n).abs
177
+ delta_e = (@e - other.e).abs
178
+ delta_d = (@d - other.d).abs
179
+
180
+ delta_n <= 1e-6 && delta_e <= 1e-6 && delta_d <= 1e-6
181
+ end
182
+
183
+
184
+ def horizontal_distance_to(other)
185
+ raise ArgumentError, "Expected NED" unless other.is_a?(NED)
186
+
187
+ dn = @n - other.n
188
+ de = @e - other.e
189
+
190
+ Math.sqrt(dn**2 + de**2)
191
+ end
192
+
193
+ # Local tangent-plane bearing to another NED point (degrees, 0-360).
194
+ # For great-circle bearing across coordinate systems, use the universal bearing_to.
195
+ def local_bearing_to(other)
196
+ raise ArgumentError, "Expected NED" unless other.is_a?(NED)
197
+
198
+ dn = other.n - @n
199
+ de = other.e - @e
200
+
201
+ bearing_rad = Math.atan2(de, dn)
202
+ bearing_deg = bearing_rad * DEG_PER_RAD
203
+
204
+ bearing_deg += 360 if bearing_deg < 0
205
+ bearing_deg
206
+ end
207
+
208
+ # Local tangent-plane elevation angle to another NED point (degrees).
209
+ # For elevation angle across coordinate systems, use the universal elevation_to.
210
+ def local_elevation_angle_to(other)
211
+ raise ArgumentError, "Expected NED" unless other.is_a?(NED)
212
+
213
+ horizontal_dist = horizontal_distance_to(other)
214
+ return 0.0 if horizontal_dist == 0.0
215
+
216
+ vertical_diff = @d - other.d
217
+ elevation_rad = Math.atan2(vertical_diff, horizontal_dist)
218
+ elevation_rad * DEG_PER_RAD
219
+ end
220
+
221
+ def distance_to_origin
222
+ Math.sqrt(@n**2 + @e**2 + @d**2)
223
+ end
224
+
225
+ def elevation_angle
226
+ horizontal_dist = Math.sqrt(@n**2 + @e**2)
227
+ return 0.0 if horizontal_dist == 0.0
228
+
229
+ elevation_rad = Math.atan2(-@d, horizontal_dist)
230
+ elevation_rad * DEG_PER_RAD
231
+ end
232
+
233
+ def bearing_from_origin
234
+ bearing_rad = Math.atan2(@e, @n)
235
+ bearing_deg = bearing_rad * DEG_PER_RAD
236
+
237
+ bearing_deg += 360 if bearing_deg < 0
238
+ bearing_deg
239
+ end
240
+
241
+ def horizontal_distance_to_origin
242
+ Math.sqrt(@n**2 + @e**2)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ # State Plane Coordinate System (SPC)
4
+ # US state-based coordinate systems using various projections
5
+ # Each state has one or more zones with specific parameters
6
+
7
+ module Geodetic
8
+ module Coordinates
9
+ class StatePlane
10
+ require_relative '../datum'
11
+
12
+ attr_reader :easting, :northing, :zone_code, :state, :datum
13
+
14
+ # State Plane zone definitions (simplified subset - real implementation would have all zones)
15
+ ZONES = {
16
+ # California zones (Lambert Conformal Conic)
17
+ 'CA_I' => {
18
+ state: 'California',
19
+ zone: 'I',
20
+ fips: '0401',
21
+ epsg: '2225',
22
+ projection: 'lambert_conformal_conic',
23
+ central_meridian: -122.0,
24
+ standard_parallel_1: 40.0,
25
+ standard_parallel_2: 41.666667,
26
+ latitude_of_origin: 39.333333,
27
+ false_easting: 2000000.0,
28
+ false_northing: 500000.0,
29
+ units: 'US Survey Feet'
30
+ },
31
+ 'CA_II' => {
32
+ state: 'California',
33
+ zone: 'II',
34
+ fips: '0402',
35
+ epsg: '2226',
36
+ projection: 'lambert_conformal_conic',
37
+ central_meridian: -122.0,
38
+ standard_parallel_1: 38.333333,
39
+ standard_parallel_2: 39.833333,
40
+ latitude_of_origin: 37.666667,
41
+ false_easting: 2000000.0,
42
+ false_northing: 500000.0,
43
+ units: 'US Survey Feet'
44
+ },
45
+ # Texas zones (Lambert Conformal Conic)
46
+ 'TX_NORTH' => {
47
+ state: 'Texas',
48
+ zone: 'North',
49
+ fips: '4201',
50
+ epsg: '2275',
51
+ projection: 'lambert_conformal_conic',
52
+ central_meridian: -101.5,
53
+ standard_parallel_1: 34.65,
54
+ standard_parallel_2: 36.183333,
55
+ latitude_of_origin: 34.0,
56
+ false_easting: 200000.0,
57
+ false_northing: 1000000.0,
58
+ units: 'US Survey Feet'
59
+ },
60
+ # Florida zones (Transverse Mercator)
61
+ 'FL_EAST' => {
62
+ state: 'Florida',
63
+ zone: 'East',
64
+ fips: '0901',
65
+ epsg: '2236',
66
+ projection: 'transverse_mercator',
67
+ central_meridian: -81.0,
68
+ scale_factor: 0.9999411764705882,
69
+ latitude_of_origin: 24.333333,
70
+ false_easting: 200000.0,
71
+ false_northing: 0.0,
72
+ units: 'US Survey Feet'
73
+ },
74
+ # New York zones (Transverse Mercator)
75
+ 'NY_LONG_ISLAND' => {
76
+ state: 'New York',
77
+ zone: 'Long Island',
78
+ fips: '3104',
79
+ epsg: '2263',
80
+ projection: 'transverse_mercator',
81
+ central_meridian: -74.0,
82
+ scale_factor: 0.9999,
83
+ latitude_of_origin: 40.166667,
84
+ false_easting: 300000.0,
85
+ false_northing: 0.0,
86
+ units: 'US Survey Feet'
87
+ }
88
+ }
89
+
90
+ # Unit conversion factors
91
+ METERS_PER_US_SURVEY_FOOT = 1200.0 / 3937.0
92
+ US_SURVEY_FEET_PER_METER = 3937.0 / 1200.0
93
+ METERS_PER_INTERNATIONAL_FOOT = 0.3048
94
+ INTERNATIONAL_FEET_PER_METER = 1.0 / 0.3048
95
+
96
+ def initialize(easting: 0.0, northing: 0.0, zone_code: 'CA_I', datum: WGS84)
97
+ @easting = easting.to_f
98
+ @northing = northing.to_f
99
+ @zone_code = zone_code.to_s.upcase
100
+ @datum = datum
101
+
102
+ validate_zone
103
+ end
104
+
105
+ def easting=(value)
106
+ @easting = value.to_f
107
+ end
108
+
109
+ def northing=(value)
110
+ @northing = value.to_f
111
+ end
112
+
113
+ def zone_code=(value)
114
+ value = value.to_s.upcase
115
+ raise ArgumentError, "Unknown State Plane zone: #{value}" unless ZONES.key?(value)
116
+ @zone_code = value
117
+ end
118
+
119
+ def to_s(precision = 2)
120
+ precision = precision.to_i
121
+ if precision == 0
122
+ "#{@easting.round}, #{@northing.round}, #{@zone_code}"
123
+ else
124
+ format("%.#{precision}f, %.#{precision}f, %s", @easting, @northing, @zone_code)
125
+ end
126
+ end
127
+
128
+ def to_a
129
+ [@easting, @northing, @zone_code]
130
+ end
131
+
132
+ def self.from_array(array)
133
+ new(easting: array[0].to_f, northing: array[1].to_f, zone_code: array[2].to_s)
134
+ end
135
+
136
+ def self.from_string(string)
137
+ parts = string.split(',').map(&:strip)
138
+ new(easting: parts[0].to_f, northing: parts[1].to_f, zone_code: parts[2].to_s)
139
+ end
140
+
141
+ def zone_info
142
+ ZONES[@zone_code]
143
+ end
144
+
145
+ def to_lla(datum = nil)
146
+ datum ||= @datum
147
+ zone_info = ZONES[@zone_code]
148
+
149
+ case zone_info[:projection]
150
+ when 'lambert_conformal_conic'
151
+ lambert_conformal_conic_to_lla(zone_info, datum)
152
+ when 'transverse_mercator'
153
+ transverse_mercator_to_lla(zone_info, datum)
154
+ else
155
+ raise ArgumentError, "Unsupported projection: #{zone_info[:projection]}"
156
+ end
157
+ end
158
+
159
+ def self.from_lla(lla_coord, zone_code, datum = WGS84)
160
+ zone_info = ZONES[zone_code.to_s.upcase]
161
+ raise ArgumentError, "Unknown zone: #{zone_code}" unless zone_info
162
+
163
+ case zone_info[:projection]
164
+ when 'lambert_conformal_conic'
165
+ from_lla_lambert_conformal_conic(lla_coord, zone_code, zone_info, datum)
166
+ when 'transverse_mercator'
167
+ from_lla_transverse_mercator(lla_coord, zone_code, zone_info, datum)
168
+ else
169
+ raise ArgumentError, "Unsupported projection: #{zone_info[:projection]}"
170
+ end
171
+ end
172
+
173
+ def to_ecef(datum = nil)
174
+ to_lla(datum).to_ecef(datum || @datum)
175
+ end
176
+
177
+ def self.from_ecef(ecef_coord, zone_code, datum = WGS84)
178
+ lla_coord = ecef_coord.to_lla(datum)
179
+ from_lla(lla_coord, zone_code, datum)
180
+ end
181
+
182
+ def to_utm(datum = nil)
183
+ to_lla(datum).to_utm(datum || @datum)
184
+ end
185
+
186
+ def self.from_utm(utm_coord, zone_code, datum = WGS84)
187
+ lla_coord = utm_coord.to_lla(datum)
188
+ from_lla(lla_coord, zone_code, datum)
189
+ end
190
+
191
+ def to_enu(reference_lla, datum = nil)
192
+ to_lla(datum).to_enu(reference_lla)
193
+ end
194
+
195
+ def self.from_enu(enu_coord, reference_lla, zone_code, datum = WGS84)
196
+ lla_coord = enu_coord.to_lla(reference_lla)
197
+ from_lla(lla_coord, zone_code, datum)
198
+ end
199
+
200
+ def to_ned(reference_lla, datum = nil)
201
+ to_lla(datum).to_ned(reference_lla)
202
+ end
203
+
204
+ def self.from_ned(ned_coord, reference_lla, zone_code, datum = WGS84)
205
+ lla_coord = ned_coord.to_lla(reference_lla)
206
+ from_lla(lla_coord, zone_code, datum)
207
+ end
208
+
209
+ def to_mgrs(datum = nil, precision = 5)
210
+ MGRS.from_lla(to_lla(datum), datum || @datum, precision)
211
+ end
212
+
213
+ def self.from_mgrs(mgrs_coord, zone_code, datum = WGS84)
214
+ lla_coord = mgrs_coord.to_lla(datum)
215
+ from_lla(lla_coord, zone_code, datum)
216
+ end
217
+
218
+ def to_usng(datum = nil, precision = 5)
219
+ USNG.from_lla(to_lla(datum), datum || @datum, precision)
220
+ end
221
+
222
+ def self.from_usng(usng_coord, zone_code, datum = WGS84)
223
+ lla_coord = usng_coord.to_lla(datum)
224
+ from_lla(lla_coord, zone_code, datum)
225
+ end
226
+
227
+ def to_web_mercator(datum = nil)
228
+ WebMercator.from_lla(to_lla(datum), datum || @datum)
229
+ end
230
+
231
+ def self.from_web_mercator(web_mercator_coord, zone_code, datum = WGS84)
232
+ lla_coord = web_mercator_coord.to_lla(datum)
233
+ from_lla(lla_coord, zone_code, datum)
234
+ end
235
+
236
+ def to_ups(datum = nil)
237
+ UPS.from_lla(to_lla(datum), datum || @datum)
238
+ end
239
+
240
+ def self.from_ups(ups_coord, zone_code, datum = WGS84)
241
+ lla_coord = ups_coord.to_lla(datum)
242
+ from_lla(lla_coord, zone_code, datum)
243
+ end
244
+
245
+ def to_bng(datum = nil)
246
+ BNG.from_lla(to_lla(datum), datum || @datum)
247
+ end
248
+
249
+ def self.from_bng(bng_coord, zone_code, datum = WGS84)
250
+ lla_coord = bng_coord.to_lla(datum)
251
+ from_lla(lla_coord, zone_code, datum)
252
+ end
253
+
254
+ def to_gh36(datum = nil, precision: 10)
255
+ GH36.new(to_lla(datum), precision: precision)
256
+ end
257
+
258
+ def self.from_gh36(gh36_coord, zone_code, datum = WGS84)
259
+ lla_coord = gh36_coord.to_lla(datum)
260
+ from_lla(lla_coord, zone_code, datum)
261
+ end
262
+
263
+ # Unit conversion methods
264
+ def to_meters
265
+ zone_info = ZONES[@zone_code]
266
+ if zone_info[:units] == 'US Survey Feet'
267
+ easting_m = @easting * METERS_PER_US_SURVEY_FOOT
268
+ northing_m = @northing * METERS_PER_US_SURVEY_FOOT
269
+ else
270
+ easting_m = @easting
271
+ northing_m = @northing
272
+ end
273
+
274
+ StatePlane.new(easting: easting_m, northing: northing_m, zone_code: @zone_code, datum: @datum)
275
+ end
276
+
277
+ def to_us_survey_feet
278
+ zone_info = ZONES[@zone_code]
279
+ if zone_info[:units] == 'US Survey Feet'
280
+ return self
281
+ else
282
+ easting_ft = @easting * US_SURVEY_FEET_PER_METER
283
+ northing_ft = @northing * US_SURVEY_FEET_PER_METER
284
+ StatePlane.new(easting: easting_ft, northing: northing_ft, zone_code: @zone_code, datum: @datum)
285
+ end
286
+ end
287
+
288
+ # Distance calculation
289
+ def ==(other)
290
+ return false unless other.is_a?(StatePlane)
291
+
292
+ (@easting - other.easting).abs <= 1e-6 &&
293
+ (@northing - other.northing).abs <= 1e-6 &&
294
+ @zone_code == other.zone_code
295
+ end
296
+
297
+
298
+ def valid?
299
+ ZONES.key?(@zone_code)
300
+ end
301
+
302
+ # Get all available zones for a state
303
+ def self.zones_for_state(state_name)
304
+ ZONES.select { |code, info| info[:state].downcase == state_name.downcase }
305
+ end
306
+
307
+ # Find appropriate zone for a given LLA coordinate
308
+ def self.find_zone_for_lla(lla_coord, state_name = nil)
309
+ # This is a simplified version - real implementation would use precise zone boundaries
310
+ candidate_zones = state_name ? zones_for_state(state_name) : ZONES
311
+
312
+ # For now, return the first zone in the state (in practice, would check boundaries)
313
+ candidate_zones.keys.first
314
+ end
315
+
316
+ private
317
+
318
+ def validate_zone
319
+ unless ZONES.key?(@zone_code)
320
+ raise ArgumentError, "Unknown State Plane zone: #{@zone_code}"
321
+ end
322
+ end
323
+
324
+ def lambert_conformal_conic_to_lla(zone_info, datum)
325
+ # Lambert Conformal Conic inverse projection
326
+ a = datum.a
327
+ e = datum.e
328
+ e2 = datum.e2
329
+
330
+ # Zone parameters
331
+ lat0_rad = zone_info[:latitude_of_origin] * RAD_PER_DEG
332
+ lon0_rad = zone_info[:central_meridian] * RAD_PER_DEG
333
+ phi1_rad = zone_info[:standard_parallel_1] * RAD_PER_DEG
334
+ phi2_rad = zone_info[:standard_parallel_2] * RAD_PER_DEG
335
+ false_easting = zone_info[:false_easting]
336
+ false_northing = zone_info[:false_northing]
337
+
338
+ # Convert to meters if necessary
339
+ x = @easting
340
+ y = @northing
341
+ if zone_info[:units] == 'US Survey Feet'
342
+ x = x * METERS_PER_US_SURVEY_FOOT - false_easting * METERS_PER_US_SURVEY_FOOT
343
+ y = y * METERS_PER_US_SURVEY_FOOT - false_northing * METERS_PER_US_SURVEY_FOOT
344
+ else
345
+ x = x - false_easting
346
+ y = y - false_northing
347
+ end
348
+
349
+ # Lambert Conformal Conic calculations (simplified)
350
+ # This is a complex calculation - using simplified approximation
351
+ lat = lat0_rad + (y / a)
352
+ lng = lon0_rad + (x / (a * Math.cos(lat0_rad)))
353
+
354
+ lat = [[-90.0, lat * DEG_PER_RAD].max, 90.0].min
355
+ lng = [[-180.0, lng * DEG_PER_RAD].max, 180.0].min
356
+
357
+ LLA.new(lat: lat, lng: lng, alt: 0.0)
358
+ end
359
+
360
+ def transverse_mercator_to_lla(zone_info, datum)
361
+ # Transverse Mercator inverse projection
362
+ a = datum.a
363
+ e = datum.e
364
+ e2 = datum.e2
365
+
366
+ # Zone parameters
367
+ lat0_rad = zone_info[:latitude_of_origin] * RAD_PER_DEG
368
+ lon0_rad = zone_info[:central_meridian] * RAD_PER_DEG
369
+ k0 = zone_info[:scale_factor] || 0.9996
370
+ false_easting = zone_info[:false_easting]
371
+ false_northing = zone_info[:false_northing]
372
+
373
+ # Convert to meters if necessary
374
+ x = @easting
375
+ y = @northing
376
+ if zone_info[:units] == 'US Survey Feet'
377
+ x = (x - false_easting) * METERS_PER_US_SURVEY_FOOT
378
+ y = (y - false_northing) * METERS_PER_US_SURVEY_FOOT
379
+ else
380
+ x = x - false_easting
381
+ y = y - false_northing
382
+ end
383
+
384
+ # Simplified Transverse Mercator inverse (approximation)
385
+ lat = lat0_rad + (y / (a * k0))
386
+ lng = lon0_rad + (x / (a * k0 * Math.cos(lat0_rad)))
387
+
388
+ lat = lat * DEG_PER_RAD
389
+ lng = lng * DEG_PER_RAD
390
+
391
+ lat = [[-90.0, lat].max, 90.0].min
392
+ lng = [[-180.0, lng].max, 180.0].min
393
+
394
+ LLA.new(lat: lat, lng: lng, alt: 0.0)
395
+ end
396
+
397
+ def self.from_lla_lambert_conformal_conic(lla_coord, zone_code, zone_info, datum)
398
+ # Lambert Conformal Conic forward projection (simplified)
399
+ lat = lla_coord.lat * RAD_PER_DEG
400
+ lng = lla_coord.lng * RAD_PER_DEG
401
+
402
+ lat0_rad = zone_info[:latitude_of_origin] * RAD_PER_DEG
403
+ lon0_rad = zone_info[:central_meridian] * RAD_PER_DEG
404
+
405
+ a = datum.a
406
+
407
+ # Simplified calculation
408
+ x = a * (lng - lon0_rad) * Math.cos(lat0_rad)
409
+ y = a * (lat - lat0_rad)
410
+
411
+ # Apply false easting/northing
412
+ if zone_info[:units] == 'US Survey Feet'
413
+ x = x * US_SURVEY_FEET_PER_METER + zone_info[:false_easting]
414
+ y = y * US_SURVEY_FEET_PER_METER + zone_info[:false_northing]
415
+ else
416
+ x = x + zone_info[:false_easting]
417
+ y = y + zone_info[:false_northing]
418
+ end
419
+
420
+ new(easting: x, northing: y, zone_code: zone_code, datum: datum)
421
+ end
422
+
423
+ def self.from_lla_transverse_mercator(lla_coord, zone_code, zone_info, datum)
424
+ # Transverse Mercator forward projection (simplified)
425
+ lat = lla_coord.lat * RAD_PER_DEG
426
+ lng = lla_coord.lng * RAD_PER_DEG
427
+
428
+ lat0_rad = zone_info[:latitude_of_origin] * RAD_PER_DEG
429
+ lon0_rad = zone_info[:central_meridian] * RAD_PER_DEG
430
+ k0 = zone_info[:scale_factor] || 0.9996
431
+
432
+ a = datum.a
433
+
434
+ # Simplified calculation
435
+ x = a * k0 * (lng - lon0_rad) * Math.cos(lat0_rad)
436
+ y = a * k0 * (lat - lat0_rad)
437
+
438
+ # Apply false easting/northing
439
+ if zone_info[:units] == 'US Survey Feet'
440
+ x = x * US_SURVEY_FEET_PER_METER + zone_info[:false_easting]
441
+ y = y * US_SURVEY_FEET_PER_METER + zone_info[:false_northing]
442
+ else
443
+ x = x + zone_info[:false_easting]
444
+ y = y + zone_info[:false_northing]
445
+ end
446
+
447
+ new(easting: x, northing: y, zone_code: zone_code, datum: datum)
448
+ end
449
+ end
450
+ end
451
+ end