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::State;
22use crate::cosmic::{GuidanceMode, Spacecraft};
23use crate::dynamics::guidance::{ThrustDirectionReplay, Thruster};
24use crate::errors::{FromAlmanacSnafu, NyxError};
25use crate::io::parquet_string::AbstractStringArray;
26use crate::io::{ArrowSnafu, InputOutputError, MissingDataSnafu, ParquetSnafu, StdIOSnafu};
27use crate::md::prelude::{Interpolatable, StateParameter};
28use crate::time::{Duration, Epoch, TimeUnits};
29use anise::analysis::prelude::OrbitalElement;
30use anise::astro::Aberration;
31use anise::ephemerides::EphemerisError;
32use anise::ephemerides::ephemeris::Ephemeris;
33use anise::errors::AlmanacError;
34use anise::prelude::{Almanac, Frame};
35use arrow::array::{Array, Float64Array, RecordBatchReader};
36use hifitime::TimeSeries;
37use log::info;
38use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
39use snafu::{ResultExt, ensure};
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: &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: &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: &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                            && let Some(frame_info) = field.metadata().get("Frame")
268                        {
269                            // Frame is expected to be serialized as Dhall.
270                            match serde_dhall::from_str(frame_info).parse::<Frame>() {
271                                Err(e) => {
272                                    return Err(InputOutputError::ParseDhall {
273                                        data: frame_info.to_string(),
274                                        err: format!("{e}"),
275                                    });
276                                }
277                                Ok(deser_frame) => frame = Some(deser_frame),
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_col = batch.column_by_name("Epoch (UTC)").unwrap();
348            let epochs = AbstractStringArray::try_from(epochs_col).context(ArrowSnafu {
349                action: "downcasting `Epoch (UTC)`",
350            })?;
351
352            let mut shared_data = vec![];
353            let guidance_mode_data: Option<AbstractStringArray> = if has_guidance_mode {
354                let col = batch
355                    .column_by_name(StateParameter::GuidanceMode().to_field(None).name())
356                    .unwrap();
357                Some(AbstractStringArray::try_from(col).context(ArrowSnafu {
358                    action: "downcasting `GuidanceMode` column",
359                })?)
360            } else {
361                None
362            };
363
364            for (field, exists) in &found_fields {
365                if *exists {
366                    shared_data.push((
367                        *field,
368                        batch
369                            .column_by_name(field.to_field(None).name())
370                            .unwrap()
371                            .as_any()
372                            .downcast_ref::<Float64Array>()
373                            .unwrap(),
374                    ));
375                }
376            }
377
378            // Grab the frame -- it should have been serialized with all of the data so we don't need to reload it.
379
380            // Build the states
381            for i in 0..batch.num_rows() {
382                let mut state = Spacecraft::zeros();
383                state.set_epoch(Epoch::from_gregorian_str(epochs.value(i)).map_err(|e| {
384                    InputOutputError::Inconsistency {
385                        msg: format!("{e} when parsing epoch"),
386                    }
387                })?);
388                state.set_frame(frame.unwrap()); // We checked it was set above with an ensure! call
389                state.unset_stm(); // We don't have any STM data, so let's unset this.
390                if found_fields.iter().any(|(field, exists)| {
391                    *exists && matches!(field, StateParameter::Isp() | StateParameter::Thrust())
392                }) {
393                    state.thruster = Some(Thruster {
394                        thrust_N: 0.0,
395                        isp_s: 0.0,
396                    });
397                }
398                if let Some(guidance_mode_data) = guidance_mode_data.as_ref() {
399                    state.mut_mode(match guidance_mode_data.value(i) {
400                        "Thrust" => GuidanceMode::Thrust,
401                        "Inhibit" => GuidanceMode::Inhibit,
402                        _ => GuidanceMode::Coast,
403                    });
404                }
405
406                for (param, data) in &shared_data {
407                    if data.is_valid(i) {
408                        state.set_value(*param, data.value(i)).unwrap();
409                    }
410                }
411
412                traj.states.push(state);
413            }
414        }
415
416        // Remove any duplicates that may exist in the imported trajectory.
417        traj.finalize();
418
419        Ok(traj)
420    }
421}
422
423#[cfg(test)]
424mod ut_ccsds_oem {
425
426    use crate::Spacecraft;
427    use crate::State;
428    use crate::cosmic::GuidanceMode;
429    use crate::dynamics::guidance::{LocalFrame, Objective, Ruggiero, Thruster};
430    use crate::md::StateParameter;
431    use crate::md::prelude::{OrbitalDynamics, Propagator, SpacecraftDynamics};
432    use crate::time::{Epoch, TimeUnits};
433    use crate::{Orbit, io::ExportCfg, md::prelude::Traj};
434    use anise::almanac::Almanac;
435    use anise::analysis::prelude::OrbitalElement;
436    use anise::constants::frames::MOON_J2000;
437    use arrow::array::{Array, Float64Array};
438    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
439    use pretty_env_logger;
440    use std::env;
441    use std::fs::File;
442    use std::str::FromStr;
443    use std::sync::Arc;
444    use std::{collections::HashMap, path::PathBuf};
445
446    #[test]
447    fn test_load_oem_leo() {
448        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
449        let path: PathBuf = [
450            env!("CARGO_MANIFEST_DIR"),
451            "../data",
452            "03_tests",
453            "ccsds",
454            "oem",
455            "LEO_10s.oem",
456        ]
457        .iter()
458        .collect();
459
460        let _ = pretty_env_logger::try_init();
461
462        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
463
464        // This trajectory has two duplicate epochs, which should be removed by the call to finalize()
465        assert_eq!(traj.states.len(), 361);
466        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
467    }
468
469    #[test]
470    fn test_load_oem_meo() {
471        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
472        let path: PathBuf = [
473            env!("CARGO_MANIFEST_DIR"),
474            "../data",
475            "03_tests",
476            "ccsds",
477            "oem",
478            "MEO_60s.oem",
479        ]
480        .iter()
481        .collect();
482
483        let _ = pretty_env_logger::try_init();
484
485        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
486
487        assert_eq!(traj.states.len(), 61);
488        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
489    }
490
491    #[test]
492    fn test_load_oem_geo() {
493        use pretty_env_logger;
494        use std::env;
495
496        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
497        let path: PathBuf = [
498            env!("CARGO_MANIFEST_DIR"),
499            "../data",
500            "03_tests",
501            "ccsds",
502            "oem",
503            "GEO_20s.oem",
504        ]
505        .iter()
506        .collect();
507
508        let _ = pretty_env_logger::try_init();
509
510        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
511
512        assert_eq!(traj.states.len(), 181);
513        assert_eq!(traj.name.as_ref().unwrap(), &"0000-000A".to_string());
514
515        // Reexport this to CCSDS.
516        let cfg = ExportCfg::builder()
517            .timestamp(true)
518            .metadata(HashMap::from([
519                ("originator".to_string(), "Test suite".to_string()),
520                ("object_name".to_string(), "TEST_OBJ".to_string()),
521            ]))
522            .build();
523
524        let path: PathBuf = [
525            env!("CARGO_MANIFEST_DIR"),
526            "../data",
527            "04_output",
528            "GEO_20s_rebuilt.oem",
529        ]
530        .iter()
531        .collect();
532
533        traj.to_oem_file(
534            &path,
535            "0000-000A".to_string(),
536            Some("Test Suite".to_string()),
537            Some("TEST_OBJ".to_string()),
538            cfg,
539        )
540        .unwrap();
541        // And reload, make sure we have the same data.
542        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(&path, None).unwrap();
543
544        assert_eq!(traj_reloaded, traj);
545
546        // Now export after trimming one state on either end
547        let cfg = ExportCfg::builder()
548            .timestamp(true)
549            .metadata(HashMap::from([
550                ("originator".to_string(), "Test suite".to_string()),
551                ("object_name".to_string(), "TEST_OBJ".to_string()),
552            ]))
553            .step(20.seconds())
554            .start_epoch(traj.first().orbit.epoch + 1.seconds())
555            .end_epoch(traj.last().orbit.epoch - 1.seconds())
556            .build();
557        traj.to_oem_file(
558            &path,
559            "TEST-OBJ-ID".to_string(),
560            Some("Test Suite".to_string()),
561            Some("TEST_OBJ".to_string()),
562            cfg,
563        )
564        .unwrap();
565        // And reload, make sure we have the same data.
566        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
567
568        // Note that the number of states has changed because we interpolated with a step similar to the original one but
569        // we started with a different time.
570        assert_eq!(traj_reloaded.states.len(), traj.states.len() - 1);
571        assert_eq!(
572            traj_reloaded.first().orbit.epoch,
573            traj.first().orbit.epoch + 1.seconds()
574        );
575        // Note: because we used a fixed step, the last epoch is actually an offset of step size - end offset
576        // from the original trajectory
577        assert_eq!(
578            traj_reloaded.last().orbit.epoch,
579            traj.last().orbit.epoch - 19.seconds()
580        );
581    }
582
583    #[test]
584    fn test_moon_frame_long_prop() {
585        use std::path::PathBuf;
586
587        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
588
589        let almanac = Almanac::new(
590            &manifest_dir
591                .clone()
592                .join("../data/01_planetary/pck08.pca")
593                .to_string_lossy(),
594        )
595        .unwrap()
596        .load(
597            &manifest_dir
598                .join("../data/01_planetary/de440s.bsp")
599                .to_string_lossy(),
600        )
601        .unwrap();
602
603        let epoch = Epoch::from_str("2022-06-13T12:00:00").unwrap();
604        let orbit = Orbit::try_keplerian_altitude(
605            350.0,
606            0.02,
607            30.0,
608            45.0,
609            85.0,
610            0.0,
611            epoch,
612            almanac.frame_info(MOON_J2000).unwrap(),
613        )
614        .unwrap();
615
616        let mut traj =
617            Propagator::default_dp78(SpacecraftDynamics::new(OrbitalDynamics::two_body()))
618                .with(orbit.into(), Arc::new(almanac))
619                .for_duration_with_traj(45.days())
620                .unwrap()
621                .1;
622        // Set the name of this object
623        traj.name = Some("TEST_MOON_OBJ".to_string());
624
625        // Export CCSDS OEM file
626        let path: PathBuf = [
627            env!("CARGO_MANIFEST_DIR"),
628            "../data",
629            "04_output",
630            "moon_45days.oem",
631        ]
632        .iter()
633        .collect();
634
635        traj.to_oem_file(
636            &path,
637            "TEST_MOON_OBJ".to_string(),
638            Some("Test Suite".to_string()),
639            Some("TEST_MOON_OBJ".to_string()),
640            ExportCfg::default(),
641        )
642        .unwrap();
643
644        // And reload
645        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
646
647        assert_eq!(traj, traj_reloaded);
648    }
649
650    #[test]
651    fn test_parquet_exports_thrust_angles() {
652        let _ = pretty_env_logger::try_init();
653
654        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
655
656        let almanac = Almanac::new(
657            &manifest_dir
658                .clone()
659                .join("../data/01_planetary/pck08.pca")
660                .to_string_lossy(),
661        )
662        .unwrap()
663        .load(
664            &manifest_dir
665                .join("../data/01_planetary/de440s.bsp")
666                .to_string_lossy(),
667        )
668        .unwrap();
669
670        let almanac = Arc::new(almanac);
671
672        let eme2k = almanac
673            .frame_info(anise::constants::frames::EARTH_J2000)
674            .unwrap();
675        let epoch = Epoch::from_gregorian_utc_at_noon(2021, 1, 1);
676        let orbit = Orbit::try_keplerian_altitude(900.0, 5e-5, 5e-3, 0.0, 178.0, 0.0, epoch, eme2k)
677            .unwrap();
678
679        let objectives = &[Objective::within_tolerance(
680            StateParameter::Element(OrbitalElement::AoP),
681            183.0,
682            5e-3,
683        )];
684
685        let guidance = Ruggiero::simple(objectives, orbit.into()).unwrap();
686        let spacecraft = Spacecraft::from_thruster(
687            orbit,
688            300.0,
689            67.0,
690            Thruster {
691                thrust_N: 89e-3,
692                isp_s: 1650.0,
693            },
694            GuidanceMode::Thrust,
695        );
696
697        let (_, traj) = Propagator::default(SpacecraftDynamics::from_guidance_law(
698            OrbitalDynamics::two_body(),
699            guidance,
700        ))
701        .with(spacecraft, almanac.clone())
702        .for_duration_with_traj(5.minutes())
703        .unwrap();
704
705        let path = manifest_dir
706            .join("../data/04_output")
707            .join("thrust_axes_export_test.parquet");
708
709        traj.to_parquet(&path, ExportCfg::default()).unwrap();
710
711        // Reload the trajectory and replay
712        let reloaded = Traj::<Spacecraft>::from_parquet(&path).unwrap();
713        assert_eq!(reloaded.first().mode(), GuidanceMode::Thrust);
714        assert!(
715            reloaded
716                .states
717                .iter()
718                .skip(1)
719                .any(|state| state.thrust_direction().is_some())
720        );
721
722        let reader = ParquetRecordBatchReaderBuilder::try_new(File::open(&path).unwrap())
723            .unwrap()
724            .build()
725            .unwrap();
726
727        let in_plane_name = StateParameter::ThrustInPlane(LocalFrame::RCN)
728            .to_field(None)
729            .name()
730            .to_string();
731        let out_of_plane_name = StateParameter::ThrustOutOfPlane(LocalFrame::RCN)
732            .to_field(None)
733            .name()
734            .to_string();
735
736        let batch = reader.into_iter().next().unwrap().unwrap();
737        let in_plane = batch
738            .column_by_name(&in_plane_name)
739            .unwrap()
740            .as_any()
741            .downcast_ref::<Float64Array>()
742            .unwrap();
743        let out_of_plane = batch
744            .column_by_name(&out_of_plane_name)
745            .unwrap()
746            .as_any()
747            .downcast_ref::<Float64Array>()
748            .unwrap();
749
750        assert!(in_plane.is_null(0));
751        assert!(out_of_plane.is_null(0));
752        assert!((1..batch.num_rows()).any(|idx| in_plane.is_valid(idx)));
753        assert!((1..batch.num_rows()).any(|idx| out_of_plane.is_valid(idx)));
754
755        let replay_guidance = reloaded.to_thrust_direction_replay();
756        let replay_initial_state = reloaded.first().to_owned();
757        let replay_duration = reloaded.last().epoch() - reloaded.first().epoch();
758        let replay_dynamics =
759            SpacecraftDynamics::from_guidance_law(OrbitalDynamics::two_body(), replay_guidance);
760        let (replayed_end, _) = Propagator::default(replay_dynamics)
761            .with(replay_initial_state, almanac)
762            .for_duration_with_traj(replay_duration)
763            .unwrap();
764
765        let truth_end = traj.last();
766        let pos_err_km = (replayed_end.orbit.radius_km - truth_end.orbit.radius_km).norm();
767        let vel_err_km_s =
768            (replayed_end.orbit.velocity_km_s - truth_end.orbit.velocity_km_s).norm();
769        let prop_err_kg = (replayed_end.mass.prop_mass_kg - truth_end.mass.prop_mass_kg).abs();
770
771        assert!(
772            dbg!(pos_err_km) < 1e-3,
773            "replay position error too large: {pos_err_km} km"
774        );
775        assert!(
776            dbg!(vel_err_km_s) < 1e-5,
777            "replay velocity error too large: {vel_err_km_s} km/s"
778        );
779        assert!(
780            dbg!(prop_err_kg) < 1e-8,
781            "replay prop mass error too large: {prop_err_kg} kg"
782        );
783    }
784}