CCSDS 502.0-B Orbit Parameter Message parser and serializer
0
fork

Configure Feed

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

Add 8 new CCSDS/RFC protocol packages

- ocaml-rice: CCSDS 121.0-B lossless compression (Rice/Golomb)
- ocaml-udpcl: RFC 7122 UDP convergence layer for Bundle Protocol
- ocaml-erasure: CCSDS 131.5-B erasure correcting codes (GF(2^8))
- ocaml-short-ldpc: CCSDS 131.4-B short block-length LDPC
- ocaml-opm: CCSDS 502.0-B Orbit Parameter Message (KVN)
- ocaml-aem: CCSDS 504.0-B Attitude Ephemeris Message (KVN)
- ocaml-tdm: CCSDS 503.0-B Tracking Data Message (KVN)
- ocaml-rdm: CCSDS 508.1-B Re-entry Data Message (KVN)

+884
+42
README.md
··· 1 + # opm 2 + 3 + CCSDS 502.0-B Orbit Parameter Message parser and serializer. 4 + 5 + Parses the KVN (Keyword=Value Notation) text format for orbit parameter data. 6 + Supports both Cartesian state vectors and Keplerian orbital elements, with 7 + optional spacecraft parameters and maneuver definitions. 8 + 9 + Reference: [CCSDS 502.0-B-3](https://public.ccsds.org/Pubs/502x0b3e1.pdf) 10 + Orbit Data Messages, Annex (OPM). 11 + 12 + ## Installation 13 + 14 + ``` 15 + opam install opm 16 + ``` 17 + 18 + ## Usage 19 + 20 + ```ocaml 21 + match Opm.of_string kvn_text with 22 + | Ok opm -> 23 + Printf.printf "Object: %s\n" opm.metadata.object_name; 24 + (match opm.state with 25 + | Opm.Cartesian sv -> 26 + Fmt.pr "Position: (%.3f, %.3f, %.3f) km\n" sv.x sv.y sv.z 27 + | Opm.Keplerian ke -> 28 + Fmt.pr "SMA: %.3f km, ecc: %.6f\n" ke.semi_major_axis ke.eccentricity) 29 + | Error e -> Fmt.epr "%a\n" Opm.pp_error e 30 + ``` 31 + 32 + ## API Overview 33 + 34 + - **`type t`** -- Complete OPM: header, metadata, state, optional maneuvers 35 + - **`type state`** -- `Cartesian` state vector or `Keplerian` elements 36 + - **`type maneuver`** -- Epoch, duration, delta-V components, reference frame 37 + - **`of_string`**, **`of_channel`**, **`of_file`** -- Parse KVN format 38 + - **`to_string`** -- Serialize back to KVN format 39 + 40 + ## License 41 + 42 + ISC
+19
dune-project
··· 1 + (lang dune 3.21) 2 + (name opm) 3 + (source (tangled gazagnaire.org/ocaml-opm)) 4 + (formatting (enabled_for ocaml)) 5 + 6 + (generate_opam_files true) 7 + 8 + (license ISC) 9 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 11 + 12 + (package 13 + (name opm) 14 + (synopsis "CCSDS 502.0-B Orbit Parameter Message parser and serializer") 15 + (depends 16 + (ocaml (>= 4.14)) 17 + kvn 18 + fmt 19 + ptime))
+4
lib/dune
··· 1 + (library 2 + (name opm) 3 + (public_name opm) 4 + (libraries kvn ptime fmt))
+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. *)
+32
opm.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "CCSDS 502.0-B Orbit Parameter Message parser and serializer" 4 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 5 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 6 + license: "ISC" 7 + homepage: "https://tangled.org/gazagnaire.org/ocaml-opm" 8 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-opm/issues" 9 + depends: [ 10 + "dune" {>= "3.21"} 11 + "ocaml" {>= "4.14"} 12 + "kvn" 13 + "fmt" 14 + "ptime" 15 + "odoc" {with-doc} 16 + ] 17 + build: [ 18 + ["dune" "subst"] {dev} 19 + [ 20 + "dune" 21 + "build" 22 + "-p" 23 + name 24 + "-j" 25 + jobs 26 + "@install" 27 + "@runtest" {with-test} 28 + "@doc" {with-doc} 29 + ] 30 + ] 31 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-opm" 32 + x-maintenance-intent: ["(latest)"]
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries opm alcotest ptime))
+1
test/test.ml
··· 1 + let () = Alcotest.run "opm" [ Test_opm.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 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" (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 + | Opm.Keplerian _ -> Alcotest.fail "expected Cartesian" 47 + | 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" (Opm.of_string keplerian_example) in 80 + Alcotest.(check string) "name" "ISS (ZARYA)" opm.metadata.object_name; 81 + match opm.state with 82 + | Opm.Cartesian _ -> Alcotest.fail "expected Keplerian" 83 + | 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" (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" (Opm.of_string ccsds_cartesian) in 145 + let kvn = Opm.to_string opm in 146 + let opm2 = check_ok "parse2" (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 + | Opm.Cartesian c1, 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" (Opm.of_string keplerian_example) in 162 + let kvn = Opm.to_string opm in 163 + let opm2 = check_ok "parse2" (Opm.of_string kvn) in 164 + match (opm.state, opm2.state) with 165 + | Opm.Keplerian k1, 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" (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). *)