nyx_space/md/trajectory/
sc_traj.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use 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    /// Builds a new trajectory built from the SPICE BSP (SPK) file loaded in the provided Almanac, provided the start and stop epochs.
52    ///
53    /// If the start and stop epochs are not provided, then the full domain of the trajectory will be used.
54    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    /// Allows converting the source trajectory into the (almost) equivalent trajectory in another frame
87    #[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    /// Exports this trajectory to the provided filename in parquet format with only the epoch, the geodetic latitude, longitude, and height at one state per minute.
130    /// Must provide a body fixed frame to correctly compute the latitude and longitude.
131    #[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    /// Initialize a new spacecraft trajectory from the path to a CCSDS OEM file.
157    ///
158    /// CCSDS OEM only contains the orbit information but Nyx builds spacecraft trajectories.
159    /// If not spacecraft template is provided, then a default massless spacecraft will be built.
160    pub fn from_oem_file<P: AsRef<Path>>(
161        path: P,
162        tpl_option: Option<Spacecraft>,
163    ) -> Result<Self, NyxError> {
164        // Open the file
165        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        // Parse the Orbit Element messages
173        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                // Extract the object ID from the line
203                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                // We can start parsing now
219                parse = true;
220            } else if line.starts_with("META_START") {
221                // Stop the parsing
222                parse = false;
223            } else if line.starts_with("COVARIANCE_START") {
224                // Stop the parsing
225                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                // Split the line into components
236                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                    // Extract the values
242                    let epoch_str = format!("{} {time_system}", parts[0]);
243                    match parts[1].parse::<f64>() {
244                        Ok(x_km) => {
245                            // Look good!
246                            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                            // Probably a comment
265                            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        // Grab the path here before we move stuff.
292        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        // Build the states iterator -- this does require copying the current states but I can't either get a reference or a copy of all the states.
306        let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
307            // Must interpolate the data!
308            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        // Epoch formmatter.
317        let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
318
319        // Write mandatory metadata
320        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        // Write optional metadata
351        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        // Return the path this was written to
430        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        // Build the custom metadata
447        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        // Check the schema
459        let mut has_epoch = false; // Required
460        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                                // Frame is expected to be serialized as Dhall.
486                                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            // Not a spacecraft, remove the prop mass
545            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        // At this stage, we know that the measurement is valid and the conversion is supported.
553        let mut traj = Traj::default();
554
555        // Now convert each batch on the fly
556        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                // Read the prop only if this is a spacecraft we're building
581                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            // Grab the frame -- it should have been serialized with all of the data so we don't need to reload it.
592
593            // Build the states
594            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()); // We checked it was set above with an ensure! call
602                state.unset_stm(); // We don't have any STM data, so let's unset this.
603
604                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        // Remove any duplicates that may exist in the imported trajectory.
615        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        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
639        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        // This trajectory has two duplicate epochs, which should be removed by the call to finalize()
655        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        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
662        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        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
687        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        // Reexport this to CCSDS.
706        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        // And reload, make sure we have the same data.
725        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
726
727        assert_eq!(traj_reloaded, traj);
728
729        // Now export after trimming one state on either end
730        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        // And reload, make sure we have the same data.
742        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
743
744        // Note that the number of states has changed because we interpolated with a step similar to the original one but
745        // we started with a different time.
746        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        // Note: because we used a fixed step, the last epoch is actually an offset of step size - end offset
752        // from the original trajectory
753        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        // Set the name of this object
800        traj.name = Some("TEST_MOON_OBJ".to_string());
801
802        // Export CCSDS OEM file
803        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        // And reload
815        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(out_path, None).unwrap();
816
817        assert_eq!(traj, traj_reloaded);
818    }
819}