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

Configure Feed

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

Fix Rice codec, consolidate ODM/ADM, add ocaml-ccsds index

Rice (CCSDS 121.0-B):
- Fix prediction error mapper per spec (modular arithmetic with
theta-based branching, matching libaec reference implementation)
- Fix select_k to use floor instead of round
- Add bounds check on decompress sample count
- All 11 tests + 4 fuzz tests pass

ODM consolidation (CCSDS 502.0-B):
- Merge OPM into ocaml-odm alongside OEM
- Access via Odm.Oem and Odm.Opm submodules
- Backward-compatible: Odm.of_kvn_string still works for OEM

ADM (CCSDS 504.0-B):
- Create ocaml-adm from ocaml-aem
- Access via Adm.Aem submodule (APM to be added)

ocaml-ccsds index:
- Meta-package with index.mld documenting the full protocol suite
- Organized by Blue Book / Green Book / Related Standards
- Links to CCSDS PDF specs for each standard
- Lists all implemented + not-yet-implemented specs

+1402 -574
+4 -3
dune-project
··· 12 12 (name odm) 13 13 (synopsis "Orbit Data Messages (CCSDS 502.0-B-3)") 14 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.") 15 + "Parse and manipulate Orbit Data Messages (CCSDS 502.0-B-3) for \ 16 + satellite ephemeris exchange. Includes OEM (Orbit Ephemeris Message) \ 17 + and OPM (Orbit Parameter Message) KVN format parsers.") 18 18 (depends 19 19 (ocaml (>= 4.14)) 20 + kvn 20 21 ptime 21 22 fmt 22 23 (alcotest :with-test)))
+1 -1
lib/dune
··· 1 1 (library 2 2 (name odm) 3 3 (public_name odm) 4 - (libraries vec3 ptime fmt)) 4 + (libraries kvn vec3 ptime fmt))
+10 -451
lib/odm.ml
··· 1 - (** Orbit Data Messages (CCSDS 502.0-B-3). *) 2 - 3 - type vec3 = Vec3.t = { 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 covariance_entry = { 27 - cov_epoch : string; 28 - cov_ref_frame : string; 29 - cov : float array; 30 - } 31 - 32 - type segment = { 33 - metadata : metadata; 34 - data : state_vector array; 35 - covariances : covariance_entry list; 36 - comments : string list; 37 - } 38 - 39 - type header = { 40 - ccsds_oem_vers : string; 41 - creation_date : string; 42 - originator : string; 43 - comments : string list; 44 - } 45 - 46 - type t = { header : header; segments : segment list } 47 - 48 - type error = 49 - | Missing_header 50 - | Missing_metadata 51 - | Bad_line of { line : int; content : string } 52 - | Parse_error of string 53 - 54 - let pp_error ppf = function 55 - | Missing_header -> Fmt.pf ppf "missing OEM header" 56 - | Missing_metadata -> Fmt.pf ppf "missing META_START" 57 - | Bad_line { line; content } -> Fmt.pf ppf "bad line %d: %s" line content 58 - | Parse_error msg -> Fmt.pf ppf "parse error: %s" msg 59 - 60 - (* Pretty-printers *) 61 - 62 - let pp_vec3 ppf v = Fmt.pf ppf "(%.6f, %.6f, %.6f)" v.x v.y v.z 63 - 64 - let pp_state_vector ppf sv = 65 - Fmt.pf ppf "%s pos=%a vel=%a" sv.epoch pp_vec3 sv.pos pp_vec3 sv.vel; 66 - match sv.accel with None -> () | Some a -> Fmt.pf ppf " accel=%a" pp_vec3 a 67 - 68 - let pp_metadata ppf m = 69 - Fmt.pf ppf 70 - "@[<v>OBJECT_NAME = %s@,\ 71 - OBJECT_ID = %s@,\ 72 - CENTER_NAME = %s@,\ 73 - REF_FRAME = %s@,\ 74 - TIME_SYSTEM = %s@,\ 75 - START_TIME = %s@,\ 76 - STOP_TIME = %s@]" 77 - m.object_name m.object_id m.center_name m.ref_frame m.time_system 78 - m.start_time m.stop_time 79 - 80 - let pp_segment ppf seg = 81 - Fmt.pf ppf "@[<v>META:@, %a@,DATA: %d vectors@]" pp_metadata seg.metadata 82 - (Array.length seg.data) 83 - 84 - let pp_header ppf h = 85 - Fmt.pf ppf "CCSDS_OEM_VERS = %s@,CREATION_DATE = %s@,ORIGINATOR = %s" 86 - h.ccsds_oem_vers h.creation_date h.originator 87 - 88 - let pp ppf t = 89 - Fmt.pf ppf "@[<v>%a@,%a@]" pp_header t.header 90 - Fmt.(list ~sep:(Fmt.any "@,") pp_segment) 91 - t.segments 1 + (** Orbit Data Messages (CCSDS 502.0-B-3). 92 2 93 - (* Accessors *) 3 + This module groups the orbit data message types defined in CCSDS 502.0-B: 94 4 95 - let segments t = t.segments 96 - let state_vectors seg = seg.data 97 - let epoch_range seg = (seg.metadata.start_time, seg.metadata.stop_time) 5 + - {!Oem}: Orbit Ephemeris Message 6 + - {!Opm}: Orbit Parameter Message 98 7 99 - (* Parsing helpers *) 8 + For backward compatibility, the OEM types and functions are also re-exported 9 + at the top level of this module (e.g., [Odm.of_kvn_string] is an alias for 10 + [Odm.Oem.of_kvn_string]). *) 100 11 101 - let trim s = String.trim s 102 - 103 - let split_kv line = 104 - match String.index_opt line '=' with 105 - | None -> None 106 - | Some i -> 107 - let key = trim (String.sub line 0 i) in 108 - let value = trim (String.sub line (i + 1) (String.length line - i - 1)) in 109 - Some (key, value) 110 - 111 - let is_data_line s = 112 - let s = trim s in 113 - String.length s > 0 114 - && 115 - let c = s.[0] in 116 - (c >= '0' && c <= '9') || c = '-' 117 - 118 - let split_spaces s = 119 - String.split_on_char ' ' s |> List.filter (fun s -> String.length s > 0) 120 - 121 - let parse_state_vector line_num line = 122 - let tokens = split_spaces (trim line) in 123 - match tokens with 124 - | epoch :: rest -> ( 125 - let floats = 126 - List.map 127 - (fun s -> 128 - match float_of_string_opt s with Some f -> f | None -> Float.nan) 129 - rest 130 - in 131 - match floats with 132 - | [ x; y; z; vx; vy; vz ] -> 133 - Ok 134 - { 135 - epoch; 136 - pos = { x; y; z }; 137 - vel = { x = vx; y = vy; z = vz }; 138 - accel = None; 139 - } 140 - | [ x; y; z; vx; vy; vz; ax; ay; az ] -> 141 - Ok 142 - { 143 - epoch; 144 - pos = { x; y; z }; 145 - vel = { x = vx; y = vy; z = vz }; 146 - accel = Some { x = ax; y = ay; z = az }; 147 - } 148 - | _ -> Error (Bad_line { line = line_num; content = line })) 149 - | [] -> Error (Bad_line { line = line_num; content = line }) 150 - 151 - let empty_metadata = 152 - { 153 - object_name = ""; 154 - object_id = ""; 155 - center_name = ""; 156 - ref_frame = ""; 157 - time_system = ""; 158 - start_time = ""; 159 - stop_time = ""; 160 - useable_start_time = None; 161 - useable_stop_time = None; 162 - interpolation = None; 163 - interpolation_degree = None; 164 - } 165 - 166 - let set_meta_field meta key value = 167 - match key with 168 - | "OBJECT_NAME" -> { meta with object_name = value } 169 - | "OBJECT_ID" -> { meta with object_id = value } 170 - | "CENTER_NAME" -> { meta with center_name = value } 171 - | "REF_FRAME" -> { meta with ref_frame = value } 172 - | "TIME_SYSTEM" -> { meta with time_system = value } 173 - | "START_TIME" -> { meta with start_time = value } 174 - | "STOP_TIME" -> { meta with stop_time = value } 175 - | "USEABLE_START_TIME" -> { meta with useable_start_time = Some value } 176 - | "USEABLE_STOP_TIME" -> { meta with useable_stop_time = Some value } 177 - | "INTERPOLATION" -> { meta with interpolation = Some value } 178 - | "INTERPOLATION_DEGREE" -> ( 179 - match int_of_string_opt value with 180 - | Some d -> { meta with interpolation_degree = Some d } 181 - | None -> meta) 182 - | _ -> meta 183 - 184 - type parse_state = Header | Meta | Data | Covariance 185 - 186 - type parse_ctx = { 187 - mutable state : parse_state; 188 - mutable header_vers : string; 189 - mutable header_date : string; 190 - mutable header_orig : string; 191 - mutable header_comments : string list; 192 - mutable cur_meta : metadata; 193 - mutable cur_data : state_vector list; 194 - mutable cur_covs : covariance_entry list; 195 - mutable cur_cov_epoch : string; 196 - mutable cur_cov_frame : string; 197 - mutable cur_cov_values : float list; 198 - mutable cur_comments : string list; 199 - mutable segments_acc : segment list; 200 - mutable has_header : bool; 201 - mutable has_meta : bool; 202 - mutable err : error option; 203 - mutable line_num : int; 204 - } 205 - 206 - let extract_comment trimmed = 207 - if String.length trimmed > 8 then 208 - trim (String.sub trimmed 8 (String.length trimmed - 8)) 209 - else "" 210 - 211 - let is_comment trimmed = 212 - String.length trimmed >= 7 && String.sub trimmed 0 7 = "COMMENT" 213 - 214 - let finish_cov_entry ctx = 215 - if ctx.cur_cov_epoch <> "" && ctx.cur_cov_values <> [] then begin 216 - let entry = 217 - { 218 - cov_epoch = ctx.cur_cov_epoch; 219 - cov_ref_frame = ctx.cur_cov_frame; 220 - cov = Array.of_list ctx.cur_cov_values; 221 - } 222 - in 223 - ctx.cur_covs <- entry :: ctx.cur_covs; 224 - ctx.cur_cov_epoch <- ""; 225 - ctx.cur_cov_frame <- ""; 226 - ctx.cur_cov_values <- [] 227 - end 228 - 229 - let finish_segment ctx = 230 - let seg = 231 - { 232 - metadata = ctx.cur_meta; 233 - data = Array.of_list (List.rev ctx.cur_data); 234 - covariances = List.rev ctx.cur_covs; 235 - comments = List.rev ctx.cur_comments; 236 - } 237 - in 238 - ctx.segments_acc <- seg :: ctx.segments_acc; 239 - ctx.cur_data <- []; 240 - ctx.cur_covs <- []; 241 - ctx.cur_comments <- [] 242 - 243 - let process_header ctx trimmed = 244 - if is_comment trimmed then 245 - ctx.header_comments <- extract_comment trimmed :: ctx.header_comments 246 - else if trimmed = "META_START" then begin 247 - ctx.has_meta <- true; 248 - ctx.state <- Meta; 249 - ctx.cur_meta <- empty_metadata 250 - end 251 - else 252 - match split_kv trimmed with 253 - | Some ("CCSDS_OEM_VERS", v) -> 254 - ctx.has_header <- true; 255 - ctx.header_vers <- v 256 - | Some ("CREATION_DATE", v) -> ctx.header_date <- v 257 - | Some ("ORIGINATOR", v) -> ctx.header_orig <- v 258 - | _ -> () 259 - 260 - let process_meta ctx trimmed = 261 - if trimmed = "META_STOP" then ctx.state <- Data 262 - else 263 - match split_kv trimmed with 264 - | Some (k, v) -> ctx.cur_meta <- set_meta_field ctx.cur_meta k v 265 - | None -> () 266 - 267 - let process_data ctx trimmed = 268 - if is_comment trimmed then 269 - ctx.cur_comments <- extract_comment trimmed :: ctx.cur_comments 270 - else if trimmed = "META_START" then begin 271 - finish_segment ctx; 272 - ctx.state <- Meta; 273 - ctx.cur_meta <- empty_metadata 274 - end 275 - else if trimmed = "COVARIANCE_START" then ctx.state <- Covariance 276 - else if is_data_line trimmed then 277 - match parse_state_vector ctx.line_num trimmed with 278 - | Ok sv -> ctx.cur_data <- sv :: ctx.cur_data 279 - | Error e -> ctx.err <- Some e 280 - else () 281 - 282 - let process_covariance ctx trimmed = 283 - if trimmed = "COVARIANCE_STOP" then begin 284 - finish_cov_entry ctx; 285 - ctx.state <- Data 286 - end 287 - else if is_comment trimmed then () 288 - else 289 - match split_kv trimmed with 290 - | Some ("EPOCH", v) -> 291 - finish_cov_entry ctx; 292 - ctx.cur_cov_epoch <- v 293 - | Some ("COV_REF_FRAME", v) -> ctx.cur_cov_frame <- v 294 - | _ -> 295 - (* Data line: parse floats (lower triangle row) *) 296 - let floats = 297 - split_spaces trimmed 298 - |> List.filter_map (fun s -> float_of_string_opt s) 299 - in 300 - ctx.cur_cov_values <- ctx.cur_cov_values @ floats 301 - 302 - let parse_lines lines = 303 - let ctx = 304 - { 305 - state = Header; 306 - header_vers = ""; 307 - header_date = ""; 308 - header_orig = ""; 309 - header_comments = []; 310 - cur_meta = empty_metadata; 311 - cur_data = []; 312 - cur_covs = []; 313 - cur_cov_epoch = ""; 314 - cur_cov_frame = ""; 315 - cur_cov_values = []; 316 - cur_comments = []; 317 - segments_acc = []; 318 - has_header = false; 319 - has_meta = false; 320 - err = None; 321 - line_num = 0; 322 - } 323 - in 324 - List.iter 325 - (fun line -> 326 - ctx.line_num <- ctx.line_num + 1; 327 - if ctx.err <> None then () 328 - else 329 - let trimmed = trim line in 330 - if String.length trimmed = 0 then () 331 - else 332 - match ctx.state with 333 - | Header -> process_header ctx trimmed 334 - | Meta -> process_meta ctx trimmed 335 - | Data -> process_data ctx trimmed 336 - | Covariance -> process_covariance ctx trimmed) 337 - lines; 338 - match ctx.err with 339 - | Some e -> Error e 340 - | None -> 341 - if not ctx.has_header then Error Missing_header 342 - else if not ctx.has_meta then Error Missing_metadata 343 - else begin 344 - (match ctx.state with 345 - | Data | Covariance -> finish_segment ctx 346 - | _ -> ()); 347 - Ok 348 - { 349 - header = 350 - { 351 - ccsds_oem_vers = ctx.header_vers; 352 - creation_date = ctx.header_date; 353 - originator = ctx.header_orig; 354 - comments = List.rev ctx.header_comments; 355 - }; 356 - segments = List.rev ctx.segments_acc; 357 - } 358 - end 359 - 360 - let of_kvn_string s = 361 - let lines = String.split_on_char '\n' s in 362 - parse_lines lines 363 - 364 - let of_kvn_channel ic = 365 - let lines = ref [] in 366 - (try 367 - while true do 368 - lines := input_line ic :: !lines 369 - done 370 - with End_of_file -> ()); 371 - parse_lines (List.rev !lines) 372 - 373 - let of_kvn_file path = 374 - let ic = open_in path in 375 - let result = of_kvn_channel ic in 376 - close_in ic; 377 - result 378 - 379 - (* Interpolation *) 380 - 381 - let epoch_to_unix epoch = 382 - (* Parse ISO 8601 timestamp to unix timestamp *) 383 - match Ptime.of_rfc3339 (epoch ^ "Z") with 384 - | Ok (t, _, _) -> Some (Ptime.to_float_s t) 385 - | Error _ -> ( 386 - (* Try without the Z suffix in case it already has timezone *) 387 - match Ptime.of_rfc3339 epoch with 388 - | Ok (t, _, _) -> Some (Ptime.to_float_s t) 389 - | Error _ -> None) 390 - 391 - (** Lagrange polynomial interpolation of vec3 over a window of data points. 392 - [times] has length [k], [positions] has length [k], [t] is the query time. 393 - *) 394 - let lagrange_vec3 times positions t = 395 - let k = Array.length times in 396 - let x = ref 0.0 and y = ref 0.0 and z = ref 0.0 in 397 - for j = 0 to k - 1 do 398 - let basis = ref 1.0 in 399 - for m = 0 to k - 1 do 400 - if m <> j then 401 - basis := !basis *. (t -. times.(m)) /. (times.(j) -. times.(m)) 402 - done; 403 - x := !x +. (!basis *. positions.(j).x); 404 - y := !y +. (!basis *. positions.(j).y); 405 - z := !z +. (!basis *. positions.(j).z) 406 - done; 407 - { x = !x; y = !y; z = !z } 408 - 409 - let interpolate seg unix_t = 410 - let data = seg.data in 411 - let n = Array.length data in 412 - let degree = 413 - match seg.metadata.interpolation_degree with Some d -> d | None -> 7 414 - in 415 - if n < 2 then None 416 - else 417 - (* Convert all epochs to unix timestamps *) 418 - let times = 419 - Array.map 420 - (fun sv -> 421 - match epoch_to_unix sv.epoch with Some t -> t | None -> Float.nan) 422 - data 423 - in 424 - if Array.exists Float.is_nan times then None 425 - else 426 - let t0 = times.(0) and tn = times.(n - 1) in 427 - if unix_t < t0 || unix_t > tn then None 428 - else 429 - (* Binary search for bracketing interval *) 430 - let lo = ref 0 and hi = ref (n - 2) in 431 - while !hi - !lo > 1 do 432 - let mid = (!lo + !hi) / 2 in 433 - if times.(mid) <= unix_t then lo := mid else hi := mid 434 - done; 435 - let i = 436 - if times.(!lo) <= unix_t && unix_t <= times.(!lo + 1) then !lo 437 - else !lo + 1 438 - in 439 - (* Select centered window of degree+1 points around interval [i, i+1] *) 440 - let needed = min (degree + 1) n in 441 - let half = (needed - 2) / 2 in 442 - let start = max 0 (min (i - half) (n - needed)) in 443 - let win_t = Array.sub times start needed in 444 - let win_p = Array.init needed (fun j -> data.(start + j).pos) in 445 - Some (lagrange_vec3 win_t win_p unix_t) 446 - 447 - let position_at oem unix_t = 448 - let rec try_segments = function 449 - | [] -> None 450 - | seg :: rest -> ( 451 - match interpolate seg unix_t with 452 - | Some _ as result -> result 453 - | None -> try_segments rest) 454 - in 455 - try_segments oem.segments 12 + module Oem = Oem 13 + module Opm = Opm 14 + include Oem
+17 -117
lib/odm.mli
··· 1 1 (** Orbit Data Messages (CCSDS 502.0-B-3). 2 2 3 - Parse and manipulate Orbit Ephemeris Messages (OEM) in KVN format. Supports 4 - multi-segment files, optional acceleration data, and linear interpolation. 5 - 6 - {b References}: 7 - - CCSDS 502.0-B-3, Orbit Data Messages *) 8 - 9 - (** {1 Types} *) 10 - 11 - type vec3 = Vec3.t = { x : float; y : float; z : float } 12 - (** 3D vector (km, km/s, or km/s²). *) 13 - 14 - type state_vector = { 15 - epoch : string; 16 - pos : vec3; 17 - vel : vec3; 18 - accel : vec3 option; 19 - } 20 - (** Cartesian state vector with epoch. Position in km, velocity in km/s, 21 - acceleration in km/s² (optional). *) 22 - 23 - type metadata = { 24 - object_name : string; 25 - object_id : string; 26 - center_name : string; 27 - ref_frame : string; 28 - time_system : string; 29 - start_time : string; 30 - stop_time : string; 31 - useable_start_time : string option; 32 - useable_stop_time : string option; 33 - interpolation : string option; 34 - interpolation_degree : int option; 35 - } 36 - (** OEM segment metadata. *) 37 - 38 - type covariance_entry = { 39 - cov_epoch : string; 40 - cov_ref_frame : string; 41 - cov : float array; (** Lower triangle of 6x6 covariance (21 elements). *) 42 - } 43 - (** Covariance at a specific epoch. Lower triangle stored row-by-row: 44 - [C11, C21, C22, C31, C32, C33, ...]. Units match the OEM position/velocity 45 - (typically km^2, km^2/s, km^2/s^2). *) 46 - 47 - type segment = { 48 - metadata : metadata; 49 - data : state_vector array; 50 - covariances : covariance_entry list; 51 - comments : string list; 52 - } 53 - (** An OEM segment: metadata, state vectors, covariances, and comments. *) 54 - 55 - type header = { 56 - ccsds_oem_vers : string; 57 - creation_date : string; 58 - originator : string; 59 - comments : string list; 60 - } 61 - (** OEM file header. *) 62 - 63 - type t = { header : header; segments : segment list } 64 - (** A complete OEM file. *) 65 - 66 - (** {1 Errors} *) 67 - 68 - type error = 69 - | Missing_header 70 - | Missing_metadata 71 - | Bad_line of { line : int; content : string } 72 - | Parse_error of string 3 + This module groups the orbit data message types defined in CCSDS 502.0-B: 73 4 74 - val pp_error : error Fmt.t 75 - (** [pp_error] pretty-prints a parse error. *) 5 + - {!module:Oem}: Orbit Ephemeris Message (OEM) 6 + - {!module:Opm}: Orbit Parameter Message (OPM) 76 7 77 - (** {1 KVN Parsing} *) 8 + For backward compatibility, the OEM types and functions are also re-exported 9 + at the top level of this module. New code should prefer [Odm.Oem] and 10 + [Odm.Opm] to access message-specific functionality. 78 11 79 - val of_kvn_string : string -> (t, error) result 80 - (** [of_kvn_string s] parses an OEM KVN string. *) 12 + {b References}: 13 + - {{:https://public.ccsds.org/Pubs/502x0b3e1.pdf} CCSDS 502.0-B-3} Orbit 14 + Data Messages *) 81 15 82 - val of_kvn_channel : in_channel -> (t, error) result 83 - (** [of_kvn_channel ic] parses OEM KVN from an input channel. *) 16 + module Oem = Oem 17 + (** Orbit Ephemeris Message (OEM). *) 84 18 85 - val of_kvn_file : string -> (t, error) result 86 - (** [of_kvn_file path] parses an OEM KVN file. *) 19 + module Opm = Opm 20 + (** Orbit Parameter Message (OPM). *) 87 21 88 - (** {1 Pretty-printers} *) 22 + (** {1 Backward-compatible OEM API} 89 23 90 - val pp : t Fmt.t 91 - (** [pp] pretty-prints an OEM. *) 24 + The following types and values are re-exported from {!Oem} for backward 25 + compatibility. New code should use [Odm.Oem] directly. *) 92 26 93 - val pp_header : header Fmt.t 94 - (** [pp_header] pretty-prints an OEM header. *) 95 - 96 - val pp_metadata : metadata Fmt.t 97 - (** [pp_metadata] pretty-prints OEM metadata. *) 98 - 99 - val pp_state_vector : state_vector Fmt.t 100 - (** [pp_state_vector] pretty-prints a state vector. *) 101 - 102 - val pp_segment : segment Fmt.t 103 - (** [pp_segment] pretty-prints an OEM segment. *) 104 - 105 - (** {1 Accessors} *) 106 - 107 - val segments : t -> segment list 108 - (** [segments t] returns the list of segments. *) 109 - 110 - val state_vectors : segment -> state_vector array 111 - (** [state_vectors seg] returns the state vectors of a segment. *) 112 - 113 - val epoch_range : segment -> string * string 114 - (** [epoch_range seg] returns [(start_time, stop_time)] from the segment 115 - metadata. *) 116 - 117 - val position_at : t -> float -> vec3 option 118 - (** [position_at oem unix_t] returns the interpolated position at the given Unix 119 - timestamp. Searches all segments and uses Lagrange polynomial interpolation 120 - (degree from OEM metadata, default 7). Returns [None] if [unix_t] is outside 121 - all segment ranges. 122 - 123 - This is the main entry point for querying an OEM. *) 124 - 125 - val interpolate : segment -> float -> vec3 option 126 - (** [interpolate seg unix_t] interpolates within a single segment. Use 127 - {!position_at} unless you need to work with a specific segment. *) 27 + include module type of Oem
+455
lib/oem.ml
··· 1 + (** Orbit Ephemeris Message (OEM) — CCSDS 502.0-B-3. *) 2 + 3 + type vec3 = Vec3.t = { 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 covariance_entry = { 27 + cov_epoch : string; 28 + cov_ref_frame : string; 29 + cov : float array; 30 + } 31 + 32 + type segment = { 33 + metadata : metadata; 34 + data : state_vector array; 35 + covariances : covariance_entry list; 36 + comments : string list; 37 + } 38 + 39 + type header = { 40 + ccsds_oem_vers : string; 41 + creation_date : string; 42 + originator : string; 43 + comments : string list; 44 + } 45 + 46 + type t = { header : header; segments : segment list } 47 + 48 + type error = 49 + | Missing_header 50 + | Missing_metadata 51 + | Bad_line of { line : int; content : string } 52 + | Parse_error of string 53 + 54 + let pp_error ppf = function 55 + | Missing_header -> Fmt.pf ppf "missing OEM header" 56 + | Missing_metadata -> Fmt.pf ppf "missing META_START" 57 + | Bad_line { line; content } -> Fmt.pf ppf "bad line %d: %s" line content 58 + | Parse_error msg -> Fmt.pf ppf "parse error: %s" msg 59 + 60 + (* Pretty-printers *) 61 + 62 + let pp_vec3 ppf v = Fmt.pf ppf "(%.6f, %.6f, %.6f)" v.x v.y v.z 63 + 64 + let pp_state_vector ppf sv = 65 + Fmt.pf ppf "%s pos=%a vel=%a" sv.epoch pp_vec3 sv.pos pp_vec3 sv.vel; 66 + match sv.accel with None -> () | Some a -> Fmt.pf ppf " accel=%a" pp_vec3 a 67 + 68 + let pp_metadata ppf m = 69 + Fmt.pf ppf 70 + "@[<v>OBJECT_NAME = %s@,\ 71 + OBJECT_ID = %s@,\ 72 + CENTER_NAME = %s@,\ 73 + REF_FRAME = %s@,\ 74 + TIME_SYSTEM = %s@,\ 75 + START_TIME = %s@,\ 76 + STOP_TIME = %s@]" 77 + m.object_name m.object_id m.center_name m.ref_frame m.time_system 78 + m.start_time m.stop_time 79 + 80 + let pp_segment ppf seg = 81 + Fmt.pf ppf "@[<v>META:@, %a@,DATA: %d vectors@]" pp_metadata seg.metadata 82 + (Array.length seg.data) 83 + 84 + let pp_header ppf h = 85 + Fmt.pf ppf "CCSDS_OEM_VERS = %s@,CREATION_DATE = %s@,ORIGINATOR = %s" 86 + h.ccsds_oem_vers h.creation_date h.originator 87 + 88 + let pp ppf t = 89 + Fmt.pf ppf "@[<v>%a@,%a@]" pp_header t.header 90 + Fmt.(list ~sep:(Fmt.any "@,") pp_segment) 91 + t.segments 92 + 93 + (* Accessors *) 94 + 95 + let segments t = t.segments 96 + let state_vectors seg = seg.data 97 + let epoch_range seg = (seg.metadata.start_time, seg.metadata.stop_time) 98 + 99 + (* Parsing helpers *) 100 + 101 + let trim s = String.trim s 102 + 103 + let split_kv line = 104 + match String.index_opt line '=' with 105 + | None -> None 106 + | Some i -> 107 + let key = trim (String.sub line 0 i) in 108 + let value = trim (String.sub line (i + 1) (String.length line - i - 1)) in 109 + Some (key, value) 110 + 111 + let is_data_line s = 112 + let s = trim s in 113 + String.length s > 0 114 + && 115 + let c = s.[0] in 116 + (c >= '0' && c <= '9') || c = '-' 117 + 118 + let split_spaces s = 119 + String.split_on_char ' ' s |> List.filter (fun s -> String.length s > 0) 120 + 121 + let parse_state_vector line_num line = 122 + let tokens = split_spaces (trim line) in 123 + match tokens with 124 + | epoch :: rest -> ( 125 + let floats = 126 + List.map 127 + (fun s -> 128 + match float_of_string_opt s with Some f -> f | None -> Float.nan) 129 + rest 130 + in 131 + match floats with 132 + | [ x; y; z; vx; vy; vz ] -> 133 + Ok 134 + { 135 + epoch; 136 + pos = { x; y; z }; 137 + vel = { x = vx; y = vy; z = vz }; 138 + accel = None; 139 + } 140 + | [ x; y; z; vx; vy; vz; ax; ay; az ] -> 141 + Ok 142 + { 143 + epoch; 144 + pos = { x; y; z }; 145 + vel = { x = vx; y = vy; z = vz }; 146 + accel = Some { x = ax; y = ay; z = az }; 147 + } 148 + | _ -> Error (Bad_line { line = line_num; content = line })) 149 + | [] -> Error (Bad_line { line = line_num; content = line }) 150 + 151 + let empty_metadata = 152 + { 153 + object_name = ""; 154 + object_id = ""; 155 + center_name = ""; 156 + ref_frame = ""; 157 + time_system = ""; 158 + start_time = ""; 159 + stop_time = ""; 160 + useable_start_time = None; 161 + useable_stop_time = None; 162 + interpolation = None; 163 + interpolation_degree = None; 164 + } 165 + 166 + let set_meta_field meta key value = 167 + match key with 168 + | "OBJECT_NAME" -> { meta with object_name = value } 169 + | "OBJECT_ID" -> { meta with object_id = value } 170 + | "CENTER_NAME" -> { meta with center_name = value } 171 + | "REF_FRAME" -> { meta with ref_frame = value } 172 + | "TIME_SYSTEM" -> { meta with time_system = value } 173 + | "START_TIME" -> { meta with start_time = value } 174 + | "STOP_TIME" -> { meta with stop_time = value } 175 + | "USEABLE_START_TIME" -> { meta with useable_start_time = Some value } 176 + | "USEABLE_STOP_TIME" -> { meta with useable_stop_time = Some value } 177 + | "INTERPOLATION" -> { meta with interpolation = Some value } 178 + | "INTERPOLATION_DEGREE" -> ( 179 + match int_of_string_opt value with 180 + | Some d -> { meta with interpolation_degree = Some d } 181 + | None -> meta) 182 + | _ -> meta 183 + 184 + type parse_state = Header | Meta | Data | Covariance 185 + 186 + type parse_ctx = { 187 + mutable state : parse_state; 188 + mutable header_vers : string; 189 + mutable header_date : string; 190 + mutable header_orig : string; 191 + mutable header_comments : string list; 192 + mutable cur_meta : metadata; 193 + mutable cur_data : state_vector list; 194 + mutable cur_covs : covariance_entry list; 195 + mutable cur_cov_epoch : string; 196 + mutable cur_cov_frame : string; 197 + mutable cur_cov_values : float list; 198 + mutable cur_comments : string list; 199 + mutable segments_acc : segment list; 200 + mutable has_header : bool; 201 + mutable has_meta : bool; 202 + mutable err : error option; 203 + mutable line_num : int; 204 + } 205 + 206 + let extract_comment trimmed = 207 + if String.length trimmed > 8 then 208 + trim (String.sub trimmed 8 (String.length trimmed - 8)) 209 + else "" 210 + 211 + let is_comment trimmed = 212 + String.length trimmed >= 7 && String.sub trimmed 0 7 = "COMMENT" 213 + 214 + let finish_cov_entry ctx = 215 + if ctx.cur_cov_epoch <> "" && ctx.cur_cov_values <> [] then begin 216 + let entry = 217 + { 218 + cov_epoch = ctx.cur_cov_epoch; 219 + cov_ref_frame = ctx.cur_cov_frame; 220 + cov = Array.of_list ctx.cur_cov_values; 221 + } 222 + in 223 + ctx.cur_covs <- entry :: ctx.cur_covs; 224 + ctx.cur_cov_epoch <- ""; 225 + ctx.cur_cov_frame <- ""; 226 + ctx.cur_cov_values <- [] 227 + end 228 + 229 + let finish_segment ctx = 230 + let seg = 231 + { 232 + metadata = ctx.cur_meta; 233 + data = Array.of_list (List.rev ctx.cur_data); 234 + covariances = List.rev ctx.cur_covs; 235 + comments = List.rev ctx.cur_comments; 236 + } 237 + in 238 + ctx.segments_acc <- seg :: ctx.segments_acc; 239 + ctx.cur_data <- []; 240 + ctx.cur_covs <- []; 241 + ctx.cur_comments <- [] 242 + 243 + let process_header ctx trimmed = 244 + if is_comment trimmed then 245 + ctx.header_comments <- extract_comment trimmed :: ctx.header_comments 246 + else if trimmed = "META_START" then begin 247 + ctx.has_meta <- true; 248 + ctx.state <- Meta; 249 + ctx.cur_meta <- empty_metadata 250 + end 251 + else 252 + match split_kv trimmed with 253 + | Some ("CCSDS_OEM_VERS", v) -> 254 + ctx.has_header <- true; 255 + ctx.header_vers <- v 256 + | Some ("CREATION_DATE", v) -> ctx.header_date <- v 257 + | Some ("ORIGINATOR", v) -> ctx.header_orig <- v 258 + | _ -> () 259 + 260 + let process_meta ctx trimmed = 261 + if trimmed = "META_STOP" then ctx.state <- Data 262 + else 263 + match split_kv trimmed with 264 + | Some (k, v) -> ctx.cur_meta <- set_meta_field ctx.cur_meta k v 265 + | None -> () 266 + 267 + let process_data ctx trimmed = 268 + if is_comment trimmed then 269 + ctx.cur_comments <- extract_comment trimmed :: ctx.cur_comments 270 + else if trimmed = "META_START" then begin 271 + finish_segment ctx; 272 + ctx.state <- Meta; 273 + ctx.cur_meta <- empty_metadata 274 + end 275 + else if trimmed = "COVARIANCE_START" then ctx.state <- Covariance 276 + else if is_data_line trimmed then 277 + match parse_state_vector ctx.line_num trimmed with 278 + | Ok sv -> ctx.cur_data <- sv :: ctx.cur_data 279 + | Error e -> ctx.err <- Some e 280 + else () 281 + 282 + let process_covariance ctx trimmed = 283 + if trimmed = "COVARIANCE_STOP" then begin 284 + finish_cov_entry ctx; 285 + ctx.state <- Data 286 + end 287 + else if is_comment trimmed then () 288 + else 289 + match split_kv trimmed with 290 + | Some ("EPOCH", v) -> 291 + finish_cov_entry ctx; 292 + ctx.cur_cov_epoch <- v 293 + | Some ("COV_REF_FRAME", v) -> ctx.cur_cov_frame <- v 294 + | _ -> 295 + (* Data line: parse floats (lower triangle row) *) 296 + let floats = 297 + split_spaces trimmed 298 + |> List.filter_map (fun s -> float_of_string_opt s) 299 + in 300 + ctx.cur_cov_values <- ctx.cur_cov_values @ floats 301 + 302 + let parse_lines lines = 303 + let ctx = 304 + { 305 + state = Header; 306 + header_vers = ""; 307 + header_date = ""; 308 + header_orig = ""; 309 + header_comments = []; 310 + cur_meta = empty_metadata; 311 + cur_data = []; 312 + cur_covs = []; 313 + cur_cov_epoch = ""; 314 + cur_cov_frame = ""; 315 + cur_cov_values = []; 316 + cur_comments = []; 317 + segments_acc = []; 318 + has_header = false; 319 + has_meta = false; 320 + err = None; 321 + line_num = 0; 322 + } 323 + in 324 + List.iter 325 + (fun line -> 326 + ctx.line_num <- ctx.line_num + 1; 327 + if ctx.err <> None then () 328 + else 329 + let trimmed = trim line in 330 + if String.length trimmed = 0 then () 331 + else 332 + match ctx.state with 333 + | Header -> process_header ctx trimmed 334 + | Meta -> process_meta ctx trimmed 335 + | Data -> process_data ctx trimmed 336 + | Covariance -> process_covariance ctx trimmed) 337 + lines; 338 + match ctx.err with 339 + | Some e -> Error e 340 + | None -> 341 + if not ctx.has_header then Error Missing_header 342 + else if not ctx.has_meta then Error Missing_metadata 343 + else begin 344 + (match ctx.state with 345 + | Data | Covariance -> finish_segment ctx 346 + | _ -> ()); 347 + Ok 348 + { 349 + header = 350 + { 351 + ccsds_oem_vers = ctx.header_vers; 352 + creation_date = ctx.header_date; 353 + originator = ctx.header_orig; 354 + comments = List.rev ctx.header_comments; 355 + }; 356 + segments = List.rev ctx.segments_acc; 357 + } 358 + end 359 + 360 + let of_kvn_string s = 361 + let lines = String.split_on_char '\n' s in 362 + parse_lines lines 363 + 364 + let of_kvn_channel ic = 365 + let lines = ref [] in 366 + (try 367 + while true do 368 + lines := input_line ic :: !lines 369 + done 370 + with End_of_file -> ()); 371 + parse_lines (List.rev !lines) 372 + 373 + let of_kvn_file path = 374 + let ic = open_in path in 375 + let result = of_kvn_channel ic in 376 + close_in ic; 377 + result 378 + 379 + (* Interpolation *) 380 + 381 + let epoch_to_unix epoch = 382 + (* Parse ISO 8601 timestamp to unix timestamp *) 383 + match Ptime.of_rfc3339 (epoch ^ "Z") with 384 + | Ok (t, _, _) -> Some (Ptime.to_float_s t) 385 + | Error _ -> ( 386 + (* Try without the Z suffix in case it already has timezone *) 387 + match Ptime.of_rfc3339 epoch with 388 + | Ok (t, _, _) -> Some (Ptime.to_float_s t) 389 + | Error _ -> None) 390 + 391 + (** Lagrange polynomial interpolation of vec3 over a window of data points. 392 + [times] has length [k], [positions] has length [k], [t] is the query time. 393 + *) 394 + let lagrange_vec3 times positions t = 395 + let k = Array.length times in 396 + let x = ref 0.0 and y = ref 0.0 and z = ref 0.0 in 397 + for j = 0 to k - 1 do 398 + let basis = ref 1.0 in 399 + for m = 0 to k - 1 do 400 + if m <> j then 401 + basis := !basis *. (t -. times.(m)) /. (times.(j) -. times.(m)) 402 + done; 403 + x := !x +. (!basis *. positions.(j).x); 404 + y := !y +. (!basis *. positions.(j).y); 405 + z := !z +. (!basis *. positions.(j).z) 406 + done; 407 + { x = !x; y = !y; z = !z } 408 + 409 + let interpolate seg unix_t = 410 + let data = seg.data in 411 + let n = Array.length data in 412 + let degree = 413 + match seg.metadata.interpolation_degree with Some d -> d | None -> 7 414 + in 415 + if n < 2 then None 416 + else 417 + (* Convert all epochs to unix timestamps *) 418 + let times = 419 + Array.map 420 + (fun sv -> 421 + match epoch_to_unix sv.epoch with Some t -> t | None -> Float.nan) 422 + data 423 + in 424 + if Array.exists Float.is_nan times then None 425 + else 426 + let t0 = times.(0) and tn = times.(n - 1) in 427 + if unix_t < t0 || unix_t > tn then None 428 + else 429 + (* Binary search for bracketing interval *) 430 + let lo = ref 0 and hi = ref (n - 2) in 431 + while !hi - !lo > 1 do 432 + let mid = (!lo + !hi) / 2 in 433 + if times.(mid) <= unix_t then lo := mid else hi := mid 434 + done; 435 + let i = 436 + if times.(!lo) <= unix_t && unix_t <= times.(!lo + 1) then !lo 437 + else !lo + 1 438 + in 439 + (* Select centered window of degree+1 points around interval [i, i+1] *) 440 + let needed = min (degree + 1) n in 441 + let half = (needed - 2) / 2 in 442 + let start = max 0 (min (i - half) (n - needed)) in 443 + let win_t = Array.sub times start needed in 444 + let win_p = Array.init needed (fun j -> data.(start + j).pos) in 445 + Some (lagrange_vec3 win_t win_p unix_t) 446 + 447 + let position_at oem unix_t = 448 + let rec try_segments = function 449 + | [] -> None 450 + | seg :: rest -> ( 451 + match interpolate seg unix_t with 452 + | Some _ as result -> result 453 + | None -> try_segments rest) 454 + in 455 + try_segments oem.segments
+128
lib/oem.mli
··· 1 + (** Orbit Ephemeris Message (OEM) — CCSDS 502.0-B-3. 2 + 3 + Parse and manipulate Orbit Ephemeris Messages (OEM) in KVN format. Supports 4 + multi-segment files, optional acceleration data, and Lagrange polynomial 5 + interpolation. 6 + 7 + {b References}: 8 + - CCSDS 502.0-B-3, Orbit Data Messages *) 9 + 10 + (** {1 Types} *) 11 + 12 + type vec3 = Vec3.t = { 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 covariance_entry = { 40 + cov_epoch : string; 41 + cov_ref_frame : string; 42 + cov : float array; (** Lower triangle of 6x6 covariance (21 elements). *) 43 + } 44 + (** Covariance at a specific epoch. Lower triangle stored row-by-row: 45 + [C11, C21, C22, C31, C32, C33, ...]. Units match the OEM position/velocity 46 + (typically km^2, km^2/s, km^2/s^2). *) 47 + 48 + type segment = { 49 + metadata : metadata; 50 + data : state_vector array; 51 + covariances : covariance_entry list; 52 + comments : string list; 53 + } 54 + (** An OEM segment: metadata, state vectors, covariances, and comments. *) 55 + 56 + type header = { 57 + ccsds_oem_vers : string; 58 + creation_date : string; 59 + originator : string; 60 + comments : string list; 61 + } 62 + (** OEM file header. *) 63 + 64 + type t = { header : header; segments : segment list } 65 + (** A complete OEM file. *) 66 + 67 + (** {1 Errors} *) 68 + 69 + type error = 70 + | Missing_header 71 + | Missing_metadata 72 + | Bad_line of { line : int; content : string } 73 + | Parse_error of string 74 + 75 + val pp_error : error Fmt.t 76 + (** [pp_error] pretty-prints a parse error. *) 77 + 78 + (** {1 KVN Parsing} *) 79 + 80 + val of_kvn_string : string -> (t, error) result 81 + (** [of_kvn_string s] parses an OEM KVN string. *) 82 + 83 + val of_kvn_channel : in_channel -> (t, error) result 84 + (** [of_kvn_channel ic] parses OEM KVN from an input channel. *) 85 + 86 + val of_kvn_file : string -> (t, error) result 87 + (** [of_kvn_file path] parses an OEM KVN file. *) 88 + 89 + (** {1 Pretty-printers} *) 90 + 91 + val pp : t Fmt.t 92 + (** [pp] pretty-prints an OEM. *) 93 + 94 + val pp_header : header Fmt.t 95 + (** [pp_header] pretty-prints an OEM header. *) 96 + 97 + val pp_metadata : metadata Fmt.t 98 + (** [pp_metadata] pretty-prints OEM metadata. *) 99 + 100 + val pp_state_vector : state_vector Fmt.t 101 + (** [pp_state_vector] pretty-prints a state vector. *) 102 + 103 + val pp_segment : segment Fmt.t 104 + (** [pp_segment] pretty-prints an OEM segment. *) 105 + 106 + (** {1 Accessors} *) 107 + 108 + val segments : t -> segment list 109 + (** [segments t] returns the list of segments. *) 110 + 111 + val state_vectors : segment -> state_vector array 112 + (** [state_vectors seg] returns the state vectors of a segment. *) 113 + 114 + val epoch_range : segment -> string * string 115 + (** [epoch_range seg] returns [(start_time, stop_time)] from the segment 116 + metadata. *) 117 + 118 + val position_at : t -> float -> vec3 option 119 + (** [position_at oem unix_t] returns the interpolated position at the given Unix 120 + timestamp. Searches all segments and uses Lagrange polynomial interpolation 121 + (degree from OEM metadata, default 7). Returns [None] if [unix_t] is outside 122 + all segment ranges. 123 + 124 + This is the main entry point for querying an OEM. *) 125 + 126 + val interpolate : segment -> float -> vec3 option 127 + (** [interpolate seg unix_t] interpolates within a single segment. Use 128 + {!position_at} unless you need to work with a specific segment. *)
+447
lib/opm.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** CCSDS 502.0-B Orbit Parameter Message (OPM) parser and serializer. *) 7 + 8 + (* ------------------------------------------------------------------ *) 9 + (* Types *) 10 + (* ------------------------------------------------------------------ *) 11 + 12 + type keplerian = { 13 + semi_major_axis : float; 14 + eccentricity : float; 15 + inclination : float; 16 + ra_of_asc_node : float; 17 + arg_of_pericenter : float; 18 + true_anomaly : float option; 19 + mean_anomaly : float option; 20 + gm : float option; 21 + } 22 + 23 + type spacecraft_parameters = { 24 + mass : float option; 25 + solar_rad_area : float option; 26 + solar_rad_coeff : float option; 27 + drag_area : float option; 28 + drag_coeff : float option; 29 + } 30 + 31 + type cartesian = { 32 + epoch : Ptime.t; 33 + x : float; 34 + y : float; 35 + z : float; 36 + x_dot : float; 37 + y_dot : float; 38 + z_dot : float; 39 + } 40 + 41 + type state = Cartesian of cartesian | Keplerian of keplerian 42 + 43 + type maneuver = { 44 + man_epoch_ignition : Ptime.t; 45 + man_duration : float; 46 + man_delta_mass : float; 47 + man_ref_frame : string; 48 + man_dv_1 : float; 49 + man_dv_2 : float; 50 + man_dv_3 : float; 51 + } 52 + 53 + type covariance = { 54 + cov_ref_frame : string option; 55 + cx_x : float; 56 + cy_x : float; 57 + cy_y : float; 58 + cz_x : float; 59 + cz_y : float; 60 + cz_z : float; 61 + cx_dot_x : float; 62 + cx_dot_y : float; 63 + cx_dot_z : float; 64 + cx_dot_x_dot : float; 65 + cy_dot_x : float; 66 + cy_dot_y : float; 67 + cy_dot_z : float; 68 + cy_dot_x_dot : float; 69 + cy_dot_y_dot : float; 70 + cz_dot_x : float; 71 + cz_dot_y : float; 72 + cz_dot_z : float; 73 + cz_dot_x_dot : float; 74 + cz_dot_y_dot : float; 75 + cz_dot_z_dot : float; 76 + } 77 + 78 + type metadata = { 79 + object_name : string; 80 + object_id : string; 81 + center_name : string; 82 + ref_frame : string; 83 + time_system : string; 84 + } 85 + 86 + type header = { version : string; creation_date : string; originator : string } 87 + 88 + type t = { 89 + header : header; 90 + metadata : metadata; 91 + epoch : Ptime.t; 92 + state : state; 93 + spacecraft : spacecraft_parameters option; 94 + covariance : covariance option; 95 + maneuvers : maneuver list; 96 + } 97 + 98 + type error = 99 + | Unexpected_eof 100 + | Bad_keyword of { line : int; got : string } 101 + | Bad_epoch of { line : int; value : string } 102 + | Bad_float of { line : int; value : string } 103 + | Missing_keyword of string 104 + | Parse_error of string 105 + 106 + let pp_error ppf = function 107 + | Unexpected_eof -> Fmt.pf ppf "Unexpected end of file" 108 + | Bad_keyword { line; got } -> Fmt.pf ppf "Line %d: bad keyword %S" line got 109 + | Bad_epoch { line; value } -> Fmt.pf ppf "Line %d: bad epoch %S" line value 110 + | Bad_float { line; value } -> Fmt.pf ppf "Line %d: bad float %S" line value 111 + | Missing_keyword kw -> Fmt.pf ppf "Missing required keyword %S" kw 112 + | Parse_error msg -> Fmt.pf ppf "Parse error: %s" msg 113 + 114 + (* ------------------------------------------------------------------ *) 115 + (* KVN helpers *) 116 + (* ------------------------------------------------------------------ *) 117 + 118 + let strip_unit s = 119 + let s = String.trim s in 120 + match String.rindex_opt s ']' with 121 + | None -> s 122 + | Some ri -> ( 123 + match String.rindex_opt s '[' with 124 + | None -> s 125 + | Some li -> if li < ri then String.trim (String.sub s 0 li) else s) 126 + 127 + let float_of v = float_of_string_opt (strip_unit v) 128 + let float_or_zero v = match float_of v with Some f -> f | None -> 0.0 129 + 130 + (* ------------------------------------------------------------------ *) 131 + (* Parsing *) 132 + (* ------------------------------------------------------------------ *) 133 + 134 + let parse_kvn_pairs s = 135 + let lines = String.split_on_char '\n' s in 136 + let pairs = ref [] in 137 + List.iter 138 + (fun line -> 139 + let line = String.trim line in 140 + if 141 + line = "" || (String.length line >= 7 && String.sub line 0 7 = "COMMENT") 142 + then () 143 + else 144 + match String.index_opt line '=' with 145 + | None -> () 146 + | Some eq -> 147 + let key = String.trim (String.sub line 0 eq) in 148 + let value = 149 + strip_unit 150 + (String.sub line (eq + 1) (String.length line - eq - 1)) 151 + in 152 + pairs := (key, value) :: !pairs) 153 + lines; 154 + List.rev !pairs 155 + 156 + let find key pairs = List.assoc_opt key pairs 157 + 158 + let find_epoch key pairs = 159 + match find key pairs with 160 + | Some v -> ( 161 + match Kvn.parse_epoch v with Some t -> t | None -> Ptime.epoch) 162 + | None -> Ptime.epoch 163 + 164 + let find_float key pairs = 165 + match find key pairs with Some v -> float_of v | None -> None 166 + 167 + let find_float_or key default pairs = 168 + match find_float key pairs with Some f -> f | None -> default 169 + 170 + let parse_header pairs = 171 + { 172 + version = 173 + (match find "CCSDS_OPM_VERS" pairs with Some v -> v | None -> ""); 174 + creation_date = 175 + (match find "CREATION_DATE" pairs with Some v -> v | None -> ""); 176 + originator = (match find "ORIGINATOR" pairs with Some v -> v | None -> ""); 177 + } 178 + 179 + let parse_metadata pairs = 180 + { 181 + object_name = 182 + (match find "OBJECT_NAME" pairs with Some v -> v | None -> ""); 183 + object_id = (match find "OBJECT_ID" pairs with Some v -> v | None -> ""); 184 + center_name = 185 + (match find "CENTER_NAME" pairs with Some v -> v | None -> "EARTH"); 186 + ref_frame = 187 + (match find "REF_FRAME" pairs with Some v -> v | None -> "EME2000"); 188 + time_system = 189 + (match find "TIME_SYSTEM" pairs with Some v -> v | None -> "UTC"); 190 + } 191 + 192 + let has_keplerian pairs = 193 + find "SEMI_MAJOR_AXIS" pairs <> None || find "ECCENTRICITY" pairs <> None 194 + 195 + let parse_keplerian pairs = 196 + { 197 + semi_major_axis = find_float_or "SEMI_MAJOR_AXIS" 0.0 pairs; 198 + eccentricity = find_float_or "ECCENTRICITY" 0.0 pairs; 199 + inclination = find_float_or "INCLINATION" 0.0 pairs; 200 + ra_of_asc_node = find_float_or "RA_OF_ASC_NODE" 0.0 pairs; 201 + arg_of_pericenter = find_float_or "ARG_OF_PERICENTER" 0.0 pairs; 202 + true_anomaly = find_float "TRUE_ANOMALY" pairs; 203 + mean_anomaly = find_float "MEAN_ANOMALY" pairs; 204 + gm = find_float "GM" pairs; 205 + } 206 + 207 + let parse_cartesian pairs epoch = 208 + { 209 + epoch; 210 + x = find_float_or "X" 0.0 pairs; 211 + y = find_float_or "Y" 0.0 pairs; 212 + z = find_float_or "Z" 0.0 pairs; 213 + x_dot = find_float_or "X_DOT" 0.0 pairs; 214 + y_dot = find_float_or "Y_DOT" 0.0 pairs; 215 + z_dot = find_float_or "Z_DOT" 0.0 pairs; 216 + } 217 + 218 + let parse_spacecraft pairs = 219 + let mass = find_float "MASS" pairs in 220 + let solar_rad_area = find_float "SOLAR_RAD_AREA" pairs in 221 + let solar_rad_coeff = find_float "SOLAR_RAD_COEFF" pairs in 222 + let drag_area = find_float "DRAG_AREA" pairs in 223 + let drag_coeff = find_float "DRAG_COEFF" pairs in 224 + match (mass, solar_rad_area, solar_rad_coeff, drag_area, drag_coeff) with 225 + | None, None, None, None, None -> None 226 + | _ -> Some { mass; solar_rad_area; solar_rad_coeff; drag_area; drag_coeff } 227 + 228 + let parse_covariance pairs = 229 + if find "CX_X" pairs = None then None 230 + else 231 + Some 232 + { 233 + cov_ref_frame = find "COV_REF_FRAME" pairs; 234 + cx_x = find_float_or "CX_X" 0.0 pairs; 235 + cy_x = find_float_or "CY_X" 0.0 pairs; 236 + cy_y = find_float_or "CY_Y" 0.0 pairs; 237 + cz_x = find_float_or "CZ_X" 0.0 pairs; 238 + cz_y = find_float_or "CZ_Y" 0.0 pairs; 239 + cz_z = find_float_or "CZ_Z" 0.0 pairs; 240 + cx_dot_x = find_float_or "CX_DOT_X" 0.0 pairs; 241 + cx_dot_y = find_float_or "CX_DOT_Y" 0.0 pairs; 242 + cx_dot_z = find_float_or "CX_DOT_Z" 0.0 pairs; 243 + cx_dot_x_dot = find_float_or "CX_DOT_X_DOT" 0.0 pairs; 244 + cy_dot_x = find_float_or "CY_DOT_X" 0.0 pairs; 245 + cy_dot_y = find_float_or "CY_DOT_Y" 0.0 pairs; 246 + cy_dot_z = find_float_or "CY_DOT_Z" 0.0 pairs; 247 + cy_dot_x_dot = find_float_or "CY_DOT_X_DOT" 0.0 pairs; 248 + cy_dot_y_dot = find_float_or "CY_DOT_Y_DOT" 0.0 pairs; 249 + cz_dot_x = find_float_or "CZ_DOT_X" 0.0 pairs; 250 + cz_dot_y = find_float_or "CZ_DOT_Y" 0.0 pairs; 251 + cz_dot_z = find_float_or "CZ_DOT_Z" 0.0 pairs; 252 + cz_dot_x_dot = find_float_or "CZ_DOT_X_DOT" 0.0 pairs; 253 + cz_dot_y_dot = find_float_or "CZ_DOT_Y_DOT" 0.0 pairs; 254 + cz_dot_z_dot = find_float_or "CZ_DOT_Z_DOT" 0.0 pairs; 255 + } 256 + 257 + let parse_maneuvers pairs = 258 + (* Collect all MAN_EPOCH_IGNITION entries; each starts a new maneuver. 259 + Since KVN pairs are in order, we group maneuver fields by scanning 260 + for the epoch keyword. *) 261 + let mans = ref [] in 262 + let cur = Hashtbl.create 8 in 263 + let flush () = 264 + if Hashtbl.mem cur "MAN_EPOCH_IGNITION" then begin 265 + let get k = try Hashtbl.find cur k with Not_found -> "" in 266 + let getf k = float_or_zero (get k) in 267 + let epoch = 268 + match Kvn.parse_epoch (get "MAN_EPOCH_IGNITION") with 269 + | Some t -> t 270 + | None -> Ptime.epoch 271 + in 272 + mans := 273 + { 274 + man_epoch_ignition = epoch; 275 + man_duration = getf "MAN_DURATION"; 276 + man_delta_mass = getf "MAN_DELTA_MASS"; 277 + man_ref_frame = get "MAN_REF_FRAME"; 278 + man_dv_1 = getf "MAN_DV_1"; 279 + man_dv_2 = getf "MAN_DV_2"; 280 + man_dv_3 = getf "MAN_DV_3"; 281 + } 282 + :: !mans; 283 + Hashtbl.clear cur 284 + end 285 + in 286 + List.iter 287 + (fun (k, v) -> 288 + if String.length k >= 4 && String.sub k 0 4 = "MAN_" then begin 289 + if k = "MAN_EPOCH_IGNITION" && Hashtbl.mem cur "MAN_EPOCH_IGNITION" then 290 + flush (); 291 + Hashtbl.replace cur k v 292 + end) 293 + pairs; 294 + flush (); 295 + List.rev !mans 296 + 297 + let of_string s = 298 + let pairs = parse_kvn_pairs s in 299 + let header = parse_header pairs in 300 + let metadata = parse_metadata pairs in 301 + let epoch = find_epoch "EPOCH" pairs in 302 + let state = 303 + if has_keplerian pairs then Keplerian (parse_keplerian pairs) 304 + else Cartesian (parse_cartesian pairs epoch) 305 + in 306 + let spacecraft = parse_spacecraft pairs in 307 + let covariance = parse_covariance pairs in 308 + let maneuvers = parse_maneuvers pairs in 309 + Ok { header; metadata; epoch; state; spacecraft; covariance; maneuvers } 310 + 311 + let of_channel ic = 312 + let buf = Buffer.create 4096 in 313 + (try 314 + while true do 315 + Buffer.add_string buf (input_line ic); 316 + Buffer.add_char buf '\n' 317 + done 318 + with End_of_file -> ()); 319 + of_string (Buffer.contents buf) 320 + 321 + let of_file path = 322 + let ic = open_in path in 323 + let r = of_channel ic in 324 + close_in ic; 325 + r 326 + 327 + (* ------------------------------------------------------------------ *) 328 + (* Serialization *) 329 + (* ------------------------------------------------------------------ *) 330 + 331 + let fmt_epoch buf t = 332 + let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in 333 + let frac = Ptime.to_float_s t -. floor (Ptime.to_float_s t) in 334 + Printf.bprintf buf "%04d-%02d-%02dT%02d:%02d:%06.3f" y m d hh mm 335 + (Float.of_int ss +. frac) 336 + 337 + let kv buf k v = Printf.bprintf buf "%s = %s\n" k v 338 + let kvf buf k f = Printf.bprintf buf "%s = %.14g\n" k f 339 + let kvf_opt buf k = function Some f -> kvf buf k f | None -> () 340 + 341 + let kv_epoch buf k t = 342 + Printf.bprintf buf "%s = " k; 343 + fmt_epoch buf t; 344 + Buffer.add_char buf '\n' 345 + 346 + let to_string opm = 347 + let buf = Buffer.create 1024 in 348 + kv buf "CCSDS_OPM_VERS" opm.header.version; 349 + kv buf "CREATION_DATE" opm.header.creation_date; 350 + kv buf "ORIGINATOR" opm.header.originator; 351 + Buffer.add_char buf '\n'; 352 + kv buf "OBJECT_NAME" opm.metadata.object_name; 353 + kv buf "OBJECT_ID" opm.metadata.object_id; 354 + kv buf "CENTER_NAME" opm.metadata.center_name; 355 + kv buf "REF_FRAME" opm.metadata.ref_frame; 356 + kv buf "TIME_SYSTEM" opm.metadata.time_system; 357 + Buffer.add_char buf '\n'; 358 + kv_epoch buf "EPOCH" opm.epoch; 359 + (match opm.state with 360 + | Cartesian c -> 361 + kvf buf "X" c.x; 362 + kvf buf "Y" c.y; 363 + kvf buf "Z" c.z; 364 + kvf buf "X_DOT" c.x_dot; 365 + kvf buf "Y_DOT" c.y_dot; 366 + kvf buf "Z_DOT" c.z_dot 367 + | Keplerian ke -> 368 + kvf buf "SEMI_MAJOR_AXIS" ke.semi_major_axis; 369 + kvf buf "ECCENTRICITY" ke.eccentricity; 370 + kvf buf "INCLINATION" ke.inclination; 371 + kvf buf "RA_OF_ASC_NODE" ke.ra_of_asc_node; 372 + kvf buf "ARG_OF_PERICENTER" ke.arg_of_pericenter; 373 + kvf_opt buf "TRUE_ANOMALY" ke.true_anomaly; 374 + kvf_opt buf "MEAN_ANOMALY" ke.mean_anomaly; 375 + kvf_opt buf "GM" ke.gm); 376 + (match opm.spacecraft with 377 + | None -> () 378 + | Some sp -> 379 + Buffer.add_char buf '\n'; 380 + kvf_opt buf "MASS" sp.mass; 381 + kvf_opt buf "SOLAR_RAD_AREA" sp.solar_rad_area; 382 + kvf_opt buf "SOLAR_RAD_COEFF" sp.solar_rad_coeff; 383 + kvf_opt buf "DRAG_AREA" sp.drag_area; 384 + kvf_opt buf "DRAG_COEFF" sp.drag_coeff); 385 + (match opm.covariance with 386 + | None -> () 387 + | Some c -> 388 + Buffer.add_char buf '\n'; 389 + (match c.cov_ref_frame with 390 + | Some f -> kv buf "COV_REF_FRAME" f 391 + | None -> ()); 392 + kvf buf "CX_X" c.cx_x; 393 + kvf buf "CY_X" c.cy_x; 394 + kvf buf "CY_Y" c.cy_y; 395 + kvf buf "CZ_X" c.cz_x; 396 + kvf buf "CZ_Y" c.cz_y; 397 + kvf buf "CZ_Z" c.cz_z; 398 + kvf buf "CX_DOT_X" c.cx_dot_x; 399 + kvf buf "CX_DOT_Y" c.cx_dot_y; 400 + kvf buf "CX_DOT_Z" c.cx_dot_z; 401 + kvf buf "CX_DOT_X_DOT" c.cx_dot_x_dot; 402 + kvf buf "CY_DOT_X" c.cy_dot_x; 403 + kvf buf "CY_DOT_Y" c.cy_dot_y; 404 + kvf buf "CY_DOT_Z" c.cy_dot_z; 405 + kvf buf "CY_DOT_X_DOT" c.cy_dot_x_dot; 406 + kvf buf "CY_DOT_Y_DOT" c.cy_dot_y_dot; 407 + kvf buf "CZ_DOT_X" c.cz_dot_x; 408 + kvf buf "CZ_DOT_Y" c.cz_dot_y; 409 + kvf buf "CZ_DOT_Z" c.cz_dot_z; 410 + kvf buf "CZ_DOT_X_DOT" c.cz_dot_x_dot; 411 + kvf buf "CZ_DOT_Y_DOT" c.cz_dot_y_dot; 412 + kvf buf "CZ_DOT_Z_DOT" c.cz_dot_z_dot); 413 + List.iter 414 + (fun man -> 415 + Buffer.add_char buf '\n'; 416 + kv_epoch buf "MAN_EPOCH_IGNITION" man.man_epoch_ignition; 417 + kvf buf "MAN_DURATION" man.man_duration; 418 + kvf buf "MAN_DELTA_MASS" man.man_delta_mass; 419 + kv buf "MAN_REF_FRAME" man.man_ref_frame; 420 + kvf buf "MAN_DV_1" man.man_dv_1; 421 + kvf buf "MAN_DV_2" man.man_dv_2; 422 + kvf buf "MAN_DV_3" man.man_dv_3) 423 + opm.maneuvers; 424 + Buffer.contents buf 425 + 426 + (* ------------------------------------------------------------------ *) 427 + (* Pretty-printing *) 428 + (* ------------------------------------------------------------------ *) 429 + 430 + let pp_header ppf h = 431 + Fmt.pf ppf "OPM v%s by %s (%s)" h.version h.originator h.creation_date 432 + 433 + let pp_metadata ppf m = 434 + Fmt.pf ppf "%s [%s] %s %s %s" m.object_name m.object_id m.center_name 435 + m.ref_frame m.time_system 436 + 437 + let pp_state ppf = function 438 + | Cartesian c -> 439 + Fmt.pf ppf "Cartesian: (%.6f, %.6f, %.6f) km (%.9f, %.9f, %.9f) km/s" c.x 440 + c.y c.z c.x_dot c.y_dot c.z_dot 441 + | Keplerian ke -> 442 + Fmt.pf ppf "Keplerian: a=%.3f km e=%.6f i=%.3f deg" ke.semi_major_axis 443 + ke.eccentricity ke.inclination 444 + 445 + let pp ppf opm = 446 + Fmt.pf ppf "@[<v>%a@,%a@,%a: %a@]" pp_header opm.header pp_metadata 447 + opm.metadata (Ptime.pp_rfc3339 ()) opm.epoch pp_state opm.state
+149
lib/opm.mli
··· 1 + (** CCSDS 502.0-B Orbit Parameter Message (OPM) parser and serializer. 2 + 3 + Parses the KVN (Keyword=Value Notation) text format for orbit parameter 4 + data. Supports Cartesian state vectors and Keplerian orbital elements, with 5 + optional spacecraft parameters and maneuver definitions. 6 + 7 + Reference: {{:https://public.ccsds.org/Pubs/502x0b3e1.pdf} CCSDS 502.0-B-3} 8 + Orbit Data Messages, Annex (OPM). *) 9 + 10 + (** {1 Types} *) 11 + 12 + type keplerian = { 13 + semi_major_axis : float; (** km *) 14 + eccentricity : float; 15 + inclination : float; (** deg *) 16 + ra_of_asc_node : float; (** deg *) 17 + arg_of_pericenter : float; (** deg *) 18 + true_anomaly : float option; (** deg *) 19 + mean_anomaly : float option; (** deg *) 20 + gm : float option; (** km^3/s^2 *) 21 + } 22 + (** Keplerian orbital elements. *) 23 + 24 + type spacecraft_parameters = { 25 + mass : float option; (** kg *) 26 + solar_rad_area : float option; (** m^2 *) 27 + solar_rad_coeff : float option; 28 + drag_area : float option; (** m^2 *) 29 + drag_coeff : float option; 30 + } 31 + (** Optional spacecraft physical parameters. *) 32 + 33 + type cartesian = { 34 + epoch : Ptime.t; 35 + x : float; 36 + y : float; 37 + z : float; (** km *) 38 + x_dot : float; 39 + y_dot : float; 40 + z_dot : float; (** km/s *) 41 + } 42 + (** Cartesian state vector at an epoch. *) 43 + 44 + type state = 45 + | Cartesian of cartesian 46 + | Keplerian of keplerian (** Orbit state: either Cartesian or Keplerian. *) 47 + 48 + type maneuver = { 49 + man_epoch_ignition : Ptime.t; 50 + man_duration : float; (** s *) 51 + man_delta_mass : float; (** kg *) 52 + man_ref_frame : string; 53 + man_dv_1 : float; (** km/s *) 54 + man_dv_2 : float; (** km/s *) 55 + man_dv_3 : float; (** km/s *) 56 + } 57 + (** A single maneuver definition. *) 58 + 59 + type covariance = { 60 + cov_ref_frame : string option; 61 + cx_x : float; 62 + cy_x : float; 63 + cy_y : float; 64 + cz_x : float; 65 + cz_y : float; 66 + cz_z : float; 67 + cx_dot_x : float; 68 + cx_dot_y : float; 69 + cx_dot_z : float; 70 + cx_dot_x_dot : float; 71 + cy_dot_x : float; 72 + cy_dot_y : float; 73 + cy_dot_z : float; 74 + cy_dot_x_dot : float; 75 + cy_dot_y_dot : float; 76 + cz_dot_x : float; 77 + cz_dot_y : float; 78 + cz_dot_z : float; 79 + cz_dot_x_dot : float; 80 + cz_dot_y_dot : float; 81 + cz_dot_z_dot : float; 82 + } 83 + (** Position/velocity covariance matrix (upper triangle, 6x6 = 21 elements). *) 84 + 85 + type metadata = { 86 + object_name : string; 87 + object_id : string; 88 + center_name : string; 89 + ref_frame : string; 90 + time_system : string; 91 + } 92 + (** OPM metadata. *) 93 + 94 + type header = { version : string; creation_date : string; originator : string } 95 + (** OPM file header. *) 96 + 97 + type t = { 98 + header : header; 99 + metadata : metadata; 100 + epoch : Ptime.t; 101 + state : state; 102 + spacecraft : spacecraft_parameters option; 103 + covariance : covariance option; 104 + maneuvers : maneuver list; 105 + } 106 + (** A complete OPM message. *) 107 + 108 + (** {1 Errors} *) 109 + 110 + type error = 111 + | Unexpected_eof 112 + | Bad_keyword of { line : int; got : string } 113 + | Bad_epoch of { line : int; value : string } 114 + | Bad_float of { line : int; value : string } 115 + | Missing_keyword of string 116 + | Parse_error of string 117 + 118 + val pp_error : error Fmt.t 119 + (** [pp_error] pretty-prints a parse error. *) 120 + 121 + (** {1 Parsing} *) 122 + 123 + val of_string : string -> (t, error) result 124 + (** [of_string s] parses an OPM message from a KVN string. *) 125 + 126 + val of_channel : in_channel -> (t, error) result 127 + (** [of_channel ic] parses an OPM message from an input channel. *) 128 + 129 + val of_file : string -> (t, error) result 130 + (** [of_file path] parses an OPM message from a file path. *) 131 + 132 + (** {1 Serialization} *) 133 + 134 + val to_string : t -> string 135 + (** [to_string opm] serializes an OPM message to KVN format. *) 136 + 137 + (** {1 Pretty-printing} *) 138 + 139 + val pp_header : header Fmt.t 140 + (** [pp_header] pretty-prints an OPM header. *) 141 + 142 + val pp_metadata : metadata Fmt.t 143 + (** [pp_metadata] pretty-prints OPM metadata. *) 144 + 145 + val pp_state : state Fmt.t 146 + (** [pp_state] pretty-prints an orbit state. *) 147 + 148 + val pp : t Fmt.t 149 + (** [pp] pretty-prints an OPM message summary. *)
+2 -1
odm.opam
··· 2 2 opam-version: "2.0" 3 3 synopsis: "Orbit Data Messages (CCSDS 502.0-B-3)" 4 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." 5 + "Parse and manipulate Orbit Data Messages (CCSDS 502.0-B-3) for satellite ephemeris exchange. Includes OEM (Orbit Ephemeris Message) and OPM (Orbit Parameter Message) KVN format parsers." 6 6 maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 7 authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 8 license: "ISC" ··· 11 11 depends: [ 12 12 "dune" {>= "3.21"} 13 13 "ocaml" {>= "4.14"} 14 + "kvn" 14 15 "ptime" 15 16 "fmt" 16 17 "alcotest" {with-test}
+2 -1
test/test.ml
··· 1 - let () = Alcotest.run "odm" [ Test_odm.suite; Test_vectors.suite ] 1 + let () = 2 + Alcotest.run "odm" [ Test_odm.suite; Test_opm.suite; Test_vectors.suite ]
+185
test/test_opm.ml
··· 1 + (** OPM parsing and serialization tests. 2 + 3 + Test vectors from CCSDS 502.0-B-3 Annex (OPM normative example). *) 4 + 5 + let eps = 1e-6 6 + 7 + let check_float msg expected actual = 8 + Alcotest.(check (float eps)) msg expected actual 9 + 10 + let check_ok msg = function 11 + | Ok v -> v 12 + | Error e -> Alcotest.failf "%s: %a" msg Odm.Opm.pp_error e 13 + 14 + (* ------------------------------------------------------------------ *) 15 + (* CCSDS Blue Book example — Cartesian state vector *) 16 + (* ------------------------------------------------------------------ *) 17 + 18 + let ccsds_cartesian = 19 + {|CCSDS_OPM_VERS = 2.0 20 + CREATION_DATE = 2004-281T17:17:08 21 + ORIGINATOR = NASA/JPL 22 + COMMENT this is an example 23 + OBJECT_NAME = MARS GLOBAL SURVEYOR 24 + OBJECT_ID = 2001-025A 25 + CENTER_NAME = MARS BARYCENTER 26 + REF_FRAME = EME2000 27 + TIME_SYSTEM = UTC 28 + EPOCH = 2004-100T00:00:00.000 29 + X = 4867.946816 [km] 30 + Y = -3175.839452 [km] 31 + Z = 2817.207577 [km] 32 + X_DOT = 3.983900621 [km/s] 33 + Y_DOT = 5.765476583 [km/s] 34 + Z_DOT = 1.876698998 [km/s] 35 + |} 36 + 37 + let test_parse_cartesian () = 38 + let opm = check_ok "parse" (Odm.Opm.of_string ccsds_cartesian) in 39 + Alcotest.(check string) "version" "2.0" opm.header.version; 40 + Alcotest.(check string) "originator" "NASA/JPL" opm.header.originator; 41 + Alcotest.(check string) "name" "MARS GLOBAL SURVEYOR" opm.metadata.object_name; 42 + Alcotest.(check string) "id" "2001-025A" opm.metadata.object_id; 43 + Alcotest.(check string) "center" "MARS BARYCENTER" opm.metadata.center_name; 44 + Alcotest.(check string) "frame" "EME2000" opm.metadata.ref_frame; 45 + match opm.state with 46 + | Odm.Opm.Keplerian _ -> Alcotest.fail "expected Cartesian" 47 + | Odm.Opm.Cartesian c -> 48 + check_float "x" 4867.946816 c.x; 49 + check_float "y" (-3175.839452) c.y; 50 + check_float "z" 2817.207577 c.z; 51 + check_float "x_dot" 3.983900621 c.x_dot; 52 + check_float "y_dot" 5.765476583 c.y_dot; 53 + check_float "z_dot" 1.876698998 c.z_dot 54 + 55 + (* ------------------------------------------------------------------ *) 56 + (* Keplerian elements *) 57 + (* ------------------------------------------------------------------ *) 58 + 59 + let keplerian_example = 60 + {|CCSDS_OPM_VERS = 2.0 61 + CREATION_DATE = 2025-01-15T00:00:00 62 + ORIGINATOR = TEST 63 + OBJECT_NAME = ISS (ZARYA) 64 + OBJECT_ID = 1998-067A 65 + CENTER_NAME = EARTH 66 + REF_FRAME = EME2000 67 + TIME_SYSTEM = UTC 68 + EPOCH = 2025-01-15T00:00:00.000 69 + SEMI_MAJOR_AXIS = 6794.137 [km] 70 + ECCENTRICITY = 0.0006703 71 + INCLINATION = 51.6437 [deg] 72 + RA_OF_ASC_NODE = 247.2506 [deg] 73 + ARG_OF_PERICENTER = 130.5360 [deg] 74 + TRUE_ANOMALY = 325.1210 [deg] 75 + GM = 398600.4418 [km**3/s**2] 76 + |} 77 + 78 + let test_parse_keplerian () = 79 + let opm = check_ok "parse" (Odm.Opm.of_string keplerian_example) in 80 + Alcotest.(check string) "name" "ISS (ZARYA)" opm.metadata.object_name; 81 + match opm.state with 82 + | Odm.Opm.Cartesian _ -> Alcotest.fail "expected Keplerian" 83 + | Odm.Opm.Keplerian ke -> 84 + check_float "sma" 6794.137 ke.semi_major_axis; 85 + check_float "ecc" 0.0006703 ke.eccentricity; 86 + check_float "inc" 51.6437 ke.inclination; 87 + check_float "raan" 247.2506 ke.ra_of_asc_node; 88 + check_float "aop" 130.5360 ke.arg_of_pericenter; 89 + check_float "ta" 325.1210 (Option.get ke.true_anomaly); 90 + check_float "gm" 398600.4418 (Option.get ke.gm) 91 + 92 + (* ------------------------------------------------------------------ *) 93 + (* Maneuvers *) 94 + (* ------------------------------------------------------------------ *) 95 + 96 + let maneuver_example = 97 + {|CCSDS_OPM_VERS = 2.0 98 + CREATION_DATE = 2025-01-15T00:00:00 99 + ORIGINATOR = ESA 100 + OBJECT_NAME = SENTINEL-1A 101 + OBJECT_ID = 2014-016A 102 + CENTER_NAME = EARTH 103 + REF_FRAME = EME2000 104 + TIME_SYSTEM = UTC 105 + EPOCH = 2025-01-15T00:00:00.000 106 + X = 7000.0 [km] 107 + Y = 0.0 [km] 108 + Z = 0.0 [km] 109 + X_DOT = 0.0 [km/s] 110 + Y_DOT = 7.546 [km/s] 111 + Z_DOT = 0.0 [km/s] 112 + MAN_EPOCH_IGNITION = 2025-01-15T06:00:00.000 113 + MAN_DURATION = 120.0 [s] 114 + MAN_DELTA_MASS = -0.5 [kg] 115 + MAN_REF_FRAME = TNW 116 + MAN_DV_1 = 0.001 [km/s] 117 + MAN_DV_2 = 0.0 [km/s] 118 + MAN_DV_3 = 0.0 [km/s] 119 + MAN_EPOCH_IGNITION = 2025-01-15T12:00:00.000 120 + MAN_DURATION = 60.0 [s] 121 + MAN_DELTA_MASS = -0.25 [kg] 122 + MAN_REF_FRAME = TNW 123 + MAN_DV_1 = 0.0005 [km/s] 124 + MAN_DV_2 = 0.0 [km/s] 125 + MAN_DV_3 = 0.0 [km/s] 126 + |} 127 + 128 + let test_parse_maneuvers () = 129 + let opm = check_ok "parse" (Odm.Opm.of_string maneuver_example) in 130 + Alcotest.(check int) "maneuver count" 2 (List.length opm.maneuvers); 131 + let m1 = List.nth opm.maneuvers 0 in 132 + check_float "man1 duration" 120.0 m1.man_duration; 133 + check_float "man1 dv_1" 0.001 m1.man_dv_1; 134 + Alcotest.(check string) "man1 frame" "TNW" m1.man_ref_frame; 135 + let m2 = List.nth opm.maneuvers 1 in 136 + check_float "man2 duration" 60.0 m2.man_duration; 137 + check_float "man2 dv_1" 0.0005 m2.man_dv_1 138 + 139 + (* ------------------------------------------------------------------ *) 140 + (* Roundtrip *) 141 + (* ------------------------------------------------------------------ *) 142 + 143 + let test_roundtrip_cartesian () = 144 + let opm = check_ok "parse1" (Odm.Opm.of_string ccsds_cartesian) in 145 + let kvn = Odm.Opm.to_string opm in 146 + let opm2 = check_ok "parse2" (Odm.Opm.of_string kvn) in 147 + Alcotest.(check string) 148 + "name" opm.metadata.object_name opm2.metadata.object_name; 149 + Alcotest.(check string) "id" opm.metadata.object_id opm2.metadata.object_id; 150 + match (opm.state, opm2.state) with 151 + | Odm.Opm.Cartesian c1, Odm.Opm.Cartesian c2 -> 152 + check_float "x" c1.x c2.x; 153 + check_float "y" c1.y c2.y; 154 + check_float "z" c1.z c2.z; 155 + check_float "x_dot" c1.x_dot c2.x_dot; 156 + check_float "y_dot" c1.y_dot c2.y_dot; 157 + check_float "z_dot" c1.z_dot c2.z_dot 158 + | _ -> Alcotest.fail "state type mismatch on roundtrip" 159 + 160 + let test_roundtrip_keplerian () = 161 + let opm = check_ok "parse1" (Odm.Opm.of_string keplerian_example) in 162 + let kvn = Odm.Opm.to_string opm in 163 + let opm2 = check_ok "parse2" (Odm.Opm.of_string kvn) in 164 + match (opm.state, opm2.state) with 165 + | Odm.Opm.Keplerian k1, Odm.Opm.Keplerian k2 -> 166 + check_float "sma" k1.semi_major_axis k2.semi_major_axis; 167 + check_float "ecc" k1.eccentricity k2.eccentricity; 168 + check_float "inc" k1.inclination k2.inclination 169 + | _ -> Alcotest.fail "state type mismatch on roundtrip" 170 + 171 + let test_empty () = 172 + let opm = check_ok "empty" (Odm.Opm.of_string "") in 173 + Alcotest.(check string) "version" "" opm.header.version; 174 + Alcotest.(check int) "no maneuvers" 0 (List.length opm.maneuvers) 175 + 176 + let suite = 177 + ( "opm", 178 + [ 179 + Alcotest.test_case "parse Cartesian" `Quick test_parse_cartesian; 180 + Alcotest.test_case "parse Keplerian" `Quick test_parse_keplerian; 181 + Alcotest.test_case "parse maneuvers" `Quick test_parse_maneuvers; 182 + Alcotest.test_case "roundtrip Cartesian" `Quick test_roundtrip_cartesian; 183 + Alcotest.test_case "roundtrip Keplerian" `Quick test_roundtrip_keplerian; 184 + Alcotest.test_case "empty" `Quick test_empty; 185 + ] )
+2
test/test_opm.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the OPM test suite (parsing + serialization). *)