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::Spacecraft;
22use crate::errors::{FromAlmanacSnafu, NyxError};
23use crate::io::{InputOutputError, MissingDataSnafu, ParquetSnafu, StdIOSnafu};
24use crate::md::prelude::{Interpolatable, StateParameter};
25use crate::time::{Duration, Epoch, TimeUnits};
26use crate::State;
27use anise::analysis::prelude::OrbitalElement;
28use anise::astro::Aberration;
29use anise::ephemerides::ephemeris::Ephemeris;
30use anise::ephemerides::EphemerisError;
31use anise::errors::AlmanacError;
32use anise::prelude::{Almanac, Frame};
33use arrow::array::RecordBatchReader;
34use arrow::array::{Float64Array, StringArray};
35use hifitime::TimeSeries;
36use log::info;
37use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
38use snafu::{ensure, ResultExt};
39use std::collections::HashMap;
40use std::error::Error;
41use std::fs::File;
42use std::path::{Path, PathBuf};
43use std::sync::Arc;
44#[cfg(not(target_arch = "wasm32"))]
45use std::time::Instant;
46
47impl Traj<Spacecraft> {
48    /// Builds a new trajectory built from the SPICE BSP (SPK) file loaded in the provided Almanac, provided the start and stop epochs.
49    ///
50    /// If the start and stop epochs are not provided, then the full domain of the trajectory will be used.
51    pub fn from_bsp(
52        target_frame: Frame,
53        observer_frame: Frame,
54        almanac: Arc<Almanac>,
55        sc_template: Spacecraft,
56        step: Duration,
57        start_epoch: Option<Epoch>,
58        end_epoch: Option<Epoch>,
59        ab_corr: Option<Aberration>,
60        name: Option<String>,
61    ) -> Result<Self, AlmanacError> {
62        let (domain_start, domain_end) =
63            almanac
64                .spk_domain(target_frame.ephemeris_id)
65                .map_err(|e| AlmanacError::Ephemeris {
66                    action: "could not fetch domain",
67                    source: Box::new(e),
68                })?;
69
70        let start_epoch = start_epoch.unwrap_or(domain_start);
71        let end_epoch = end_epoch.unwrap_or(domain_end);
72
73        let time_series = TimeSeries::inclusive(start_epoch, end_epoch, step);
74        let mut states = Vec::with_capacity(time_series.len());
75        for epoch in time_series {
76            let orbit = almanac.transform(target_frame, observer_frame, epoch, ab_corr)?;
77
78            states.push(sc_template.with_orbit(orbit));
79        }
80
81        Ok(Self { name, states })
82    }
83    /// Allows converting the source trajectory into the (almost) equivalent trajectory in another frame
84    #[allow(clippy::map_clone)]
85    pub fn to_frame(&self, new_frame: Frame, almanac: Arc<Almanac>) -> Result<Self, NyxError> {
86        if self.states.is_empty() {
87            return Err(NyxError::Trajectory {
88                source: TrajError::CreationError {
89                    msg: "No trajectory to convert".to_string(),
90                },
91            });
92        }
93
94        #[cfg(not(target_arch = "wasm32"))]
95        let start_instant = Instant::now();
96        let mut traj = Self::new();
97        for state in &self.states {
98            let new_orbit =
99                almanac
100                    .transform_to(state.orbit, new_frame, None)
101                    .context(FromAlmanacSnafu {
102                        action: "transforming trajectory into new frame",
103                    })?;
104            traj.states.push(state.with_orbit(new_orbit));
105        }
106        traj.finalize();
107
108        #[cfg(not(target_arch = "wasm32"))]
109        info!(
110            "Converted trajectory from {} to {} in {} ms: {traj}",
111            self.first().orbit.frame,
112            new_frame,
113            (Instant::now() - start_instant).as_millis()
114        );
115
116        #[cfg(target_arch = "wasm32")]
117        info!(
118            "Converted trajectory from {} to {}: {traj}",
119            self.first().orbit.frame,
120            new_frame,
121        );
122
123        Ok(traj)
124    }
125
126    /// 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.
127    /// Must provide a body fixed frame to correctly compute the latitude and longitude.
128    #[allow(clippy::identity_op)]
129    pub fn to_groundtrack_parquet<P: AsRef<Path>>(
130        &self,
131        path: P,
132        body_fixed_frame: Frame,
133        metadata: Option<HashMap<String, String>>,
134        almanac: Arc<Almanac>,
135    ) -> Result<PathBuf, Box<dyn Error>> {
136        let traj = self.to_frame(body_fixed_frame, almanac)?;
137
138        let mut cfg = ExportCfg::builder()
139            .step(1.minutes())
140            .fields(vec![
141                StateParameter::Element(OrbitalElement::Latitude),
142                StateParameter::Element(OrbitalElement::Longitude),
143                StateParameter::Element(OrbitalElement::Height),
144                StateParameter::Element(OrbitalElement::Rmag),
145            ])
146            .build();
147        cfg.metadata = metadata;
148
149        traj.to_parquet(path, cfg)
150    }
151
152    /// Export this spacecraft trajectory estimate to an ANISE Ephemeris
153    pub fn to_ephemeris(&self, object_id: String, cfg: ExportCfg) -> Ephemeris {
154        let mut ephem = Ephemeris::new(object_id);
155
156        // 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.
157        let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
158            // Must interpolate the data!
159            let start = cfg.start_epoch.unwrap_or_else(|| self.first().epoch());
160            let end = cfg.end_epoch.unwrap_or_else(|| self.last().epoch());
161            let step = cfg.step.unwrap_or_else(|| 1.minutes());
162            self.every_between(step, start, end).collect()
163        } else {
164            self.states.to_vec()
165        };
166
167        for sc_state in &states {
168            ephem.insert_orbit(sc_state.orbit());
169        }
170
171        ephem
172    }
173
174    /// Initialize a new spacecraft trajectory from the path to a CCSDS OEM file.
175    ///
176    /// CCSDS OEM only contains the orbit information but Nyx builds spacecraft trajectories.
177    /// If not spacecraft template is provided, then a default massless spacecraft will be built.
178    pub fn from_oem_file<P: AsRef<Path>>(
179        path: P,
180        tpl_option: Option<Spacecraft>,
181    ) -> Result<Self, EphemerisError> {
182        // Read the ephemeris
183        let ephem = Ephemeris::from_ccsds_oem_file(path)?;
184        // Rebuild a trajectory by applying the template
185        let template = tpl_option.unwrap_or_default();
186        let mut traj = Self::default();
187        for record in &ephem {
188            traj.states.push(template.with_orbit(record.orbit));
189        }
190        traj.name = Some(ephem.object_id);
191
192        Ok(traj)
193    }
194
195    pub fn to_oem_file<P: AsRef<Path>>(
196        &self,
197        path: P,
198        object_id: String,
199        originator: Option<String>,
200        object_name: Option<String>,
201        cfg: ExportCfg,
202    ) -> Result<(), EphemerisError> {
203        let ephem = self.to_ephemeris(object_id, cfg);
204        ephem.write_ccsds_oem(path, originator, object_name)
205    }
206
207    pub fn from_parquet<P: AsRef<Path>>(path: P) -> Result<Self, InputOutputError> {
208        let file = File::open(&path).context(StdIOSnafu {
209            action: "opening trajectory file",
210        })?;
211
212        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
213
214        let mut metadata = HashMap::new();
215        // Build the custom metadata
216        if let Some(file_metadata) = builder.metadata().file_metadata().key_value_metadata() {
217            for key_value in file_metadata {
218                if !key_value.key.starts_with("ARROW:") {
219                    metadata.insert(
220                        key_value.key.clone(),
221                        key_value.value.clone().unwrap_or("[unset]".to_string()),
222                    );
223                }
224            }
225        }
226
227        // Check the schema
228        let mut has_epoch = false; // Required
229        let mut frame = None;
230
231        let mut found_fields = vec![
232            (StateParameter::Element(OrbitalElement::X), false),
233            (StateParameter::Element(OrbitalElement::Y), false),
234            (StateParameter::Element(OrbitalElement::Z), false),
235            (StateParameter::Element(OrbitalElement::VX), false),
236            (StateParameter::Element(OrbitalElement::VY), false),
237            (StateParameter::Element(OrbitalElement::VZ), false),
238            (StateParameter::PropMass(), false),
239        ];
240
241        let reader = builder.build().context(ParquetSnafu {
242            action: "building output trajectory file",
243        })?;
244
245        for field in &reader.schema().fields {
246            if field.name().as_str() == "Epoch (UTC)" {
247                has_epoch = true;
248            } else {
249                for potential_field in &mut found_fields {
250                    if field.name() == potential_field.0.to_field(None).name() {
251                        potential_field.1 = true;
252                        if potential_field.0 != StateParameter::PropMass() {
253                            if let Some(frame_info) = field.metadata().get("Frame") {
254                                // Frame is expected to be serialized as Dhall.
255                                match serde_dhall::from_str(frame_info).parse::<Frame>() {
256                                    Err(e) => {
257                                        return Err(InputOutputError::ParseDhall {
258                                            data: frame_info.to_string(),
259                                            err: format!("{e}"),
260                                        })
261                                    }
262                                    Ok(deser_frame) => frame = Some(deser_frame),
263                                };
264                            }
265                        }
266                        break;
267                    }
268                }
269            }
270        }
271
272        ensure!(
273            has_epoch,
274            MissingDataSnafu {
275                which: "Epoch (UTC)"
276            }
277        );
278
279        ensure!(
280            frame.is_some(),
281            MissingDataSnafu {
282                which: "Frame in metadata"
283            }
284        );
285
286        for (field, exists) in found_fields.iter().take(found_fields.len() - 1) {
287            ensure!(
288                exists,
289                MissingDataSnafu {
290                    which: format!("Missing `{}` field", field.to_field(None).name())
291                }
292            );
293        }
294
295        let sc_compat = found_fields.last().unwrap().1;
296
297        let expected_type = std::any::type_name::<Spacecraft>()
298            .split("::")
299            .last()
300            .unwrap();
301
302        if expected_type == "Spacecraft" {
303            ensure!(
304                sc_compat,
305                MissingDataSnafu {
306                    which: format!(
307                        "Missing `{}` field",
308                        found_fields.last().unwrap().0.to_field(None).name()
309                    )
310                }
311            );
312        } else if sc_compat {
313            // Not a spacecraft, remove the prop mass
314            if let Some(last_field) = found_fields.last_mut() {
315                if last_field.0 == StateParameter::PropMass() && last_field.1 {
316                    last_field.1 = false;
317                }
318            }
319        }
320
321        // At this stage, we know that the measurement is valid and the conversion is supported.
322        let mut traj = Traj::default();
323
324        // Now convert each batch on the fly
325        for maybe_batch in reader {
326            let batch = maybe_batch.unwrap();
327
328            let epochs = batch
329                .column_by_name("Epoch (UTC)")
330                .unwrap()
331                .as_any()
332                .downcast_ref::<StringArray>()
333                .unwrap();
334
335            let mut shared_data = vec![];
336
337            for (field, _) in found_fields.iter().take(found_fields.len() - 1) {
338                shared_data.push(
339                    batch
340                        .column_by_name(field.to_field(None).name())
341                        .unwrap()
342                        .as_any()
343                        .downcast_ref::<Float64Array>()
344                        .unwrap(),
345                );
346            }
347
348            if expected_type == "Spacecraft" {
349                // Read the prop only if this is a spacecraft we're building
350                shared_data.push(
351                    batch
352                        .column_by_name("prop_mass (kg)")
353                        .unwrap()
354                        .as_any()
355                        .downcast_ref::<Float64Array>()
356                        .unwrap(),
357                );
358            }
359
360            // Grab the frame -- it should have been serialized with all of the data so we don't need to reload it.
361
362            // Build the states
363            for i in 0..batch.num_rows() {
364                let mut state = Spacecraft::zeros();
365                state.set_epoch(Epoch::from_gregorian_str(epochs.value(i)).map_err(|e| {
366                    InputOutputError::Inconsistency {
367                        msg: format!("{e} when parsing epoch"),
368                    }
369                })?);
370                state.set_frame(frame.unwrap()); // We checked it was set above with an ensure! call
371                state.unset_stm(); // We don't have any STM data, so let's unset this.
372
373                for (j, (param, exists)) in found_fields.iter().enumerate() {
374                    if *exists {
375                        state.set_value(*param, shared_data[j].value(i)).unwrap();
376                    }
377                }
378
379                traj.states.push(state);
380            }
381        }
382
383        // Remove any duplicates that may exist in the imported trajectory.
384        traj.finalize();
385
386        Ok(traj)
387    }
388}
389
390#[cfg(test)]
391mod ut_ccsds_oem {
392
393    use crate::md::prelude::{OrbitalDynamics, Propagator, SpacecraftDynamics};
394    use crate::time::{Epoch, TimeUnits};
395    use crate::Spacecraft;
396    use crate::{io::ExportCfg, md::prelude::Traj, Orbit};
397    use anise::almanac::Almanac;
398    use anise::constants::frames::MOON_J2000;
399    use pretty_env_logger;
400    use std::env;
401    use std::str::FromStr;
402    use std::sync::Arc;
403    use std::{collections::HashMap, path::PathBuf};
404
405    #[test]
406    fn test_load_oem_leo() {
407        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
408        let path: PathBuf = [
409            env!("CARGO_MANIFEST_DIR"),
410            "data",
411            "03_tests",
412            "ccsds",
413            "oem",
414            "LEO_10s.oem",
415        ]
416        .iter()
417        .collect();
418
419        let _ = pretty_env_logger::try_init();
420
421        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
422
423        // This trajectory has two duplicate epochs, which should be removed by the call to finalize()
424        assert_eq!(traj.states.len(), 361);
425        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
426    }
427
428    #[test]
429    fn test_load_oem_meo() {
430        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
431        let path: PathBuf = [
432            env!("CARGO_MANIFEST_DIR"),
433            "data",
434            "03_tests",
435            "ccsds",
436            "oem",
437            "MEO_60s.oem",
438        ]
439        .iter()
440        .collect();
441
442        let _ = pretty_env_logger::try_init();
443
444        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
445
446        assert_eq!(traj.states.len(), 61);
447        assert_eq!(traj.name.unwrap(), "0000-000A".to_string());
448    }
449
450    #[test]
451    fn test_load_oem_geo() {
452        use pretty_env_logger;
453        use std::env;
454
455        // All three samples were taken from https://github.com/bradsease/oem/blob/main/tests/samples/real/
456        let path: PathBuf = [
457            env!("CARGO_MANIFEST_DIR"),
458            "data",
459            "03_tests",
460            "ccsds",
461            "oem",
462            "GEO_20s.oem",
463        ]
464        .iter()
465        .collect();
466
467        let _ = pretty_env_logger::try_init();
468
469        let traj: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
470
471        assert_eq!(traj.states.len(), 181);
472        assert_eq!(traj.name.as_ref().unwrap(), &"0000-000A".to_string());
473
474        // Reexport this to CCSDS.
475        let cfg = ExportCfg::builder()
476            .timestamp(true)
477            .metadata(HashMap::from([
478                ("originator".to_string(), "Test suite".to_string()),
479                ("object_name".to_string(), "TEST_OBJ".to_string()),
480            ]))
481            .build();
482
483        let path: PathBuf = [
484            env!("CARGO_MANIFEST_DIR"),
485            "data",
486            "04_output",
487            "GEO_20s_rebuilt.oem",
488        ]
489        .iter()
490        .collect();
491
492        traj.to_oem_file(
493            &path,
494            "0000-000A".to_string(),
495            Some("Test Suite".to_string()),
496            Some("TEST_OBJ".to_string()),
497            cfg,
498        )
499        .unwrap();
500        // And reload, make sure we have the same data.
501        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(&path, None).unwrap();
502
503        assert_eq!(traj_reloaded, traj);
504
505        // Now export after trimming one state on either end
506        let cfg = ExportCfg::builder()
507            .timestamp(true)
508            .metadata(HashMap::from([
509                ("originator".to_string(), "Test suite".to_string()),
510                ("object_name".to_string(), "TEST_OBJ".to_string()),
511            ]))
512            .step(20.seconds())
513            .start_epoch(traj.first().orbit.epoch + 1.seconds())
514            .end_epoch(traj.last().orbit.epoch - 1.seconds())
515            .build();
516        traj.to_oem_file(
517            &path,
518            "TEST-OBJ-ID".to_string(),
519            Some("Test Suite".to_string()),
520            Some("TEST_OBJ".to_string()),
521            cfg,
522        )
523        .unwrap();
524        // And reload, make sure we have the same data.
525        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
526
527        // Note that the number of states has changed because we interpolated with a step similar to the original one but
528        // we started with a different time.
529        assert_eq!(traj_reloaded.states.len(), traj.states.len() - 1);
530        assert_eq!(
531            traj_reloaded.first().orbit.epoch,
532            traj.first().orbit.epoch + 1.seconds()
533        );
534        // Note: because we used a fixed step, the last epoch is actually an offset of step size - end offset
535        // from the original trajectory
536        assert_eq!(
537            traj_reloaded.last().orbit.epoch,
538            traj.last().orbit.epoch - 19.seconds()
539        );
540    }
541
542    #[test]
543    fn test_moon_frame_long_prop() {
544        use std::path::PathBuf;
545
546        let manifest_dir =
547            PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string()));
548
549        let almanac = Almanac::new(
550            &manifest_dir
551                .clone()
552                .join("data/01_planetary/pck08.pca")
553                .to_string_lossy(),
554        )
555        .unwrap()
556        .load(
557            &manifest_dir
558                .join("data/01_planetary/de440s.bsp")
559                .to_string_lossy(),
560        )
561        .unwrap();
562
563        let epoch = Epoch::from_str("2022-06-13T12:00:00").unwrap();
564        let orbit = Orbit::try_keplerian_altitude(
565            350.0,
566            0.02,
567            30.0,
568            45.0,
569            85.0,
570            0.0,
571            epoch,
572            almanac.frame_info(MOON_J2000).unwrap(),
573        )
574        .unwrap();
575
576        let mut traj =
577            Propagator::default_dp78(SpacecraftDynamics::new(OrbitalDynamics::two_body()))
578                .with(orbit.into(), Arc::new(almanac))
579                .for_duration_with_traj(45.days())
580                .unwrap()
581                .1;
582        // Set the name of this object
583        traj.name = Some("TEST_MOON_OBJ".to_string());
584
585        // Export CCSDS OEM file
586        let path: PathBuf = [
587            env!("CARGO_MANIFEST_DIR"),
588            "data",
589            "04_output",
590            "moon_45days.oem",
591        ]
592        .iter()
593        .collect();
594
595        traj.to_oem_file(
596            &path,
597            "TEST_MOON_OBJ".to_string(),
598            Some("Test Suite".to_string()),
599            Some("TEST_MOON_OBJ".to_string()),
600            ExportCfg::default(),
601        )
602        .unwrap();
603
604        // And reload
605        let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
606
607        assert_eq!(traj, traj_reloaded);
608    }
609}