CCSDS 508.1-B Re-entry Data 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)

+758
+41
README.md
··· 1 + # rdm 2 + 3 + CCSDS 508.1-B Re-entry Data Message parser and serializer. 4 + 5 + Parses the KVN (Keyword=Value Notation) text format for re-entry prediction 6 + data messages. Contains impact time, location, object properties, and 7 + uncertainty information for controlled and uncontrolled re-entries. 8 + 9 + Reference: [CCSDS 508.1-B-1](https://public.ccsds.org/Pubs/508x1b1.pdf) 10 + Re-entry Data Message. 11 + 12 + ## Installation 13 + 14 + ``` 15 + opam install rdm 16 + ``` 17 + 18 + ## Usage 19 + 20 + ```ocaml 21 + match Rdm.of_string kvn_text with 22 + | Ok rdm -> 23 + Printf.printf "Object: %s\n" rdm.metadata.object_name; 24 + Printf.printf "Reentry epoch: %s\n" rdm.reentry_epoch; 25 + (match rdm.impact_latitude, rdm.impact_longitude with 26 + | Some lat, Some lon -> 27 + Printf.printf "Impact: %.3f N, %.3f E\n" lat lon 28 + | _ -> print_endline "Impact location unknown") 29 + | Error e -> Fmt.epr "%a\n" Rdm.pp_error e 30 + ``` 31 + 32 + ## API Overview 33 + 34 + - **`type t`** -- Complete RDM: header, metadata, re-entry and object data 35 + - **`type metadata`** -- Object identification, reference frame, time system 36 + - **`of_string`**, **`of_channel`**, **`of_file`** -- Parse KVN format 37 + - **`to_string`** -- Serialize back to KVN format 38 + 39 + ## License 40 + 41 + ISC
+19
dune-project
··· 1 + (lang dune 3.21) 2 + (name rdm) 3 + (source (tangled gazagnaire.org/ocaml-rdm)) 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 rdm) 14 + (synopsis "CCSDS 508.1-B Re-entry Data Message parser and serializer") 15 + (depends 16 + (ocaml (>= 4.14)) 17 + kvn 18 + fmt 19 + ptime))
+4
lib/dune
··· 1 + (library 2 + (name rdm) 3 + (public_name rdm) 4 + (libraries kvn ptime fmt))
+348
lib/rdm.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** CCSDS 508.1-B Re-entry Data Message (RDM) parser and serializer. *) 7 + 8 + (* ------------------------------------------------------------------ *) 9 + (* Types *) 10 + (* ------------------------------------------------------------------ *) 11 + 12 + type metadata = { 13 + object_designator : string; 14 + catalog_name : string option; 15 + object_name : string; 16 + international_designator : string option; 17 + object_type : string option; 18 + operator_organization : string option; 19 + operator_phone : string option; 20 + operator_email : string option; 21 + center_name : string; 22 + ref_frame : string; 23 + time_system : string; 24 + epoch_tzero : string; 25 + } 26 + 27 + type state_vector = { 28 + x : float; 29 + y : float; 30 + z : float; 31 + x_dot : float; 32 + y_dot : float; 33 + z_dot : float; 34 + } 35 + 36 + type physical_parameters = { 37 + wet_mass : float option; 38 + dry_mass : float option; 39 + area_pc : float option; 40 + cd_area_over_mass : float option; 41 + cr_area_over_mass : float option; 42 + initial_orbit_lifetime : float option; 43 + } 44 + 45 + type reentry_parameters = { 46 + reentry_epoch : string; 47 + reentry_epoch_ptime : Ptime.t option; 48 + reentry_epoch_uncertainty : float option; 49 + controlled : string option; 50 + impact_latitude : float option; 51 + impact_longitude : float option; 52 + impact_altitude : float option; 53 + impact_epoch : string option; 54 + disintegration_altitude : float option; 55 + surviving_mass_fraction : float option; 56 + ground_casualty_area : float option; 57 + casualty_expectation : float option; 58 + } 59 + 60 + type header = { 61 + version : string; 62 + classification : string option; 63 + creation_date : string; 64 + originator : string; 65 + message_for : string option; 66 + message_id : string; 67 + } 68 + 69 + type t = { 70 + header : header; 71 + metadata : metadata; 72 + state : state_vector option; 73 + physical : physical_parameters option; 74 + reentry : reentry_parameters; 75 + } 76 + 77 + type error = 78 + | Unexpected_eof 79 + | Bad_keyword of { line : int; got : string } 80 + | Bad_epoch of { line : int; value : string } 81 + | Bad_float of { line : int; value : string } 82 + | Missing_keyword of string 83 + | Parse_error of string 84 + 85 + let pp_error ppf = function 86 + | Unexpected_eof -> Fmt.pf ppf "Unexpected end of file" 87 + | Bad_keyword { line; got } -> Fmt.pf ppf "Line %d: bad keyword %S" line got 88 + | Bad_epoch { line; value } -> Fmt.pf ppf "Line %d: bad epoch %S" line value 89 + | Bad_float { line; value } -> Fmt.pf ppf "Line %d: bad float %S" line value 90 + | Missing_keyword kw -> Fmt.pf ppf "Missing required keyword %S" kw 91 + | Parse_error msg -> Fmt.pf ppf "Parse error: %s" msg 92 + 93 + (* ------------------------------------------------------------------ *) 94 + (* KVN helpers *) 95 + (* ------------------------------------------------------------------ *) 96 + 97 + let strip_unit s = 98 + let s = String.trim s in 99 + match String.rindex_opt s ']' with 100 + | None -> s 101 + | Some ri -> ( 102 + match String.rindex_opt s '[' with 103 + | None -> s 104 + | Some li -> if li < ri then String.trim (String.sub s 0 li) else s) 105 + 106 + let float_of v = float_of_string_opt (strip_unit v) 107 + 108 + (* ------------------------------------------------------------------ *) 109 + (* Parsing *) 110 + (* ------------------------------------------------------------------ *) 111 + 112 + let parse_kvn_pairs s = 113 + let lines = String.split_on_char '\n' s in 114 + let pairs = ref [] in 115 + List.iter 116 + (fun line -> 117 + let line = String.trim line in 118 + if 119 + line = "" || (String.length line >= 7 && String.sub line 0 7 = "COMMENT") 120 + then () 121 + else 122 + match String.index_opt line '=' with 123 + | None -> () 124 + | Some eq -> 125 + let key = String.trim (String.sub line 0 eq) in 126 + let value = 127 + strip_unit 128 + (String.sub line (eq + 1) (String.length line - eq - 1)) 129 + in 130 + pairs := (key, value) :: !pairs) 131 + lines; 132 + List.rev !pairs 133 + 134 + let find key pairs = List.assoc_opt key pairs 135 + 136 + let find_float key pairs = 137 + match find key pairs with Some v -> float_of v | None -> None 138 + 139 + let find_float_or key default pairs = 140 + match find_float key pairs with Some f -> f | None -> default 141 + 142 + let parse_header pairs = 143 + { 144 + version = 145 + (match find "CCSDS_RDM_VERS" pairs with Some v -> v | None -> ""); 146 + classification = find "CLASSIFICATION" pairs; 147 + creation_date = 148 + (match find "CREATION_DATE" pairs with Some v -> v | None -> ""); 149 + originator = (match find "ORIGINATOR" pairs with Some v -> v | None -> ""); 150 + message_for = find "MESSAGE_FOR" pairs; 151 + message_id = (match find "MESSAGE_ID" pairs with Some v -> v | None -> ""); 152 + } 153 + 154 + let parse_metadata pairs = 155 + { 156 + object_designator = 157 + (match find "OBJECT_DESIGNATOR" pairs with Some v -> v | None -> ""); 158 + catalog_name = find "CATALOG_NAME" pairs; 159 + object_name = 160 + (match find "OBJECT_NAME" pairs with Some v -> v | None -> ""); 161 + international_designator = find "INTERNATIONAL_DESIGNATOR" pairs; 162 + object_type = find "OBJECT_TYPE" pairs; 163 + operator_organization = find "OPERATOR_ORGANIZATION" pairs; 164 + operator_phone = find "OPERATOR_PHONE" pairs; 165 + operator_email = find "OPERATOR_EMAIL" pairs; 166 + center_name = 167 + (match find "CENTER_NAME" pairs with Some v -> v | None -> "EARTH"); 168 + ref_frame = 169 + (match find "REF_FRAME" pairs with Some v -> v | None -> "EME2000"); 170 + time_system = 171 + (match find "TIME_SYSTEM" pairs with Some v -> v | None -> "UTC"); 172 + epoch_tzero = 173 + (match find "EPOCH_TZERO" pairs with Some v -> v | None -> ""); 174 + } 175 + 176 + let parse_state pairs = 177 + if find "X" pairs = None then None 178 + else 179 + Some 180 + { 181 + x = find_float_or "X" 0.0 pairs; 182 + y = find_float_or "Y" 0.0 pairs; 183 + z = find_float_or "Z" 0.0 pairs; 184 + x_dot = find_float_or "X_DOT" 0.0 pairs; 185 + y_dot = find_float_or "Y_DOT" 0.0 pairs; 186 + z_dot = find_float_or "Z_DOT" 0.0 pairs; 187 + } 188 + 189 + let parse_physical pairs = 190 + let wet_mass = find_float "WET_MASS" pairs in 191 + let dry_mass = find_float "DRY_MASS" pairs in 192 + let area_pc = find_float "AREA_PC" pairs in 193 + let cd_area_over_mass = find_float "CD_AREA_OVER_MASS" pairs in 194 + let cr_area_over_mass = find_float "CR_AREA_OVER_MASS" pairs in 195 + let initial_orbit_lifetime = find_float "INITIAL_ORBIT_LIFETIME" pairs in 196 + match 197 + ( wet_mass, 198 + dry_mass, 199 + area_pc, 200 + cd_area_over_mass, 201 + cr_area_over_mass, 202 + initial_orbit_lifetime ) 203 + with 204 + | None, None, None, None, None, None -> None 205 + | _ -> 206 + Some 207 + { 208 + wet_mass; 209 + dry_mass; 210 + area_pc; 211 + cd_area_over_mass; 212 + cr_area_over_mass; 213 + initial_orbit_lifetime; 214 + } 215 + 216 + let parse_reentry pairs = 217 + let reentry_epoch = 218 + match find "REENTRY_EPOCH" pairs with Some v -> v | None -> "" 219 + in 220 + { 221 + reentry_epoch; 222 + reentry_epoch_ptime = Kvn.parse_epoch reentry_epoch; 223 + reentry_epoch_uncertainty = find_float "REENTRY_EPOCH_UNCERTAINTY" pairs; 224 + controlled = find "CONTROLLED" pairs; 225 + impact_latitude = find_float "IMPACT_LATITUDE" pairs; 226 + impact_longitude = find_float "IMPACT_LONGITUDE" pairs; 227 + impact_altitude = find_float "IMPACT_ALTITUDE" pairs; 228 + impact_epoch = find "IMPACT_EPOCH" pairs; 229 + disintegration_altitude = find_float "DISINTEGRATION_ALTITUDE" pairs; 230 + surviving_mass_fraction = find_float "SURVIVING_MASS_FRACTION" pairs; 231 + ground_casualty_area = find_float "GROUND_CASUALTY_AREA" pairs; 232 + casualty_expectation = find_float "CASUALTY_EXPECTATION" pairs; 233 + } 234 + 235 + let of_string s = 236 + let pairs = parse_kvn_pairs s in 237 + let header = parse_header pairs in 238 + let metadata = parse_metadata pairs in 239 + let state = parse_state pairs in 240 + let physical = parse_physical pairs in 241 + let reentry = parse_reentry pairs in 242 + Ok { header; metadata; state; physical; reentry } 243 + 244 + let of_channel ic = 245 + let buf = Buffer.create 4096 in 246 + (try 247 + while true do 248 + Buffer.add_string buf (input_line ic); 249 + Buffer.add_char buf '\n' 250 + done 251 + with End_of_file -> ()); 252 + of_string (Buffer.contents buf) 253 + 254 + let of_file path = 255 + let ic = open_in path in 256 + let r = of_channel ic in 257 + close_in ic; 258 + r 259 + 260 + (* ------------------------------------------------------------------ *) 261 + (* Serialization *) 262 + (* ------------------------------------------------------------------ *) 263 + 264 + let kv buf k v = Printf.bprintf buf "%s = %s\n" k v 265 + let kv_opt buf k = function Some v -> kv buf k v | None -> () 266 + let kvf buf k f = Printf.bprintf buf "%s = %.14g\n" k f 267 + let kvf_opt buf k = function Some f -> kvf buf k f | None -> () 268 + 269 + let to_string rdm = 270 + let buf = Buffer.create 2048 in 271 + kv buf "CCSDS_RDM_VERS" rdm.header.version; 272 + kv_opt buf "CLASSIFICATION" rdm.header.classification; 273 + kv buf "CREATION_DATE" rdm.header.creation_date; 274 + kv buf "ORIGINATOR" rdm.header.originator; 275 + kv_opt buf "MESSAGE_FOR" rdm.header.message_for; 276 + kv buf "MESSAGE_ID" rdm.header.message_id; 277 + Buffer.add_char buf '\n'; 278 + kv buf "OBJECT_DESIGNATOR" rdm.metadata.object_designator; 279 + kv_opt buf "CATALOG_NAME" rdm.metadata.catalog_name; 280 + kv buf "OBJECT_NAME" rdm.metadata.object_name; 281 + kv_opt buf "INTERNATIONAL_DESIGNATOR" rdm.metadata.international_designator; 282 + kv_opt buf "OBJECT_TYPE" rdm.metadata.object_type; 283 + kv_opt buf "OPERATOR_ORGANIZATION" rdm.metadata.operator_organization; 284 + kv_opt buf "OPERATOR_PHONE" rdm.metadata.operator_phone; 285 + kv_opt buf "OPERATOR_EMAIL" rdm.metadata.operator_email; 286 + kv buf "CENTER_NAME" rdm.metadata.center_name; 287 + kv buf "REF_FRAME" rdm.metadata.ref_frame; 288 + kv buf "TIME_SYSTEM" rdm.metadata.time_system; 289 + kv buf "EPOCH_TZERO" rdm.metadata.epoch_tzero; 290 + (match rdm.state with 291 + | None -> () 292 + | Some sv -> 293 + Buffer.add_char buf '\n'; 294 + kvf buf "X" sv.x; 295 + kvf buf "Y" sv.y; 296 + kvf buf "Z" sv.z; 297 + kvf buf "X_DOT" sv.x_dot; 298 + kvf buf "Y_DOT" sv.y_dot; 299 + kvf buf "Z_DOT" sv.z_dot); 300 + (match rdm.physical with 301 + | None -> () 302 + | Some phys -> 303 + Buffer.add_char buf '\n'; 304 + kvf_opt buf "WET_MASS" phys.wet_mass; 305 + kvf_opt buf "DRY_MASS" phys.dry_mass; 306 + kvf_opt buf "AREA_PC" phys.area_pc; 307 + kvf_opt buf "CD_AREA_OVER_MASS" phys.cd_area_over_mass; 308 + kvf_opt buf "CR_AREA_OVER_MASS" phys.cr_area_over_mass; 309 + kvf_opt buf "INITIAL_ORBIT_LIFETIME" phys.initial_orbit_lifetime); 310 + Buffer.add_char buf '\n'; 311 + kv buf "REENTRY_EPOCH" rdm.reentry.reentry_epoch; 312 + kvf_opt buf "REENTRY_EPOCH_UNCERTAINTY" rdm.reentry.reentry_epoch_uncertainty; 313 + kv_opt buf "CONTROLLED" rdm.reentry.controlled; 314 + kvf_opt buf "IMPACT_LATITUDE" rdm.reentry.impact_latitude; 315 + kvf_opt buf "IMPACT_LONGITUDE" rdm.reentry.impact_longitude; 316 + kvf_opt buf "IMPACT_ALTITUDE" rdm.reentry.impact_altitude; 317 + kv_opt buf "IMPACT_EPOCH" rdm.reentry.impact_epoch; 318 + kvf_opt buf "DISINTEGRATION_ALTITUDE" rdm.reentry.disintegration_altitude; 319 + kvf_opt buf "SURVIVING_MASS_FRACTION" rdm.reentry.surviving_mass_fraction; 320 + kvf_opt buf "GROUND_CASUALTY_AREA" rdm.reentry.ground_casualty_area; 321 + kvf_opt buf "CASUALTY_EXPECTATION" rdm.reentry.casualty_expectation; 322 + Buffer.contents buf 323 + 324 + (* ------------------------------------------------------------------ *) 325 + (* Pretty-printing *) 326 + (* ------------------------------------------------------------------ *) 327 + 328 + let pp_header ppf h = 329 + Fmt.pf ppf "RDM v%s by %s (%s) [%s]" h.version h.originator h.creation_date 330 + h.message_id 331 + 332 + let pp_metadata ppf m = 333 + Fmt.pf ppf "%s [%s] %s %s %s" m.object_name m.object_designator m.center_name 334 + m.ref_frame m.time_system 335 + 336 + let pp_reentry ppf r = 337 + Fmt.pf ppf "@[<v>epoch: %s" r.reentry_epoch; 338 + (match r.controlled with 339 + | Some c -> Fmt.pf ppf "@,controlled: %s" c 340 + | None -> ()); 341 + (match (r.impact_latitude, r.impact_longitude) with 342 + | Some lat, Some lon -> Fmt.pf ppf "@,impact: %.3f N, %.3f E" lat lon 343 + | _ -> ()); 344 + Fmt.pf ppf "@]" 345 + 346 + let pp ppf rdm = 347 + Fmt.pf ppf "@[<v>%a@,%a@,reentry: %a@]" pp_header rdm.header pp_metadata 348 + rdm.metadata pp_reentry rdm.reentry
+124
lib/rdm.mli
··· 1 + (** CCSDS 508.1-B Re-entry Data Message (RDM) parser and serializer. 2 + 3 + Parses the KVN (Keyword=Value Notation) text format for re-entry data 4 + messages containing impact predictions, object properties, and uncertainty 5 + information for controlled and uncontrolled re-entries. 6 + 7 + Reference: {{:https://public.ccsds.org/Pubs/508x1b1.pdf} CCSDS 508.1-B-1} 8 + Re-entry Data Message. *) 9 + 10 + (** {1 Types} *) 11 + 12 + type metadata = { 13 + object_designator : string; 14 + catalog_name : string option; 15 + object_name : string; 16 + international_designator : string option; 17 + object_type : string option; 18 + operator_organization : string option; 19 + operator_phone : string option; 20 + operator_email : string option; 21 + center_name : string; 22 + ref_frame : string; 23 + time_system : string; 24 + epoch_tzero : string; 25 + } 26 + (** RDM metadata section describing the re-entering object. *) 27 + 28 + type state_vector = { 29 + x : float; 30 + y : float; 31 + z : float; (** km *) 32 + x_dot : float; 33 + y_dot : float; 34 + z_dot : float; (** km/s *) 35 + } 36 + (** Cartesian state vector at epoch TZERO. *) 37 + 38 + type physical_parameters = { 39 + wet_mass : float option; (** kg *) 40 + dry_mass : float option; (** kg *) 41 + area_pc : float option; (** m^2 *) 42 + cd_area_over_mass : float option; (** m^2/kg *) 43 + cr_area_over_mass : float option; (** m^2/kg *) 44 + initial_orbit_lifetime : float option; (** days *) 45 + } 46 + (** Physical properties of the re-entering object. *) 47 + 48 + type reentry_parameters = { 49 + reentry_epoch : string; 50 + reentry_epoch_ptime : Ptime.t option; 51 + reentry_epoch_uncertainty : float option; (** s *) 52 + controlled : string option; (** "YES" or "NO" *) 53 + impact_latitude : float option; (** deg *) 54 + impact_longitude : float option; (** deg *) 55 + impact_altitude : float option; (** km *) 56 + impact_epoch : string option; 57 + disintegration_altitude : float option; (** km *) 58 + surviving_mass_fraction : float option; (** 0.0-1.0 *) 59 + ground_casualty_area : float option; (** m^2 *) 60 + casualty_expectation : float option; 61 + } 62 + (** Re-entry prediction parameters. *) 63 + 64 + type header = { 65 + version : string; 66 + classification : string option; 67 + creation_date : string; 68 + originator : string; 69 + message_for : string option; 70 + message_id : string; 71 + } 72 + (** RDM file header. *) 73 + 74 + type t = { 75 + header : header; 76 + metadata : metadata; 77 + state : state_vector option; 78 + physical : physical_parameters option; 79 + reentry : reentry_parameters; 80 + } 81 + (** A complete RDM message. *) 82 + 83 + (** {1 Errors} *) 84 + 85 + type error = 86 + | Unexpected_eof 87 + | Bad_keyword of { line : int; got : string } 88 + | Bad_epoch of { line : int; value : string } 89 + | Bad_float of { line : int; value : string } 90 + | Missing_keyword of string 91 + | Parse_error of string 92 + 93 + val pp_error : error Fmt.t 94 + (** [pp_error] pretty-prints a parse error. *) 95 + 96 + (** {1 Parsing} *) 97 + 98 + val of_string : string -> (t, error) result 99 + (** [of_string s] parses an RDM from a KVN string. *) 100 + 101 + val of_channel : in_channel -> (t, error) result 102 + (** [of_channel ic] parses an RDM from an input channel. *) 103 + 104 + val of_file : string -> (t, error) result 105 + (** [of_file path] parses an RDM from a file path. *) 106 + 107 + (** {1 Serialization} *) 108 + 109 + val to_string : t -> string 110 + (** [to_string rdm] serializes an RDM to KVN format. *) 111 + 112 + (** {1 Pretty-printing} *) 113 + 114 + val pp_header : header Fmt.t 115 + (** [pp_header] pretty-prints an RDM header. *) 116 + 117 + val pp_metadata : metadata Fmt.t 118 + (** [pp_metadata] pretty-prints RDM metadata. *) 119 + 120 + val pp_reentry : reentry_parameters Fmt.t 121 + (** [pp_reentry] pretty-prints re-entry parameters. *) 122 + 123 + val pp : t Fmt.t 124 + (** [pp] pretty-prints an RDM summary. *)
+32
rdm.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "CCSDS 508.1-B Re-entry Data 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-rdm" 8 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-rdm/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-rdm" 32 + x-maintenance-intent: ["(latest)"]
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries rdm alcotest ptime))
+1
test/test.ml
··· 1 + let () = Alcotest.run "rdm" [ Test_rdm.suite ]
+184
test/test_rdm.ml
··· 1 + (** RDM parsing and serialization tests. 2 + 3 + Test vectors based on CCSDS 508.1-B-1 examples. *) 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 Rdm.pp_error e 13 + 14 + (* ------------------------------------------------------------------ *) 15 + (* Uncontrolled re-entry example *) 16 + (* ------------------------------------------------------------------ *) 17 + 18 + let uncontrolled_example = 19 + {|CCSDS_RDM_VERS = 1.0 20 + CREATION_DATE = 2025-03-15T12:00:00.000 21 + ORIGINATOR = ESA/ESOC 22 + MESSAGE_FOR = CZ-5B R/B 23 + MESSAGE_ID = ESA-RDM-20250315-001 24 + OBJECT_DESIGNATOR = 48275 25 + CATALOG_NAME = SATCAT 26 + OBJECT_NAME = CZ-5B R/B 27 + INTERNATIONAL_DESIGNATOR = 2021-035B 28 + OBJECT_TYPE = ROCKET BODY 29 + OPERATOR_ORGANIZATION = CASC 30 + CENTER_NAME = EARTH 31 + REF_FRAME = EME2000 32 + TIME_SYSTEM = UTC 33 + EPOCH_TZERO = 2025-03-15T10:00:00.000 34 + X = 6578.137 [km] 35 + Y = 1234.567 [km] 36 + Z = -2345.678 [km] 37 + X_DOT = -1.234 [km/s] 38 + Y_DOT = 6.789 [km/s] 39 + Z_DOT = 2.345 [km/s] 40 + WET_MASS = 21000.0 [kg] 41 + DRY_MASS = 5000.0 [kg] 42 + AREA_PC = 52.0 [m**2] 43 + CD_AREA_OVER_MASS = 0.012 [m**2/kg] 44 + CR_AREA_OVER_MASS = 0.008 [m**2/kg] 45 + INITIAL_ORBIT_LIFETIME = 2.5 [d] 46 + REENTRY_EPOCH = 2025-03-17T22:15:00.000 47 + REENTRY_EPOCH_UNCERTAINTY = 14400 [s] 48 + CONTROLLED = NO 49 + IMPACT_LATITUDE = 28.5 [deg] 50 + IMPACT_LONGITUDE = -45.2 [deg] 51 + IMPACT_ALTITUDE = 0.0 [km] 52 + DISINTEGRATION_ALTITUDE = 78.0 [km] 53 + SURVIVING_MASS_FRACTION = 0.15 54 + GROUND_CASUALTY_AREA = 120.5 [m**2] 55 + CASUALTY_EXPECTATION = 0.00032 56 + |} 57 + 58 + let test_parse_uncontrolled () = 59 + let rdm = check_ok "parse" (Rdm.of_string uncontrolled_example) in 60 + Alcotest.(check string) "version" "1.0" rdm.header.version; 61 + Alcotest.(check string) "originator" "ESA/ESOC" rdm.header.originator; 62 + Alcotest.(check string) 63 + "message_id" "ESA-RDM-20250315-001" rdm.header.message_id; 64 + Alcotest.(check (option string)) 65 + "message_for" (Some "CZ-5B R/B") rdm.header.message_for; 66 + (* Metadata *) 67 + Alcotest.(check string) "name" "CZ-5B R/B" rdm.metadata.object_name; 68 + Alcotest.(check string) "designator" "48275" rdm.metadata.object_designator; 69 + Alcotest.(check (option string)) 70 + "intl_desig" (Some "2021-035B") rdm.metadata.international_designator; 71 + Alcotest.(check (option string)) 72 + "object_type" (Some "ROCKET BODY") rdm.metadata.object_type; 73 + (* State vector *) 74 + (match rdm.state with 75 + | None -> Alcotest.fail "expected state vector" 76 + | Some sv -> 77 + check_float "x" 6578.137 sv.x; 78 + check_float "y" 1234.567 sv.y; 79 + check_float "z" (-2345.678) sv.z; 80 + check_float "x_dot" (-1.234) sv.x_dot; 81 + check_float "y_dot" 6.789 sv.y_dot; 82 + check_float "z_dot" 2.345 sv.z_dot); 83 + (* Physical parameters *) 84 + (match rdm.physical with 85 + | None -> Alcotest.fail "expected physical parameters" 86 + | Some phys -> 87 + check_float "wet_mass" 21000.0 (Option.get phys.wet_mass); 88 + check_float "dry_mass" 5000.0 (Option.get phys.dry_mass); 89 + check_float "area_pc" 52.0 (Option.get phys.area_pc); 90 + check_float "cd_a/m" 0.012 (Option.get phys.cd_area_over_mass)); 91 + (* Reentry parameters *) 92 + Alcotest.(check string) 93 + "reentry_epoch" "2025-03-17T22:15:00.000" rdm.reentry.reentry_epoch; 94 + check_float "uncertainty" 14400.0 95 + (Option.get rdm.reentry.reentry_epoch_uncertainty); 96 + Alcotest.(check (option string)) 97 + "controlled" (Some "NO") rdm.reentry.controlled; 98 + check_float "impact_lat" 28.5 (Option.get rdm.reentry.impact_latitude); 99 + check_float "impact_lon" (-45.2) (Option.get rdm.reentry.impact_longitude); 100 + check_float "impact_alt" 0.0 (Option.get rdm.reentry.impact_altitude); 101 + check_float "disint_alt" 78.0 (Option.get rdm.reentry.disintegration_altitude); 102 + check_float "surviving" 0.15 (Option.get rdm.reentry.surviving_mass_fraction); 103 + check_float "casualty_area" 120.5 104 + (Option.get rdm.reentry.ground_casualty_area); 105 + check_float "casualty_exp" 0.00032 106 + (Option.get rdm.reentry.casualty_expectation) 107 + 108 + (* ------------------------------------------------------------------ *) 109 + (* Controlled re-entry (minimal fields) *) 110 + (* ------------------------------------------------------------------ *) 111 + 112 + let controlled_example = 113 + {|CCSDS_RDM_VERS = 1.0 114 + CREATION_DATE = 2025-06-01T00:00:00 115 + ORIGINATOR = NASA/JSC 116 + MESSAGE_ID = NASA-RDM-2025-001 117 + OBJECT_DESIGNATOR = 25544 118 + OBJECT_NAME = ISS 119 + CENTER_NAME = EARTH 120 + REF_FRAME = EME2000 121 + TIME_SYSTEM = UTC 122 + EPOCH_TZERO = 2031-01-01T00:00:00 123 + REENTRY_EPOCH = 2031-01-15T18:30:00 124 + CONTROLLED = YES 125 + IMPACT_LATITUDE = -30.0 [deg] 126 + IMPACT_LONGITUDE = -150.0 [deg] 127 + |} 128 + 129 + let test_parse_controlled () = 130 + let rdm = check_ok "parse" (Rdm.of_string controlled_example) in 131 + Alcotest.(check string) "name" "ISS" rdm.metadata.object_name; 132 + Alcotest.(check (option string)) 133 + "controlled" (Some "YES") rdm.reentry.controlled; 134 + check_float "impact_lat" (-30.0) (Option.get rdm.reentry.impact_latitude); 135 + check_float "impact_lon" (-150.0) (Option.get rdm.reentry.impact_longitude); 136 + Alcotest.(check bool) "no state" true (rdm.state = None); 137 + Alcotest.(check bool) "no physical" true (rdm.physical = None) 138 + 139 + (* ------------------------------------------------------------------ *) 140 + (* Roundtrip *) 141 + (* ------------------------------------------------------------------ *) 142 + 143 + let test_roundtrip () = 144 + let rdm = check_ok "parse1" (Rdm.of_string uncontrolled_example) in 145 + let kvn = Rdm.to_string rdm in 146 + let rdm2 = check_ok "parse2" (Rdm.of_string kvn) in 147 + Alcotest.(check string) 148 + "name" rdm.metadata.object_name rdm2.metadata.object_name; 149 + Alcotest.(check string) 150 + "designator" rdm.metadata.object_designator rdm2.metadata.object_designator; 151 + Alcotest.(check string) 152 + "reentry_epoch" rdm.reentry.reentry_epoch rdm2.reentry.reentry_epoch; 153 + check_float "impact_lat" 154 + (Option.get rdm.reentry.impact_latitude) 155 + (Option.get rdm2.reentry.impact_latitude); 156 + check_float "impact_lon" 157 + (Option.get rdm.reentry.impact_longitude) 158 + (Option.get rdm2.reentry.impact_longitude); 159 + (match (rdm.state, rdm2.state) with 160 + | Some sv1, Some sv2 -> 161 + check_float "x" sv1.x sv2.x; 162 + check_float "y" sv1.y sv2.y; 163 + check_float "z" sv1.z sv2.z 164 + | None, None -> () 165 + | _ -> Alcotest.fail "state mismatch on roundtrip"); 166 + match (rdm.physical, rdm2.physical) with 167 + | Some p1, Some p2 -> 168 + check_float "wet_mass" (Option.get p1.wet_mass) (Option.get p2.wet_mass) 169 + | None, None -> () 170 + | _ -> Alcotest.fail "physical mismatch on roundtrip" 171 + 172 + let test_empty () = 173 + let rdm = check_ok "empty" (Rdm.of_string "") in 174 + Alcotest.(check string) "version" "" rdm.header.version; 175 + Alcotest.(check bool) "no state" true (rdm.state = None) 176 + 177 + let suite = 178 + ( "rdm", 179 + [ 180 + Alcotest.test_case "parse uncontrolled" `Quick test_parse_uncontrolled; 181 + Alcotest.test_case "parse controlled" `Quick test_parse_controlled; 182 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 183 + Alcotest.test_case "empty" `Quick test_empty; 184 + ] )
+2
test/test_rdm.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the RDM test suite (parsing + serialization). *)