CCSDS 504.0-B Attitude Ephemeris 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)

+749
+41
README.md
··· 1 + # aem 2 + 3 + CCSDS 504.0-B Attitude Ephemeris Message parser and serializer. 4 + 5 + Parses the KVN (Keyword=Value Notation) text format for attitude ephemeris 6 + data. Supports quaternion (scalar-last) and Euler angle attitude representations, 7 + with time-tagged attitude data points organized in segments. 8 + 9 + Reference: [CCSDS 504.0-B-2](https://public.ccsds.org/Pubs/504x0b2.pdf) 10 + Attitude Data Messages. 11 + 12 + ## Installation 13 + 14 + ``` 15 + opam install aem 16 + ``` 17 + 18 + ## Usage 19 + 20 + ```ocaml 21 + match Aem.of_string kvn_text with 22 + | Ok aem -> 23 + Printf.printf "Object: %s\n" (List.hd aem.segments).metadata.object_name; 24 + let seg = List.hd aem.segments in 25 + let att = seg.data.(0) in 26 + Printf.printf "Quaternion: (%.6f, %.6f, %.6f, %.6f)\n" 27 + att.q1 att.q2 att.q3 att.qc 28 + | Error e -> Fmt.epr "%a\n" Aem.pp_error e 29 + ``` 30 + 31 + ## API Overview 32 + 33 + - **`type t`** -- Complete AEM file: header + segment list 34 + - **`type segment`** -- Metadata + array of attitude data points 35 + - **`type attitude_data`** -- Epoch + quaternion or Euler angles 36 + - **`of_string`**, **`of_channel`**, **`of_file`** -- Parse KVN format 37 + - **`to_string`** -- Serialize back to KVN format 38 + 39 + ## License 40 + 41 + ISC
+32
aem.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "CCSDS 504.0-B Attitude Ephemeris 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-aem" 8 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-aem/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-aem" 32 + x-maintenance-intent: ["(latest)"]
+19
dune-project
··· 1 + (lang dune 3.21) 2 + (name aem) 3 + (source (tangled gazagnaire.org/ocaml-aem)) 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 aem) 14 + (synopsis "CCSDS 504.0-B Attitude Ephemeris Message parser and serializer") 15 + (depends 16 + (ocaml (>= 4.14)) 17 + kvn 18 + fmt 19 + ptime))
+352
lib/aem.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** CCSDS 504.0-B Attitude Ephemeris Message (AEM) parser and serializer. *) 7 + 8 + (* ------------------------------------------------------------------ *) 9 + (* Types *) 10 + (* ------------------------------------------------------------------ *) 11 + 12 + type quaternion = { q1 : float; q2 : float; q3 : float; qc : float } 13 + type euler_angles = { angle_1 : float; angle_2 : float; angle_3 : float } 14 + type attitude = Quaternion of quaternion | Euler of euler_angles 15 + type attitude_data = { epoch : Ptime.t; attitude : attitude } 16 + 17 + type metadata = { 18 + object_name : string; 19 + object_id : string; 20 + center_name : string; 21 + ref_frame_a : string; 22 + ref_frame_b : string; 23 + attitude_dir : string; 24 + time_system : string; 25 + start_time : Ptime.t; 26 + stop_time : Ptime.t; 27 + attitude_type : string; 28 + interpolation_method : string option; 29 + interpolation_degree : int option; 30 + } 31 + 32 + type segment = { metadata : metadata; data : attitude_data array } 33 + type header = { version : string; creation_date : string; originator : string } 34 + type t = { header : header; segments : segment list } 35 + 36 + type error = 37 + | Unexpected_eof 38 + | Bad_keyword of { line : int; got : string } 39 + | Bad_epoch of { line : int; value : string } 40 + | Bad_float of { line : int; value : string } 41 + | Missing_keyword of string 42 + | Parse_error of string 43 + 44 + let pp_error ppf = function 45 + | Unexpected_eof -> Fmt.pf ppf "Unexpected end of file" 46 + | Bad_keyword { line; got } -> Fmt.pf ppf "Line %d: bad keyword %S" line got 47 + | Bad_epoch { line; value } -> Fmt.pf ppf "Line %d: bad epoch %S" line value 48 + | Bad_float { line; value } -> Fmt.pf ppf "Line %d: bad float %S" line value 49 + | Missing_keyword kw -> Fmt.pf ppf "Missing required keyword %S" kw 50 + | Parse_error msg -> Fmt.pf ppf "Parse error: %s" msg 51 + 52 + (* ------------------------------------------------------------------ *) 53 + (* KVN parsing *) 54 + (* ------------------------------------------------------------------ *) 55 + 56 + let parse_epoch = Kvn.parse_epoch 57 + 58 + let parse_header st = 59 + Kvn.skip_blanks st; 60 + let version = ref "" in 61 + let creation_date = ref "" in 62 + let originator = ref "" in 63 + let cont = ref true in 64 + while !cont do 65 + match Kvn.peek st with 66 + | Some (_, s) -> ( 67 + match Kvn.classify s with 68 + | Kvn.Keyword ("CCSDS_AEM_VERS", v) -> 69 + version := v; 70 + Kvn.advance st 71 + | Kvn.Keyword ("CREATION_DATE", v) -> 72 + creation_date := v; 73 + Kvn.advance st 74 + | Kvn.Keyword ("ORIGINATOR", v) -> 75 + originator := v; 76 + Kvn.advance st 77 + | Kvn.Comment _ -> Kvn.advance st 78 + | Kvn.Blank -> Kvn.advance st 79 + | _ -> cont := false) 80 + | None -> cont := false 81 + done; 82 + { 83 + version = !version; 84 + creation_date = !creation_date; 85 + originator = !originator; 86 + } 87 + 88 + type meta_acc = { 89 + mutable m_object_name : string; 90 + mutable m_object_id : string; 91 + mutable m_center_name : string; 92 + mutable m_ref_frame_a : string; 93 + mutable m_ref_frame_b : string; 94 + mutable m_attitude_dir : string; 95 + mutable m_time_system : string; 96 + mutable m_start_time : Ptime.t; 97 + mutable m_stop_time : Ptime.t; 98 + mutable m_attitude_type : string; 99 + mutable m_interpolation_method : string option; 100 + mutable m_interpolation_degree : int option; 101 + } 102 + 103 + let set_meta_field acc key value = 104 + match key with 105 + | "OBJECT_NAME" -> acc.m_object_name <- value 106 + | "OBJECT_ID" -> acc.m_object_id <- value 107 + | "CENTER_NAME" -> acc.m_center_name <- value 108 + | "REF_FRAME_A" -> acc.m_ref_frame_a <- value 109 + | "REF_FRAME_B" -> acc.m_ref_frame_b <- value 110 + | "ATTITUDE_DIR" -> acc.m_attitude_dir <- value 111 + | "TIME_SYSTEM" -> acc.m_time_system <- value 112 + | "START_TIME" -> ( 113 + match parse_epoch value with 114 + | Some t -> acc.m_start_time <- t 115 + | None -> ()) 116 + | "STOP_TIME" -> ( 117 + match parse_epoch value with Some t -> acc.m_stop_time <- t | None -> ()) 118 + | "ATTITUDE_TYPE" -> acc.m_attitude_type <- value 119 + | "INTERPOLATION_METHOD" -> acc.m_interpolation_method <- Some value 120 + | "INTERPOLATION_DEGREE" -> 121 + acc.m_interpolation_degree <- 122 + (match int_of_string_opt value with Some i -> Some i | None -> None) 123 + | _ -> () 124 + 125 + let meta_acc_to_metadata acc = 126 + { 127 + object_name = acc.m_object_name; 128 + object_id = acc.m_object_id; 129 + center_name = acc.m_center_name; 130 + ref_frame_a = acc.m_ref_frame_a; 131 + ref_frame_b = acc.m_ref_frame_b; 132 + attitude_dir = acc.m_attitude_dir; 133 + time_system = acc.m_time_system; 134 + start_time = acc.m_start_time; 135 + stop_time = acc.m_stop_time; 136 + attitude_type = acc.m_attitude_type; 137 + interpolation_method = acc.m_interpolation_method; 138 + interpolation_degree = acc.m_interpolation_degree; 139 + } 140 + 141 + let parse_metadata st = 142 + Kvn.skip_blanks st; 143 + (match Kvn.next st with 144 + | Some (_, s) when String.trim s = "META_START" -> () 145 + | _ -> ()); 146 + let acc = 147 + { 148 + m_object_name = ""; 149 + m_object_id = ""; 150 + m_center_name = "EARTH"; 151 + m_ref_frame_a = ""; 152 + m_ref_frame_b = ""; 153 + m_attitude_dir = "A2B"; 154 + m_time_system = "UTC"; 155 + m_start_time = Ptime.epoch; 156 + m_stop_time = Ptime.epoch; 157 + m_attitude_type = "QUATERNION"; 158 + m_interpolation_method = None; 159 + m_interpolation_degree = None; 160 + } 161 + in 162 + let cont = ref true in 163 + while !cont do 164 + match Kvn.peek st with 165 + | Some (_, s) -> ( 166 + match Kvn.classify s with 167 + | Kvn.Keyword ("META_STOP", _) -> 168 + Kvn.advance st; 169 + cont := false 170 + | Kvn.Comment _ -> Kvn.advance st 171 + | Kvn.Keyword (k, v) -> 172 + set_meta_field acc k v; 173 + Kvn.advance st 174 + | Kvn.Blank -> Kvn.advance st 175 + | Kvn.Data _ -> cont := false) 176 + | None -> cont := false 177 + done; 178 + meta_acc_to_metadata acc 179 + 180 + let is_euler att_type = 181 + let s = String.uppercase_ascii att_type in 182 + s = "EULER_ANGLE" || s = "EULER_ANGLES" || s = "EULER" 183 + 184 + let parse_data st att_type = 185 + Kvn.skip_blanks st; 186 + let points = ref [] in 187 + let cont = ref true in 188 + while !cont do 189 + match Kvn.peek st with 190 + | Some (_, s) -> ( 191 + let trimmed = String.trim s in 192 + if trimmed = "META_START" || trimmed = "" then cont := false 193 + else 194 + match Kvn.classify s with 195 + | Kvn.Keyword _ -> cont := false 196 + | Kvn.Blank -> Kvn.advance st 197 + | Kvn.Comment _ -> Kvn.advance st 198 + | Kvn.Data d -> ( 199 + let parts = Kvn.split_data d in 200 + match parts with 201 + | epoch_s :: rest when List.length rest >= 4 -> ( 202 + match parse_epoch epoch_s with 203 + | Some epoch -> 204 + let floats = 205 + List.map 206 + (fun s -> 207 + match float_of_string_opt s with 208 + | Some f -> f 209 + | None -> 0.) 210 + rest 211 + in 212 + let a = Array.of_list floats in 213 + let attitude = 214 + if is_euler att_type then 215 + Euler 216 + { 217 + angle_1 = a.(0); 218 + angle_2 = a.(1); 219 + angle_3 = a.(2); 220 + } 221 + else 222 + Quaternion 223 + { q1 = a.(0); q2 = a.(1); q3 = a.(2); qc = a.(3) } 224 + in 225 + points := { epoch; attitude } :: !points; 226 + Kvn.advance st 227 + | None -> Kvn.advance st) 228 + | _ -> Kvn.advance st)) 229 + | None -> cont := false 230 + done; 231 + Array.of_list (List.rev !points) 232 + 233 + let parse_segment st = 234 + let metadata = parse_metadata st in 235 + let data = parse_data st metadata.attitude_type in 236 + { metadata; data } 237 + 238 + let parse st = 239 + let header = parse_header st in 240 + let segments = ref [] in 241 + let cont = ref true in 242 + while !cont do 243 + Kvn.skip_blanks st; 244 + match Kvn.peek st with 245 + | Some (_, s) when String.trim s = "META_START" -> 246 + let seg = parse_segment st in 247 + segments := seg :: !segments 248 + | Some _ -> Kvn.advance st 249 + | None -> cont := false 250 + done; 251 + Ok { header; segments = List.rev !segments } 252 + 253 + let of_string s = 254 + let st = Kvn.of_string s in 255 + parse st 256 + 257 + let of_channel ic = 258 + let st = Kvn.of_channel ic in 259 + parse st 260 + 261 + let of_file path = 262 + let ic = open_in path in 263 + let r = of_channel ic in 264 + close_in ic; 265 + r 266 + 267 + (* ------------------------------------------------------------------ *) 268 + (* Serialization *) 269 + (* ------------------------------------------------------------------ *) 270 + 271 + let fmt_epoch buf t = 272 + let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in 273 + let frac = Ptime.to_float_s t -. floor (Ptime.to_float_s t) in 274 + Printf.bprintf buf "%04d-%02d-%02dT%02d:%02d:%06.3f" y m d hh mm 275 + (Float.of_int ss +. frac) 276 + 277 + let kv buf k v = Printf.bprintf buf "%s = %s\n" k v 278 + 279 + let to_string aem = 280 + let buf = Buffer.create 2048 in 281 + kv buf "CCSDS_AEM_VERS" aem.header.version; 282 + kv buf "CREATION_DATE" aem.header.creation_date; 283 + kv buf "ORIGINATOR" aem.header.originator; 284 + List.iter 285 + (fun seg -> 286 + Buffer.add_char buf '\n'; 287 + Buffer.add_string buf "META_START\n"; 288 + kv buf "OBJECT_NAME" seg.metadata.object_name; 289 + kv buf "OBJECT_ID" seg.metadata.object_id; 290 + kv buf "CENTER_NAME" seg.metadata.center_name; 291 + kv buf "REF_FRAME_A" seg.metadata.ref_frame_a; 292 + kv buf "REF_FRAME_B" seg.metadata.ref_frame_b; 293 + kv buf "ATTITUDE_DIR" seg.metadata.attitude_dir; 294 + kv buf "TIME_SYSTEM" seg.metadata.time_system; 295 + Printf.bprintf buf "START_TIME = "; 296 + fmt_epoch buf seg.metadata.start_time; 297 + Buffer.add_char buf '\n'; 298 + Printf.bprintf buf "STOP_TIME = "; 299 + fmt_epoch buf seg.metadata.stop_time; 300 + Buffer.add_char buf '\n'; 301 + kv buf "ATTITUDE_TYPE" seg.metadata.attitude_type; 302 + (match seg.metadata.interpolation_method with 303 + | Some m -> kv buf "INTERPOLATION_METHOD" m 304 + | None -> ()); 305 + (match seg.metadata.interpolation_degree with 306 + | Some d -> kv buf "INTERPOLATION_DEGREE" (string_of_int d) 307 + | None -> ()); 308 + Buffer.add_string buf "META_STOP\n"; 309 + Buffer.add_char buf '\n'; 310 + Array.iter 311 + (fun (ad : attitude_data) -> 312 + fmt_epoch buf ad.epoch; 313 + (match ad.attitude with 314 + | Quaternion q -> 315 + Printf.bprintf buf " %.14g %.14g %.14g %.14g" q.q1 q.q2 q.q3 q.qc 316 + | Euler e -> 317 + Printf.bprintf buf " %.14g %.14g %.14g" e.angle_1 e.angle_2 318 + e.angle_3); 319 + Buffer.add_char buf '\n') 320 + seg.data) 321 + aem.segments; 322 + Buffer.contents buf 323 + 324 + (* ------------------------------------------------------------------ *) 325 + (* Pretty-printing *) 326 + (* ------------------------------------------------------------------ *) 327 + 328 + let pp_header ppf h = 329 + Fmt.pf ppf "AEM v%s by %s (%s)" h.version h.originator h.creation_date 330 + 331 + let pp_metadata ppf m = 332 + Fmt.pf ppf "%s [%s] %s %s->%s %s %a-%a" m.object_name m.object_id 333 + m.attitude_type m.ref_frame_a m.ref_frame_b m.time_system 334 + (Ptime.pp_rfc3339 ()) m.start_time (Ptime.pp_rfc3339 ()) m.stop_time 335 + 336 + let pp_attitude_data ppf ad = 337 + match ad.attitude with 338 + | Quaternion q -> 339 + Fmt.pf ppf "%a q=(%.6f, %.6f, %.6f, %.6f)" (Ptime.pp_rfc3339 ()) ad.epoch 340 + q.q1 q.q2 q.q3 q.qc 341 + | Euler e -> 342 + Fmt.pf ppf "%a euler=(%.3f, %.3f, %.3f)" (Ptime.pp_rfc3339 ()) ad.epoch 343 + e.angle_1 e.angle_2 e.angle_3 344 + 345 + let pp ppf aem = 346 + Fmt.pf ppf "@[<v>%a (%d segments)@," pp_header aem.header 347 + (List.length aem.segments); 348 + List.iter 349 + (fun seg -> 350 + Fmt.pf ppf " %a (%d points)@," pp_metadata seg.metadata 351 + (Array.length seg.data)) 352 + aem.segments
+101
lib/aem.mli
··· 1 + (** CCSDS 504.0-B Attitude Ephemeris Message (AEM) parser and serializer. 2 + 3 + Parses the KVN (Keyword=Value Notation) text format for attitude ephemeris 4 + data. Supports quaternion (scalar-last QC convention) and Euler angle 5 + representations with time-tagged data points. 6 + 7 + Reference: {{:https://public.ccsds.org/Pubs/504x0b2.pdf} CCSDS 504.0-B-2} 8 + Attitude Data Messages. *) 9 + 10 + (** {1 Types} *) 11 + 12 + type quaternion = { 13 + q1 : float; (** Vector component 1. *) 14 + q2 : float; (** Vector component 2. *) 15 + q3 : float; (** Vector component 3. *) 16 + qc : float; (** Scalar component. *) 17 + } 18 + (** Attitude quaternion (scalar-last convention per CCSDS). *) 19 + 20 + type euler_angles = { 21 + angle_1 : float; (** First rotation angle, deg. *) 22 + angle_2 : float; (** Second rotation angle, deg. *) 23 + angle_3 : float; (** Third rotation angle, deg. *) 24 + } 25 + (** Euler angle attitude representation. *) 26 + 27 + type attitude = 28 + | Quaternion of quaternion 29 + | Euler of euler_angles 30 + (** Attitude representation: quaternion or Euler angles. *) 31 + 32 + type attitude_data = { epoch : Ptime.t; attitude : attitude } 33 + (** A single time-tagged attitude data point. *) 34 + 35 + type metadata = { 36 + object_name : string; 37 + object_id : string; 38 + center_name : string; 39 + ref_frame_a : string; 40 + ref_frame_b : string; 41 + attitude_dir : string; 42 + time_system : string; 43 + start_time : Ptime.t; 44 + stop_time : Ptime.t; 45 + attitude_type : string; 46 + interpolation_method : string option; 47 + interpolation_degree : int option; 48 + } 49 + (** Metadata block describing an attitude segment. *) 50 + 51 + type segment = { metadata : metadata; data : attitude_data array } 52 + (** One attitude segment (metadata + data). *) 53 + 54 + type header = { version : string; creation_date : string; originator : string } 55 + (** AEM file header. *) 56 + 57 + type t = { header : header; segments : segment list } 58 + (** A complete AEM file with header and one or more segments. *) 59 + 60 + (** {1 Errors} *) 61 + 62 + type error = 63 + | Unexpected_eof 64 + | Bad_keyword of { line : int; got : string } 65 + | Bad_epoch of { line : int; value : string } 66 + | Bad_float of { line : int; value : string } 67 + | Missing_keyword of string 68 + | Parse_error of string 69 + 70 + val pp_error : error Fmt.t 71 + (** [pp_error] pretty-prints a parse error. *) 72 + 73 + (** {1 Parsing} *) 74 + 75 + val of_string : string -> (t, error) result 76 + (** [of_string s] parses an AEM file from a KVN string. *) 77 + 78 + val of_channel : in_channel -> (t, error) result 79 + (** [of_channel ic] parses an AEM file from an input channel. *) 80 + 81 + val of_file : string -> (t, error) result 82 + (** [of_file path] parses an AEM file from a file path. *) 83 + 84 + (** {1 Serialization} *) 85 + 86 + val to_string : t -> string 87 + (** [to_string aem] serializes an AEM file to KVN format. *) 88 + 89 + (** {1 Pretty-printing} *) 90 + 91 + val pp_header : header Fmt.t 92 + (** [pp_header] pretty-prints an AEM header. *) 93 + 94 + val pp_metadata : metadata Fmt.t 95 + (** [pp_metadata] pretty-prints segment metadata. *) 96 + 97 + val pp_attitude_data : attitude_data Fmt.t 98 + (** [pp_attitude_data] pretty-prints a single attitude data point. *) 99 + 100 + val pp : t Fmt.t 101 + (** [pp] pretty-prints an AEM file summary. *)
+4
lib/dune
··· 1 + (library 2 + (name aem) 3 + (public_name aem) 4 + (libraries kvn ptime fmt))
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries aem alcotest ptime))
+1
test/test.ml
··· 1 + let () = Alcotest.run "aem" [ Test_aem.suite ]
+194
test/test_aem.ml
··· 1 + (** AEM parsing and serialization tests. 2 + 3 + Test vectors from CCSDS 504.0-B-2 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 Aem.pp_error e 13 + 14 + (* ------------------------------------------------------------------ *) 15 + (* CCSDS-style quaternion example *) 16 + (* ------------------------------------------------------------------ *) 17 + 18 + let quaternion_example = 19 + {|CCSDS_AEM_VERS = 1.0 20 + CREATION_DATE = 2025-01-15T00:00:00 21 + ORIGINATOR = NASA/GSFC 22 + 23 + META_START 24 + OBJECT_NAME = AURA 25 + OBJECT_ID = 2004-026A 26 + CENTER_NAME = EARTH 27 + REF_FRAME_A = EME2000 28 + REF_FRAME_B = SC_BODY_1 29 + ATTITUDE_DIR = A2B 30 + TIME_SYSTEM = UTC 31 + START_TIME = 2025-01-15T00:00:00.000 32 + STOP_TIME = 2025-01-15T01:00:00.000 33 + ATTITUDE_TYPE = QUATERNION 34 + INTERPOLATION_METHOD = LINEAR 35 + INTERPOLATION_DEGREE = 1 36 + META_STOP 37 + 38 + 2025-01-15T00:00:00.000 0.68574 0.69573 0.15380 0.14397 39 + 2025-01-15T00:20:00.000 0.67893 0.70312 0.15010 0.14856 40 + 2025-01-15T00:40:00.000 0.67212 0.71045 0.14635 0.15315 41 + 2025-01-15T01:00:00.000 0.66530 0.71771 0.14256 0.15773 42 + |} 43 + 44 + let test_parse_quaternion () = 45 + let aem = check_ok "parse" (Aem.of_string quaternion_example) in 46 + Alcotest.(check string) "version" "1.0" aem.header.version; 47 + Alcotest.(check string) "originator" "NASA/GSFC" aem.header.originator; 48 + Alcotest.(check int) "segments" 1 (List.length aem.segments); 49 + let seg = List.hd aem.segments in 50 + Alcotest.(check string) "name" "AURA" seg.metadata.object_name; 51 + Alcotest.(check string) "id" "2004-026A" seg.metadata.object_id; 52 + Alcotest.(check string) "type" "QUATERNION" seg.metadata.attitude_type; 53 + Alcotest.(check string) "frame_a" "EME2000" seg.metadata.ref_frame_a; 54 + Alcotest.(check string) "frame_b" "SC_BODY_1" seg.metadata.ref_frame_b; 55 + Alcotest.(check string) "dir" "A2B" seg.metadata.attitude_dir; 56 + Alcotest.(check int) "data points" 4 (Array.length seg.data); 57 + let ad0 = seg.data.(0) in 58 + match ad0.attitude with 59 + | Aem.Euler _ -> Alcotest.fail "expected Quaternion" 60 + | Aem.Quaternion q -> 61 + check_float "q1" 0.68574 q.q1; 62 + check_float "q2" 0.69573 q.q2; 63 + check_float "q3" 0.15380 q.q3; 64 + check_float "qc" 0.14397 q.qc 65 + 66 + (* ------------------------------------------------------------------ *) 67 + (* Euler angle example *) 68 + (* ------------------------------------------------------------------ *) 69 + 70 + let euler_example = 71 + {|CCSDS_AEM_VERS = 1.0 72 + CREATION_DATE = 2025-03-01T00:00:00 73 + ORIGINATOR = ESA 74 + 75 + META_START 76 + OBJECT_NAME = ENVISAT 77 + OBJECT_ID = 2002-009A 78 + CENTER_NAME = EARTH 79 + REF_FRAME_A = EME2000 80 + REF_FRAME_B = SC_BODY_1 81 + ATTITUDE_DIR = A2B 82 + TIME_SYSTEM = UTC 83 + START_TIME = 2025-03-01T00:00:00.000 84 + STOP_TIME = 2025-03-01T00:10:00.000 85 + ATTITUDE_TYPE = EULER_ANGLE 86 + META_STOP 87 + 88 + 2025-03-01T00:00:00.000 45.123 -12.456 178.901 0.0 89 + 2025-03-01T00:05:00.000 45.234 -12.567 179.012 0.0 90 + 2025-03-01T00:10:00.000 45.345 -12.678 179.123 0.0 91 + |} 92 + 93 + let test_parse_euler () = 94 + let aem = check_ok "parse" (Aem.of_string euler_example) in 95 + let seg = List.hd aem.segments in 96 + Alcotest.(check string) "type" "EULER_ANGLE" seg.metadata.attitude_type; 97 + Alcotest.(check int) "data points" 3 (Array.length seg.data); 98 + let ad0 = seg.data.(0) in 99 + match ad0.attitude with 100 + | Aem.Quaternion _ -> Alcotest.fail "expected Euler" 101 + | Aem.Euler e -> 102 + check_float "angle_1" 45.123 e.angle_1; 103 + check_float "angle_2" (-12.456) e.angle_2; 104 + check_float "angle_3" 178.901 e.angle_3 105 + 106 + (* ------------------------------------------------------------------ *) 107 + (* Multi-segment *) 108 + (* ------------------------------------------------------------------ *) 109 + 110 + let multi_segment = 111 + {|CCSDS_AEM_VERS = 1.0 112 + CREATION_DATE = 2025-01-15T00:00:00 113 + ORIGINATOR = TEST 114 + 115 + META_START 116 + OBJECT_NAME = SAT1 117 + OBJECT_ID = 2020-001A 118 + CENTER_NAME = EARTH 119 + REF_FRAME_A = EME2000 120 + REF_FRAME_B = SC_BODY_1 121 + ATTITUDE_DIR = A2B 122 + TIME_SYSTEM = UTC 123 + START_TIME = 2025-01-15T00:00:00.000 124 + STOP_TIME = 2025-01-15T01:00:00.000 125 + ATTITUDE_TYPE = QUATERNION 126 + META_STOP 127 + 128 + 2025-01-15T00:00:00.000 0.5 0.5 0.5 0.5 129 + 2025-01-15T01:00:00.000 0.6 0.4 0.5 0.4796 130 + 131 + META_START 132 + OBJECT_NAME = SAT1 133 + OBJECT_ID = 2020-001A 134 + CENTER_NAME = EARTH 135 + REF_FRAME_A = EME2000 136 + REF_FRAME_B = SC_BODY_1 137 + ATTITUDE_DIR = A2B 138 + TIME_SYSTEM = UTC 139 + START_TIME = 2025-01-15T02:00:00.000 140 + STOP_TIME = 2025-01-15T03:00:00.000 141 + ATTITUDE_TYPE = QUATERNION 142 + META_STOP 143 + 144 + 2025-01-15T02:00:00.000 0.7 0.3 0.5 0.3873 145 + 2025-01-15T03:00:00.000 0.8 0.2 0.5 0.2449 146 + |} 147 + 148 + let test_multi_segment () = 149 + let aem = check_ok "parse" (Aem.of_string multi_segment) in 150 + Alcotest.(check int) "segments" 2 (List.length aem.segments); 151 + let seg2 = List.nth aem.segments 1 in 152 + Alcotest.(check int) "seg2 points" 2 (Array.length seg2.data); 153 + match seg2.data.(0).attitude with 154 + | Aem.Euler _ -> Alcotest.fail "expected Quaternion" 155 + | Aem.Quaternion q -> check_float "seg2 q1" 0.7 q.q1 156 + 157 + (* ------------------------------------------------------------------ *) 158 + (* Roundtrip *) 159 + (* ------------------------------------------------------------------ *) 160 + 161 + let test_roundtrip () = 162 + let aem = check_ok "parse1" (Aem.of_string quaternion_example) in 163 + let kvn = Aem.to_string aem in 164 + let aem2 = check_ok "parse2" (Aem.of_string kvn) in 165 + Alcotest.(check string) "name" aem.header.originator aem2.header.originator; 166 + Alcotest.(check int) 167 + "segments" (List.length aem.segments) 168 + (List.length aem2.segments); 169 + let seg1 = List.hd aem.segments and seg2 = List.hd aem2.segments in 170 + Alcotest.(check string) 171 + "object_name" seg1.metadata.object_name seg2.metadata.object_name; 172 + Alcotest.(check int) 173 + "data points" (Array.length seg1.data) (Array.length seg2.data); 174 + match (seg1.data.(0).attitude, seg2.data.(0).attitude) with 175 + | Aem.Quaternion q1, Aem.Quaternion q2 -> 176 + check_float "q1" q1.q1 q2.q1; 177 + check_float "q2" q1.q2 q2.q2; 178 + check_float "q3" q1.q3 q2.q3; 179 + check_float "qc" q1.qc q2.qc 180 + | _ -> Alcotest.fail "attitude type mismatch on roundtrip" 181 + 182 + let test_empty () = 183 + let aem = check_ok "empty" (Aem.of_string "") in 184 + Alcotest.(check int) "no segments" 0 (List.length aem.segments) 185 + 186 + let suite = 187 + ( "aem", 188 + [ 189 + Alcotest.test_case "parse quaternion" `Quick test_parse_quaternion; 190 + Alcotest.test_case "parse Euler" `Quick test_parse_euler; 191 + Alcotest.test_case "multi-segment" `Quick test_multi_segment; 192 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 193 + Alcotest.test_case "empty" `Quick test_empty; 194 + ] )
+2
test/test_aem.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the AEM test suite (parsing + serialization). *)