@@ -315,6 +315,90 @@ pub fn ecef_to_lla(latitude: &f64, longitude: &f64) -> Matrix3<f64> {
315315pub fn lla_to_ecef ( latitude : & f64 , longitude : & f64 ) -> Matrix3 < f64 > {
316316 ecef_to_lla ( latitude, longitude) . transpose ( )
317317}
318+ /// Convert NED displacements (north/east, meters) into changes in latitude/longitude (radians).
319+ ///
320+ /// Uses a simplified WGS84 ellipsoidal Earth model to compute radii of curvature
321+ /// in the meridian and prime vertical, then converts linear displacements to
322+ /// angular offsets.
323+ ///
324+ /// ## Arguments
325+ /// - `lat_rad`: Current geodetic latitude in **radians**.
326+ /// - `alt_m`: Current altitude above the ellipsoid in meters.
327+ /// - `d_n`: Northward displacement in meters.
328+ /// - `d_e`: Eastward displacement in meters.
329+ ///
330+ /// ## Returns
331+ /// A tuple `(dlat, dlon)`:
332+ /// - `dlat`: Change in latitude (radians).
333+ /// - `dlon`: Change in longitude (radians).
334+ ///
335+ /// ## Notes
336+ /// - A small clamp is applied to `cos(lat)` to avoid division by zero at the poles.
337+ /// - This is an approximation sufficient for small offsets (meters to kilometers).
338+ ///
339+ /// ## Example
340+ ///
341+ /// ```
342+ /// let lat0 = 40.0_f64.to_radians();
343+ /// let alt = 0.0;
344+ /// // Move 100 m north and 50 m east
345+ /// let (dlat, dlon) = strapdown::earth::meters_ned_to_dlat_dlon(lat0, alt, 100.0, 50.0);
346+ /// println!("Δlat = {} rad, Δlon = {} rad", dlat, dlon);
347+ /// ```
348+ pub fn meters_ned_to_dlat_dlon ( lat_rad : f64 , alt_m : f64 , d_n : f64 , d_e : f64 ) -> ( f64 , f64 ) {
349+ // WGS84 principal radii (approx)
350+ let a = 6378137.0 ;
351+ let e2 = 6.69437999014e-3 ;
352+ let sin_lat = lat_rad. sin ( ) ;
353+ let rn = a / ( 1.0 - e2 * sin_lat * sin_lat) . sqrt ( ) ;
354+ let re = rn * ( 1.0 - e2) / ( 1.0 - e2 * sin_lat * sin_lat) ;
355+ let dlat = d_n / ( rn + alt_m) ;
356+ let dlon = d_e / ( ( re + alt_m) * lat_rad. cos ( ) . max ( 1e-6 ) ) ;
357+ ( dlat, dlon)
358+ }
359+ /// Compute the great-circle distance between two geodetic coordinates
360+ /// using the haversine formula.
361+ ///
362+ /// This assumes a spherical Earth with mean radius `R = 6,371,000 m`.
363+ /// For higher precision you can adapt this to use ellipsoidal formulas,
364+ /// but haversine is usually accurate to within ~0.5% for most navigation
365+ /// and simulation purposes.
366+ ///
367+ /// ## Arguments
368+ /// - `lat1_rad`: Latitude of first point (radians).
369+ /// - `lon1_rad`: Longitude of first point (radians).
370+ /// - `lat2_rad`: Latitude of second point (radians).
371+ /// - `lon2_rad`: Longitude of second point (radians).
372+ ///
373+ /// ## Returns
374+ /// Great-circle distance between the two points, in meters.
375+ ///
376+ /// ## Example
377+ /// ```
378+ /// let d = strapdown::earth::haversine_distance(
379+ /// 40.0_f64.to_radians(), -75.0_f64.to_radians(),
380+ /// 41.0_f64.to_radians(), -74.0_f64.to_radians(),
381+ /// );
382+ /// println!("Distance: {:.1} km", d / 1000.0);
383+ /// ```
384+ pub fn haversine_distance (
385+ lat1_rad : f64 ,
386+ lon1_rad : f64 ,
387+ lat2_rad : f64 ,
388+ lon2_rad : f64 ,
389+ ) -> f64 {
390+ let dlat = lat2_rad - lat1_rad;
391+ let dlon = lon2_rad - lon1_rad;
392+
393+ let a = ( dlat / 2.0 ) . sin ( ) . powi ( 2 )
394+ + lat1_rad. cos ( ) * lat2_rad. cos ( ) * ( dlon / 2.0 ) . sin ( ) . powi ( 2 ) ;
395+
396+ let c = 2.0 * a. sqrt ( ) . atan2 ( ( 1.0 - a) . sqrt ( ) ) ;
397+
398+ const R : f64 = 6_371_000.0 ; // mean Earth radius in meters
399+ R * c
400+ }
401+
318402/// Calculate principal radii of curvature
319403///
320404/// The [principal radii of curvature](https://en.wikipedia.org/wiki/Earth_radius) are used to
@@ -728,6 +812,7 @@ pub fn magnetic_anomaly(
728812mod tests {
729813 use super :: * ;
730814 use assert_approx_eq:: assert_approx_eq;
815+ const KM : f64 = 1000.0 ;
731816 #[ test]
732817 fn vector_to_skew_symmetric ( ) {
733818 let v: Vector3 < f64 > = Vector3 :: new ( 1.0 , 2.0 , 3.0 ) ;
@@ -855,4 +940,70 @@ mod tests {
855940 assert_approx_eq ! ( rot1[ ( 2 , 1 ) ] , rot2[ ( 2 , 1 ) ] , 1e-7 ) ;
856941 assert_approx_eq ! ( rot1[ ( 2 , 2 ) ] , rot2[ ( 2 , 2 ) ] , 1e-7 ) ;
857942 }
943+ #[ test]
944+ fn test_meters_ned_to_dlat_dlon ( ) {
945+ // Test the conversion from North/East meters to latitude/longitude deltas
946+
947+ // At the equator, 1 degree of latitude is approximately 111.32 km
948+ // and 1 degree of longitude is approximately 111.32 km as well
949+ let lat_rad = 0.0 ; // equator
950+ let alt_m = 0.0 ; // sea level
951+
952+ // Test northward offset
953+ let ( dlat, dlon) = meters_ned_to_dlat_dlon ( lat_rad, alt_m, 1852.0 , 0.0 ) ;
954+ assert_approx_eq ! ( dlat. abs( ) , ( 1.0 / 60.0 as f64 ) . to_radians( ) , 0.0001 ) ; // ~1 degree latitude change
955+ assert_approx_eq ! ( dlon. abs( ) , 0.0 , 0.0001 ) ; // ~0 longitude change
956+
957+ // Test eastward offset
958+ let ( dlat, dlon) = meters_ned_to_dlat_dlon ( lat_rad, alt_m, 0.0 , 1852.0 ) ;
959+ assert ! ( dlat. abs( ) < 0.0001 ) ; // ~0 latitude change
960+ assert_approx_eq ! ( dlon. abs( ) , ( 1.0 / 60.0 as f64 ) . to_radians( ) , 0.0001 ) ; // ~1 degree longitude change
961+
962+ // Test at 45 degrees north, where longitude degrees are shorter
963+ let lat_rad = 45.0_f64 . to_radians ( ) ;
964+
965+ // At 45°N, 1 degree of longitude is about 111.32 * cos(45°) = 78.7 km
966+ let ( dlat, dlon) = meters_ned_to_dlat_dlon ( lat_rad, alt_m, 0.0 , 111.320 ) ;
967+ assert_approx_eq ! ( dlat. abs( ) , 0.0 , 0.0001 ) ; // ~0 latitude change
968+ assert_approx_eq ! ( dlon. abs( ) , 0.0 , 0.0001 ) ; // ~1 degree longitude change
969+ }
970+
971+ #[ test]
972+ fn zero_distance ( ) {
973+ let lat = 40.0_f64 . to_radians ( ) ;
974+ let lon = -75.0_f64 . to_radians ( ) ;
975+ let d = haversine_distance ( lat, lon, lat, lon) ;
976+ assert ! ( d. abs( ) < 1e-9 , "Zero distance should be ~0, got {}" , d) ;
977+ }
978+
979+ #[ test]
980+ fn quarter_circumference_equator ( ) {
981+ // From (0,0) to (0,90°E) is a quarter of Earth's circumference
982+ let d = haversine_distance ( 0.0 , 0.0 , 0.0 , 90_f64 . to_radians ( ) ) ;
983+ let expected = std:: f64:: consts:: FRAC_PI_2 * 6_371_000.0 ; // R * π/2
984+ let err = ( d - expected) . abs ( ) ;
985+ assert ! ( err < 1e-6 * expected, "Error too large: got {}, expected {}" , d, expected) ;
986+ }
987+
988+ #[ test]
989+ fn antipodal_points ( ) {
990+ // (0,0) vs (0,180°E) → half Earth's circumference
991+ let d = haversine_distance ( 0.0 , 0.0 , 0.0 , 180_f64 . to_radians ( ) ) ;
992+ let expected = std:: f64:: consts:: PI * 6_371_000.0 ; // R * π
993+ let err = ( d - expected) . abs ( ) ;
994+ assert ! ( err < 1e-6 * expected, "Error too large: got {}, expected {}" , d, expected) ;
995+ }
996+
997+ #[ test]
998+ fn known_baseline_nyc_to_london ( ) {
999+ // Approx coordinates
1000+ let nyc_lat = 40.7128_f64 . to_radians ( ) ;
1001+ let nyc_lon = -74.0060_f64 . to_radians ( ) ;
1002+ let lon_lat = 51.5074_f64 . to_radians ( ) ;
1003+ let lon_lon = -0.1278_f64 . to_radians ( ) ;
1004+
1005+ let d = haversine_distance ( nyc_lat, nyc_lon, lon_lat, lon_lon) ;
1006+ // Real-world great-circle distance ~5567 km
1007+ assert ! ( ( d / KM - 5567.0 ) . abs( ) < 50.0 , "NYC-LON distance off: {} km" , d / KM ) ;
1008+ }
8581009}
0 commit comments