1use 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 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 #[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 #[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 pub fn to_ephemeris(&self, object_id: String, cfg: ExportCfg) -> Ephemeris {
159 let mut ephem = Ephemeris::new(object_id);
160
161 let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
163 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 pub fn from_oem_file<P: AsRef<Path>>(
184 path: P,
185 tpl_option: Option<Spacecraft>,
186 ) -> Result<Self, EphemerisError> {
187 let ephem = Ephemeris::from_ccsds_oem_file(path)?;
189 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 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 let mut has_epoch = false; 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 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 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 let mut traj = Traj::default();
342
343 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 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()); state.unset_stm(); 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 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 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 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 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 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 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 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(&path, None).unwrap();
547
548 assert_eq!(traj_reloaded, traj);
549
550 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 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
571
572 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 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 traj.name = Some("TEST_MOON_OBJ".to_string());
629
630 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 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 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}