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,236 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of bearing calculations and the Bearing class
4
+ # Shows great-circle bearings, compass directions, elevation angles,
5
+ # cross-system bearings, and Bearing class features.
6
+
7
+ require_relative "../lib/geodetic"
8
+
9
+ Bearing = Geodetic::Bearing
10
+
11
+ # ── Notable locations ────────────────────────────────────────────
12
+
13
+ seattle = GCS::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
14
+ portland = GCS::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
15
+ sf = GCS::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
16
+ nyc = GCS::LLA.new(lat: 40.7128, lng: -74.0060, alt: 0.0)
17
+ london = GCS::LLA.new(lat: 51.5074, lng: -0.1278, alt: 0.0)
18
+
19
+ puts "=== Bearing Calculations Demo ==="
20
+ puts
21
+
22
+ # ── Basic bearing_to ──────────────────────────────────────────────
23
+
24
+ puts "--- Great-Circle Bearings (Forward Azimuth) ---"
25
+ puts
26
+
27
+ b = seattle.bearing_to(portland)
28
+ puts <<~HEREDOC
29
+ Seattle -> Portland:
30
+ #{b.to_s} (default: 4 decimals)
31
+ #{b.to_s(2)} (2 decimals)
32
+ #{b.to_s(0)} (integer)
33
+ #{b.to_compass(points: 16)} (16-point compass)
34
+ #{b.to_radians.round(4)} radians
35
+ Back azimuth: #{b.reverse.to_s}
36
+ HEREDOC
37
+
38
+ b = seattle.bearing_to(nyc)
39
+ puts "Seattle -> NYC: #{b.to_s} (#{b.to_compass(points: 8)})"
40
+
41
+ b = seattle.bearing_to(london)
42
+ puts "Seattle -> London: #{b.to_s} (#{b.to_compass(points: 8)})"
43
+
44
+ b = seattle.bearing_to(sf)
45
+ puts "Seattle -> SF: #{b.to_s} (#{b.to_compass(points: 8)})"
46
+ puts
47
+
48
+ # ── Compass directions at different resolutions ──────────────────
49
+
50
+ puts "--- Compass Resolution Comparison ---"
51
+ puts
52
+
53
+ targets = { "Portland" => portland, "SF" => sf, "NYC" => nyc, "London" => london }
54
+
55
+ puts " %-10s %12s %4s %4s %4s" % ["Target", "Degrees", "4pt", "8pt", "16pt"]
56
+ puts " " + "-" * 42
57
+
58
+ targets.each do |name, target|
59
+ b = seattle.bearing_to(target)
60
+ puts " %-10s %12s %4s %4s %4s" % [
61
+ name, b.to_s, b.to_compass(points: 4),
62
+ b.to_compass(points: 8), b.to_compass(points: 16)
63
+ ]
64
+ end
65
+ puts
66
+
67
+ # ── Elevation angles ─────────────────────────────────────────────
68
+
69
+ puts "--- Elevation Angles ---"
70
+ puts
71
+
72
+ ground = GCS::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
73
+ hilltop = GCS::LLA.new(lat: 47.6205, lng: -122.3493, alt: 5000.0)
74
+ plane = GCS::LLA.new(lat: 47.6300, lng: -122.3400, alt: 10000.0)
75
+ nearby = GCS::LLA.new(lat: 47.6210, lng: -122.3490, alt: 100.0)
76
+
77
+ puts " From ground (0m) to directly above (5000m):"
78
+ puts " Elevation: #{ground.elevation_to(hilltop).round(2)}° (nearly straight up)"
79
+ puts
80
+ puts " From ground to airplane (10km alt, ~1km away):"
81
+ puts " Elevation: #{ground.elevation_to(plane).round(2)}°"
82
+ puts " Bearing: #{ground.bearing_to(plane).to_s}"
83
+ puts
84
+ puts " From ground to nearby hilltop (100m alt, ~60m away):"
85
+ puts " Elevation: #{ground.elevation_to(nearby).round(2)}°"
86
+ puts " Bearing: #{ground.bearing_to(nearby).to_s}"
87
+ puts
88
+
89
+ # ── Chain bearings ────────────────────────────────────────────────
90
+
91
+ puts "--- Chain Bearings (Consecutive Legs) ---"
92
+ puts "Route: Seattle -> Portland -> SF -> NYC -> London"
93
+ puts
94
+
95
+ bearings = GCS.bearing_between(seattle, portland, sf, nyc, london)
96
+ legs = [
97
+ "Seattle -> Portland",
98
+ "Portland -> SF",
99
+ "SF -> NYC",
100
+ "NYC -> London"
101
+ ]
102
+
103
+ legs.each_with_index do |label, i|
104
+ b = bearings[i]
105
+ puts " %-22s %12s %-3s (reverse: %s)" % [
106
+ label, b.to_s, b.to_compass(points: 8), b.reverse.to_s
107
+ ]
108
+ end
109
+ puts
110
+
111
+ # ── Cross-system bearings ────────────────────────────────────────
112
+
113
+ puts "--- Cross-System Bearings ---"
114
+ puts
115
+
116
+ utm_seattle = seattle.to_utm
117
+ mgrs_portland = GCS::MGRS.from_lla(portland)
118
+ wm_sf = GCS::WebMercator.from_lla(sf)
119
+
120
+ b = utm_seattle.bearing_to(mgrs_portland)
121
+ puts " UTM(Seattle) -> MGRS(Portland): #{b.to_s} (#{b.to_compass})"
122
+
123
+ b = mgrs_portland.bearing_to(wm_sf)
124
+ puts " MGRS(Portland) -> WebMercator(SF): #{b.to_s} (#{b.to_compass})"
125
+
126
+ b = wm_sf.bearing_to(utm_seattle)
127
+ puts " WebMercator(SF) -> UTM(Seattle): #{b.to_s} (#{b.to_compass})"
128
+
129
+ gh36_nyc = GCS::GH36.new(nyc)
130
+ b = gh36_nyc.bearing_to(utm_seattle)
131
+ puts " GH36(NYC) -> UTM(Seattle): #{b.to_s} (#{b.to_compass})"
132
+ puts
133
+
134
+ # ── Local bearings (ENU/NED) ─────────────────────────────────────
135
+
136
+ puts "--- Local Tangent Plane Bearings (ENU / NED) ---"
137
+ puts
138
+
139
+ enu_origin = GCS::ENU.new(e: 0.0, n: 0.0, u: 0.0)
140
+ enu_ne = GCS::ENU.new(e: 100.0, n: 100.0, u: 0.0)
141
+ enu_south = GCS::ENU.new(e: 0.0, n: -200.0, u: 50.0)
142
+
143
+ puts " ENU local_bearing_to:"
144
+ puts " Origin -> NE point (100,100): #{enu_origin.local_bearing_to(enu_ne).round(2)}°"
145
+ puts " Origin -> South point (0,-200): #{enu_origin.local_bearing_to(enu_south).round(2)}°"
146
+ puts
147
+
148
+ ned_origin = GCS::NED.new(n: 0.0, e: 0.0, d: 0.0)
149
+ ned_above = GCS::NED.new(n: 100.0, e: 0.0, d: -100.0)
150
+ ned_below = GCS::NED.new(n: 100.0, e: 0.0, d: 100.0)
151
+
152
+ puts " NED local_bearing_to / local_elevation_angle_to:"
153
+ puts " Origin -> N+Up: bearing #{ned_origin.local_bearing_to(ned_above).round(2)}°, elevation #{ned_origin.local_elevation_angle_to(ned_above).round(2)}°"
154
+ puts " Origin -> N+Down: bearing #{ned_origin.local_bearing_to(ned_below).round(2)}°, elevation #{ned_origin.local_elevation_angle_to(ned_below).round(2)}°"
155
+ puts
156
+
157
+ # ── Bearing class features ───────────────────────────────────────
158
+
159
+ puts "=== Bearing Class Features ==="
160
+ puts
161
+
162
+ # ── Construction and normalization ────────────────────────────────
163
+
164
+ puts "--- Construction (auto-normalizes to 0-360) ---"
165
+ puts
166
+
167
+ [0, 90, -90, 450, 720, -180].each do |deg|
168
+ puts " Bearing.new(%-4d) => %s" % [deg, Bearing.new(deg).to_s]
169
+ end
170
+ puts
171
+
172
+ # ── Reverse (back azimuth) ───────────────────────────────────────
173
+
174
+ puts "--- Reverse (Back Azimuth) ---"
175
+ puts
176
+
177
+ [0, 45, 90, 180, 270, 315].each do |deg|
178
+ b = Bearing.new(deg)
179
+ puts " %-10s reverse => %s" % [b.to_s, b.reverse.to_s]
180
+ end
181
+ puts
182
+
183
+ # ── Arithmetic ───────────────────────────────────────────────────
184
+
185
+ puts "--- Arithmetic ---"
186
+ puts
187
+
188
+ b = Bearing.new(350)
189
+ puts " b = #{b.to_s}"
190
+ puts " b + 20 = #{(b + 20).to_s} (wraps past 360)"
191
+ puts " b - 10 = #{(b - 10).to_s}"
192
+ puts
193
+
194
+ b1 = Bearing.new(90)
195
+ b2 = Bearing.new(45)
196
+ puts " Bearing(90) - Bearing(45) = #{(b1 - b2)} (Float, angular difference)"
197
+ puts " Bearing(45) - Bearing(90) = #{(b2 - b1)} (negative difference)"
198
+ puts
199
+
200
+ # ── Comparison ───────────────────────────────────────────────────
201
+
202
+ puts "--- Comparison ---"
203
+ puts
204
+
205
+ puts " Bearing(90) == Bearing(90)? #{Bearing.new(90) == Bearing.new(90)}"
206
+ puts " Bearing(90) == Bearing(450)? #{Bearing.new(90) == Bearing.new(450)}"
207
+ puts " Bearing(45) < Bearing(90)? #{Bearing.new(45) < Bearing.new(90)}"
208
+ puts " Bearing(270) > 180? #{Bearing.new(270) > 180}"
209
+ puts
210
+
211
+ # ── Combined distance and bearing ────────────────────────────────
212
+
213
+ puts "=== Combined Distance + Bearing ==="
214
+ puts
215
+
216
+ puts " %-22s %10s %12s %6s" % ["Leg", "Distance", "Bearing", "Dir"]
217
+ puts " " + "-" * 54
218
+
219
+ route = [
220
+ ["Seattle", seattle],
221
+ ["Portland", portland],
222
+ ["SF", sf],
223
+ ["NYC", nyc],
224
+ ["London", london]
225
+ ]
226
+
227
+ route.each_cons(2) do |(name_a, coord_a), (name_b, coord_b)|
228
+ d = coord_a.distance_to(coord_b)
229
+ b = coord_a.bearing_to(coord_b)
230
+ label = "#{name_a} -> #{name_b}"
231
+ km_str = "%.1f km" % d.to_km.to_f
232
+ puts " %-22s %10s %12s %6s" % [label, km_str, b.to_s, b.to_compass(points: 8)]
233
+ end
234
+ puts
235
+
236
+ puts "=== Done ==="
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../coordinates/lla'
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Circle
8
+ attr_reader :centroid, :radius
9
+
10
+ def initialize(centroid:, radius:)
11
+ @centroid = centroid # LLA point
12
+ @radius = radius # in units of meters
13
+ end
14
+
15
+ def includes?(a_point)
16
+ @centroid.distance_to(a_point).meters <= @radius
17
+ end
18
+
19
+ def excludes?(a_point)
20
+ !includes?(a_point)
21
+ end
22
+
23
+ alias_method :include?, :includes?
24
+ alias_method :exclude?, :excludes?
25
+ alias_method :inside?, :includes?
26
+ alias_method :outside?, :excludes?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../coordinates/lla'
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Polygon
8
+ attr_reader :boundary, :centroid
9
+
10
+ def initialize(boundary:)
11
+ raise ArgumentError, "A Polygon requires more than #{boundary.length} points on its boundary" unless boundary.length > 2
12
+
13
+ @boundary = boundary.dup
14
+ @boundary << boundary[0] unless boundary.first == boundary.last
15
+
16
+ centroid_lat = 0.0
17
+ centroid_lng = 0.0
18
+ area = 0.0
19
+
20
+ 0.upto(@boundary.length - 2) do |i|
21
+ cross = @boundary[i].lng * @boundary[i + 1].lat - @boundary[i + 1].lng * @boundary[i].lat
22
+ area += 0.5 * cross
23
+ centroid_lng += (@boundary[i].lng + @boundary[i + 1].lng) * cross
24
+ centroid_lat += (@boundary[i].lat + @boundary[i + 1].lat) * cross
25
+ end
26
+
27
+ centroid_lng /= (6.0 * area)
28
+ centroid_lat /= (6.0 * area)
29
+
30
+ @centroid = Coordinates::LLA.new(lat: centroid_lat, lng: centroid_lng, alt: 0.0)
31
+ end
32
+
33
+ def includes?(a_point)
34
+ turn_angle = 0.0
35
+
36
+ (@boundary.length - 2).times do |index|
37
+ return true if @boundary[index] == a_point
38
+
39
+ d_turn_angle = a_point.bearing_to(@boundary[index + 1]) - a_point.bearing_to(@boundary[index])
40
+ d_turn_angle += (d_turn_angle > 0.0 ? -360.0 : 360.0) if d_turn_angle.abs > 180.0
41
+ turn_angle += d_turn_angle
42
+ end
43
+
44
+ turn_angle.abs > 180.0
45
+ end
46
+
47
+ def excludes?(a_point)
48
+ !includes?(a_point)
49
+ end
50
+
51
+ alias_method :include?, :includes?
52
+ alias_method :exclude?, :excludes?
53
+ alias_method :inside?, :includes?
54
+ alias_method :outside?, :excludes?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../coordinates/lla'
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Rectangle
8
+ attr_reader :nw, :se, :centroid
9
+
10
+ # Define an axis-aligned rectangle by its NW and SE corners.
11
+ # Accepts any coordinate that responds to to_lla.
12
+ #
13
+ # Rectangle.new(
14
+ # nw: LLA.new(lat: 41.0, lng: -75.0),
15
+ # se: LLA.new(lat: 40.0, lng: -74.0)
16
+ # )
17
+ def initialize(nw:, se:)
18
+ @nw = nw.is_a?(Coordinates::LLA) ? nw : nw.to_lla
19
+ @se = se.is_a?(Coordinates::LLA) ? se : se.to_lla
20
+
21
+ raise ArgumentError, "NW corner must have higher latitude than SE corner" if @nw.lat < @se.lat
22
+ raise ArgumentError, "NW corner must have lower longitude than SE corner" if @nw.lng > @se.lng
23
+
24
+ @centroid = Coordinates::LLA.new(
25
+ lat: (@nw.lat + @se.lat) / 2.0,
26
+ lng: (@nw.lng + @se.lng) / 2.0,
27
+ alt: 0.0
28
+ )
29
+ end
30
+
31
+ def ne
32
+ Coordinates::LLA.new(lat: @nw.lat, lng: @se.lng, alt: 0.0)
33
+ end
34
+
35
+ def sw
36
+ Coordinates::LLA.new(lat: @se.lat, lng: @nw.lng, alt: 0.0)
37
+ end
38
+
39
+ def includes?(a_point)
40
+ lla = a_point.respond_to?(:to_lla) ? a_point.to_lla : a_point
41
+ lla.lat >= @se.lat && lla.lat <= @nw.lat &&
42
+ lla.lng >= @nw.lng && lla.lng <= @se.lng
43
+ end
44
+
45
+ def excludes?(a_point)
46
+ !includes?(a_point)
47
+ end
48
+
49
+ alias_method :include?, :includes?
50
+ alias_method :exclude?, :excludes?
51
+ alias_method :inside?, :includes?
52
+ alias_method :outside?, :excludes?
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "areas/circle"
4
+ require_relative "areas/polygon"
5
+ require_relative "areas/rectangle"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ class Bearing
5
+ include Comparable
6
+
7
+ COMPASS_16 = %w[N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW].freeze
8
+ COMPASS_8 = %w[N NE E SE S SW W NW].freeze
9
+ COMPASS_4 = %w[N E S W].freeze
10
+
11
+ attr_reader :degrees
12
+
13
+ def initialize(degrees)
14
+ @degrees = degrees.to_f % 360.0
15
+ end
16
+
17
+ def to_f
18
+ @degrees
19
+ end
20
+
21
+ def to_i
22
+ @degrees.to_i
23
+ end
24
+
25
+ def to_radians
26
+ @degrees * RAD_PER_DEG
27
+ end
28
+
29
+ # Back azimuth (reverse bearing)
30
+ def reverse
31
+ Bearing.new(@degrees + 180.0)
32
+ end
33
+
34
+ # Compass direction string.
35
+ # points: 4 (N/E/S/W), 8 (N/NE/E/...), or 16 (N/NNE/NE/ENE/...)
36
+ def to_compass(points: 16)
37
+ case points
38
+ when 4 then COMPASS_4[(((@degrees + 45.0) % 360.0) / 90.0).to_i]
39
+ when 8 then COMPASS_8[(((@degrees + 22.5) % 360.0) / 45.0).to_i]
40
+ when 16 then COMPASS_16[(((@degrees + 11.25) % 360.0) / 22.5).to_i]
41
+ else raise ArgumentError, "points must be 4, 8, or 16"
42
+ end
43
+ end
44
+
45
+ def to_s(precision = 4)
46
+ precision = precision.to_i
47
+ if precision == 0
48
+ "#{@degrees.round}°"
49
+ else
50
+ format("%.#{precision}f°", @degrees)
51
+ end
52
+ end
53
+
54
+ def inspect
55
+ "#<Geodetic::Bearing #{to_s} (#{to_compass})>"
56
+ end
57
+
58
+ def <=>(other)
59
+ case other
60
+ when Bearing then @degrees <=> other.degrees
61
+ when Numeric then @degrees <=> other.to_f
62
+ end
63
+ end
64
+
65
+ # Bearing - Bearing => Float (angular difference)
66
+ # Bearing - Numeric => Bearing (subtract degrees)
67
+ def -(other)
68
+ case other
69
+ when Bearing then @degrees - other.degrees
70
+ when Numeric then Bearing.new(@degrees - other)
71
+ else raise ArgumentError, "Cannot subtract #{other.class} from Bearing"
72
+ end
73
+ end
74
+
75
+ # Bearing + Numeric => Bearing
76
+ def +(other)
77
+ case other
78
+ when Numeric then Bearing.new(@degrees + other)
79
+ else raise ArgumentError, "Cannot add #{other.class} to Bearing"
80
+ end
81
+ end
82
+
83
+ def coerce(other)
84
+ case other
85
+ when Numeric then [other, @degrees]
86
+ else raise TypeError, "#{other.class} can't be coerced into Bearing"
87
+ end
88
+ end
89
+
90
+ def zero?
91
+ @degrees == 0.0
92
+ end
93
+ end
94
+ end