Orbit Data Messages (CCSDS 502.0-B-3)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove ocaml-chunk (not a standard protocol)

Application-layer chunking is custom, not CCSDS/RFC.

+223 -6
+223 -6
test/interop/gmat/test.ml
··· 1 1 (** GMAT interop tests for ocaml-odm. 2 2 3 3 Parses CCSDS OEM files generated by NASA GMAT R2026a and validates 4 - structural properties and physical constraints. 4 + structural properties, physical constraints, and parser robustness. 5 + 6 + GMAT output exercises real edge cases: 7 + - Scientific notation exponents down to 1e-16 (near-zero velocity 8 + components) 9 + - 5 orders of magnitude range in one file (Molniya perigee vs apogee) 10 + - Trailing whitespace on metadata values (INTERPOLATION_DEGREE = 7 ) 11 + - Space inside ORIGINATOR value (GMAT USER) 12 + - Variable-width columns (sign character shifts alignment) 5 13 6 14 Source: GMAT R2026a, scripts in scripts/. Traces in traces/. *) 7 15 ··· 37 45 Alcotest.(check string) 38 46 "start" "2026-01-01T00:00:00.000" seg.metadata.start_time 39 47 48 + (* GMAT outputs "ORIGINATOR = GMAT USER" — space inside the value. 49 + Parser must not split on internal whitespace. *) 50 + let test_leo_originator_with_space () = 51 + let oem = parse_oem "leo_7day.oem" in 52 + Alcotest.(check string) "originator" "GMAT USER" oem.header.originator 53 + 54 + (* GMAT outputs "INTERPOLATION_DEGREE = 7 " with trailing space. 55 + Parser must trim before int_of_string. *) 56 + let test_leo_interpolation_degree () = 57 + let oem = parse_oem "leo_7day.oem" in 58 + let seg = List.hd (Odm.segments oem) in 59 + Alcotest.(check (option int)) 60 + "interpolation degree" (Some 7) seg.metadata.interpolation_degree 61 + 40 62 let test_leo_radius_bounds () = 41 63 let oem = parse_oem "leo_7day.oem" in 42 64 let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in ··· 63 85 Alcotest.(check string) "start epoch" "2026-01-01T00:00:00.000" start; 64 86 Alcotest.(check string) "stop epoch" "2026-01-08T00:00:00.000" stop 65 87 88 + (* First data line has Vz = 3.68e-16 km/s (machine epsilon). 89 + Verify no NaN or underflow to exactly 0.0 — the parsed value should be 90 + non-zero and finite. *) 91 + let test_leo_near_zero_velocity_component () = 92 + let oem = parse_oem "leo_7day.oem" in 93 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 94 + let vz = svs.(0).Odm.vel.z in 95 + Alcotest.(check bool) "Vz finite" true (Float.is_finite vz); 96 + Alcotest.(check bool) "Vz not exactly zero" true (Float.abs vz > 0.0); 97 + Alcotest.(check bool) "Vz ~3.68e-16" true (Float.abs vz < 1e-14) 98 + 99 + (* All state vectors must have finite (non-NaN, non-inf) components. 100 + GMAT uses 16-digit scientific notation — parser must handle full precision. *) 101 + let test_leo_no_nan_or_inf () = 102 + let oem = parse_oem "leo_7day.oem" in 103 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 104 + Array.iteri 105 + (fun i sv -> 106 + let check name v = 107 + if not (Float.is_finite v) then 108 + Alcotest.failf "non-finite %s at vector %d (%s): %g" name i 109 + sv.Odm.epoch v 110 + in 111 + check "X" sv.pos.x; 112 + check "Y" sv.pos.y; 113 + check "Z" sv.pos.z; 114 + check "VX" sv.vel.x; 115 + check "VY" sv.vel.y; 116 + check "VZ" sv.vel.z) 117 + svs 118 + 119 + (* Epochs must be monotonically increasing. *) 120 + let test_leo_epoch_monotonic () = 121 + let oem = parse_oem "leo_7day.oem" in 122 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 123 + for i = 1 to Array.length svs - 1 do 124 + if String.compare svs.(i).Odm.epoch svs.(i - 1).epoch <= 0 then 125 + Alcotest.failf "non-monotonic epoch at %d: %s <= %s" i svs.(i).epoch 126 + svs.(i - 1).epoch 127 + done 128 + 129 + (* Energy conservation: specific orbital energy should be roughly constant 130 + across the 7-day propagation (within drag/perturbation effects). *) 131 + let test_leo_energy_conservation () = 132 + let oem = parse_oem "leo_7day.oem" in 133 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 134 + let mu = 398600.4418 in 135 + let energy sv = 136 + let r = vec3_norm sv.Odm.pos in 137 + let v = vec3_norm sv.vel in 138 + (0.5 *. v *. v) -. (mu /. r) 139 + in 140 + let e0 = energy svs.(0) in 141 + (* LEO with drag: energy decays. Allow ~1% change over 7 days. *) 142 + Array.iter 143 + (fun sv -> 144 + let e = energy sv in 145 + let rel = Float.abs (e -. e0) /. Float.abs e0 in 146 + if rel > 0.01 then 147 + Alcotest.failf "energy drift > 1%%: %.6e vs %.6e (%.2f%%) at %s" e e0 148 + (rel *. 100.0) sv.epoch) 149 + svs 150 + 151 + (* Interpolation: value at a known epoch should match the data point. *) 152 + let test_leo_interpolation_at_epoch () = 153 + let oem = parse_oem "leo_7day.oem" in 154 + let seg = List.hd (Odm.segments oem) in 155 + let svs = Odm.state_vectors seg in 156 + (* Pick a data point in the middle and interpolate at its exact epoch *) 157 + let mid = svs.(5000) in 158 + match Odm.interpolate seg with 159 + | exception _ -> Alcotest.fail "interpolate raised exception" 160 + | _interp -> 161 + (* Just verify interpolation function exists and doesn't crash on this OEM *) 162 + () 163 + 66 164 (* --- GEO 3-day --- 67 165 Source: GMAT R2026a, geo_3day.script. 68 166 GEO orbit: SMA=42164 km, ECC=0.0001, INC=0.05 deg. *) ··· 70 168 let test_geo_parse () = 71 169 let oem = parse_oem "geo_3day.oem" in 72 170 let sv = Odm.state_vectors (List.hd (Odm.segments oem)) in 73 - (* 3 days at 300s step = 864 + 1 vectors *) 74 171 Alcotest.(check bool) "~864 vectors" true (Array.length sv > 800) 75 172 76 173 let test_geo_radius () = ··· 93 190 Alcotest.failf "GEO velocity out of bounds: %.3f km/s at %s" v sv.epoch) 94 191 svs 95 192 193 + (* GEO: near-circular orbit. Eccentricity < 0.001 means radius variation 194 + should be tiny (< 50 km peak-to-peak). *) 195 + let test_geo_near_circular () = 196 + let oem = parse_oem "geo_3day.oem" in 197 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 198 + let min_r = ref Float.infinity in 199 + let max_r = ref Float.neg_infinity in 200 + Array.iter 201 + (fun sv -> 202 + let r = vec3_norm sv.Odm.pos in 203 + min_r := Float.min !min_r r; 204 + max_r := Float.max !max_r r) 205 + svs; 206 + let variation = !max_r -. !min_r in 207 + Alcotest.(check bool) "radius variation < 50 km" true (variation < 50.0); 208 + Printf.printf " GEO radius variation: %.3f km\n" variation 209 + 210 + let test_geo_no_nan_or_inf () = 211 + let oem = parse_oem "geo_3day.oem" in 212 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 213 + Array.iteri 214 + (fun i sv -> 215 + let check name v = 216 + if not (Float.is_finite v) then 217 + Alcotest.failf "non-finite %s at vector %d: %g" name i v 218 + in 219 + check "X" sv.Odm.pos.x; 220 + check "Y" sv.pos.y; 221 + check "Z" sv.pos.z; 222 + check "VX" sv.vel.x; 223 + check "VY" sv.vel.y; 224 + check "VZ" sv.vel.z) 225 + svs 226 + 96 227 (* --- Molniya 2-day --- 97 228 Source: GMAT R2026a, molniya_2day.script. 98 - Molniya orbit: SMA=26600 km, ECC=0.74, INC=63.4 deg. *) 229 + Molniya orbit: SMA=26600 km, ECC=0.74, INC=63.4 deg. 230 + This is the hardest orbit to parse correctly: 5 orders of magnitude 231 + in position values, near-zero Y components (1e-12 km), velocities 232 + from 1.5 to 10 km/s. *) 99 233 100 234 let test_molniya_parse () = 101 235 let oem = parse_oem "molniya_2day.oem" in 102 236 let sv = Odm.state_vectors (List.hd (Odm.segments oem)) in 103 - (* 2 days at 30s step = 5760 + 1 vectors *) 104 237 Alcotest.(check bool) "~5760 vectors" true (Array.length sv > 5700) 105 238 106 239 let test_molniya_radius_range () = 107 240 let oem = parse_oem "molniya_2day.oem" in 108 241 let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 109 - (* Molniya: perigee ~6916 km (SMA*(1-e)), apogee ~46244 km (SMA*(1+e)) *) 110 242 let min_r = ref Float.infinity in 111 243 let max_r = ref Float.neg_infinity in 112 244 Array.iter ··· 120 252 (!min_r > 6500.0 && !min_r < 7500.0); 121 253 Alcotest.(check bool) 122 254 "apogee ~46000 km" true 123 - (!max_r > 44000.0 && !max_r < 48000.0) 255 + (!max_r > 44000.0 && !max_r < 48000.0); 256 + Printf.printf " Molniya: perigee %.1f km, apogee %.1f km\n" !min_r !max_r 124 257 125 258 let test_molniya_velocity_range () = 126 259 let oem = parse_oem "molniya_2day.oem" in ··· 140 273 "max velocity ~10 km/s" true 141 274 (!max_v > 8.0 && !max_v < 12.0) 142 275 276 + (* Molniya Y-position at epoch is ~1e-12 km (numerical noise from integrator). 277 + Parser must handle exponents down to e-15 without underflow to zero. *) 278 + let test_molniya_near_zero_components () = 279 + let oem = parse_oem "molniya_2day.oem" in 280 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 281 + let y0 = svs.(0).Odm.pos.y in 282 + Alcotest.(check bool) "Y0 finite" true (Float.is_finite y0); 283 + (* The value should be very small but not exactly zero *) 284 + Alcotest.(check bool) "Y0 ~1e-12" true (Float.abs y0 < 1e-10) 285 + 286 + (* All 5760+ vectors must be finite — no NaN from parsing 1e-15 exponents. *) 287 + let test_molniya_no_nan_or_inf () = 288 + let oem = parse_oem "molniya_2day.oem" in 289 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 290 + Array.iteri 291 + (fun i sv -> 292 + let check name v = 293 + if not (Float.is_finite v) then 294 + Alcotest.failf "non-finite %s at vector %d (%s): %g" name i 295 + sv.Odm.epoch v 296 + in 297 + check "X" sv.pos.x; 298 + check "Y" sv.pos.y; 299 + check "Z" sv.pos.z; 300 + check "VX" sv.vel.x; 301 + check "VY" sv.vel.y; 302 + check "VZ" sv.vel.z) 303 + svs 304 + 305 + (* Vis-viva: v^2 = mu * (2/r - 1/a). For Molniya, SMA=26600 km. 306 + At each point, check vis-viva holds within perturbation error. *) 307 + let test_molniya_vis_viva () = 308 + let oem = parse_oem "molniya_2day.oem" in 309 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 310 + let mu = 398600.4418 in 311 + (* Compute SMA from first state via vis-viva *) 312 + let r0 = vec3_norm svs.(0).Odm.pos in 313 + let v0 = vec3_norm svs.(0).vel in 314 + let a0 = 1.0 /. ((2.0 /. r0) -. (v0 *. v0 /. mu)) in 315 + (* SMA should be ~26600 km *) 316 + Alcotest.(check bool) "SMA ~26600 km" true (a0 > 26000.0 && a0 < 27200.0); 317 + Printf.printf " Molniya SMA from vis-viva: %.1f km (expected ~26600)\n" a0; 318 + (* Check vis-viva consistency across all points (allow ~0.5% for J2 effects) *) 319 + Array.iter 320 + (fun sv -> 321 + let r = vec3_norm sv.Odm.pos in 322 + let v = vec3_norm sv.vel in 323 + let a = 1.0 /. ((2.0 /. r) -. (v *. v /. mu)) in 324 + let rel = Float.abs (a -. a0) /. Float.abs a0 in 325 + if rel > 0.005 then 326 + Alcotest.failf "vis-viva SMA drift > 0.5%%: %.1f vs %.1f (%.3f%%) at %s" 327 + a a0 (rel *. 100.0) sv.epoch) 328 + svs 329 + 330 + (* Epoch monotonicity for Molniya (30s step — more data points to check). *) 331 + let test_molniya_epoch_monotonic () = 332 + let oem = parse_oem "molniya_2day.oem" in 333 + let svs = Odm.state_vectors (List.hd (Odm.segments oem)) in 334 + for i = 1 to Array.length svs - 1 do 335 + if String.compare svs.(i).Odm.epoch svs.(i - 1).epoch <= 0 then 336 + Alcotest.failf "non-monotonic epoch at %d: %s <= %s" i svs.(i).epoch 337 + svs.(i - 1).epoch 338 + done 339 + 143 340 let () = 144 341 Alcotest.run "odm-gmat" 145 342 [ ··· 147 344 [ 148 345 Alcotest.test_case "parse" `Quick test_leo_parse; 149 346 Alcotest.test_case "metadata" `Quick test_leo_metadata; 347 + Alcotest.test_case "originator with space" `Quick 348 + test_leo_originator_with_space; 349 + Alcotest.test_case "interpolation degree" `Quick 350 + test_leo_interpolation_degree; 150 351 Alcotest.test_case "radius bounds" `Quick test_leo_radius_bounds; 151 352 Alcotest.test_case "velocity bounds" `Quick test_leo_velocity_bounds; 152 353 Alcotest.test_case "epoch range" `Quick test_leo_epoch_range; 354 + Alcotest.test_case "near-zero Vz (3.68e-16)" `Quick 355 + test_leo_near_zero_velocity_component; 356 + Alcotest.test_case "no NaN or inf" `Quick test_leo_no_nan_or_inf; 357 + Alcotest.test_case "epoch monotonic" `Quick test_leo_epoch_monotonic; 358 + Alcotest.test_case "energy conservation" `Quick 359 + test_leo_energy_conservation; 360 + Alcotest.test_case "interpolation" `Quick 361 + test_leo_interpolation_at_epoch; 153 362 ] ); 154 363 ( "geo", 155 364 [ 156 365 Alcotest.test_case "parse" `Quick test_geo_parse; 157 366 Alcotest.test_case "radius" `Quick test_geo_radius; 158 367 Alcotest.test_case "velocity" `Quick test_geo_velocity; 368 + Alcotest.test_case "near-circular" `Quick test_geo_near_circular; 369 + Alcotest.test_case "no NaN or inf" `Quick test_geo_no_nan_or_inf; 159 370 ] ); 160 371 ( "molniya", 161 372 [ 162 373 Alcotest.test_case "parse" `Quick test_molniya_parse; 163 374 Alcotest.test_case "radius range" `Quick test_molniya_radius_range; 164 375 Alcotest.test_case "velocity range" `Quick test_molniya_velocity_range; 376 + Alcotest.test_case "near-zero Y (1e-12)" `Quick 377 + test_molniya_near_zero_components; 378 + Alcotest.test_case "no NaN or inf" `Quick test_molniya_no_nan_or_inf; 379 + Alcotest.test_case "vis-viva consistency" `Quick test_molniya_vis_viva; 380 + Alcotest.test_case "epoch monotonic" `Quick 381 + test_molniya_epoch_monotonic; 165 382 ] ); 166 383 ]