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,195 @@
1
+ # Areas Reference
2
+
3
+ The `Geodetic::Areas` module provides three geometric area classes for point-in-area testing: `Circle`, `Polygon`, and `Rectangle`. All operate on `Geodetic::Coordinates::LLA` points.
4
+
5
+ ---
6
+
7
+ ## Geodetic::Areas::Circle
8
+
9
+ Defines a circular area on the Earth's surface.
10
+
11
+ ### Constructor
12
+
13
+ ```ruby
14
+ center = Geodetic::Coordinates::LLA.new(lat: 38.8977, lng: -77.0365, alt: 0.0)
15
+
16
+ circle = Geodetic::Areas::Circle.new(
17
+ centroid: center, # LLA point at the center
18
+ radius: 1000.0 # radius in meters
19
+ )
20
+ ```
21
+
22
+ ### Attributes
23
+
24
+ | Attribute | Type | Description |
25
+ |-----------|------|-------------|
26
+ | `centroid` | LLA | The center point of the circle |
27
+ | `radius` | Float | The radius in meters |
28
+
29
+ ### Methods
30
+
31
+ #### `includes?(a_point)` / `include?(a_point)` / `inside?(a_point)`
32
+
33
+ Returns `true` if the given LLA point falls within (or on the boundary of) the circle. The distance from centroid to the point is compared against the radius.
34
+
35
+ ```ruby
36
+ point = Geodetic::Coordinates::LLA.new(lat: 38.898, lng: -77.036, alt: 0.0)
37
+ circle.includes?(point) # => true or false
38
+ ```
39
+
40
+ #### `excludes?(a_point)` / `exclude?(a_point)` / `outside?(a_point)`
41
+
42
+ Returns `true` if the given LLA point falls outside the circle. The logical inverse of `includes?`.
43
+
44
+ ```ruby
45
+ circle.excludes?(point) # => true or false
46
+ ```
47
+
48
+ ### Alias Summary
49
+
50
+ | Primary Method | Aliases |
51
+ |---------------|---------|
52
+ | `includes?` | `include?`, `inside?` |
53
+ | `excludes?` | `exclude?`, `outside?` |
54
+
55
+ ---
56
+
57
+ ## Geodetic::Areas::Polygon
58
+
59
+ Defines an arbitrary polygon area on the Earth's surface.
60
+
61
+ ### Constructor
62
+
63
+ ```ruby
64
+ boundary = [
65
+ Geodetic::Coordinates::LLA.new(lat: 38.90, lng: -77.04, alt: 0.0),
66
+ Geodetic::Coordinates::LLA.new(lat: 38.90, lng: -77.03, alt: 0.0),
67
+ Geodetic::Coordinates::LLA.new(lat: 38.89, lng: -77.03, alt: 0.0),
68
+ Geodetic::Coordinates::LLA.new(lat: 38.89, lng: -77.04, alt: 0.0)
69
+ ]
70
+
71
+ polygon = Geodetic::Areas::Polygon.new(boundary: boundary)
72
+ ```
73
+
74
+ A minimum of 3 points is required. The constructor throws an error if fewer points are provided.
75
+
76
+ The polygon is automatically closed: if the first and last points in the boundary array are not equal, the first point is appended to close the polygon.
77
+
78
+ ### Attributes
79
+
80
+ | Attribute | Type | Description |
81
+ |-----------|------|-------------|
82
+ | `boundary` | Array<LLA> | The ordered array of LLA points forming the polygon boundary (auto-closed) |
83
+ | `centroid` | LLA | The computed centroid of the polygon, calculated automatically during initialization |
84
+
85
+ ### Centroid Calculation
86
+
87
+ The centroid is computed using the standard polygon centroid formula based on the signed area of the polygon in the latitude/longitude coordinate space. This is calculated automatically during initialization and stored in the `centroid` attribute.
88
+
89
+ ### Methods
90
+
91
+ #### `includes?(a_point)` / `include?(a_point)` / `inside?(a_point)`
92
+
93
+ Returns `true` if the given LLA point falls within the polygon. Uses the winding angle algorithm: sums the turning angles from the test point to each consecutive pair of boundary vertices. If the absolute accumulated angle exceeds 180 degrees, the point is inside.
94
+
95
+ Also returns `true` if the point is exactly equal to any boundary vertex.
96
+
97
+ ```ruby
98
+ point = Geodetic::Coordinates::LLA.new(lat: 38.895, lng: -77.035, alt: 0.0)
99
+ polygon.includes?(point) # => true or false
100
+ ```
101
+
102
+ #### `excludes?(a_point)` / `exclude?(a_point)` / `outside?(a_point)`
103
+
104
+ Returns `true` if the given LLA point falls outside the polygon. The logical inverse of `includes?`.
105
+
106
+ ```ruby
107
+ polygon.excludes?(point) # => true or false
108
+ ```
109
+
110
+ ### Alias Summary
111
+
112
+ | Primary Method | Aliases |
113
+ |---------------|---------|
114
+ | `includes?` | `include?`, `inside?` |
115
+ | `excludes?` | `exclude?`, `outside?` |
116
+
117
+ ---
118
+
119
+ ## Geodetic::Areas::Rectangle
120
+
121
+ Defines an axis-aligned rectangle by its northwest and southeast corners.
122
+
123
+ ### Constructor
124
+
125
+ ```ruby
126
+ nw = Geodetic::Coordinates::LLA.new(lat: 41.0, lng: -75.0)
127
+ se = Geodetic::Coordinates::LLA.new(lat: 40.0, lng: -74.0)
128
+
129
+ rectangle = Geodetic::Areas::Rectangle.new(nw: nw, se: se)
130
+ ```
131
+
132
+ The constructor accepts any coordinate type that responds to `to_lla` -- coordinates are automatically converted to LLA.
133
+
134
+ ```ruby
135
+ nw_wm = Geodetic::Coordinates::WebMercator.from_lla(nw)
136
+ se_wm = Geodetic::Coordinates::WebMercator.from_lla(se)
137
+ rectangle = Geodetic::Areas::Rectangle.new(nw: nw_wm, se: se_wm)
138
+ ```
139
+
140
+ Raises `ArgumentError` if the NW corner has a lower latitude than the SE corner, or if the NW corner has a higher longitude than the SE corner.
141
+
142
+ ### Attributes
143
+
144
+ | Attribute | Type | Description |
145
+ |------------|------|-------------|
146
+ | `nw` | LLA | The northwest corner (max latitude, min longitude) |
147
+ | `se` | LLA | The southeast corner (min latitude, max longitude) |
148
+ | `centroid` | LLA | The center point, computed automatically |
149
+
150
+ All attributes are read-only.
151
+
152
+ ### Computed Corners
153
+
154
+ ```ruby
155
+ rectangle.ne # => LLA (nw.lat, se.lng)
156
+ rectangle.sw # => LLA (se.lat, nw.lng)
157
+ ```
158
+
159
+ ### Methods
160
+
161
+ #### `includes?(a_point)` / `include?(a_point)` / `inside?(a_point)`
162
+
163
+ Returns `true` if the given point falls within (or on the boundary of) the rectangle. Accepts any coordinate type that responds to `to_lla`.
164
+
165
+ ```ruby
166
+ point = Geodetic::Coordinates::LLA.new(lat: 40.5, lng: -74.5)
167
+ rectangle.includes?(point) # => true
168
+ ```
169
+
170
+ #### `excludes?(a_point)` / `exclude?(a_point)` / `outside?(a_point)`
171
+
172
+ Returns `true` if the given point falls outside the rectangle.
173
+
174
+ ```ruby
175
+ rectangle.excludes?(point) # => true or false
176
+ ```
177
+
178
+ ### Alias Summary
179
+
180
+ | Primary Method | Aliases |
181
+ |---------------|---------|
182
+ | `includes?` | `include?`, `inside?` |
183
+ | `excludes?` | `exclude?`, `outside?` |
184
+
185
+ ### Integration with GH36
186
+
187
+ `Geodetic::Coordinates::GH36#to_area` returns a `Rectangle` representing the geohash cell's bounding box:
188
+
189
+ ```ruby
190
+ gh36 = Geodetic::Coordinates::GH36.new("bdrdC26BqH")
191
+ area = gh36.to_area
192
+ # => Geodetic::Areas::Rectangle
193
+
194
+ area.includes?(gh36.to_lla) # => true (midpoint is inside the cell)
195
+ ```
@@ -0,0 +1,351 @@
1
+ # Conversions Reference
2
+
3
+ Every coordinate class in the Geodetic gem can convert to every other coordinate class. Conversions are available as both instance methods (on the source object) and class methods (on the target class).
4
+
5
+ ---
6
+
7
+ ## Conversion Method Patterns
8
+
9
+ ### Instance Methods (on the source)
10
+
11
+ ```ruby
12
+ source.to_<target>(datum = WGS84)
13
+ ```
14
+
15
+ Examples:
16
+
17
+ ```ruby
18
+ lla.to_ecef # LLA -> ECEF
19
+ lla.to_utm # LLA -> UTM
20
+ ecef.to_lla # ECEF -> LLA
21
+ utm.to_lla # UTM -> LLA
22
+ web_mercator.to_lla # WebMercator -> LLA
23
+ bng.to_utm # BNG -> UTM
24
+ ups.to_mgrs # UPS -> MGRS
25
+ state_plane.to_web_mercator # StatePlane -> WebMercator
26
+ ```
27
+
28
+ ### Class Methods (on the target)
29
+
30
+ ```ruby
31
+ TargetClass.from_<source>(source_object, datum = WGS84)
32
+ ```
33
+
34
+ Examples:
35
+
36
+ ```ruby
37
+ Geodetic::Coordinates::ECEF.from_lla(lla)
38
+ Geodetic::Coordinates::LLA.from_ecef(ecef)
39
+ Geodetic::Coordinates::UTM.from_lla(lla)
40
+ Geodetic::Coordinates::LLA.from_utm(utm)
41
+ Geodetic::Coordinates::WebMercator.from_ecef(ecef)
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Local Coordinate Systems (ENU, NED)
47
+
48
+ ENU and NED are local tangent plane systems that require a **reference LLA** point defining the origin of the local frame. All conversions to/from ENU and NED include a reference parameter.
49
+
50
+ ### Instance Methods
51
+
52
+ ```ruby
53
+ ref = Geodetic::Coordinates::LLA.new(lat: 38.8977, lng: -77.0365, alt: 0.0)
54
+
55
+ # LLA to local
56
+ lla.to_enu(ref)
57
+ lla.to_ned(ref)
58
+
59
+ # Local to LLA
60
+ enu.to_lla(ref)
61
+ ned.to_lla(ref)
62
+
63
+ # Local to UTM (requires reference)
64
+ enu.to_utm(ref)
65
+ ned.to_utm(ref)
66
+
67
+ # ENU <-> NED (direct, no reference needed)
68
+ enu.to_ned
69
+ ned.to_enu
70
+
71
+ # ECEF to local (reference_ecef required, reference_lla optional)
72
+ ecef.to_enu(reference_ecef, reference_lla)
73
+ ecef.to_ned(reference_ecef, reference_lla)
74
+
75
+ # Local to ECEF (reference_ecef required, reference_lla optional)
76
+ enu.to_ecef(reference_ecef, reference_lla)
77
+ ned.to_ecef(reference_ecef, reference_lla)
78
+ ```
79
+
80
+ ### Class Methods
81
+
82
+ ```ruby
83
+ ref = Geodetic::Coordinates::LLA.new(lat: 38.8977, lng: -77.0365, alt: 0.0)
84
+
85
+ Geodetic::Coordinates::ENU.from_lla(lla, ref)
86
+ Geodetic::Coordinates::NED.from_lla(lla, ref)
87
+ Geodetic::Coordinates::LLA.from_enu(enu, ref)
88
+ Geodetic::Coordinates::LLA.from_ned(ned, ref)
89
+
90
+ # From other systems via reference
91
+ Geodetic::Coordinates::ENU.from_utm(utm, ref)
92
+ Geodetic::Coordinates::NED.from_utm(utm, ref)
93
+ Geodetic::Coordinates::UTM.from_enu(enu, ref)
94
+ Geodetic::Coordinates::UTM.from_ned(ned, ref)
95
+
96
+ # ECEF-based
97
+ Geodetic::Coordinates::ENU.from_ecef(ecef, ref_ecef, ref_lla)
98
+ Geodetic::Coordinates::NED.from_ecef(ecef, ref_ecef, ref_lla)
99
+ Geodetic::Coordinates::ECEF.from_enu(enu, ref_ecef, ref_lla)
100
+ Geodetic::Coordinates::ECEF.from_ned(ned, ref_ecef, ref_lla)
101
+ ```
102
+
103
+ When `reference_lla` is omitted in ECEF-based conversions, it is computed automatically from `reference_ecef` via `reference_ecef.to_lla`.
104
+
105
+ ---
106
+
107
+ ## MGRS and USNG Conversions
108
+
109
+ MGRS and USNG accept an optional `precision` parameter (1-5, default 5) controlling the coordinate resolution:
110
+
111
+ | Precision | Resolution |
112
+ |-----------|-----------|
113
+ | 1 | 10 km |
114
+ | 2 | 1 km |
115
+ | 3 | 100 m |
116
+ | 4 | 10 m |
117
+ | 5 | 1 m |
118
+
119
+ ```ruby
120
+ # LLA to MGRS with precision
121
+ Geodetic::Coordinates::MGRS.from_lla(lla, datum, precision)
122
+ lla_point.to_mgrs(datum, precision) # available on UPS, WebMercator, BNG, StatePlane
123
+
124
+ # LLA to USNG with precision
125
+ Geodetic::Coordinates::USNG.from_lla(lla, datum, precision)
126
+
127
+ # MGRS <-> USNG (direct conversion)
128
+ mgrs.to_usng # implicit via component transfer
129
+ Geodetic::Coordinates::USNG.from_mgrs(mgrs)
130
+ usng.to_mgrs
131
+ Geodetic::Coordinates::MGRS.from_usng(usng) # implicit via string
132
+ ```
133
+
134
+ ---
135
+
136
+ ## StatePlane Conversions
137
+
138
+ StatePlane requires a **zone code** when converting into the system:
139
+
140
+ ```ruby
141
+ # To StatePlane (zone_code required)
142
+ Geodetic::Coordinates::StatePlane.from_lla(lla, 'CA_I')
143
+ Geodetic::Coordinates::StatePlane.from_ecef(ecef, 'CA_I')
144
+ Geodetic::Coordinates::StatePlane.from_utm(utm, 'CA_I')
145
+ Geodetic::Coordinates::StatePlane.from_enu(enu, ref_lla, 'CA_I')
146
+ Geodetic::Coordinates::StatePlane.from_ned(ned, ref_lla, 'CA_I')
147
+
148
+ # From StatePlane (zone is stored in the object)
149
+ state_plane.to_lla
150
+ state_plane.to_ecef
151
+ state_plane.to_utm
152
+ state_plane.to_enu(ref_lla)
153
+ state_plane.to_ned(ref_lla)
154
+ ```
155
+
156
+ BNG also provides conversion to StatePlane with a zone code:
157
+
158
+ ```ruby
159
+ bng.to_state_plane('CA_I')
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Datum Parameter
165
+
166
+ Most conversion methods accept an optional datum parameter that defaults to `Geodetic::WGS84`:
167
+
168
+ ```ruby
169
+ # Using a different datum
170
+ clarke = Geodetic::Datum.new(name: 'CLARKE_1866')
171
+
172
+ lla.to_ecef(clarke)
173
+ ecef.to_lla(clarke)
174
+ lla.to_utm(clarke)
175
+ utm.to_lla(clarke)
176
+ ```
177
+
178
+ StatePlane stores its datum internally and uses it when no datum argument is provided:
179
+
180
+ ```ruby
181
+ sp = Geodetic::Coordinates::StatePlane.new(
182
+ easting: 2000000.0, northing: 500000.0,
183
+ zone_code: 'CA_I', datum: clarke
184
+ )
185
+ sp.to_lla # uses the stored clarke datum
186
+ sp.to_lla(wgs84) # overrides with wgs84
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Conversion Chains
192
+
193
+ Most conversions are not direct but route through intermediate systems. The gem handles this transparently. Here are the typical chains:
194
+
195
+ | Conversion | Chain |
196
+ |-----------|-------|
197
+ | LLA <-> ECEF | Direct mathematical transformation |
198
+ | LLA <-> UTM | Direct mathematical transformation |
199
+ | LLA <-> ENU | LLA -> ECEF -> ENU (and reverse) |
200
+ | LLA <-> NED | LLA -> ECEF -> ENU -> NED (and reverse) |
201
+ | ENU <-> NED | Direct axis swap: `NED(n, e, d) = ENU(e, n, -u)` |
202
+ | UTM <-> ECEF | UTM -> LLA -> ECEF (and reverse) |
203
+ | UTM <-> ENU | UTM -> LLA -> ENU (and reverse) |
204
+ | MGRS <-> LLA | MGRS -> UTM -> LLA (and reverse) |
205
+ | USNG <-> LLA | USNG -> MGRS -> UTM -> LLA (and reverse) |
206
+ | WebMercator <-> LLA | Direct mathematical transformation |
207
+ | UPS <-> LLA | Direct mathematical transformation |
208
+ | BNG <-> LLA | BNG -> OSGB36 LLA -> WGS84 LLA (with datum shift) |
209
+ | StatePlane <-> LLA | Direct projection (Lambert or Transverse Mercator) |
210
+ | GH36 <-> LLA | Encode/decode via 6x6 matrix subdivision |
211
+ | Any <-> Any | Routes through LLA as the universal hub |
212
+
213
+ ---
214
+
215
+ ## Conversion Accuracy Notes
216
+
217
+ - **LLA <-> ECEF**: Full precision. Iterative algorithm converges to sub-millimeter accuracy (tolerance: 1e-12 radians for latitude, 1e-12 meters for altitude, max 100 iterations).
218
+ - **LLA <-> UTM**: Simplified series expansion. Accurate for typical use but may diverge at extreme latitudes or far from the central meridian.
219
+ - **ENU / NED**: Full precision when going through ECEF. The rotation matrices are exact.
220
+ - **MGRS / USNG**: Precision depends on the grid precision level (1-5). A 5-digit precision gives 1-meter resolution.
221
+ - **WebMercator**: Latitude is clamped to +/-85.0511 degrees. Altitude information is lost (always 0.0).
222
+ - **UPS**: Iterative refinement (5 iterations) for the inverse projection. Designed for polar regions.
223
+ - **BNG**: Uses a simplified datum transformation between OSGB36 and WGS84 (approximate offset). A full Helmert 7-parameter transformation would provide higher accuracy.
224
+ - **StatePlane**: Uses simplified projection formulas. Production applications may require the full NOAA/NGS projection equations for survey-grade accuracy.
225
+ - **GH36**: Precision depends on hash length. Default 10 characters gives sub-meter resolution. Altitude information is lost (always 0.0).
226
+ - **Equality comparisons**: All classes use tolerance-based equality. Coordinates (in meters) use 1e-6 m tolerance. LLA uses 1e-10 degrees for lat/lng and 1e-6 m for altitude. GH36 uses exact string comparison.
227
+
228
+ ---
229
+
230
+ ## Distance Calculations
231
+
232
+ Universal distance methods are available on all coordinate types and work across different coordinate systems.
233
+
234
+ ### Great-Circle Distance (Vincenty)
235
+
236
+ - **`distance_to(other, *others)`** — Instance method. Computes the Vincenty great-circle distance from the receiver to one or more target coordinates. Returns a `Distance` for a single target, or an Array of `Distance` objects for multiple targets (radial distances from the receiver).
237
+ - **`GCS.distance_between(*coords)`** — Class method on `Geodetic::Coordinates` (aliased as `GCS`). Computes consecutive chain distances between an ordered sequence of coordinates. Returns a `Distance` for two coordinates, or an Array of `Distance` objects for three or more.
238
+
239
+ > **`Distance` objects** wrap a distance value and provide unit-aware access. Call `.meters` to get the raw Float value in meters, or `.to_f` to get the value in the current display unit.
240
+
241
+ ```ruby
242
+ seattle = GCS::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
243
+ portland = GCS::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
244
+ sf = GCS::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
245
+
246
+ # Radial distances from receiver
247
+ seattle.distance_to(portland) # => Distance (235393.17 m)
248
+ seattle.distance_to(portland, sf) # => [Distance, Distance] (Array)
249
+
250
+ # Consecutive chain distances
251
+ GCS.distance_between(seattle, portland, sf) # => [Distance, Distance] (Array)
252
+ ```
253
+
254
+ ### Straight-Line Distance (ECEF Euclidean)
255
+
256
+ - **`straight_line_distance_to(other, *others)`** — Instance method. Computes the Euclidean distance in ECEF (3D Cartesian) space. Returns a `Distance` for a single target, or an Array of `Distance` objects for multiple targets.
257
+ - **`GCS.straight_line_distance_between(*coords)`** — Class method. Computes consecutive chain Euclidean distances.
258
+
259
+ ```ruby
260
+ seattle.straight_line_distance_to(portland) # => Distance
261
+ GCS.straight_line_distance_between(seattle, portland) # => Distance
262
+ ```
263
+
264
+ ### Cross-System Distances
265
+
266
+ Both `distance_to` and `straight_line_distance_to` accept any coordinate type. Coordinates are converted to LLA (for Vincenty) or ECEF (for Euclidean) internally:
267
+
268
+ ```ruby
269
+ utm = seattle.to_utm
270
+ mgrs = GCS::MGRS.from_lla(portland)
271
+ utm.distance_to(mgrs) # => Distance (235393.17 m)
272
+ ```
273
+
274
+ ### ENU and NED (Relative Systems)
275
+
276
+ ENU and NED are relative coordinate systems and do not support `distance_to` or `straight_line_distance_to` directly. Convert to an absolute system first:
277
+
278
+ ```ruby
279
+ ref = GCS::LLA.new(lat: 47.62, lng: -122.35, alt: 0.0)
280
+ lla = enu.to_lla(ref)
281
+ lla.distance_to(other_lla)
282
+ ```
283
+
284
+ ENU and NED retain `horizontal_distance_to` and `local_bearing_to` for local Euclidean operations within the tangent plane.
285
+
286
+ ---
287
+
288
+ ## Bearing Calculations
289
+
290
+ Universal bearing methods are available on all coordinate types and work across different coordinate systems. All bearing methods return `Bearing` objects.
291
+
292
+ ### Great-Circle Bearing (Forward Azimuth)
293
+
294
+ - **`bearing_to(other)`** — Instance method. Computes the great-circle forward azimuth from the receiver to the target coordinate. Returns a `Bearing` object.
295
+ - **`elevation_to(other)`** — Instance method. Computes the vertical look angle (elevation) from the receiver to the target. Returns a Float in degrees (-90 to +90).
296
+ - **`GCS.bearing_between(*coords)`** — Class method on `Geodetic::Coordinates` (aliased as `GCS`). Computes consecutive chain bearings between an ordered sequence of coordinates. Returns a `Bearing` for two coordinates, or an Array of `Bearing` objects for three or more.
297
+
298
+ ```ruby
299
+ seattle = GCS::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
300
+ portland = GCS::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
301
+ sf = GCS::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
302
+
303
+ # Forward azimuth
304
+ b = seattle.bearing_to(portland) # => Bearing
305
+ b.degrees # => 188.2...
306
+ b.to_compass(points: 8) # => "S"
307
+ b.reverse # => Bearing (back azimuth)
308
+
309
+ # Elevation angle
310
+ seattle.elevation_to(portland) # => Float (degrees)
311
+
312
+ # Consecutive chain bearings
313
+ GCS.bearing_between(seattle, portland, sf) # => [Bearing, Bearing]
314
+ ```
315
+
316
+ ### Cross-System Bearings
317
+
318
+ `bearing_to` and `elevation_to` accept any coordinate type. Coordinates are converted to LLA internally:
319
+
320
+ ```ruby
321
+ utm = seattle.to_utm
322
+ mgrs = GCS::MGRS.from_lla(portland)
323
+ utm.bearing_to(mgrs) # => Bearing
324
+ ```
325
+
326
+ ### ENU and NED (Relative Systems)
327
+
328
+ ENU and NED are relative coordinate systems and do not support `bearing_to` or `elevation_to` directly (these raise `ArgumentError`). Convert to an absolute system first, or use the local methods:
329
+
330
+ - **`local_bearing_to(other)`** — Local tangent plane bearing (degrees from north, 0-360)
331
+ - **`local_elevation_angle_to(other)`** — Local elevation angle (NED only, degrees)
332
+
333
+ ### Bearing Class
334
+
335
+ `Bearing` wraps an azimuth angle (0-360) with compass and radian conversions:
336
+
337
+ ```ruby
338
+ b = Geodetic::Bearing.new(225)
339
+ b.degrees # => 225.0
340
+ b.to_radians # => 3.926...
341
+ b.reverse # => Bearing (45)
342
+ b.to_compass(points: 4) # => "W"
343
+ b.to_compass(points: 8) # => "SW"
344
+ b.to_compass(points: 16) # => "SW"
345
+ b.to_s # => "225.0000°"
346
+
347
+ # Arithmetic
348
+ b + 10 # => Bearing (235°)
349
+ b - 10 # => Bearing (215°)
350
+ Bearing.new(90) - Bearing.new(45) # => 45.0 (Float, angular difference)
351
+ ```
@@ -0,0 +1,134 @@
1
+ # Datums Reference
2
+
3
+ ## Geodetic::Datum
4
+
5
+ A datum defines the reference ellipsoid used for geodetic calculations. The `Geodetic::Datum` class provides access to 16 pre-defined geodetic datums.
6
+
7
+ ### Constructor
8
+
9
+ ```ruby
10
+ datum = Geodetic::Datum.new(name: 'WGS84')
11
+ ```
12
+
13
+ The `name` parameter is case-insensitive and must match one of the 16 available datum names.
14
+
15
+ Raises `NameError` if the datum name is not recognized.
16
+
17
+ ### Instance Attributes
18
+
19
+ | Attribute | Type | Description |
20
+ |-----------|------|-------------|
21
+ | `name` | String | Uppercase datum name |
22
+ | `desc` | String | Human-readable description |
23
+ | `a` | Float | Semi-major axis (equatorial radius) in meters |
24
+ | `b` | Float | Semi-minor axis (polar radius) in meters |
25
+ | `f` | Float | Flattening (computed as `1.0 / f_inv`) |
26
+ | `f_inv` | Float | Inverse flattening |
27
+ | `e` | Float | First eccentricity (computed as `sqrt(e2)`) |
28
+ | `e2` | Float | First eccentricity squared |
29
+
30
+ All attributes have both reader and writer accessors.
31
+
32
+ ### WGS84 Constant
33
+
34
+ The most commonly used datum is available as a pre-built constant:
35
+
36
+ ```ruby
37
+ Geodetic::WGS84
38
+ # => #<Geodetic::Datum name="WGS84", a=6378137.0, f_inv=298.257223563>
39
+ ```
40
+
41
+ This constant is the default datum for all conversion methods throughout the gem.
42
+
43
+ ### Class Methods
44
+
45
+ #### `Datum.list`
46
+
47
+ Prints all available datums to STDOUT and returns `nil`.
48
+
49
+ ```ruby
50
+ Geodetic::Datum.list
51
+ # AIRY: Airy 1830
52
+ # MODIFIED_AIRY: Modified Airy
53
+ # ...
54
+ # WGS84: World Geodetic System 1984
55
+ ```
56
+
57
+ #### `Datum.get(name)`
58
+
59
+ Returns a Hash with all datum parameters for the given name. The name is case-insensitive. Raises `NameError` if the datum is not found.
60
+
61
+ ```ruby
62
+ Geodetic::Datum.get('WGS84')
63
+ # => {
64
+ # "name" => "WGS84",
65
+ # "desc" => "World Geodetic System 1984",
66
+ # "a" => 6378137.0,
67
+ # "f_inv" => 298.257223563,
68
+ # "f" => 0.0033528106647...,
69
+ # "b" => 6356752.3142451793,
70
+ # "e2" => 0.00669437999014132,
71
+ # "e" => 0.08181919084...
72
+ # }
73
+ ```
74
+
75
+ ### Available Datums
76
+
77
+ | Name | Description | Semi-Major Axis (a) |
78
+ |------|-------------|---------------------|
79
+ | `AIRY` | Airy 1830 | 6,377,563.396 m |
80
+ | `MODIFIED_AIRY` | Modified Airy | 6,377,340.189 m |
81
+ | `AUSTRALIAN_NATIONAL` | Australian National | 6,378,160.0 m |
82
+ | `BESSEL_1841` | Bessel 1841 | 6,377,397.155 m |
83
+ | `CLARKE_1866` | Clarke 1866 | 6,378,206.4 m |
84
+ | `CLARKE_1880` | Clarke 1880 | 6,378,249.145 m |
85
+ | `EVEREST_INDIA_1830` | Everest (India 1830) | 6,377,276.345 m |
86
+ | `EVEREST_BRUNEI_E_MALAYSIA` | Everest (Brunei & E.Malaysia) | 6,377,298.556 m |
87
+ | `EVEREST_W_MALAYSIA_SINGAPORE` | Everest (W.Malaysia & Singapore) | 6,377,304.063 m |
88
+ | `GRS_1980` | Geodetic Reference System 1980 | 6,378,137.0 m |
89
+ | `HELMERT_1906` | Helmert 1906 | 6,378,200.0 m |
90
+ | `HOUGH_1960` | Hough 1960 | 6,378,270.0 m |
91
+ | `INTERNATIONAL_1924` | International 1924 | 6,378,388.0 m |
92
+ | `SOUTH_AMERICAN_1969` | South American 1969 | 6,378,160.0 m |
93
+ | `WGS72` | World Geodetic System 1972 | 6,378,135.0 m |
94
+ | `WGS84` | World Geodetic System 1984 | 6,378,137.0 m |
95
+
96
+ ---
97
+
98
+ ## Module Functions
99
+
100
+ The `Geodetic` module provides two conversion functions available as module methods:
101
+
102
+ ```ruby
103
+ Geodetic.deg2rad(180.0) # => 3.14159265...
104
+ Geodetic.rad2deg(Math::PI) # => 180.0
105
+ ```
106
+
107
+ | Function | Description |
108
+ |----------|-------------|
109
+ | `deg2rad(deg)` | Converts degrees to radians. Multiplies by `RAD_PER_DEG`. |
110
+ | `rad2deg(rad)` | Converts radians to degrees. Multiplies by `DEG_PER_RAD`. |
111
+
112
+ ---
113
+
114
+ ## Module Constants
115
+
116
+ | Constant | Value | Description |
117
+ |----------|-------|-------------|
118
+ | `RAD_PER_DEG` | 0.0174532925199433 | Radians per degree |
119
+ | `DEG_PER_RAD` | 57.2957795130823 | Degrees per radian |
120
+ | `QUARTER_PI` | 0.785398163397448 | Pi / 4 |
121
+ | `HALF_PI` | 1.5707963267949 | Pi / 2 |
122
+ | `FEET_PER_METER` | 3.2808399 | International feet per meter |
123
+ | `FEET_PER_MILE` | 5280.0 | Feet per statute mile |
124
+ | `INCH_PER_FOOT` | 12.0 | Inches per foot |
125
+ | `KM_PER_MILE` | 1.609344 | Kilometers per statute mile |
126
+ | `MILE_PER_KM` | 0.621371192237334 | Statute miles per kilometer |
127
+ | `SM_PER_NM` | 0.868976242 | Statute miles per nautical mile |
128
+ | `NM_PER_SM` | 1.15077944789197 | Nautical miles per statute mile |
129
+ | `NM_PER_DEG` | 60.0 | Nautical miles per degree of latitude |
130
+ | `SM_PER_DEG` | 52.13857452 | Statute miles per degree of latitude |
131
+ | `MILES_PER_DEG` | 52.13857452 | Alias for `SM_PER_DEG` |
132
+ | `GRAVITY_MS2` | 9.80665 | Standard gravity in m/s^2 |
133
+ | `GRAVITY_FS2` | 32.174 | Standard gravity in ft/s^2 |
134
+ | `GRAVITY` | 9.80665 | Alias for `GRAVITY_MS2` |