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