1use anise::astro::Aberration;
20use anise::constants::orientations::J2000;
21use anise::errors::AlmanacError;
22use anise::prelude::{Almanac, Frame, Orbit};
23use arrow::array::RecordBatchReader;
24use arrow::array::{Float64Array, StringArray};
25use hifitime::TimeSeries;
26use log::{debug, info, warn};
27use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
28use snafu::{ensure, ResultExt};
29
30use super::TrajError;
31use super::{ExportCfg, Traj};
32use crate::cosmic::Spacecraft;
33use crate::errors::{FromAlmanacSnafu, NyxError};
34use crate::io::watermark::prj_name_ver;
35use crate::io::{InputOutputError, MissingDataSnafu, ParquetSnafu, StdIOSnafu};
36use crate::md::prelude::{Interpolatable, StateParameter};
37use crate::md::EventEvaluator;
38use crate::time::{Duration, Epoch, Format, Formatter, TimeUnits};
39use crate::State;
40use std::collections::{HashMap, HashSet};
41use std::error::Error;
42use std::fs::File;
43use std::io::{BufRead, BufReader, BufWriter, Write};
44use std::path::{Path, PathBuf};
45use std::str::FromStr;
46use std::sync::Arc;
47#[cfg(not(target_arch = "wasm32"))]
48use std::time::Instant;
49
50impl Traj<Spacecraft> {
51 pub fn from_bsp(
55 target_frame: Frame,
56 observer_frame: Frame,
57 almanac: Arc<Almanac>,
58 sc_template: Spacecraft,
59 step: Duration,
60 start_epoch: Option<Epoch>,
61 end_epoch: Option<Epoch>,
62 ab_corr: Option<Aberration>,
63 name: Option<String>,
64 ) -> Result<Self, AlmanacError> {
65 let (domain_start, domain_end) =
66 almanac
67 .spk_domain(target_frame.ephemeris_id)
68 .map_err(|e| AlmanacError::Ephemeris {
69 action: "could not fetch domain",
70 source: Box::new(e),
71 })?;
72
73 let start_epoch = start_epoch.unwrap_or(domain_start);
74 let end_epoch = end_epoch.unwrap_or(domain_end);
75
76 let time_series = TimeSeries::inclusive(start_epoch, end_epoch, step);
77 let mut states = Vec::with_capacity(time_series.len());
78 for epoch in time_series {
79 let orbit = almanac.transform(target_frame, observer_frame, epoch, ab_corr)?;
80
81 states.push(sc_template.with_orbit(orbit));
82 }
83
84 Ok(Self { name, states })
85 }
86 #[allow(clippy::map_clone)]
88 pub fn to_frame(&self, new_frame: Frame, almanac: Arc<Almanac>) -> Result<Self, NyxError> {
89 if self.states.is_empty() {
90 return Err(NyxError::Trajectory {
91 source: TrajError::CreationError {
92 msg: "No trajectory to convert".to_string(),
93 },
94 });
95 }
96
97 #[cfg(not(target_arch = "wasm32"))]
98 let start_instant = Instant::now();
99 let mut traj = Self::new();
100 for state in &self.states {
101 let new_orbit =
102 almanac
103 .transform_to(state.orbit, new_frame, None)
104 .context(FromAlmanacSnafu {
105 action: "transforming trajectory into new frame",
106 })?;
107 traj.states.push(state.with_orbit(new_orbit));
108 }
109 traj.finalize();
110
111 #[cfg(not(target_arch = "wasm32"))]
112 info!(
113 "Converted trajectory from {} to {} in {} ms: {traj}",
114 self.first().orbit.frame,
115 new_frame,
116 (Instant::now() - start_instant).as_millis()
117 );
118
119 #[cfg(target_arch = "wasm32")]
120 info!(
121 "Converted trajectory from {} to {}: {traj}",
122 self.first().orbit.frame,
123 new_frame,
124 );
125
126 Ok(traj)
127 }
128
129 #[allow(clippy::identity_op)]
132 pub fn to_groundtrack_parquet<P: AsRef<Path>>(
133 &self,
134 path: P,
135 body_fixed_frame: Frame,
136 events: Option<Vec<&dyn EventEvaluator<Spacecraft>>>,
137 metadata: Option<HashMap<String, String>>,
138 almanac: Arc<Almanac>,
139 ) -> Result<PathBuf, Box<dyn Error>> {
140 let traj = self.to_frame(body_fixed_frame, almanac.clone())?;
141
142 let mut cfg = ExportCfg::builder()
143 .step(1.minutes())
144 .fields(vec![
145 StateParameter::Latitude,
146 StateParameter::Longitude,
147 StateParameter::Height,
148 StateParameter::Rmag,
149 ])
150 .build();
151 cfg.metadata = metadata;
152
153 traj.to_parquet(path, events, cfg, almanac)
154 }
155
156 pub fn from_oem_file<P: AsRef<Path>>(
161 path: P,
162 tpl_option: Option<Spacecraft>,
163 ) -> Result<Self, NyxError> {
164 let file = File::open(path).map_err(|e| NyxError::CCSDS {
166 msg: format!("File opening error: {e}"),
167 })?;
168 let reader = BufReader::new(file);
169
170 let template = tpl_option.unwrap_or_default();
171
172 let mut time_system = String::new();
174
175 let ignored_tokens: HashSet<_> = [
176 "CCSDS_OMM_VERS".to_string(),
177 "CREATION_DATE".to_string(),
178 "ORIGINATOR".to_string(),
179 ]
180 .into();
181
182 let mut traj = Self::default();
183
184 let mut parse = false;
185
186 let mut center_name = None;
187 let mut orient_name = None;
188
189 'lines: for (lno, line) in reader.lines().enumerate() {
190 let line = line.map_err(|e| NyxError::CCSDS {
191 msg: format!("File read error: {e}"),
192 })?;
193 let line = line.trim();
194 if line.is_empty() {
195 continue;
196 }
197
198 if ignored_tokens.iter().any(|t| line.starts_with(t)) {
199 continue 'lines;
200 }
201 if line.starts_with("OBJECT_NAME") {
202 let parts: Vec<&str> = line.split('=').collect();
204 let name = parts[1].trim().to_string();
205 debug!("[line: {}] Found object {name}", lno + 1);
206 traj.name = Some(name);
207 } else if line.starts_with("CENTER_NAME") {
208 let parts: Vec<&str> = line.split('=').collect();
209 center_name = Some(parts[1].trim().to_owned());
210 } else if line.starts_with("REF_FRAME") {
211 let parts: Vec<&str> = line.split('=').collect();
212 orient_name = Some(parts[1].trim().to_owned());
213 } else if line.starts_with("TIME_SYSTEM") {
214 let parts: Vec<&str> = line.split('=').collect();
215 time_system = parts[1].trim().to_string();
216 debug!("[line: {}] Found time system `{time_system}`", lno + 1);
217 } else if line.starts_with("META_STOP") {
218 parse = true;
220 } else if line.starts_with("META_START") {
221 parse = false;
223 } else if line.starts_with("COVARIANCE_START") {
224 warn!("[line: {}] Skipping covariance in OEM parsing", lno + 1);
226 parse = false;
227 } else if parse {
228 let frame = Frame::from_name(
229 center_name.clone().unwrap().as_str(),
230 orient_name.clone().unwrap().as_str(),
231 )
232 .map_err(|e| NyxError::CCSDS {
233 msg: format!("frame error `{center_name:?} {orient_name:?}`: {e}"),
234 })?;
235 let parts: Vec<&str> = line.split_whitespace().collect();
237
238 if parts.len() < 7 {
239 debug!("[line: {}] Could not understand `{parts:?}`", lno + 1);
240 } else {
241 let epoch_str = format!("{} {time_system}", parts[0]);
243 match parts[1].parse::<f64>() {
244 Ok(x_km) => {
245 let y_km = parts[2].parse::<f64>().unwrap();
247 let z_km = parts[3].parse::<f64>().unwrap();
248 let vx_km_s = parts[4].parse::<f64>().unwrap();
249 let vy_km_s = parts[5].parse::<f64>().unwrap();
250 let vz_km_s = parts[6].parse::<f64>().unwrap();
251
252 let epoch =
253 Epoch::from_str(epoch_str.trim()).map_err(|e| NyxError::CCSDS {
254 msg: format!("Parsing epoch error: {e}"),
255 })?;
256
257 let orbit = Orbit::new(
258 x_km, y_km, z_km, vx_km_s, vy_km_s, vz_km_s, epoch, frame,
259 );
260
261 traj.states.push(template.with_orbit(orbit));
262 }
263 Err(_) => {
264 debug!("[line: {}] Could not parse `{parts:?}`", lno + 1);
266 continue;
267 }
268 };
269 }
270 }
271 }
272
273 traj.finalize();
274
275 Ok(traj)
276 }
277
278 pub fn to_oem_file<P: AsRef<Path>>(
279 &self,
280 path: P,
281 cfg: ExportCfg,
282 ) -> Result<PathBuf, NyxError> {
283 if self.states.is_empty() {
284 return Err(NyxError::CCSDS {
285 msg: "Cannot export an empty trajectory to OEM".to_string(),
286 });
287 }
288 let tick = Epoch::now().unwrap();
289 info!("Exporting trajectory to CCSDS OEM file...");
290
291 let path_buf = cfg.actual_path(path);
293
294 let metadata = cfg.metadata.unwrap_or_default();
295
296 let file = File::create(&path_buf).map_err(|e| NyxError::CCSDS {
297 msg: format!("File creation error: {e}"),
298 })?;
299 let mut writer = BufWriter::new(file);
300
301 let err_hdlr = |e| NyxError::CCSDS {
302 msg: format!("Could not write: {e}"),
303 };
304
305 let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
307 let start = cfg.start_epoch.unwrap_or_else(|| self.first().epoch());
309 let end = cfg.end_epoch.unwrap_or_else(|| self.last().epoch());
310 let step = cfg.step.unwrap_or_else(|| 1.minutes());
311 self.every_between(step, start, end).collect()
312 } else {
313 self.states.to_vec()
314 };
315
316 let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
318
319 writeln!(writer, "CCSDS_OMM_VERS = 2.0").map_err(err_hdlr)?;
321
322 writeln!(
323 writer,
324 "COMMENT Built by {} -- https://nyxspace.com/\n",
325 prj_name_ver()
326 )
327 .map_err(err_hdlr)?;
328 writeln!(
329 writer,
330 "COMMENT Nyx Space provided under the AGPL v3 open source license -- https://nyxspace.com/pricing\n"
331 )
332 .map_err(err_hdlr)?;
333
334 writeln!(
335 writer,
336 "CREATION_DATE = {}",
337 Formatter::new(Epoch::now().unwrap(), iso8601_no_ts)
338 )
339 .map_err(err_hdlr)?;
340 writeln!(
341 writer,
342 "ORIGINATOR = {}\n",
343 metadata
344 .get("originator")
345 .unwrap_or(&"Nyx Space".to_string())
346 )
347 .map_err(err_hdlr)?;
348
349 writeln!(writer, "META_START").map_err(err_hdlr)?;
350 if let Some(object_name) = metadata.get("object_name") {
352 writeln!(writer, "\tOBJECT_NAME = {object_name}").map_err(err_hdlr)?;
353 } else if let Some(object_name) = &self.name {
354 writeln!(writer, "\tOBJECT_NAME = {object_name}").map_err(err_hdlr)?;
355 }
356
357 let first_orbit = states[0].orbit;
358 let first_frame = first_orbit.frame;
359 let frame_str = format!(
360 "{first_frame:e} {}",
361 match first_frame.orientation_id {
362 J2000 => "ICRF".to_string(),
363 _ => format!("{first_frame:o}"),
364 }
365 );
366 let splt: Vec<&str> = frame_str.split(' ').collect();
367 let center = splt[0];
368 let ref_frame = frame_str.replace(center, " ");
369 writeln!(
370 writer,
371 "\tREF_FRAME = {}",
372 match ref_frame.trim() {
373 "J2000" => "ICRF",
374 _ => ref_frame.trim(),
375 }
376 )
377 .map_err(err_hdlr)?;
378
379 writeln!(writer, "\tCENTER_NAME = {center}",).map_err(err_hdlr)?;
380
381 writeln!(writer, "\tTIME_SYSTEM = {}", first_orbit.epoch.time_scale).map_err(err_hdlr)?;
382
383 writeln!(
384 writer,
385 "\tSTART_TIME = {}",
386 Formatter::new(states[0].epoch(), iso8601_no_ts)
387 )
388 .map_err(err_hdlr)?;
389 writeln!(
390 writer,
391 "\tUSEABLE_START_TIME = {}",
392 Formatter::new(states[0].epoch(), iso8601_no_ts)
393 )
394 .map_err(err_hdlr)?;
395 writeln!(
396 writer,
397 "\tUSEABLE_STOP_TIME = {}",
398 Formatter::new(states[states.len() - 1].epoch(), iso8601_no_ts)
399 )
400 .map_err(err_hdlr)?;
401 writeln!(
402 writer,
403 "\tSTOP_TIME = {}",
404 Formatter::new(states[states.len() - 1].epoch(), iso8601_no_ts)
405 )
406 .map_err(err_hdlr)?;
407
408 writeln!(writer, "META_STOP\n").map_err(err_hdlr)?;
409
410 for sc_state in &states {
411 let state = sc_state.orbit;
412 writeln!(
413 writer,
414 "{} {:E} {:E} {:E} {:E} {:E} {:E}",
415 Formatter::new(state.epoch, iso8601_no_ts),
416 state.radius_km.x,
417 state.radius_km.y,
418 state.radius_km.z,
419 state.velocity_km_s.x,
420 state.velocity_km_s.y,
421 state.velocity_km_s.z
422 )
423 .map_err(err_hdlr)?;
424 }
425
426 #[allow(clippy::writeln_empty_string)]
427 writeln!(writer, "").map_err(err_hdlr)?;
428
429 let tock_time = Epoch::now().unwrap() - tick;
431 info!(
432 "Trajectory written to {} in {tock_time}",
433 path_buf.display()
434 );
435 Ok(path_buf)
436 }
437
438 pub fn from_parquet<P: AsRef<Path>>(path: P) -> Result<Self, InputOutputError> {
439 let file = File::open(&path).context(StdIOSnafu {
440 action: "opening trajectory file",
441 })?;
442
443 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
444
445 let mut metadata = HashMap::new();
446 if let Some(file_metadata) = builder.metadata().file_metadata().key_value_metadata() {
448 for key_value in file_metadata {
449 if !key_value.key.starts_with("ARROW:") {
450 metadata.insert(
451 key_value.key.clone(),
452 key_value.value.clone().unwrap_or("[unset]".to_string()),
453 );
454 }
455 }
456 }
457
458 let mut has_epoch = false; let mut frame = None;
461
462 let mut found_fields = vec![
463 (StateParameter::X, false),
464 (StateParameter::Y, false),
465 (StateParameter::Z, false),
466 (StateParameter::VX, false),
467 (StateParameter::VY, false),
468 (StateParameter::VZ, false),
469 (StateParameter::PropMass, false),
470 ];
471
472 let reader = builder.build().context(ParquetSnafu {
473 action: "building output trajectory file",
474 })?;
475
476 for field in &reader.schema().fields {
477 if field.name().as_str() == "Epoch (UTC)" {
478 has_epoch = true;
479 } else {
480 for potential_field in &mut found_fields {
481 if field.name() == potential_field.0.to_field(None).name() {
482 potential_field.1 = true;
483 if potential_field.0 != StateParameter::PropMass {
484 if let Some(frame_info) = field.metadata().get("Frame") {
485 match serde_dhall::from_str(frame_info).parse::<Frame>() {
487 Err(e) => {
488 return Err(InputOutputError::ParseDhall {
489 data: frame_info.to_string(),
490 err: format!("{e}"),
491 })
492 }
493 Ok(deser_frame) => frame = Some(deser_frame),
494 };
495 }
496 }
497 break;
498 }
499 }
500 }
501 }
502
503 ensure!(
504 has_epoch,
505 MissingDataSnafu {
506 which: "Epoch (UTC)"
507 }
508 );
509
510 ensure!(
511 frame.is_some(),
512 MissingDataSnafu {
513 which: "Frame in metadata"
514 }
515 );
516
517 for (field, exists) in found_fields.iter().take(found_fields.len() - 1) {
518 ensure!(
519 exists,
520 MissingDataSnafu {
521 which: format!("Missing `{}` field", field.to_field(None).name())
522 }
523 );
524 }
525
526 let sc_compat = found_fields.last().unwrap().1;
527
528 let expected_type = std::any::type_name::<Spacecraft>()
529 .split("::")
530 .last()
531 .unwrap();
532
533 if expected_type == "Spacecraft" {
534 ensure!(
535 sc_compat,
536 MissingDataSnafu {
537 which: format!(
538 "Missing `{}` field",
539 found_fields.last().unwrap().0.to_field(None).name()
540 )
541 }
542 );
543 } else if sc_compat {
544 if let Some(last_field) = found_fields.last_mut() {
546 if last_field.0 == StateParameter::PropMass && last_field.1 {
547 last_field.1 = false;
548 }
549 }
550 }
551
552 let mut traj = Traj::default();
554
555 for maybe_batch in reader {
557 let batch = maybe_batch.unwrap();
558
559 let epochs = batch
560 .column_by_name("Epoch (UTC)")
561 .unwrap()
562 .as_any()
563 .downcast_ref::<StringArray>()
564 .unwrap();
565
566 let mut shared_data = vec![];
567
568 for (field, _) in found_fields.iter().take(found_fields.len() - 1) {
569 shared_data.push(
570 batch
571 .column_by_name(field.to_field(None).name())
572 .unwrap()
573 .as_any()
574 .downcast_ref::<Float64Array>()
575 .unwrap(),
576 );
577 }
578
579 if expected_type == "Spacecraft" {
580 shared_data.push(
582 batch
583 .column_by_name("prop_mass (kg)")
584 .unwrap()
585 .as_any()
586 .downcast_ref::<Float64Array>()
587 .unwrap(),
588 );
589 }
590
591 for i in 0..batch.num_rows() {
595 let mut state = Spacecraft::zeros();
596 state.set_epoch(Epoch::from_gregorian_str(epochs.value(i)).map_err(|e| {
597 InputOutputError::Inconsistency {
598 msg: format!("{e} when parsing epoch"),
599 }
600 })?);
601 state.set_frame(frame.unwrap()); state.unset_stm(); for (j, (param, exists)) in found_fields.iter().enumerate() {
605 if *exists {
606 state.set_value(*param, shared_data[j].value(i)).unwrap();
607 }
608 }
609
610 traj.states.push(state);
611 }
612 }
613
614 traj.finalize();
616
617 Ok(traj)
618 }
619}
620
621#[cfg(test)]
622mod ut_ccsds_oem {
623
624 use crate::md::prelude::{OrbitalDynamics, Propagator, SpacecraftDynamics};
625 use crate::time::{Epoch, TimeUnits};
626 use crate::Spacecraft;
627 use crate::{io::ExportCfg, md::prelude::Traj, Orbit};
628 use anise::almanac::Almanac;
629 use anise::constants::frames::MOON_J2000;
630 use pretty_env_logger;
631 use std::env;
632 use std::str::FromStr;
633 use std::sync::Arc;
634 use std::{collections::HashMap, path::PathBuf};
635
636 #[test]
637 fn test_load_oem_leo() {
638 let path: PathBuf = [
640 env!("CARGO_MANIFEST_DIR"),
641 "data",
642 "03_tests",
643 "ccsds",
644 "oem",
645 "LEO_10s.oem",
646 ]
647 .iter()
648 .collect();
649
650 let _ = pretty_env_logger::try_init();
651
652 let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
653
654 assert_eq!(traj.states.len(), 361);
656 assert_eq!(traj.name.unwrap(), "TEST_OBJ".to_string());
657 }
658
659 #[test]
660 fn test_load_oem_meo() {
661 let path: PathBuf = [
663 env!("CARGO_MANIFEST_DIR"),
664 "data",
665 "03_tests",
666 "ccsds",
667 "oem",
668 "MEO_60s.oem",
669 ]
670 .iter()
671 .collect();
672
673 let _ = pretty_env_logger::try_init();
674
675 let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
676
677 assert_eq!(traj.states.len(), 61);
678 assert_eq!(traj.name.unwrap(), "TEST_OBJ".to_string());
679 }
680
681 #[test]
682 fn test_load_oem_geo() {
683 use pretty_env_logger;
684 use std::env;
685
686 let path: PathBuf = [
688 env!("CARGO_MANIFEST_DIR"),
689 "data",
690 "03_tests",
691 "ccsds",
692 "oem",
693 "GEO_20s.oem",
694 ]
695 .iter()
696 .collect();
697
698 let _ = pretty_env_logger::try_init();
699
700 let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
701
702 assert_eq!(traj.states.len(), 181);
703 assert_eq!(traj.name.as_ref().unwrap(), &"TEST_OBJ".to_string());
704
705 let cfg = ExportCfg::builder()
707 .timestamp(true)
708 .metadata(HashMap::from([
709 ("originator".to_string(), "Test suite".to_string()),
710 ("object_name".to_string(), "TEST_OBJ".to_string()),
711 ]))
712 .build();
713
714 let path: PathBuf = [
715 env!("CARGO_MANIFEST_DIR"),
716 "data",
717 "04_output",
718 "GEO_20s_rebuilt.oem",
719 ]
720 .iter()
721 .collect();
722
723 let out_path = traj.to_oem_file(path.clone(), cfg).unwrap();
724 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
726
727 assert_eq!(traj_reloaded, traj);
728
729 let cfg = ExportCfg::builder()
731 .timestamp(true)
732 .metadata(HashMap::from([
733 ("originator".to_string(), "Test suite".to_string()),
734 ("object_name".to_string(), "TEST_OBJ".to_string()),
735 ]))
736 .step(20.seconds())
737 .start_epoch(traj.first().orbit.epoch + 1.seconds())
738 .end_epoch(traj.last().orbit.epoch - 1.seconds())
739 .build();
740 let out_path = traj.to_oem_file(path, cfg).unwrap();
741 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
743
744 assert_eq!(traj_reloaded.states.len(), traj.states.len() - 1);
747 assert_eq!(
748 traj_reloaded.first().orbit.epoch,
749 traj.first().orbit.epoch + 1.seconds()
750 );
751 assert_eq!(
754 traj_reloaded.last().orbit.epoch,
755 traj.last().orbit.epoch - 19.seconds()
756 );
757 }
758
759 #[test]
760 fn test_moon_frame_long_prop() {
761 use std::path::PathBuf;
762
763 let manifest_dir =
764 PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string()));
765
766 let almanac = Almanac::new(
767 &manifest_dir
768 .clone()
769 .join("data/01_planetary/pck08.pca")
770 .to_string_lossy(),
771 )
772 .unwrap()
773 .load(
774 &manifest_dir
775 .join("data/01_planetary/de440s.bsp")
776 .to_string_lossy(),
777 )
778 .unwrap();
779
780 let epoch = Epoch::from_str("2022-06-13T12:00:00").unwrap();
781 let orbit = Orbit::try_keplerian_altitude(
782 350.0,
783 0.02,
784 30.0,
785 45.0,
786 85.0,
787 0.0,
788 epoch,
789 almanac.frame_info(MOON_J2000).unwrap(),
790 )
791 .unwrap();
792
793 let mut traj =
794 Propagator::default_dp78(SpacecraftDynamics::new(OrbitalDynamics::two_body()))
795 .with(orbit.into(), Arc::new(almanac))
796 .for_duration_with_traj(45.days())
797 .unwrap()
798 .1;
799 traj.name = Some("TEST_MOON_OBJ".to_string());
801
802 let path: PathBuf = [
804 env!("CARGO_MANIFEST_DIR"),
805 "data",
806 "04_output",
807 "moon_45days.oem",
808 ]
809 .iter()
810 .collect();
811
812 let out_path = traj.to_oem_file(path, ExportCfg::default()).unwrap();
813
814 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
816
817 assert_eq!(traj, traj_reloaded);
818 }
819}