1use 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 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 #[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 #[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 pub fn to_ephemeris(&self, object_id: String, cfg: ExportCfg) -> Ephemeris {
154 let mut ephem = Ephemeris::new(object_id);
155
156 let states = if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
158 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 pub fn from_oem_file<P: AsRef<Path>>(
179 path: P,
180 tpl_option: Option<Spacecraft>,
181 ) -> Result<Self, EphemerisError> {
182 let ephem = Ephemeris::from_ccsds_oem_file(path)?;
184 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 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 let mut has_epoch = false; 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 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 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 let mut traj = Traj::default();
323
324 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 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 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()); state.unset_stm(); 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 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 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 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 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 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 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 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(&path, None).unwrap();
502
503 assert_eq!(traj_reloaded, traj);
504
505 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 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
526
527 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 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 traj.name = Some("TEST_MOON_OBJ".to_string());
584
585 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 let traj_reloaded: Traj<Spacecraft> = Traj::from_oem_file(path, None).unwrap();
606
607 assert_eq!(traj, traj_reloaded);
608 }
609}