Skip to main content

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 super::TrajError;
20use super::{ExportCfg, Traj};
21use crate::cosmic::{GuidanceMode, Spacecraft};
22use crate::dynamics::guidance::{ThrustDirectionReplay, Thruster};
23use crate::errors::{FromAlmanacSnafu, NyxError};
24use crate::io::{InputOutputError, MissingDataSnafu, ParquetSnafu, StdIOSnafu};
25use crate::md::prelude::{Interpolatable, StateParameter};
26use crate::time::{Duration, Epoch, TimeUnits};
27use crate::State;
28use anise::analysis::prelude::OrbitalElement;
29use anise::astro::Aberration;
30use anise::ephemerides::ephemeris::Ephemeris;
31use anise::ephemerides::EphemerisError;
32use anise::errors::AlmanacError;
33use anise::prelude::{Almanac, Frame};
34use arrow::array::RecordBatchReader;
35use arrow::array::{Array, Float64Array, StringArray};
36use hifitime::TimeSeries;
37use log::info;
38use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
39use snafu::{ensure, ResultExt};
40use std::collections::HashMap;
41use std::error::Error;
42use std::fs::File;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45#[cfg(not(target_arch = "wasm32"))]
46use std::time::Instant;
47
48impl Traj<Spacecraft> {
49    pub fn to_thrust_direction_replay(&self) -> Arc<ThrustDirectionReplay> {
50        ThrustDirectionReplay::from_trajectory(self.clone())
51    }
52
53    /// Builds a new trajectory built from the SPICE BSP (SPK) file loaded in the provided Almanac, provided the start and stop epochs.
54    ///
55    /// If the start and stop epochs are not provided, then the full domain of the trajectory will be used.
56    pub fn from_bsp(
57        target_frame: Frame,
58        observer_frame: Frame,
59        almanac: Arc<Almanac>,
60        sc_template: Spacecraft,
61        step: Duration,
62        start_epoch: Option<Epoch>,
63        end_epoch: Option<Epoch>,
64        ab_corr: Option<Aberration>,
65        name: Option<String>,
66    ) -> Result<Self, AlmanacError> {
67        let (domain_start, domain_end) =
68            almanac
69                .spk_domain(target_frame.ephemeris_id)
70                .map_err(|e| AlmanacError::Ephemeris {
71                    action: "could not fetch domain",
72                    source: Box::new(e),
73                })?;
74
75        let start_epoch = start_epoch.unwrap_or(domain_start);
76        let end_epoch = end_epoch.unwrap_or(domain_end);
77
78        let time_series = TimeSeries::inclusive(start_epoch, end_epoch, step);
79        let mut states = Vec::with_capacity(time_series.len());
80        for epoch in time_series {
81            let orbit = almanac.transform(target_frame, observer_frame, epoch, ab_corr)?;
82
83            states.push(sc_template.with_orbit(orbit));
84        }
85
86        Ok(Self { name, states })
87    }
88    /// Allows converting the source trajectory into the (almost) equivalent trajectory in another frame
89    #[allow(clippy::map_clone)]
90    pub fn to_frame(&self, new_frame: Frame, almanac: Arc<Almanac>) -> Result<Self, NyxError> {
91        if self.states.is_empty() {
92            return Err(NyxError::Trajectory {
93                source: TrajError::CreationError {
94                    msg: "No trajectory to convert".to_string(),
95                },
96            });
97        }
98
99        #[cfg(not(target_arch = "wasm32"))]
100        let start_instant = Instant::now();
101        let mut traj = Self::new();
102        for state in &self.states {
103            let new_orbit =
104                almanac
105                    .transform_to(state.orbit, new_frame, None)
106                    .context(FromAlmanacSnafu {
107                        action: "transforming trajectory into new frame",
108                    })?;
109            traj.states.push(state.with_orbit(new_orbit));
110        }
111        traj.finalize();
112
113        #[cfg(not(target_arch = "wasm32"))]
114        info!(
115            "Converted trajectory from {} to {} in {} ms: {traj}",
116            self.first().orbit.frame,
117            new_frame,
118            (Instant::now() - start_instant).as_millis()
119        );
120
121        #[cfg(target_arch = "wasm32")]
122        info!(
123            "Converted trajectory from {} to {}: {traj}",
124            self.first().orbit.frame,
125            new_frame,
126        );
127
128        Ok(traj)
129    }
130
131    /// 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.
132    /// Must provide a body fixed frame to correctly compute the latitude and longitude.
133    #[allow(clippy::identity_op)]
134    pub fn to_groundtrack_parquet<P: AsRef<Path>>(
135        &self,
136        path: P,
137        body_fixed_frame: Frame,
138        metadata: Option<HashMap<String, String>>,
139        almanac: Arc<Almanac>,
140    ) -> Result<PathBuf, Box<dyn Error>> {
141        let traj = self.to_frame(body_fixed_frame, almanac)?;
142
143        let mut cfg = ExportCfg::builder()
144            .step(1.minutes())
145            .fields(vec![
146                StateParameter::Element(OrbitalElement::Latitude),
147                StateParameter::Element(OrbitalElement::Longitude),
148                StateParameter::Element(OrbitalElement::Height),
149                StateParameter::Element(OrbitalElement::Rmag),
150            ])
151            .build();
152        cfg.metadata = metadata;
153
154        traj.to_parquet(path, cfg)
155    }
156
157    /// Export this spacecraft trajectory estimate to an ANISE Ephemeris
158    pub fn to_ephemeris(&self, object_id: String, cfg: ExportCfg) -> Ephemeris {
159        let mut ephem = Ephemeris::new(object_id);
160
161        // 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.
162        let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
163            // Must interpolate the data!
164            let start = cfg.start_epoch.unwrap_or_else(|| self.first().epoch());
165            let end = cfg.end_epoch.unwrap_or_else(|| self.last().epoch());
166            let step = cfg.step.unwrap_or_else(|| 1.minutes());
167            self.every_between(step, start, end).collect()
168        } else {
169            self.states.to_vec()
170        };
171
172        for sc_state in &states {
173            ephem.insert_orbit(sc_state.orbit());
174        }
175
176        ephem
177    }
178
179    /// Initialize a new spacecraft trajectory from the path to a CCSDS OEM file.
180    ///
181    /// CCSDS OEM only contains the orbit information but Nyx builds spacecraft trajectories.
182    /// If not spacecraft template is provided, then a default massless spacecraft will be built.
183    pub fn from_oem_file<P: AsRef<Path>>(
184        path: P,
185        tpl_option: Option<Spacecraft>,
186    ) -> Result<Self, EphemerisError> {
187        // Read the ephemeris
188        let ephem = Ephemeris::from_ccsds_oem_file(path)?;
189        // Rebuild a trajectory by applying the template
190        let template = tpl_option.unwrap_or_default();
191        let mut traj = Self::default();
192        for record in &ephem {
193            traj.states.push(template.with_orbit(record.orbit));
194        }
195        traj.name = Some(ephem.object_id);
196
197        Ok(traj)
198    }
199
200    pub fn to_oem_file<P: AsRef<Path>>(
201        &self,
202        path: P,
203        object_id: String,
204        originator: Option<String>,
205        object_name: Option<String>,
206        cfg: ExportCfg,
207    ) -> Result<(), EphemerisError> {
208        let ephem = self.to_ephemeris(object_id, cfg);
209        ephem.write_ccsds_oem(path, originator, object_name)
210    }
211
212    pub fn from_parquet<P: AsRef<Path>>(path: P) -> Result<Self, InputOutputError> {
213        let file = File::open(&path).context(StdIOSnafu {
214            action: "opening trajectory file",
215        })?;
216
217        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
218
219        let mut metadata = HashMap::new();
220        // Build the custom metadata
221        if let Some(file_metadata) = builder.metadata().file_metadata().key_value_metadata() {
222            for key_value in file_metadata {
223                if !key_value.key.starts_with("ARROW:") {
224                    metadata.insert(
225                        key_value.key.clone(),
226                        key_value.value.clone().unwrap_or("[unset]".to_string()),
227                    );
228                }
229            }
230        }
231
232        // Check the schema
233        let mut has_epoch = false; // Required
234        let mut frame = None;
235        let mut has_guidance_mode = false;
236
237        let mut found_fields = vec![
238            (StateParameter::Element(OrbitalElement::X), false),
239            (StateParameter::Element(OrbitalElement::Y), false),
240            (StateParameter::Element(OrbitalElement::Z), false),
241            (StateParameter::Element(OrbitalElement::VX), false),
242            (StateParameter::Element(OrbitalElement::VY), false),
243            (StateParameter::Element(OrbitalElement::VZ), false),
244            (StateParameter::DryMass(), false),
245            (StateParameter::Isp(), false),
246            (StateParameter::PropMass(), false),
247            (StateParameter::Thrust(), false),
248            (StateParameter::ThrustX(), false),
249            (StateParameter::ThrustY(), false),
250            (StateParameter::ThrustZ(), false),
251        ];
252
253        let reader = builder.build().context(ParquetSnafu {
254            action: "building output trajectory file",
255        })?;
256
257        for field in &reader.schema().fields {
258            if field.name().as_str() == "Epoch (UTC)" {
259                has_epoch = true;
260            } else if field.name().as_str() == "guidance_mode" {
261                has_guidance_mode = true;
262            } else {
263                for potential_field in &mut found_fields {
264                    if field.name() == potential_field.0.to_field(None).name() {
265                        potential_field.1 = true;
266                        if potential_field.0 != StateParameter::PropMass() {
267                            if let Some(frame_info) = field.metadata().get("Frame") {
268                                // Frame is expected to be serialized as Dhall.
269                                match serde_dhall::from_str(frame_info).parse::<Frame>() {
270                                    Err(e) => {
271                                        return Err(InputOutputError::ParseDhall {
272                                            data: frame_info.to_string(),
273                                            err: format!("{e}"),
274                                        })
275                                    }
276                                    Ok(deser_frame) => frame = Some(deser_frame),
277                                };
278                            }
279                        }
280                        break;
281                    }
282                }
283            }
284        }
285
286        ensure!(
287            has_epoch,
288            MissingDataSnafu {
289                which: "Epoch (UTC)"
290            }
291        );
292
293        ensure!(
294            frame.is_some(),
295            MissingDataSnafu {
296                which: "Frame in metadata"
297            }
298        );
299
300        for (field, exists) in found_fields.iter().take(6) {
301            ensure!(
302                exists,
303                MissingDataSnafu {
304                    which: format!("Missing `{}` field", field.to_field(None).name())
305                }
306            );
307        }
308
309        let sc_compat = found_fields
310            .iter()
311            .find(|(field, _)| *field == StateParameter::PropMass())
312            .map(|(_, exists)| *exists)
313            .unwrap_or(false);
314
315        let expected_type = std::any::type_name::<Spacecraft>()
316            .split("::")
317            .last()
318            .unwrap();
319
320        if expected_type == "Spacecraft" {
321            ensure!(
322                sc_compat,
323                MissingDataSnafu {
324                    which: format!(
325                        "Missing `{}` field",
326                        found_fields.last().unwrap().0.to_field(None).name()
327                    )
328                }
329            );
330        } else if sc_compat {
331            // Not a spacecraft, remove the prop mass
332            for found_field in &mut found_fields {
333                if found_field.0 == StateParameter::PropMass() && found_field.1 {
334                    found_field.1 = false;
335                    break;
336                }
337            }
338        }
339
340        // At this stage, we know that the measurement is valid and the conversion is supported.
341        let mut traj = Traj::default();
342
343        // Now convert each batch on the fly
344        for maybe_batch in reader {
345            let batch = maybe_batch.unwrap();
346
347            let epochs = batch
348                .column_by_name("Epoch (UTC)")
349                .unwrap()
350                .as_any()
351                .downcast_ref::<StringArray>()
352                .unwrap();
353
354            let mut shared_data = vec![];
355            let guidance_mode_data = if has_guidance_mode {
356                Some(
357                    batch
358                        .column_by_name(StateParameter::GuidanceMode().to_field(None).name())
359                        .unwrap()
360                        .as_any()
361                        .downcast_ref::<StringArray>()
362                        .unwrap(),
363                )
364            } else {
365                None
366            };
367
368            for (field, exists) in &found_fields {
369                if *exists {
370                    shared_data.push((
371                        *field,
372                        batch
373                            .column_by_name(field.to_field(None).name())
374                            .unwrap()
375                            .as_any()
376                            .downcast_ref::<Float64Array>()
377                            .unwrap(),
378                    ));
379                }
380            }
381
382            // Grab the frame -- it should have been serialized with all of the data so we don't need to reload it.
383
384            // Build the states
385            for i in 0..batch.num_rows() {
386                let mut state = Spacecraft::zeros();
387                state.set_epoch(Epoch::from_gregorian_str(epochs.value(i)).map_err(|e| {
388                    InputOutputError::Inconsistency {
389                        msg: format!("{e} when parsing epoch"),
390                    }
391                })?);
392                state.set_frame(frame.unwrap()); // We checked it was set above with an ensure! call
393                state.unset_stm(); // We don't have any STM data, so let's unset this.
394                if found_fields.iter().any(|(field, exists)| {
395                    *exists && matches!(field, StateParameter::Isp() | StateParameter::Thrust())
396                }) {
397                    state.thruster = Some(Thruster {
398                        thrust_N: 0.0,
399                        isp_s: 0.0,
400                    });
401                }
402                if let Some(guidance_mode_data) = guidance_mode_data {
403                    state.mut_mode(match guidance_mode_data.value(i) {
404                        "Thrust" => GuidanceMode::Thrust,
405                        "Inhibit" => GuidanceMode::Inhibit,
406                        _ => GuidanceMode::Coast,
407                    });
408                }
409
410                for (param, data) in &shared_data {
411                    if data.is_valid(i) {
412                        state.set_value(*param, data.value(i)).unwrap();
413                    }
414                }
415
416                traj.states.push(state);
417            }
418        }
419
420        // Remove any duplicates that may exist in the imported trajectory.
421        traj.finalize();
422
423        Ok(traj)
424    }
425}
426
427#[cfg(test)]
428mod ut_ccsds_oem {
429
430    use crate::cosmic::GuidanceMode;
431    use crate::dynamics::guidance::{LocalFrame, Objective, Ruggiero, Thruster};
432    use crate::md::prelude::{OrbitalDynamics, Propagator, SpacecraftDynamics};
433    use crate::md::StateParameter;
434    use crate::time::{Epoch, TimeUnits};
435    use crate::Spacecraft;
436    use crate::State;
437    use crate::{io::ExportCfg, md::prelude::Traj, Orbit};
438    use anise::almanac::Almanac;
439    use anise::analysis::prelude::OrbitalElement;
440    use anise::constants::frames::MOON_J2000;
441    use arrow::array::{Array, Float64Array};
442    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
443    use pretty_env_logger;
444    use std::env;
445    use std::fs::File;
446    use std::str::FromStr;
447    use std::sync::Arc;
448    use std::{collections::HashMap, path::PathBuf};
449
450    #[test]
451    fn test_load_oem_leo() {
452        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
453        let path: PathBuf = [
454            env!("CARGO_MANIFEST_DIR"),
455            "data",
456            "03_tests",
457            "ccsds",
458            "oem",
459            "LEO_10s.oem",
460        ]
461        .iter()
462        .collect();
463
464        let _ = pretty_env_logger::try_init();
465
466        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
467
468        // This trajectory has two duplicate epochs, which should be removed by the call to finalize()
469        assert_eq!(traj.states.len(), 361);
470        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
471    }
472
473    #[test]
474    fn test_load_oem_meo() {
475        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
476        let path: PathBuf = [
477            env!("CARGO_MANIFEST_DIR"),
478            "data",
479            "03_tests",
480            "ccsds",
481            "oem",
482            "MEO_60s.oem",
483        ]
484        .iter()
485        .collect();
486
487        let _ = pretty_env_logger::try_init();
488
489        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
490
491        assert_eq!(traj.states.len(), 61);
492        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
493    }
494
495    #[test]
496    fn test_load_oem_geo() {
497        use pretty_env_logger;
498        use std::env;
499
500        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
501        let path: PathBuf = [
502            env!("CARGO_MANIFEST_DIR"),
503            "data",
504            "03_tests",
505            "ccsds",
506            "oem",
507            "GEO_20s.oem",
508        ]
509        .iter()
510        .collect();
511
512        let _ = pretty_env_logger::try_init();
513
514        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
515
516        assert_eq!(traj.states.len(), 181);
517        assert_eq!(traj.name.as_ref().unwrap(), &"0000-000A".to_string());
518
519        // Reexport this to CCSDS.
520        let cfg = ExportCfg::builder()
521            .timestamp(true)
522            .metadata(HashMap::from([
523                ("originator".to_string(), "Test suite".to_string()),
524                ("object_name".to_string(), "TEST_OBJ".to_string()),
525            ]))
526            .build();
527
528        let path: PathBuf = [
529            env!("CARGO_MANIFEST_DIR"),
530            "data",
531            "04_output",
532            "GEO_20s_rebuilt.oem",
533        ]
534        .iter()
535        .collect();
536
537        traj.to_oem_file(
538            &path,
539            "0000-000A".to_string(),
540            Some("Test Suite".to_string()),
541            Some("TEST_OBJ".to_string()),
542            cfg,
543        )
544        .unwrap();
545        // And reload, make sure we have the same data.
546        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(&path, None).unwrap();
547
548        assert_eq!(traj_reloaded, traj);
549
550        // Now export after trimming one state on either end
551        let cfg = ExportCfg::builder()
552            .timestamp(true)
553            .metadata(HashMap::from([
554                ("originator".to_string(), "Test suite".to_string()),
555                ("object_name".to_string(), "TEST_OBJ".to_string()),
556            ]))
557            .step(20.seconds())
558            .start_epoch(traj.first().orbit.epoch + 1.seconds())
559            .end_epoch(traj.last().orbit.epoch - 1.seconds())
560            .build();
561        traj.to_oem_file(
562            &path,
563            "TEST-OBJ-ID".to_string(),
564            Some("Test Suite".to_string()),
565            Some("TEST_OBJ".to_string()),
566            cfg,
567        )
568        .unwrap();
569        // And reload, make sure we have the same data.
570        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
571
572        // Note that the number of states has changed because we interpolated with a step similar to the original one but
573        // we started with a different time.
574        assert_eq!(traj_reloaded.states.len(), traj.states.len() - 1);
575        assert_eq!(
576            traj_reloaded.first().orbit.epoch,
577            traj.first().orbit.epoch + 1.seconds()
578        );
579        // Note: because we used a fixed step, the last epoch is actually an offset of step size - end offset
580        // from the original trajectory
581        assert_eq!(
582            traj_reloaded.last().orbit.epoch,
583            traj.last().orbit.epoch - 19.seconds()
584        );
585    }
586
587    #[test]
588    fn test_moon_frame_long_prop() {
589        use std::path::PathBuf;
590
591        let manifest_dir =
592            PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string()));
593
594        let almanac = Almanac::new(
595            &manifest_dir
596                .clone()
597                .join("data/01_planetary/pck08.pca")
598                .to_string_lossy(),
599        )
600        .unwrap()
601        .load(
602            &manifest_dir
603                .join("data/01_planetary/de440s.bsp")
604                .to_string_lossy(),
605        )
606        .unwrap();
607
608        let epoch = Epoch::from_str("2022-06-13T12:00:00").unwrap();
609        let orbit = Orbit::try_keplerian_altitude(
610            350.0,
611            0.02,
612            30.0,
613            45.0,
614            85.0,
615            0.0,
616            epoch,
617            almanac.frame_info(MOON_J2000).unwrap(),
618        )
619        .unwrap();
620
621        let mut traj =
622            Propagator::default_dp78(SpacecraftDynamics::new(OrbitalDynamics::two_body()))
623                .with(orbit.into(), Arc::new(almanac))
624                .for_duration_with_traj(45.days())
625                .unwrap()
626                .1;
627        // Set the name of this object
628        traj.name = Some("TEST_MOON_OBJ".to_string());
629
630        // Export CCSDS OEM file
631        let path: PathBuf = [
632            env!("CARGO_MANIFEST_DIR"),
633            "data",
634            "04_output",
635            "moon_45days.oem",
636        ]
637        .iter()
638        .collect();
639
640        traj.to_oem_file(
641            &path,
642            "TEST_MOON_OBJ".to_string(),
643            Some("Test Suite".to_string()),
644            Some("TEST_MOON_OBJ".to_string()),
645            ExportCfg::default(),
646        )
647        .unwrap();
648
649        // And reload
650        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
651
652        assert_eq!(traj, traj_reloaded);
653    }
654
655    #[test]
656    fn test_parquet_exports_thrust_angles() {
657        let _ = pretty_env_logger::try_init();
658
659        let manifest_dir =
660            PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string()));
661
662        let almanac = Almanac::new(
663            &manifest_dir
664                .clone()
665                .join("data/01_planetary/pck08.pca")
666                .to_string_lossy(),
667        )
668        .unwrap()
669        .load(
670            &manifest_dir
671                .join("data/01_planetary/de440s.bsp")
672                .to_string_lossy(),
673        )
674        .unwrap();
675
676        let almanac = Arc::new(almanac);
677
678        let eme2k = almanac
679            .frame_info(anise::constants::frames::EARTH_J2000)
680            .unwrap();
681        let epoch = Epoch::from_gregorian_utc_at_noon(2021, 1, 1);
682        let orbit = Orbit::try_keplerian_altitude(900.0, 5e-5, 5e-3, 0.0, 178.0, 0.0, epoch, eme2k)
683            .unwrap();
684
685        let objectives = &[Objective::within_tolerance(
686            StateParameter::Element(OrbitalElement::AoP),
687            183.0,
688            5e-3,
689        )];
690
691        let guidance = Ruggiero::simple(objectives, orbit.into()).unwrap();
692        let spacecraft = Spacecraft::from_thruster(
693            orbit,
694            300.0,
695            67.0,
696            Thruster {
697                thrust_N: 89e-3,
698                isp_s: 1650.0,
699            },
700            GuidanceMode::Thrust,
701        );
702
703        let (_, traj) = Propagator::default(SpacecraftDynamics::from_guidance_law(
704            OrbitalDynamics::two_body(),
705            guidance,
706        ))
707        .with(spacecraft, almanac.clone())
708        .for_duration_with_traj(5.minutes())
709        .unwrap();
710
711        let path = manifest_dir
712            .join("data/04_output")
713            .join("thrust_axes_export_test.parquet");
714
715        traj.to_parquet(&path, ExportCfg::default()).unwrap();
716
717        // Reload the trajectory and replay
718        let reloaded = Traj::<Spacecraft>::from_parquet(&path).unwrap();
719        assert_eq!(reloaded.first().mode(), GuidanceMode::Thrust);
720        assert!(reloaded
721            .states
722            .iter()
723            .skip(1)
724            .any(|state| state.thrust_direction().is_some()));
725
726        let reader = ParquetRecordBatchReaderBuilder::try_new(File::open(&path).unwrap())
727            .unwrap()
728            .build()
729            .unwrap();
730
731        let in_plane_name = StateParameter::ThrustInPlane(LocalFrame::RCN)
732            .to_field(None)
733            .name()
734            .to_string();
735        let out_of_plane_name = StateParameter::ThrustOutOfPlane(LocalFrame::RCN)
736            .to_field(None)
737            .name()
738            .to_string();
739
740        let batch = reader.into_iter().next().unwrap().unwrap();
741        let in_plane = batch
742            .column_by_name(&in_plane_name)
743            .unwrap()
744            .as_any()
745            .downcast_ref::<Float64Array>()
746            .unwrap();
747        let out_of_plane = batch
748            .column_by_name(&out_of_plane_name)
749            .unwrap()
750            .as_any()
751            .downcast_ref::<Float64Array>()
752            .unwrap();
753
754        assert!(in_plane.is_null(0));
755        assert!(out_of_plane.is_null(0));
756        assert!((1..batch.num_rows()).any(|idx| in_plane.is_valid(idx)));
757        assert!((1..batch.num_rows()).any(|idx| out_of_plane.is_valid(idx)));
758
759        let replay_guidance = reloaded.to_thrust_direction_replay();
760        let replay_initial_state = reloaded.first().to_owned();
761        let replay_duration = reloaded.last().epoch() - reloaded.first().epoch();
762        let replay_dynamics =
763            SpacecraftDynamics::from_guidance_law(OrbitalDynamics::two_body(), replay_guidance);
764        let (replayed_end, _) = Propagator::default(replay_dynamics)
765            .with(replay_initial_state, almanac)
766            .for_duration_with_traj(replay_duration)
767            .unwrap();
768
769        let truth_end = traj.last();
770        let pos_err_km = (replayed_end.orbit.radius_km - truth_end.orbit.radius_km).norm();
771        let vel_err_km_s =
772            (replayed_end.orbit.velocity_km_s - truth_end.orbit.velocity_km_s).norm();
773        let prop_err_kg = (replayed_end.mass.prop_mass_kg - truth_end.mass.prop_mass_kg).abs();
774
775        assert!(
776            dbg!(pos_err_km) < 1e-3,
777            "replay position error too large: {pos_err_km} km"
778        );
779        assert!(
780            dbg!(vel_err_km_s) < 1e-5,
781            "replay velocity error too large: {vel_err_km_s} km/s"
782        );
783        assert!(
784            dbg!(prop_err_kg) < 1e-8,
785            "replay prop mass error too large: {prop_err_kg} kg"
786        );
787    }
788}