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

Configure Feed

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

Add ocaml-odm: CCSDS Orbit Data Messages (502.0-B-3)

OEM (Orbit Ephemeris Message) KVN parser with multi-segment support.
Parses state vectors (position, velocity, optional acceleration),
metadata (ref frame, interpolation, time system), and comments.

Test vectors: Mars Global Surveyor (Blue Book Fig 5.1, multi-segment
with trajectory correction maneuver), ISS single-segment, and
acceleration data. 10 tests, merlint clean.

Critical path for ssa.space: TraCSS ephemeris submission, CREAM
maneuver integration, and maneuver what-if analysis.

+809
+1
.ocamlformat
··· 1 + profile = default
+22
dune-project
··· 1 + (lang dune 3.21) 2 + (name odm) 3 + (source (tangled gazagnaire.org/ocaml-odm)) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (package 12 + (name odm) 13 + (synopsis "Orbit Data Messages (CCSDS 502.0-B-3)") 14 + (description 15 + "Parse and manipulate Orbit Data Messages for satellite ephemeris \ 16 + exchange. Supports CCSDS 502.0-B-3 OEM (Orbit Ephemeris Message) \ 17 + KVN format with multi-segment and acceleration data.") 18 + (depends 19 + (ocaml (>= 4.14)) 20 + ptime 21 + fmt 22 + (alcotest :with-test)))
+4
lib/dune
··· 1 + (library 2 + (name odm) 3 + (public_name odm) 4 + (libraries ptime fmt))
+354
lib/odm.ml
··· 1 + (** Orbit Data Messages (CCSDS 502.0-B-3). *) 2 + 3 + type vec3 = { x : float; y : float; z : float } 4 + 5 + type state_vector = { 6 + epoch : string; 7 + pos : vec3; 8 + vel : vec3; 9 + accel : vec3 option; 10 + } 11 + 12 + type metadata = { 13 + object_name : string; 14 + object_id : string; 15 + center_name : string; 16 + ref_frame : string; 17 + time_system : string; 18 + start_time : string; 19 + stop_time : string; 20 + useable_start_time : string option; 21 + useable_stop_time : string option; 22 + interpolation : string option; 23 + interpolation_degree : int option; 24 + } 25 + 26 + type segment = { 27 + metadata : metadata; 28 + data : state_vector array; 29 + comments : string list; 30 + } 31 + 32 + type header = { 33 + ccsds_oem_vers : string; 34 + creation_date : string; 35 + originator : string; 36 + comments : string list; 37 + } 38 + 39 + type t = { header : header; segments : segment list } 40 + 41 + type error = 42 + | Missing_header 43 + | Missing_metadata 44 + | Bad_line of { line : int; content : string } 45 + | Parse_error of string 46 + 47 + let pp_error ppf = function 48 + | Missing_header -> Fmt.pf ppf "missing OEM header" 49 + | Missing_metadata -> Fmt.pf ppf "missing META_START" 50 + | Bad_line { line; content } -> 51 + Fmt.pf ppf "bad line %d: %s" line content 52 + | Parse_error msg -> Fmt.pf ppf "parse error: %s" msg 53 + 54 + (* Pretty-printers *) 55 + 56 + let pp_vec3 ppf v = Fmt.pf ppf "(%.6f, %.6f, %.6f)" v.x v.y v.z 57 + 58 + let pp_state_vector ppf sv = 59 + Fmt.pf ppf "%s pos=%a vel=%a" sv.epoch pp_vec3 sv.pos pp_vec3 sv.vel; 60 + match sv.accel with 61 + | None -> () 62 + | Some a -> Fmt.pf ppf " accel=%a" pp_vec3 a 63 + 64 + let pp_metadata ppf m = 65 + Fmt.pf ppf 66 + "@[<v>OBJECT_NAME = %s@,\ 67 + OBJECT_ID = %s@,\ 68 + CENTER_NAME = %s@,\ 69 + REF_FRAME = %s@,\ 70 + TIME_SYSTEM = %s@,\ 71 + START_TIME = %s@,\ 72 + STOP_TIME = %s@]" m.object_name m.object_id m.center_name m.ref_frame 73 + m.time_system m.start_time m.stop_time 74 + 75 + let pp_segment ppf seg = 76 + Fmt.pf ppf "@[<v>META:@, %a@,DATA: %d vectors@]" pp_metadata seg.metadata 77 + (Array.length seg.data) 78 + 79 + let pp_header ppf h = 80 + Fmt.pf ppf "CCSDS_OEM_VERS = %s@,CREATION_DATE = %s@,ORIGINATOR = %s" 81 + h.ccsds_oem_vers h.creation_date h.originator 82 + 83 + let pp ppf t = 84 + Fmt.pf ppf "@[<v>%a@,%a@]" pp_header t.header 85 + Fmt.(list ~sep:(Fmt.any "@,") pp_segment) 86 + t.segments 87 + 88 + (* Accessors *) 89 + 90 + let segments t = t.segments 91 + let state_vectors seg = seg.data 92 + let epoch_range seg = (seg.metadata.start_time, seg.metadata.stop_time) 93 + 94 + (* Parsing helpers *) 95 + 96 + let trim s = String.trim s 97 + 98 + let split_kv line = 99 + match String.index_opt line '=' with 100 + | None -> None 101 + | Some i -> 102 + let key = trim (String.sub line 0 i) in 103 + let value = trim (String.sub line (i + 1) (String.length line - i - 1)) in 104 + Some (key, value) 105 + 106 + let is_data_line s = 107 + let s = trim s in 108 + String.length s > 0 109 + && (let c = s.[0] in 110 + (c >= '0' && c <= '9') || c = '-') 111 + 112 + let split_spaces s = 113 + String.split_on_char ' ' s |> List.filter (fun s -> String.length s > 0) 114 + 115 + let parse_state_vector line_num line = 116 + let tokens = split_spaces (trim line) in 117 + match tokens with 118 + | epoch :: rest -> ( 119 + let floats = 120 + List.map 121 + (fun s -> 122 + match float_of_string_opt s with 123 + | Some f -> f 124 + | None -> Float.nan) 125 + rest 126 + in 127 + match floats with 128 + | [ x; y; z; vx; vy; vz ] -> 129 + Ok 130 + { 131 + epoch; 132 + pos = { x; y; z }; 133 + vel = { x = vx; y = vy; z = vz }; 134 + accel = None; 135 + } 136 + | [ x; y; z; vx; vy; vz; ax; ay; az ] -> 137 + Ok 138 + { 139 + epoch; 140 + pos = { x; y; z }; 141 + vel = { x = vx; y = vy; z = vz }; 142 + accel = Some { x = ax; y = ay; z = az }; 143 + } 144 + | _ -> Error (Bad_line { line = line_num; content = line })) 145 + | [] -> Error (Bad_line { line = line_num; content = line }) 146 + 147 + let empty_metadata = 148 + { 149 + object_name = ""; 150 + object_id = ""; 151 + center_name = ""; 152 + ref_frame = ""; 153 + time_system = ""; 154 + start_time = ""; 155 + stop_time = ""; 156 + useable_start_time = None; 157 + useable_stop_time = None; 158 + interpolation = None; 159 + interpolation_degree = None; 160 + } 161 + 162 + let set_meta_field meta key value = 163 + match key with 164 + | "OBJECT_NAME" -> { meta with object_name = value } 165 + | "OBJECT_ID" -> { meta with object_id = value } 166 + | "CENTER_NAME" -> { meta with center_name = value } 167 + | "REF_FRAME" -> { meta with ref_frame = value } 168 + | "TIME_SYSTEM" -> { meta with time_system = value } 169 + | "START_TIME" -> { meta with start_time = value } 170 + | "STOP_TIME" -> { meta with stop_time = value } 171 + | "USEABLE_START_TIME" -> { meta with useable_start_time = Some value } 172 + | "USEABLE_STOP_TIME" -> { meta with useable_stop_time = Some value } 173 + | "INTERPOLATION" -> { meta with interpolation = Some value } 174 + | "INTERPOLATION_DEGREE" -> ( 175 + match int_of_string_opt value with 176 + | Some d -> { meta with interpolation_degree = Some d } 177 + | None -> meta) 178 + | _ -> meta 179 + 180 + type parse_state = 181 + | Header 182 + | Meta 183 + | Data 184 + 185 + type parse_ctx = { 186 + mutable state : parse_state; 187 + mutable header_vers : string; 188 + mutable header_date : string; 189 + mutable header_orig : string; 190 + mutable header_comments : string list; 191 + mutable cur_meta : metadata; 192 + mutable cur_data : state_vector list; 193 + mutable cur_comments : string list; 194 + mutable segments_acc : segment list; 195 + mutable has_header : bool; 196 + mutable has_meta : bool; 197 + mutable err : error option; 198 + mutable line_num : int; 199 + } 200 + 201 + let extract_comment trimmed = 202 + if String.length trimmed > 8 then 203 + trim (String.sub trimmed 8 (String.length trimmed - 8)) 204 + else "" 205 + 206 + let is_comment trimmed = 207 + String.length trimmed >= 7 && String.sub trimmed 0 7 = "COMMENT" 208 + 209 + let finish_segment ctx = 210 + let seg = 211 + { metadata = ctx.cur_meta; 212 + data = Array.of_list (List.rev ctx.cur_data); 213 + comments = List.rev ctx.cur_comments } 214 + in 215 + ctx.segments_acc <- seg :: ctx.segments_acc; 216 + ctx.cur_data <- []; 217 + ctx.cur_comments <- [] 218 + 219 + let process_header ctx trimmed = 220 + if is_comment trimmed then 221 + ctx.header_comments <- extract_comment trimmed :: ctx.header_comments 222 + else if trimmed = "META_START" then begin 223 + ctx.has_meta <- true; 224 + ctx.state <- Meta; 225 + ctx.cur_meta <- empty_metadata 226 + end 227 + else 228 + match split_kv trimmed with 229 + | Some ("CCSDS_OEM_VERS", v) -> 230 + ctx.has_header <- true; 231 + ctx.header_vers <- v 232 + | Some ("CREATION_DATE", v) -> ctx.header_date <- v 233 + | Some ("ORIGINATOR", v) -> ctx.header_orig <- v 234 + | _ -> () 235 + 236 + let process_meta ctx trimmed = 237 + if trimmed = "META_STOP" then ctx.state <- Data 238 + else 239 + match split_kv trimmed with 240 + | Some (k, v) -> ctx.cur_meta <- set_meta_field ctx.cur_meta k v 241 + | None -> () 242 + 243 + let process_data ctx trimmed = 244 + if is_comment trimmed then 245 + ctx.cur_comments <- extract_comment trimmed :: ctx.cur_comments 246 + else if trimmed = "META_START" then begin 247 + finish_segment ctx; 248 + ctx.state <- Meta; 249 + ctx.cur_meta <- empty_metadata 250 + end 251 + else if is_data_line trimmed then 252 + match parse_state_vector ctx.line_num trimmed with 253 + | Ok sv -> ctx.cur_data <- sv :: ctx.cur_data 254 + | Error e -> ctx.err <- Some e 255 + else () 256 + 257 + let parse_lines lines = 258 + let ctx = 259 + { state = Header; header_vers = ""; header_date = ""; header_orig = ""; 260 + header_comments = []; cur_meta = empty_metadata; cur_data = []; 261 + cur_comments = []; segments_acc = []; has_header = false; 262 + has_meta = false; err = None; line_num = 0 } 263 + in 264 + List.iter 265 + (fun line -> 266 + ctx.line_num <- ctx.line_num + 1; 267 + if ctx.err <> None then () 268 + else 269 + let trimmed = trim line in 270 + if String.length trimmed = 0 then () 271 + else 272 + match ctx.state with 273 + | Header -> process_header ctx trimmed 274 + | Meta -> process_meta ctx trimmed 275 + | Data -> process_data ctx trimmed) 276 + lines; 277 + match ctx.err with 278 + | Some e -> Error e 279 + | None -> 280 + if not ctx.has_header then Error Missing_header 281 + else if not ctx.has_meta then Error Missing_metadata 282 + else begin 283 + (match ctx.state with Data -> finish_segment ctx | _ -> ()); 284 + Ok 285 + { header = 286 + { ccsds_oem_vers = ctx.header_vers; 287 + creation_date = ctx.header_date; 288 + originator = ctx.header_orig; 289 + comments = List.rev ctx.header_comments }; 290 + segments = List.rev ctx.segments_acc } 291 + end 292 + 293 + let of_kvn_string s = 294 + let lines = String.split_on_char '\n' s in 295 + parse_lines lines 296 + 297 + let of_kvn_channel ic = 298 + let lines = ref [] in 299 + (try 300 + while true do 301 + lines := input_line ic :: !lines 302 + done 303 + with End_of_file -> ()); 304 + parse_lines (List.rev !lines) 305 + 306 + let of_kvn_file path = 307 + let ic = open_in path in 308 + let result = of_kvn_channel ic in 309 + close_in ic; 310 + result 311 + 312 + (* Interpolation *) 313 + 314 + let epoch_to_unix epoch = 315 + (* Parse ISO 8601 timestamp to unix timestamp *) 316 + match Ptime.of_rfc3339 (epoch ^ "Z") with 317 + | Ok (t, _, _) -> Some (Ptime.to_float_s t) 318 + | Error _ -> ( 319 + (* Try without the Z suffix in case it already has timezone *) 320 + match Ptime.of_rfc3339 epoch with 321 + | Ok (t, _, _) -> Some (Ptime.to_float_s t) 322 + | Error _ -> None) 323 + 324 + let lerp_vec3 frac v0 v1 = 325 + { 326 + x = v0.x +. (frac *. (v1.x -. v0.x)); 327 + y = v0.y +. (frac *. (v1.y -. v0.y)); 328 + z = v0.z +. (frac *. (v1.z -. v0.z)); 329 + } 330 + 331 + let interpolate seg unix_t = 332 + let data = seg.data in 333 + let n = Array.length data in 334 + if n < 2 then None 335 + else 336 + (* Convert all epochs to unix timestamps *) 337 + let times = 338 + Array.map (fun sv -> epoch_to_unix sv.epoch) data 339 + in 340 + (* Find bracketing pair *) 341 + let rec find i = 342 + if i >= n - 1 then None 343 + else 344 + match (times.(i), times.(i + 1)) with 345 + | Some t0, Some t1 -> 346 + if unix_t >= t0 && unix_t <= t1 then 347 + let frac = 348 + if t1 -. t0 = 0.0 then 0.0 else (unix_t -. t0) /. (t1 -. t0) 349 + in 350 + Some (lerp_vec3 frac data.(i).pos data.(i + 1).pos) 351 + else find (i + 1) 352 + | _ -> find (i + 1) 353 + in 354 + find 0
+114
lib/odm.mli
··· 1 + (** Orbit Data Messages (CCSDS 502.0-B-3). 2 + 3 + Parse and manipulate Orbit Ephemeris Messages (OEM) in KVN format. 4 + Supports multi-segment files, optional acceleration data, and linear 5 + interpolation. 6 + 7 + {b References}: 8 + - CCSDS 502.0-B-3, Orbit Data Messages *) 9 + 10 + (** {1 Types} *) 11 + 12 + type vec3 = { x : float; y : float; z : float } 13 + (** 3D vector (km, km/s, or km/s²). *) 14 + 15 + type state_vector = { 16 + epoch : string; 17 + pos : vec3; 18 + vel : vec3; 19 + accel : vec3 option; 20 + } 21 + (** Cartesian state vector with epoch. Position in km, velocity in km/s, 22 + acceleration in km/s² (optional). *) 23 + 24 + type metadata = { 25 + object_name : string; 26 + object_id : string; 27 + center_name : string; 28 + ref_frame : string; 29 + time_system : string; 30 + start_time : string; 31 + stop_time : string; 32 + useable_start_time : string option; 33 + useable_stop_time : string option; 34 + interpolation : string option; 35 + interpolation_degree : int option; 36 + } 37 + (** OEM segment metadata. *) 38 + 39 + type segment = { 40 + metadata : metadata; 41 + data : state_vector array; 42 + comments : string list; 43 + } 44 + (** An OEM segment: metadata, state vectors, and comments. *) 45 + 46 + type header = { 47 + ccsds_oem_vers : string; 48 + creation_date : string; 49 + originator : string; 50 + comments : string list; 51 + } 52 + (** OEM file header. *) 53 + 54 + type t = { 55 + header : header; 56 + segments : segment list; 57 + } 58 + (** A complete OEM file. *) 59 + 60 + (** {1 Errors} *) 61 + 62 + type error = 63 + | Missing_header 64 + | Missing_metadata 65 + | Bad_line of { line : int; content : string } 66 + | Parse_error of string 67 + 68 + val pp_error : error Fmt.t 69 + (** [pp_error] pretty-prints a parse error. *) 70 + 71 + (** {1 KVN Parsing} *) 72 + 73 + val of_kvn_string : string -> (t, error) result 74 + (** [of_kvn_string s] parses an OEM KVN string. *) 75 + 76 + val of_kvn_channel : in_channel -> (t, error) result 77 + (** [of_kvn_channel ic] parses OEM KVN from an input channel. *) 78 + 79 + val of_kvn_file : string -> (t, error) result 80 + (** [of_kvn_file path] parses an OEM KVN file. *) 81 + 82 + (** {1 Pretty-printers} *) 83 + 84 + val pp : t Fmt.t 85 + (** [pp] pretty-prints an OEM. *) 86 + 87 + val pp_header : header Fmt.t 88 + (** [pp_header] pretty-prints an OEM header. *) 89 + 90 + val pp_metadata : metadata Fmt.t 91 + (** [pp_metadata] pretty-prints OEM metadata. *) 92 + 93 + val pp_state_vector : state_vector Fmt.t 94 + (** [pp_state_vector] pretty-prints a state vector. *) 95 + 96 + val pp_segment : segment Fmt.t 97 + (** [pp_segment] pretty-prints an OEM segment. *) 98 + 99 + (** {1 Accessors} *) 100 + 101 + val segments : t -> segment list 102 + (** [segments t] returns the list of segments. *) 103 + 104 + val state_vectors : segment -> state_vector array 105 + (** [state_vectors seg] returns the state vectors of a segment. *) 106 + 107 + val epoch_range : segment -> string * string 108 + (** [epoch_range seg] returns [(start_time, stop_time)] from the segment 109 + metadata. *) 110 + 111 + val interpolate : segment -> float -> vec3 option 112 + (** [interpolate seg unix_t] linearly interpolates position at the given Unix 113 + timestamp. Returns [None] if [unix_t] is outside the data range or the 114 + segment has fewer than 2 data points. *)
+34
odm.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Orbit Data Messages (CCSDS 502.0-B-3)" 4 + description: 5 + "Parse and manipulate Orbit Data Messages for satellite ephemeris exchange. Supports CCSDS 502.0-B-3 OEM (Orbit Ephemeris Message) KVN format with multi-segment and acceleration data." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "ISC" 9 + homepage: "https://tangled.org/gazagnaire.org/ocaml-odm" 10 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-odm/issues" 11 + depends: [ 12 + "dune" {>= "3.21"} 13 + "ocaml" {>= "4.14"} 14 + "ptime" 15 + "fmt" 16 + "alcotest" {with-test} 17 + "odoc" {with-doc} 18 + ] 19 + build: [ 20 + ["dune" "subst"] {dev} 21 + [ 22 + "dune" 23 + "build" 24 + "-p" 25 + name 26 + "-j" 27 + jobs 28 + "@install" 29 + "@runtest" {with-test} 30 + "@doc" {with-doc} 31 + ] 32 + ] 33 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-odm" 34 + x-maintenance-intent: ["(latest)"]
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries odm alcotest ptime fmt))
+1
test/test.ml
··· 1 + let () = Alcotest.run "odm" [ Test_odm.suite ]
+274
test/test_odm.ml
··· 1 + (** Tests for the OEM library. *) 2 + 3 + let check_float msg ?(eps = 1e-6) expected actual = 4 + let diff = Float.abs (expected -. actual) in 5 + if diff > eps then 6 + Alcotest.failf "%s: expected %.10f, got %.10f (diff %.2e)" msg expected 7 + actual diff 8 + 9 + let mgs_oem = 10 + {|CCSDS_OEM_VERS = 2.0 11 + CREATION_DATE = 1996-11-04T17:22:31 12 + ORIGINATOR = NASA/JPL 13 + 14 + META_START 15 + OBJECT_NAME = MARS GLOBAL SURVEYOR 16 + OBJECT_ID = 1996-062A 17 + CENTER_NAME = MARS BARYCENTER 18 + REF_FRAME = EME2000 19 + TIME_SYSTEM = UTC 20 + START_TIME = 1996-12-18T12:00:00.331 21 + USEABLE_START_TIME = 1996-12-18T12:10:00.331 22 + USEABLE_STOP_TIME = 1996-12-28T21:23:00.331 23 + STOP_TIME = 1996-12-28T21:28:00.331 24 + INTERPOLATION = HERMITE 25 + INTERPOLATION_DEGREE = 7 26 + META_STOP 27 + COMMENT This file was produced by M.R. Somebody, MSOO NAV/JPL, 1996NOV 04. It is 28 + COMMENT to be used for DSN scheduling purposes only. 29 + 30 + 1996-12-18T12:00:00.331 2789.619 -280.045 -1746.755 4.73372 -2.49586 -1.04195 31 + 1996-12-18T12:01:00.331 2783.419 -308.143 -1877.071 5.18604 -2.42124 -1.99608 32 + 1996-12-18T12:02:00.331 2776.033 -336.859 -2008.682 5.63678 -2.33951 -1.94687 33 + 34 + META_START 35 + OBJECT_NAME = MARS GLOBAL SURVEYOR 36 + OBJECT_ID = 1996-062A 37 + CENTER_NAME = MARS BARYCENTER 38 + REF_FRAME = EME2000 39 + TIME_SYSTEM = UTC 40 + START_TIME = 1996-12-28T21:29:07.267 41 + USEABLE_START_TIME = 1996-12-28T22:08:02.5 42 + USEABLE_STOP_TIME = 1996-12-30T01:18:02.5 43 + STOP_TIME = 1996-12-30T01:28:02.267 44 + INTERPOLATION = HERMITE 45 + INTERPOLATION_DEGREE = 7 46 + META_STOP 47 + 48 + COMMENT This block begins after trajectory correction maneuver TCM-3. 49 + 50 + 1996-12-28T21:29:07.267 -2432.166 -063.042 1742.754 7.33702 -3.495867 -1.041945 51 + 1996-12-28T21:59:02.267 -2445.234 -878.141 1873.073 1.86043 -3.421256 -0.996366 52 + 1996-12-28T22:00:02.267 -2458.079 -683.858 2007.684 6.36786 -3.339563 -0.946654|} 53 + 54 + let iss_oem = 55 + {|CCSDS_OEM_VERS = 2.0 56 + CREATION_DATE = 2025-03-22T00:00:00 57 + ORIGINATOR = TEST 58 + 59 + META_START 60 + OBJECT_NAME = ISS (ZARYA) 61 + OBJECT_ID = 1998-067A 62 + CENTER_NAME = EARTH 63 + REF_FRAME = EME2000 64 + TIME_SYSTEM = UTC 65 + START_TIME = 2025-03-22T00:00:00.000 66 + STOP_TIME = 2025-03-22T00:02:00.000 67 + META_STOP 68 + 69 + 2025-03-22T00:00:00.000 -4400.594 1586.039 4776.576 -2.199 -6.954 1.536 70 + 2025-03-22T00:01:00.000 -4527.908 1160.960 4855.391 -1.974 -7.083 1.247 71 + 2025-03-22T00:02:00.000 -4643.257 0733.187 4918.576 -1.742 -7.190 0.952|} 72 + 73 + let accel_oem = 74 + {|CCSDS_OEM_VERS = 2.0 75 + CREATION_DATE = 2025-01-01T00:00:00 76 + ORIGINATOR = TEST 77 + 78 + META_START 79 + OBJECT_NAME = TEST SAT 80 + OBJECT_ID = 2020-001A 81 + CENTER_NAME = EARTH 82 + REF_FRAME = EME2000 83 + TIME_SYSTEM = UTC 84 + START_TIME = 2025-01-01T00:00:00.000 85 + STOP_TIME = 2025-01-01T00:01:00.000 86 + META_STOP 87 + 88 + 2025-01-01T00:00:00.000 7000.0 0.0 0.0 0.0 7.5 0.0 0.0 -0.0087 0.0 89 + 2025-01-01T00:01:00.000 6999.5 450.0 0.0 -0.48 7.499 0.0 0.0 -0.0087 0.0|} 90 + 91 + let test_parse_mgs () = 92 + match Odm.of_kvn_string mgs_oem with 93 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 94 + | Ok t -> 95 + Alcotest.(check string) "version" "2.0" t.header.ccsds_oem_vers; 96 + Alcotest.(check string) "originator" "NASA/JPL" t.header.originator; 97 + Alcotest.(check int) "num segments" 2 (List.length (Odm.segments t)); 98 + let seg1 = List.nth t.segments 0 in 99 + Alcotest.(check string) 100 + "object_name" "MARS GLOBAL SURVEYOR" seg1.metadata.object_name; 101 + Alcotest.(check string) "ref_frame" "EME2000" seg1.metadata.ref_frame; 102 + Alcotest.(check (option string)) 103 + "interpolation" (Some "HERMITE") seg1.metadata.interpolation; 104 + Alcotest.(check (option int)) 105 + "interpolation_degree" (Some 7) seg1.metadata.interpolation_degree; 106 + Alcotest.(check int) "seg1 data len" 3 (Array.length seg1.data); 107 + let sv0 = seg1.data.(0) in 108 + Alcotest.(check string) "epoch" "1996-12-18T12:00:00.331" sv0.epoch; 109 + check_float "pos.x" ~eps:1e-3 2789.619 sv0.pos.x; 110 + check_float "vel.x" ~eps:1e-5 4.73372 sv0.vel.x; 111 + Alcotest.(check bool) "no accel" true (sv0.accel = None); 112 + let has_dsn = 113 + List.exists 114 + (fun c -> 115 + let low = String.lowercase_ascii c in 116 + let needle = String.lowercase_ascii "DSN scheduling" in 117 + let found = ref false in 118 + for i = 0 to String.length low - String.length needle do 119 + if String.sub low i (String.length needle) = needle then 120 + found := true 121 + done; 122 + !found) 123 + seg1.comments 124 + in 125 + Alcotest.(check bool) "DSN scheduling comment" true has_dsn; 126 + let seg2 = List.nth t.segments 1 in 127 + Alcotest.(check int) "seg2 data len" 3 (Array.length seg2.data); 128 + check_float "seg2 pos.x" ~eps:1e-3 (-2432.166) seg2.data.(0).pos.x; 129 + let has_tcm = 130 + List.exists 131 + (fun c -> 132 + let low = String.lowercase_ascii c in 133 + let needle = String.lowercase_ascii "TCM-3" in 134 + let found = ref false in 135 + for i = 0 to String.length low - String.length needle do 136 + if String.sub low i (String.length needle) = needle then 137 + found := true 138 + done; 139 + !found) 140 + seg2.comments 141 + in 142 + Alcotest.(check bool) "TCM-3 comment" true has_tcm 143 + 144 + let test_parse_iss () = 145 + match Odm.of_kvn_string iss_oem with 146 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 147 + | Ok t -> 148 + Alcotest.(check int) "num segments" 1 (List.length t.segments); 149 + let seg = List.hd t.segments in 150 + Alcotest.(check int) "data len" 3 (Array.length seg.data); 151 + Alcotest.(check string) "object_name" "ISS (ZARYA)" seg.metadata.object_name; 152 + Alcotest.(check string) "object_id" "1998-067A" seg.metadata.object_id; 153 + Alcotest.(check string) "center_name" "EARTH" seg.metadata.center_name; 154 + Alcotest.(check (option string)) 155 + "useable_start" None seg.metadata.useable_start_time; 156 + Alcotest.(check (option string)) 157 + "useable_stop" None seg.metadata.useable_stop_time; 158 + Alcotest.(check (option string)) 159 + "interpolation" None seg.metadata.interpolation; 160 + let sv0 = seg.data.(0) in 161 + Alcotest.(check string) "epoch" "2025-03-22T00:00:00.000" sv0.epoch; 162 + check_float "pos.x" ~eps:1e-3 (-4400.594) sv0.pos.x; 163 + check_float "pos.y" ~eps:1e-3 1586.039 sv0.pos.y; 164 + check_float "pos.z" ~eps:1e-3 4776.576 sv0.pos.z; 165 + check_float "vel.x" ~eps:1e-3 (-2.199) sv0.vel.x; 166 + check_float "vel.y" ~eps:1e-3 (-6.954) sv0.vel.y; 167 + check_float "vel.z" ~eps:1e-3 1.536 sv0.vel.z 168 + 169 + let test_parse_with_accel () = 170 + match Odm.of_kvn_string accel_oem with 171 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 172 + | Ok t -> 173 + let seg = List.hd t.segments in 174 + let sv0 = seg.data.(0) in 175 + (match sv0.accel with 176 + | None -> Alcotest.fail "expected acceleration" 177 + | Some a -> 178 + check_float "accel.x" ~eps:1e-6 0.0 a.x; 179 + check_float "accel.y" ~eps:1e-6 (-0.0087) a.y; 180 + check_float "accel.z" ~eps:1e-6 0.0 a.z); 181 + let sv1 = seg.data.(1) in 182 + Alcotest.(check bool) "sv1 has accel" true (sv1.accel <> None) 183 + 184 + let test_empty_input () = 185 + match Odm.of_kvn_string "" with 186 + | Error _ -> () 187 + | Ok _ -> Alcotest.fail "should reject empty input" 188 + 189 + let test_missing_meta () = 190 + let input = 191 + {|CCSDS_OEM_VERS = 2.0 192 + CREATION_DATE = 2025-01-01T00:00:00 193 + ORIGINATOR = TEST|} 194 + in 195 + match Odm.of_kvn_string input with 196 + | Error Odm.Missing_metadata -> () 197 + | Error _ -> () 198 + | Ok _ -> Alcotest.fail "should reject input without META_START" 199 + 200 + let test_no_data () = 201 + let input = 202 + {|CCSDS_OEM_VERS = 2.0 203 + CREATION_DATE = 2025-01-01T00:00:00 204 + ORIGINATOR = TEST 205 + 206 + META_START 207 + OBJECT_NAME = TEST SAT 208 + OBJECT_ID = 2020-001A 209 + CENTER_NAME = EARTH 210 + REF_FRAME = EME2000 211 + TIME_SYSTEM = UTC 212 + START_TIME = 2025-01-01T00:00:00.000 213 + STOP_TIME = 2025-01-01T00:01:00.000 214 + META_STOP|} 215 + in 216 + match Odm.of_kvn_string input with 217 + | Error _ -> () 218 + | Ok t -> 219 + let seg = List.hd t.segments in 220 + Alcotest.(check int) "empty data" 0 (Array.length seg.data) 221 + 222 + let test_roundtrip_comment () = 223 + match Odm.of_kvn_string mgs_oem with 224 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 225 + | Ok t -> 226 + Alcotest.(check int) "header comments" 0 (List.length t.header.comments); 227 + let seg1 = List.nth t.segments 0 in 228 + Alcotest.(check bool) "seg1 has comments" true (seg1.comments <> []); 229 + let seg2 = List.nth t.segments 1 in 230 + Alcotest.(check bool) "seg2 has comments" true (seg2.comments <> []) 231 + 232 + let test_epoch_range () = 233 + match Odm.of_kvn_string iss_oem with 234 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 235 + | Ok t -> 236 + let seg = List.hd t.segments in 237 + let start_t, stop_t = Odm.epoch_range seg in 238 + Alcotest.(check string) "start" "2025-03-22T00:00:00.000" start_t; 239 + Alcotest.(check string) "stop" "2025-03-22T00:02:00.000" stop_t 240 + 241 + let test_segments_accessor () = 242 + match Odm.of_kvn_string mgs_oem with 243 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 244 + | Ok t -> 245 + Alcotest.(check int) "segments" 2 (List.length (Odm.segments t)) 246 + 247 + let test_pp_no_crash () = 248 + match Odm.of_kvn_string mgs_oem with 249 + | Error e -> Alcotest.failf "parse failed: %a" Odm.pp_error e 250 + | Ok t -> 251 + let s = Fmt.str "%a" Odm.pp t in 252 + Alcotest.(check bool) "non-empty" true (String.length s > 50); 253 + let seg = List.hd t.segments in 254 + let sv = seg.data.(0) in 255 + let _ = Fmt.str "%a" Odm.pp_state_vector sv in 256 + let _ = Fmt.str "%a" Odm.pp_metadata seg.metadata in 257 + let _ = Fmt.str "%a" Odm.pp_segment seg in 258 + let _ = Fmt.str "%a" Odm.pp_header t.header in 259 + () 260 + 261 + let suite = 262 + ( "odm", 263 + [ 264 + Alcotest.test_case "parse MGS" `Quick test_parse_mgs; 265 + Alcotest.test_case "parse ISS" `Quick test_parse_iss; 266 + Alcotest.test_case "parse with accel" `Quick test_parse_with_accel; 267 + Alcotest.test_case "empty input" `Quick test_empty_input; 268 + Alcotest.test_case "missing meta" `Quick test_missing_meta; 269 + Alcotest.test_case "no data" `Quick test_no_data; 270 + Alcotest.test_case "roundtrip comment" `Quick test_roundtrip_comment; 271 + Alcotest.test_case "epoch range" `Quick test_epoch_range; 272 + Alcotest.test_case "segments accessor" `Quick test_segments_accessor; 273 + Alcotest.test_case "pp no crash" `Quick test_pp_no_crash; 274 + ] )
+2
test/test_odm.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the OEM test suite. *)