1use std::collections::HashMap;
6use std::fmt;
7
8use lox_bodies::{DynOrigin, Origin};
9use lox_core::units::AngularRate;
10use lox_frames::rotations::TryRotation;
11use lox_frames::{DynFrame, ReferenceFrame};
12use lox_time::Time;
13use lox_time::intervals::TimeInterval;
14use lox_time::time_scales::{DynTimeScale, Tai};
15use rayon::prelude::*;
16
17#[cfg(feature = "comms")]
18use lox_comms::system::CommunicationSystem;
19
20use crate::visibility::ElevationMask;
21use lox_orbits::constellations::{ConstellationPropagator, DynConstellation};
22use lox_orbits::ground::DynGroundLocation;
23use lox_orbits::orbits::{Ensemble, KeplerianOrbit};
24use lox_orbits::propagators::numerical::DynJ2Propagator;
25use lox_orbits::propagators::semi_analytical::DynVallado;
26use lox_orbits::propagators::{OrbitSource, PropagateError};
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct AssetId(String);
31
32impl AssetId {
33 pub fn new(id: impl Into<String>) -> Self {
35 Self(id.into())
36 }
37
38 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44impl fmt::Display for AssetId {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "{}", self.0)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct ConstellationId(String);
53
54impl ConstellationId {
55 pub fn new(id: impl Into<String>) -> Self {
57 Self(id.into())
58 }
59
60 pub fn as_str(&self) -> &str {
62 &self.0
63 }
64}
65
66impl fmt::Display for ConstellationId {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 write!(f, "{}", self.0)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct NetworkId(String);
75
76impl NetworkId {
77 pub fn new(id: impl Into<String>) -> Self {
79 Self(id.into())
80 }
81
82 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86}
87
88impl fmt::Display for NetworkId {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 write!(f, "{}", self.0)
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct GroundStation {
97 id: AssetId,
98 location: DynGroundLocation,
99 mask: ElevationMask,
100 body_fixed_frame: DynFrame,
101 network: Option<NetworkId>,
102 #[cfg(feature = "comms")]
103 communication_systems: Vec<CommunicationSystem>,
104}
105
106impl GroundStation {
107 pub fn new(id: impl Into<String>, location: DynGroundLocation, mask: ElevationMask) -> Self {
109 let body_fixed_frame = DynFrame::Iau(location.origin());
110 Self {
111 id: AssetId::new(id),
112 location,
113 mask,
114 body_fixed_frame,
115 network: None,
116 #[cfg(feature = "comms")]
117 communication_systems: Vec::new(),
118 }
119 }
120
121 pub fn with_body_fixed_frame(mut self, frame: impl Into<DynFrame>) -> Self {
123 self.body_fixed_frame = frame.into();
124 self
125 }
126
127 pub fn with_network_id(mut self, id: impl Into<String>) -> Self {
129 self.network = Some(NetworkId(id.into()));
130 self
131 }
132
133 #[cfg(feature = "comms")]
135 pub fn with_communication_system(mut self, system: CommunicationSystem) -> Self {
136 self.communication_systems.push(system);
137 self
138 }
139
140 pub fn id(&self) -> &AssetId {
142 &self.id
143 }
144
145 pub fn location(&self) -> &DynGroundLocation {
147 &self.location
148 }
149
150 pub fn mask(&self) -> &ElevationMask {
152 &self.mask
153 }
154
155 pub fn body_fixed_frame(&self) -> DynFrame {
157 self.body_fixed_frame
158 }
159
160 #[cfg(feature = "comms")]
162 pub fn communication_systems(&self) -> &[CommunicationSystem] {
163 &self.communication_systems
164 }
165}
166
167#[derive(Debug, Clone)]
169pub struct Spacecraft {
170 id: AssetId,
171 orbit: OrbitSource,
172 max_slew_rate: Option<AngularRate>,
173 constellation: Option<ConstellationId>,
174 #[cfg(feature = "comms")]
175 communication_systems: Vec<CommunicationSystem>,
176}
177
178impl Spacecraft {
179 pub fn new(id: impl Into<String>, orbit: OrbitSource) -> Self {
181 Self {
182 id: AssetId::new(id),
183 orbit,
184 max_slew_rate: None,
185 constellation: None,
186 #[cfg(feature = "comms")]
187 communication_systems: Vec::new(),
188 }
189 }
190
191 pub fn with_max_slew_rate(mut self, rate: AngularRate) -> Self {
193 self.max_slew_rate = Some(rate);
194 self
195 }
196
197 pub fn with_constellation_id(mut self, id: impl Into<String>) -> Self {
199 self.constellation = Some(ConstellationId(id.into()));
200 self
201 }
202
203 #[cfg(feature = "comms")]
205 pub fn with_communication_system(mut self, system: CommunicationSystem) -> Self {
206 self.communication_systems.push(system);
207 self
208 }
209
210 pub fn id(&self) -> &AssetId {
212 &self.id
213 }
214
215 pub fn orbit(&self) -> &OrbitSource {
217 &self.orbit
218 }
219
220 pub fn max_slew_rate(&self) -> Option<AngularRate> {
222 self.max_slew_rate
223 }
224
225 #[cfg(feature = "comms")]
227 pub fn communication_systems(&self) -> &[CommunicationSystem] {
228 &self.communication_systems
229 }
230}
231
232#[derive(Debug, Clone)]
238pub struct Scenario<O: Origin, R: ReferenceFrame> {
239 interval: TimeInterval<Tai>,
240 origin: O,
241 frame: R,
242 ground_stations: Vec<GroundStation>,
243 spacecraft: Vec<Spacecraft>,
244 constellations: Vec<DynConstellation>,
245}
246
247pub type DynScenario = Scenario<DynOrigin, DynFrame>;
249
250#[derive(Debug, thiserror::Error)]
252pub enum ConstellationConvertError {
253 #[error("failed to create Keplerian orbit: {0}")]
255 KeplerianOrbit(String),
256 #[error("failed to convert to Cartesian orbit: {0}")]
258 CartesianConversion(String),
259 #[error("failed to create propagator: {0}")]
261 Propagator(String),
262}
263
264#[derive(Debug, thiserror::Error)]
266pub enum ScenarioPropagateError {
267 #[error("propagation failed for spacecraft \"{0}\": {1}")]
269 Propagate(AssetId, PropagateError),
270 #[error("frame transformation failed for spacecraft \"{0}\": {1}")]
272 FrameTransformation(AssetId, String),
273}
274
275impl<O: Origin + Copy + Send + Sync, R: ReferenceFrame + Copy + Send + Sync> Scenario<O, R> {
276 pub fn new(start_time: Time<Tai>, end_time: Time<Tai>, origin: O, frame: R) -> Self {
278 let interval = TimeInterval::new(start_time, end_time);
279 Self::with_interval(interval, origin, frame)
280 }
281
282 pub fn with_interval(interval: TimeInterval<Tai>, origin: O, frame: R) -> Self {
284 Self {
285 interval,
286 origin,
287 frame,
288 ground_stations: Vec::new(),
289 spacecraft: Vec::new(),
290 constellations: Vec::new(),
291 }
292 }
293
294 pub fn with_spacecraft(mut self, spacecraft: &[Spacecraft]) -> Self {
296 self.spacecraft = spacecraft.into();
297 self
298 }
299
300 pub fn with_ground_stations(mut self, ground_stations: &[GroundStation]) -> Self {
302 self.ground_stations = ground_stations.into();
303 self
304 }
305
306 pub fn interval(&self) -> &TimeInterval<Tai> {
308 &self.interval
309 }
310
311 pub fn origin(&self) -> O {
313 self.origin
314 }
315
316 pub fn frame(&self) -> R {
318 self.frame
319 }
320
321 pub fn with_constellation(
324 mut self,
325 constellation: DynConstellation,
326 ) -> Result<Self, ConstellationConvertError> {
327 let epoch = constellation.epoch();
328 let origin = constellation.origin();
329 let frame = constellation.frame();
330 let propagator_kind = constellation.propagator();
331 let name = constellation.name().to_string();
332
333 for sat in constellation.satellites() {
334 let keplerian_orbit =
335 KeplerianOrbit::try_from_keplerian(sat.elements, epoch, origin, frame)
336 .map_err(|e| ConstellationConvertError::KeplerianOrbit(e.to_string()))?;
337 let cartesian_orbit = keplerian_orbit
338 .try_to_cartesian()
339 .map_err(|e| ConstellationConvertError::CartesianConversion(e.to_string()))?;
340
341 let orbit_source = match propagator_kind {
342 ConstellationPropagator::Vallado => {
343 let v = DynVallado::try_new(cartesian_orbit)
344 .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
345 OrbitSource::Vallado(v)
346 }
347 ConstellationPropagator::J2 => {
348 let j2 = DynJ2Propagator::try_new(cartesian_orbit)
349 .map_err(|e| ConstellationConvertError::Propagator(e.to_string()))?;
350 OrbitSource::J2(j2)
351 }
352 };
353
354 let sc_id = format!("{} P{} S{}", name, sat.plane + 1, sat.index_in_plane + 1);
355 let sc = Spacecraft::new(sc_id, orbit_source).with_constellation_id(&name);
356 self.spacecraft.push(sc);
357 }
358
359 self.constellations.push(constellation);
360 Ok(self)
361 }
362
363 pub fn constellations(&self) -> &[DynConstellation] {
365 &self.constellations
366 }
367
368 pub fn ground_stations(&self) -> &[GroundStation] {
370 &self.ground_stations
371 }
372
373 pub fn spacecraft(&self) -> &[Spacecraft] {
375 &self.spacecraft
376 }
377
378 pub fn propagate<P>(
387 &self,
388 provider: &P,
389 ) -> Result<Ensemble<AssetId, Tai, O, R>, ScenarioPropagateError>
390 where
391 R: Into<DynFrame>,
392 P: TryRotation<DynFrame, R, DynTimeScale> + Send + Sync,
393 P::Error: std::fmt::Display,
394 {
395 let dyn_interval = TimeInterval::new(
396 self.interval.start().into_dyn(),
397 self.interval.end().into_dyn(),
398 );
399 let origin = self.origin;
400 let frame = self.frame;
401 let entries: Result<HashMap<_, _>, _> = self
402 .spacecraft
403 .par_iter()
404 .map(|sc| {
405 let traj = sc
406 .orbit
407 .propagate(dyn_interval)
408 .map_err(|e| ScenarioPropagateError::Propagate(sc.id.clone(), e))?;
409 let rotated = traj.into_frame(frame, provider).map_err(|e| {
412 ScenarioPropagateError::FrameTransformation(sc.id.clone(), e.to_string())
413 })?;
414 let (epoch, _origin, frame, data) = rotated.into_parts();
416 let typed = lox_orbits::orbits::Trajectory::from_parts(
417 epoch.with_scale(Tai),
418 origin,
419 frame,
420 data,
421 );
422 Ok((sc.id.clone(), typed))
423 })
424 .collect();
425 Ok(Ensemble::new(entries?))
426 }
427
428 pub fn filter_by_constellations(&self, constellations: &[ConstellationId]) -> Self {
430 let spacecraft = self
431 .spacecraft
432 .clone()
433 .into_iter()
434 .filter(|s| s.constellation.is_some())
435 .filter(|s| constellations.contains(s.constellation.as_ref().unwrap()))
436 .collect();
437 Scenario {
438 spacecraft,
439 ..self.clone()
440 }
441 }
442
443 pub fn filter_by_networks(&self, networks: &[NetworkId]) -> Self {
445 let ground_stations = self
446 .ground_stations
447 .clone()
448 .into_iter()
449 .filter(|s| s.network.is_some())
450 .filter(|s| networks.contains(s.network.as_ref().unwrap()))
451 .collect();
452 Scenario {
453 ground_stations,
454 ..self.clone()
455 }
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use lox_core::coords::LonLatAlt;
463 use lox_frames::DynFrame;
464 use lox_orbits::ground::GroundLocation;
465 use lox_time::deltas::TimeDelta;
466
467 fn dummy_location() -> DynGroundLocation {
468 let coords = LonLatAlt::from_degrees(-4.3676, 40.4527, 0.0).unwrap();
469 GroundLocation::try_new(coords, DynOrigin::Earth).unwrap()
470 }
471
472 fn dummy_mask() -> ElevationMask {
473 ElevationMask::with_fixed_elevation(0.0)
474 }
475
476 #[test]
479 fn test_asset_id() {
480 let id = AssetId::new("station-1");
481 assert_eq!(id.as_str(), "station-1");
482 assert_eq!(format!("{id}"), "station-1");
483 assert_eq!(id, AssetId::new("station-1"));
484 assert_ne!(id, AssetId::new("station-2"));
485 }
486
487 #[test]
488 fn test_constellation_id() {
489 let id = ConstellationId::new("oneweb");
490 assert_eq!(id.as_str(), "oneweb");
491 assert_eq!(format!("{id}"), "oneweb");
492 }
493
494 #[test]
495 fn test_network_id() {
496 let id = NetworkId::new("estrack");
497 assert_eq!(id.as_str(), "estrack");
498 assert_eq!(format!("{id}"), "estrack");
499 }
500
501 #[test]
504 fn test_ground_station_new() {
505 let loc = dummy_location();
506 let mask = dummy_mask();
507 let gs = GroundStation::new("gs1", loc, mask);
508 assert_eq!(gs.id().as_str(), "gs1");
509 assert_eq!(gs.body_fixed_frame(), DynFrame::Iau(DynOrigin::Earth));
510 }
511
512 #[test]
513 fn test_ground_station_with_body_fixed_frame() {
514 let gs = GroundStation::new("gs1", dummy_location(), dummy_mask())
515 .with_body_fixed_frame(DynFrame::Itrf);
516 assert_eq!(gs.body_fixed_frame(), DynFrame::Itrf);
517 }
518
519 #[test]
520 fn test_ground_station_with_network_id() {
521 let gs =
522 GroundStation::new("gs1", dummy_location(), dummy_mask()).with_network_id("estrack");
523 let start = Time::j2000(Tai);
525 let end = start + TimeDelta::from_seconds(86400);
526 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
527 .with_ground_stations(&[gs]);
528 let filtered = scenario.filter_by_networks(&[NetworkId::new("estrack")]);
529 assert_eq!(filtered.ground_stations().len(), 1);
530 }
531
532 #[test]
533 fn test_ground_station_location_getter() {
534 let loc = dummy_location();
535 let gs = GroundStation::new("gs1", loc.clone(), dummy_mask());
536 let _ = gs.location(); }
538
539 #[test]
540 fn test_ground_station_mask_getter() {
541 let mask = ElevationMask::with_fixed_elevation(0.1);
542 let gs = GroundStation::new("gs1", dummy_location(), mask.clone());
543 assert_eq!(gs.mask().min_elevation(0.0), 0.1);
544 }
545
546 #[test]
549 fn test_spacecraft_new() {
550 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
551 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
552 DynOrigin::Earth,
553 DynFrame::Icrf,
554 )
555 .unwrap();
556 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
557 assert_eq!(sc.id().as_str(), "sc1");
558 assert!(sc.max_slew_rate().is_none());
559 }
560
561 #[test]
562 fn test_spacecraft_with_max_slew_rate() {
563 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
564 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
565 DynOrigin::Earth,
566 DynFrame::Icrf,
567 )
568 .unwrap();
569 let rate = AngularRate::degrees_per_second(5.0);
570 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj)).with_max_slew_rate(rate);
571 assert!(sc.max_slew_rate().is_some());
572 }
573
574 #[test]
575 fn test_spacecraft_with_constellation_id() {
576 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
577 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
578 DynOrigin::Earth,
579 DynFrame::Icrf,
580 )
581 .unwrap();
582 let sc =
583 Spacecraft::new("sc1", OrbitSource::Trajectory(traj)).with_constellation_id("oneweb");
584 let _ = sc;
585 }
586
587 #[test]
588 fn test_spacecraft_orbit_getter() {
589 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
590 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
591 DynOrigin::Earth,
592 DynFrame::Icrf,
593 )
594 .unwrap();
595 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
596 assert!(matches!(sc.orbit(), OrbitSource::Trajectory(_)));
597 }
598
599 #[test]
602 fn test_scenario_construction() {
603 let start = Time::j2000(Tai);
604 let end = start + TimeDelta::from_seconds(86400);
605 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
606 assert_eq!(scenario.origin(), DynOrigin::Earth);
607 assert_eq!(scenario.frame(), DynFrame::Icrf);
608 assert!(scenario.spacecraft().is_empty());
609 assert!(scenario.ground_stations().is_empty());
610 }
611
612 #[test]
613 fn test_scenario_with_assets() {
614 let start = Time::j2000(Tai);
615 let end = start + TimeDelta::from_seconds(86400);
616 let gs = GroundStation::new("gs1", dummy_location(), dummy_mask());
617 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
618 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
619 DynOrigin::Earth,
620 DynFrame::Icrf,
621 )
622 .unwrap();
623 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
624 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
625 .with_ground_stations(&[gs])
626 .with_spacecraft(&[sc]);
627 assert_eq!(scenario.ground_stations().len(), 1);
628 assert_eq!(scenario.spacecraft().len(), 1);
629 }
630
631 #[test]
632 fn test_scenario_filter_by_constellations() {
633 let start = Time::j2000(Tai);
634 let end = start + TimeDelta::from_seconds(86400);
635 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
636 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
637 DynOrigin::Earth,
638 DynFrame::Icrf,
639 )
640 .unwrap();
641 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(traj.clone()))
642 .with_constellation_id("oneweb");
643 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(traj));
644 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
645 .with_spacecraft(&[sc1, sc2]);
646 let filtered = scenario.filter_by_constellations(&[ConstellationId::new("oneweb")]);
647 assert_eq!(filtered.spacecraft().len(), 1);
648 assert_eq!(filtered.spacecraft()[0].id().as_str(), "sc1");
649 }
650
651 #[test]
652 fn test_scenario_filter_by_networks() {
653 let start = Time::j2000(Tai);
654 let end = start + TimeDelta::from_seconds(86400);
655 let gs1 =
656 GroundStation::new("gs1", dummy_location(), dummy_mask()).with_network_id("estrack");
657 let gs2 = GroundStation::new("gs2", dummy_location(), dummy_mask());
658 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf)
659 .with_ground_stations(&[gs1, gs2]);
660 let filtered = scenario.filter_by_networks(&[NetworkId::new("estrack")]);
661 assert_eq!(filtered.ground_stations().len(), 1);
662 assert_eq!(filtered.ground_stations()[0].id().as_str(), "gs1");
663 }
664
665 #[test]
666 fn test_scenario_with_constellation() {
667 use lox_core::units::{AngleUnits, DistanceUnits};
668 use lox_orbits::constellations::WalkerDeltaBuilder;
669
670 let start = Time::j2000(Tai);
671 let end = start + TimeDelta::from_seconds(86400);
672 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
673
674 let constellation = WalkerDeltaBuilder::new(6, 3)
675 .with_semi_major_axis(7000.0_f64.km(), 0.0)
676 .with_inclination(53.0_f64.deg())
677 .build_constellation("test", start, DynOrigin::Earth, DynFrame::Icrf)
678 .unwrap()
679 .into_dyn();
680
681 let scenario = scenario.with_constellation(constellation).unwrap();
682 assert_eq!(scenario.spacecraft().len(), 6);
683 assert_eq!(scenario.constellations().len(), 1);
684 assert_eq!(scenario.constellations()[0].name(), "test");
685 assert!(scenario.spacecraft()[0].id().as_str().contains("test"));
687 }
688
689 #[test]
690 fn test_scenario_interval() {
691 let start = Time::j2000(Tai);
692 let end = start + TimeDelta::from_seconds(86400);
693 let scenario = DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf);
694 assert_eq!(scenario.interval().start(), start);
695 assert_eq!(scenario.interval().end(), end);
696 }
697
698 #[test]
699 fn test_scenario_propagate() {
700 let traj = lox_orbits::orbits::DynTrajectory::from_csv_dyn(
701 &lox_test_utils::read_data_file("trajectory_lunar.csv"),
702 DynOrigin::Earth,
703 DynFrame::Icrf,
704 )
705 .unwrap();
706 let start = traj.start_time().to_scale(Tai);
707 let end = traj.end_time().to_scale(Tai);
708 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(traj));
709 let scenario =
710 DynScenario::new(start, end, DynOrigin::Earth, DynFrame::Icrf).with_spacecraft(&[sc]);
711 let ensemble = scenario
712 .propagate(&lox_frames::providers::DefaultRotationProvider)
713 .unwrap();
714 assert_eq!(ensemble.len(), 1);
715 assert!(ensemble.get(&AssetId::new("sc1")).is_some());
716 }
717}