1use std::collections::HashMap;
6
7use lox_bodies::{DynOrigin, Origin, TryMeanRadius, TrySpheroid, UndefinedOriginPropertyError};
8use lox_core::glam::DVec3;
9use lox_ephem::Ephemeris;
10use lox_frames::providers::DefaultRotationProvider;
11use lox_frames::rotations::{DynRotationError, RotationError, TryRotation};
12use lox_frames::{DynFrame, ReferenceFrame};
13use lox_math::series::{InterpolationType, Series, SeriesError};
14use lox_time::Time;
15use lox_time::deltas::TimeDelta;
16use lox_time::intervals::TimeInterval;
17use lox_time::series::TimeSeries;
18use lox_time::time_scales::{DynTimeScale, Tai, Tdb, TimeScale};
19use rayon::prelude::*;
20use std::f64::consts::PI;
21use thiserror::Error;
22
23use lox_core::units::{AngularRate, Distance};
24
25use crate::assets::{AssetId, GroundStation, Scenario, Spacecraft};
26use lox_orbits::events::{
27 DetectError, DetectFn, EventsToIntervals, IntervalDetector, IntervalDetectorExt,
28 RootFindingDetector,
29};
30use lox_orbits::ground::{DynGroundLocation, Observables};
31use lox_orbits::orbits::{Ensemble, Trajectory};
32
33pub fn line_of_sight(radius: f64, r1: DVec3, r2: DVec3) -> f64 {
46 let r1n = r1.length();
47 let r2n = r2.length();
48 let theta1 = radius / r1n;
49 let theta2 = radius / r2n;
50 let theta = (r1.dot(r2) / r1n / r2n).clamp(-1.0, 1.0);
52 theta1.acos() + theta2.acos() - theta.acos()
53}
54
55pub fn line_of_sight_spheroid(
58 mean_radius: f64,
59 radius_eq: f64,
60 radius_p: f64,
61 r1: DVec3,
62 r2: DVec3,
63) -> f64 {
64 let eps = (1.0 - radius_p.powi(2) / radius_eq.powi(2)).sqrt();
65 let scale = (1.0 - eps.powi(2)).sqrt();
66 let r1 = DVec3::new(r1.x, r1.y, r1.z / scale);
67 let r2 = DVec3::new(r2.x, r2.y, r2.z / scale);
68 line_of_sight(mean_radius, r1, r2)
69}
70
71pub trait LineOfSight: TrySpheroid + TryMeanRadius {
74 fn line_of_sight(&self, r1: DVec3, r2: DVec3) -> Result<f64, UndefinedOriginPropertyError> {
76 let mean_radius = self.try_mean_radius()?.to_meters();
77 if let (Ok(r_eq), Ok(r_p)) = (self.try_equatorial_radius(), self.try_polar_radius()) {
78 return Ok(line_of_sight_spheroid(
79 mean_radius,
80 r_eq.to_meters(),
81 r_p.to_meters(),
82 r1,
83 r2,
84 ));
85 }
86 Ok(line_of_sight(mean_radius, r1, r2))
87 }
88}
89
90impl<T: TrySpheroid + TryMeanRadius> LineOfSight for T {}
91
92#[derive(Debug, Clone, Error, PartialEq)]
98pub enum ElevationMaskError {
99 #[error("invalid azimuth range: {}..{}", .0.to_degrees(), .1.to_degrees())]
101 InvalidAzimuthRange(f64, f64),
102 #[error("series error")]
104 SeriesError(#[from] SeriesError),
105}
106
107#[derive(Debug, Clone, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113pub enum ElevationMask {
114 Fixed(f64),
116 Variable(Series),
118}
119
120impl ElevationMask {
121 pub fn new(azimuth: Vec<f64>, elevation: Vec<f64>) -> Result<Self, ElevationMaskError> {
123 if !azimuth.is_empty() {
124 let az_min = *azimuth.iter().min_by(|a, b| a.total_cmp(b)).unwrap();
125 let az_max = *azimuth.iter().max_by(|a, b| a.total_cmp(b)).unwrap();
126 if az_min != -PI || az_max != PI {
127 return Err(ElevationMaskError::InvalidAzimuthRange(az_min, az_max));
128 }
129 }
130 Ok(Self::Variable(Series::try_new(
131 azimuth,
132 elevation,
133 InterpolationType::Linear,
134 )?))
135 }
136
137 pub fn with_fixed_elevation(elevation: f64) -> Self {
139 Self::Fixed(elevation)
140 }
141
142 pub fn min_elevation(&self, azimuth: f64) -> f64 {
144 match self {
145 ElevationMask::Fixed(min_elevation) => *min_elevation,
146 ElevationMask::Variable(series) => series.interpolate(azimuth),
147 }
148 }
149}
150
151#[derive(Debug, Error)]
157pub enum VisibilityError {
158 #[error(transparent)]
160 Detect(#[from] DetectError),
161 #[error(transparent)]
163 Series(#[from] SeriesError),
164}
165
166#[derive(Debug, Error)]
168pub enum PassError {
169 #[error(
170 "passes are not supported for inter-satellite pair ({0}, {1}): use intervals() instead"
171 )]
172 InterSatellitePair(String, String),
174}
175
176#[derive(Debug, Clone)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
186pub struct Pass<T: TimeScale> {
187 interval: TimeInterval<T>,
188 times: Vec<Time<T>>,
189 observables: Vec<Observables>,
190 azimuth_series: TimeSeries<T>,
191 elevation_series: TimeSeries<T>,
192 range_series: TimeSeries<T>,
193 range_rate_series: TimeSeries<T>,
194}
195
196pub type DynPass = Pass<DynTimeScale>;
198
199impl DynPass {
200 pub fn from_interval(
205 interval: TimeInterval<DynTimeScale>,
206 time_resolution: TimeDelta,
207 gs: &DynGroundLocation,
208 mask: &ElevationMask,
209 sc: &lox_orbits::orbits::DynTrajectory,
210 body_fixed_frame: DynFrame,
211 ) -> Option<DynPass> {
212 let mut pass_times = Vec::new();
213 let mut pass_observables = Vec::new();
214
215 for current_time in interval.step_by(time_resolution) {
216 let state = sc.interpolate_at(current_time);
217 let state_bf = state
218 .try_to_frame(body_fixed_frame, &DefaultRotationProvider)
219 .unwrap();
220 let obs = gs.observables_dyn(state_bf);
221
222 let min_elev = mask.min_elevation(obs.azimuth());
223 if obs.elevation() >= min_elev {
224 pass_times.push(current_time);
225 pass_observables.push(obs);
226 }
227 }
228
229 if pass_times.is_empty() {
230 return None;
231 }
232
233 Pass::try_new(interval, pass_times, pass_observables).ok()
234 }
235}
236
237impl<T: TimeScale> Pass<T> {
238 pub fn try_new(
243 interval: TimeInterval<T>,
244 times: Vec<Time<T>>,
245 observables: Vec<Observables>,
246 ) -> Result<Self, SeriesError>
247 where
248 T: Copy,
249 {
250 if times.len() < 2 {
251 return Err(SeriesError::InsufficientPoints(times.len()));
252 }
253
254 let epoch = interval.start();
255 let time_seconds: Vec<f64> = times
256 .iter()
257 .map(|t| (*t - epoch).to_seconds().to_f64())
258 .collect();
259 let azimuths: Vec<f64> = observables.iter().map(|o| o.azimuth()).collect();
260 let elevations: Vec<f64> = observables.iter().map(|o| o.elevation()).collect();
261 let ranges: Vec<f64> = observables.iter().map(|o| o.range()).collect();
262 let range_rates: Vec<f64> = observables.iter().map(|o| o.range_rate()).collect();
263
264 let azimuth_series = TimeSeries::try_new(
265 epoch,
266 time_seconds.clone(),
267 azimuths,
268 InterpolationType::Linear,
269 )?;
270 let elevation_series = TimeSeries::try_new(
271 epoch,
272 time_seconds.clone(),
273 elevations,
274 InterpolationType::Linear,
275 )?;
276 let range_series = TimeSeries::try_new(
277 epoch,
278 time_seconds.clone(),
279 ranges,
280 InterpolationType::Linear,
281 )?;
282 let range_rate_series =
283 TimeSeries::try_new(epoch, time_seconds, range_rates, InterpolationType::Linear)?;
284
285 Ok(Pass {
286 interval,
287 times,
288 observables,
289 azimuth_series,
290 elevation_series,
291 range_series,
292 range_rate_series,
293 })
294 }
295
296 pub fn interval(&self) -> &TimeInterval<T> {
298 &self.interval
299 }
300
301 pub fn times(&self) -> &[Time<T>] {
303 &self.times
304 }
305
306 pub fn observables(&self) -> &[Observables] {
308 &self.observables
309 }
310
311 pub fn interpolate(&self, time: Time<T>) -> Option<Observables>
313 where
314 T: Copy + Eq,
315 {
316 if time < self.interval.start() || time > self.interval.end() {
317 return None;
318 }
319
320 if self.times.is_empty() {
321 return None;
322 }
323
324 let azimuth = self.azimuth_series.interpolate(time);
325 let elevation = self.elevation_series.interpolate(time);
326 let range = self.range_series.interpolate(time);
327 let range_rate = self.range_rate_series.interpolate(time);
328
329 Some(Observables::new(azimuth, elevation, range, range_rate))
330 }
331}
332
333#[derive(Debug, Error)]
339pub enum EvalError {
340 #[error("rotation error: {0}")]
342 Rotation(Box<dyn std::error::Error + Send + Sync>),
343 #[error(transparent)]
345 UndefinedProperty(#[from] UndefinedOriginPropertyError),
346 #[error("ephemeris error: {0}")]
348 Ephemeris(Box<dyn std::error::Error + Send + Sync>),
349}
350
351impl From<DynRotationError> for EvalError {
352 fn from(e: DynRotationError) -> Self {
353 EvalError::Rotation(Box::new(e))
354 }
355}
356
357impl From<RotationError> for EvalError {
358 fn from(e: RotationError) -> Self {
359 EvalError::Rotation(Box::new(e))
360 }
361}
362
363struct ElevationDetectFn<'a, O: Origin, R: ReferenceFrame> {
375 gs: &'a DynGroundLocation,
376 mask: &'a ElevationMask,
377 sc: &'a Trajectory<Tai, O, R>,
378 body_fixed_frame: DynFrame,
379}
380
381impl<O, R> DetectFn<Tai> for ElevationDetectFn<'_, O, R>
382where
383 O: TrySpheroid + Copy,
384 R: ReferenceFrame + Copy,
385 DefaultRotationProvider: TryRotation<R, DynFrame, Tai>,
386 <DefaultRotationProvider as TryRotation<R, DynFrame, Tai>>::Error:
387 std::error::Error + Send + Sync + 'static,
388{
389 type Error = EvalError;
390
391 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
392 let sc = self.sc.interpolate_at(time);
393 let sc = sc
394 .try_to_frame(self.body_fixed_frame, &DefaultRotationProvider)
395 .map_err(|e| EvalError::Rotation(Box::new(e)))?;
396 let obs = self.gs.compute_observables(sc.position(), sc.velocity());
397 Ok(obs.elevation() - self.mask.min_elevation(obs.azimuth()))
398 }
399}
400
401struct LineOfSightDetectFn<'a, O: Origin, R: ReferenceFrame, E> {
404 gs: &'a DynGroundLocation,
405 sc: &'a Trajectory<Tai, O, R>,
406 body: DynOrigin,
407 ephemeris: &'a E,
408 body_fixed_frame: DynFrame,
409}
410
411impl<O, R, E: Ephemeris> DetectFn<Tai> for LineOfSightDetectFn<'_, O, R, E>
412where
413 O: TrySpheroid + Copy,
414 R: ReferenceFrame + Copy,
415 E::Error: 'static,
416 DefaultRotationProvider: TryRotation<DynFrame, R, Tai>,
417 <DefaultRotationProvider as TryRotation<DynFrame, R, Tai>>::Error:
418 std::error::Error + Send + Sync + 'static,
419{
420 type Error = EvalError;
421
422 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
423 let tdb = time.to_scale(Tdb);
425 let r_body = self
426 .ephemeris
427 .position(tdb, self.sc.origin(), self.body)
428 .map_err(|e| EvalError::Ephemeris(Box::new(e)))?;
429 let r_sc = self.sc.interpolate_at(time).position() - r_body;
430 let rot = DefaultRotationProvider
433 .try_rotation(self.body_fixed_frame, self.sc.reference_frame(), time)
434 .map_err(|e| EvalError::Rotation(Box::new(e)))?;
435 let (r_gs_frame, _) = rot.rotate_state(self.gs.body_fixed_position(), DVec3::ZERO);
436 let r_gs = r_gs_frame - r_body;
437 Ok(self.body.line_of_sight(r_gs, r_sc)?)
438 }
439}
440
441struct InterSatelliteLosDetectFn<'a, O: Origin, R: ReferenceFrame, E> {
443 sc1: &'a Trajectory<Tai, O, R>,
444 sc2: &'a Trajectory<Tai, O, R>,
445 body: DynOrigin,
446 ephemeris: &'a E,
447}
448
449impl<O, R, E: Ephemeris> DetectFn<Tai> for InterSatelliteLosDetectFn<'_, O, R, E>
450where
451 O: Origin + Copy,
452 R: ReferenceFrame + Copy,
453 E::Error: 'static,
454{
455 type Error = EvalError;
456
457 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
458 let tdb = time.to_scale(Tdb);
459 let r_body = self
460 .ephemeris
461 .position(tdb, self.sc1.origin(), self.body)
462 .map_err(|e| EvalError::Ephemeris(Box::new(e)))?;
463 let r_sc1 = self.sc1.interpolate_at(time).position() - r_body;
464 let r_sc2 = self.sc2.interpolate_at(time).position() - r_body;
465 Ok(self.body.line_of_sight(r_sc1, r_sc2)?)
466 }
467}
468
469enum RangeDirection {
471 Max,
473 Min,
475}
476
477struct InterSatelliteRangeDetectFn<'a, O: Origin, R: ReferenceFrame> {
479 sc1: &'a Trajectory<Tai, O, R>,
480 sc2: &'a Trajectory<Tai, O, R>,
481 threshold: Distance,
482 direction: RangeDirection,
483}
484
485impl<O, R> DetectFn<Tai> for InterSatelliteRangeDetectFn<'_, O, R>
486where
487 O: Origin + Copy,
488 R: ReferenceFrame + Copy,
489{
490 type Error = EvalError;
491
492 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
493 let r1 = self.sc1.interpolate_at(time).position();
494 let r2 = self.sc2.interpolate_at(time).position();
495 let range = (r1 - r2).length();
496 let threshold = self.threshold.to_meters();
497 Ok(match self.direction {
498 RangeDirection::Max => threshold - range,
499 RangeDirection::Min => range - threshold,
500 })
501 }
502}
503
504struct InterSatelliteSlewRateDetectFn<'a, O: Origin, R: ReferenceFrame> {
510 sc1: &'a Trajectory<Tai, O, R>,
511 sc2: &'a Trajectory<Tai, O, R>,
512 threshold: AngularRate,
513}
514
515impl<O, R> DetectFn<Tai> for InterSatelliteSlewRateDetectFn<'_, O, R>
516where
517 O: Origin + Copy,
518 R: ReferenceFrame + Copy,
519{
520 type Error = EvalError;
521
522 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
523 let s1 = self.sc1.interpolate_at(time);
524 let s2 = self.sc2.interpolate_at(time);
525 let r = s2.position() - s1.position();
526 let v = s2.velocity() - s1.velocity();
527 let r_len_sq = r.length_squared();
528 let omega = if r_len_sq > 0.0 {
529 r.cross(v).length() / r_len_sq
530 } else {
531 0.0
532 };
533 Ok(self.threshold.to_radians_per_second() - omega)
534 }
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
543pub enum PairType {
544 GroundSpace,
546 InterSatellite,
548}
549
550type IntervalMap = HashMap<(AssetId, AssetId), Vec<TimeInterval<Tai>>>;
551type PairTypeMap = HashMap<(AssetId, AssetId), PairType>;
552type GroundSpaceFilter<'a> = Box<dyn Fn(&GroundStation, &Spacecraft) -> bool + 'a>;
553type InterSatelliteFilter<'a> = Box<dyn Fn(&Spacecraft, &Spacecraft) -> bool + 'a>;
554
555struct ComputeParams<'a, O: Origin, R: ReferenceFrame, E> {
559 scenario: &'a Scenario<O, R>,
560 ensemble: &'a Ensemble<AssetId, Tai, O, R>,
561 ephemeris: &'a E,
562 occulting_bodies: &'a [DynOrigin],
563 step: TimeDelta,
564 min_pass_duration: Option<TimeDelta>,
565 min_range: Option<Distance>,
566 max_range: Option<Distance>,
567}
568
569impl<O, R, E> ComputeParams<'_, O, R, E>
570where
571 O: TrySpheroid + TryMeanRadius + Copy + Send + Sync + Into<DynOrigin>,
572 R: ReferenceFrame + Copy + Send + Sync + Into<DynFrame>,
573 E: Ephemeris + Send + Sync,
574 E::Error: 'static,
575 DefaultRotationProvider: TryRotation<R, DynFrame, Tai> + TryRotation<DynFrame, R, Tai>,
576 <DefaultRotationProvider as TryRotation<R, DynFrame, Tai>>::Error:
577 std::error::Error + Send + Sync + 'static,
578 <DefaultRotationProvider as TryRotation<DynFrame, R, Tai>>::Error:
579 std::error::Error + Send + Sync + 'static,
580{
581 fn apply_coarse_step<F>(&self, det: RootFindingDetector<F>) -> RootFindingDetector<F> {
583 match self.min_pass_duration {
584 Some(d) => {
585 let coarse = TimeDelta::from_seconds_f64(d.to_seconds().to_f64() / 2.0);
586 if coarse > self.step {
587 det.with_coarse_step(coarse)
588 } else {
589 det
590 }
591 }
592 None => det,
593 }
594 }
595
596 fn compute_ground_space_pair(
598 &self,
599 gs: &GroundStation,
600 sc_traj: &Trajectory<Tai, O, R>,
601 interval: TimeInterval<Tai>,
602 ) -> Result<Vec<TimeInterval<Tai>>, VisibilityError> {
603 let body_fixed_frame = gs.body_fixed_frame();
604
605 let make_elev = || {
606 let det = RootFindingDetector::new(
607 ElevationDetectFn {
608 gs: gs.location(),
609 mask: gs.mask(),
610 sc: sc_traj,
611 body_fixed_frame,
612 },
613 self.step,
614 );
615 EventsToIntervals::new(self.apply_coarse_step(det))
616 };
617
618 if self.occulting_bodies.is_empty() {
619 return Ok(make_elev().detect(interval)?);
620 }
621
622 let make_los = |body: DynOrigin| {
623 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
624 LineOfSightDetectFn {
625 gs: gs.location(),
626 sc: sc_traj,
627 body,
628 ephemeris: self.ephemeris,
629 body_fixed_frame,
630 },
631 self.step,
632 )))
633 };
634
635 let mut los: Box<dyn IntervalDetector<Tai> + '_> =
636 Box::new(make_los(self.occulting_bodies[0]));
637 for &body in &self.occulting_bodies[1..] {
638 los = Box::new(los.intersect(make_los(body)));
639 }
640
641 Ok(make_elev().chain(los).detect(interval)?)
642 }
643
644 fn compute_inter_satellite_pair(
650 &self,
651 sc1: &Spacecraft,
652 sc2: &Spacecraft,
653 traj1: &Trajectory<Tai, O, R>,
654 traj2: &Trajectory<Tai, O, R>,
655 interval: TimeInterval<Tai>,
656 ) -> Result<Vec<TimeInterval<Tai>>, VisibilityError> {
657 let effective_slew_rate = match (sc1.max_slew_rate(), sc2.max_slew_rate()) {
659 (Some(a), Some(b)) => Some(if a.to_radians_per_second() < b.to_radians_per_second() {
660 a
661 } else {
662 b
663 }),
664 (Some(a), None) => Some(a),
665 (None, Some(b)) => Some(b),
666 (None, None) => None,
667 };
668
669 let make_range = |threshold: Distance, direction: RangeDirection| {
670 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
671 InterSatelliteRangeDetectFn {
672 sc1: traj1,
673 sc2: traj2,
674 threshold,
675 direction,
676 },
677 self.step,
678 )))
679 };
680
681 let make_los = |body: DynOrigin| {
682 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
683 InterSatelliteLosDetectFn {
684 sc1: traj1,
685 sc2: traj2,
686 body,
687 ephemeris: self.ephemeris,
688 },
689 self.step,
690 )))
691 };
692
693 let mut detector: Option<Box<dyn IntervalDetector<Tai> + '_>> = None;
695
696 if let Some(max) = self.max_range {
697 detector = Some(Box::new(make_range(max, RangeDirection::Max)));
698 }
699 if let Some(min) = self.min_range {
700 let min_det = make_range(min, RangeDirection::Min);
701 detector = Some(match detector {
702 Some(d) => Box::new(d.intersect(min_det)),
703 None => Box::new(min_det),
704 });
705 }
706
707 if let Some(threshold) = effective_slew_rate {
709 let slew = EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
710 InterSatelliteSlewRateDetectFn {
711 sc1: traj1,
712 sc2: traj2,
713 threshold,
714 },
715 self.step,
716 )));
717 detector = Some(match detector {
718 Some(d) => Box::new(d.chain(slew)),
719 None => Box::new(slew),
720 });
721 }
722
723 let central_body: DynOrigin = self.scenario.origin().into();
726 let los = make_los(central_body);
727 detector = Some(match detector {
728 Some(d) => Box::new(d.chain(los)),
729 None => Box::new(los),
730 });
731 for &body in self.occulting_bodies {
732 let los = make_los(body);
733 detector = Some(match detector {
734 Some(d) => Box::new(d.chain(los)),
735 None => Box::new(los),
736 });
737 }
738
739 Ok(detector.unwrap().detect(interval)?)
740 }
741}
742
743pub struct VisibilityResults {
749 intervals: IntervalMap,
750 pair_types: PairTypeMap,
751}
752
753impl VisibilityResults {
754 pub fn intervals_for(&self, id1: &AssetId, id2: &AssetId) -> Option<&[TimeInterval<Tai>]> {
756 let key = (id1.clone(), id2.clone());
757 self.intervals.get(&key).map(|v| v.as_slice())
758 }
759
760 pub fn all_intervals(&self) -> &IntervalMap {
762 &self.intervals
763 }
764
765 pub fn pair_ids(&self) -> impl Iterator<Item = &(AssetId, AssetId)> {
767 self.intervals.keys()
768 }
769
770 pub fn pair_type(&self, id1: &AssetId, id2: &AssetId) -> Option<PairType> {
772 self.pair_types.get(&(id1.clone(), id2.clone())).copied()
773 }
774
775 pub fn ground_space_pair_ids(&self) -> Vec<&(AssetId, AssetId)> {
777 self.pair_types
778 .iter()
779 .filter(|&(_, &pt)| pt == PairType::GroundSpace)
780 .map(|(k, _)| k)
781 .collect()
782 }
783
784 pub fn inter_satellite_pair_ids(&self) -> Vec<&(AssetId, AssetId)> {
786 self.pair_types
787 .iter()
788 .filter(|&(_, &pt)| pt == PairType::InterSatellite)
789 .map(|(k, _)| k)
790 .collect()
791 }
792
793 pub fn is_empty(&self) -> bool {
795 self.intervals.is_empty()
796 }
797
798 pub fn num_pairs(&self) -> usize {
800 self.intervals.len()
801 }
802
803 pub fn total_intervals(&self) -> usize {
805 self.intervals.values().map(|v| v.len()).sum()
806 }
807
808 pub fn into_parts(self) -> (IntervalMap, PairTypeMap) {
810 (self.intervals, self.pair_types)
811 }
812
813 #[allow(clippy::too_many_arguments)]
819 pub fn to_passes(
820 &self,
821 ground_id: &AssetId,
822 space_id: &AssetId,
823 gs: &DynGroundLocation,
824 mask: &ElevationMask,
825 sc: &lox_orbits::orbits::DynTrajectory,
826 time_resolution: TimeDelta,
827 body_fixed_frame: DynFrame,
828 ) -> Result<Vec<DynPass>, PassError> {
829 let key = (ground_id.clone(), space_id.clone());
830 if self.pair_types.get(&key) == Some(&PairType::InterSatellite) {
831 return Err(PassError::InterSatellitePair(
832 ground_id.as_str().to_string(),
833 space_id.as_str().to_string(),
834 ));
835 }
836 Ok(self
837 .intervals
838 .get(&key)
839 .map(|intervals| {
840 intervals
841 .iter()
842 .filter_map(|interval| {
843 let dyn_interval = TimeInterval::new(
844 interval.start().into_dyn(),
845 interval.end().into_dyn(),
846 );
847 DynPass::from_interval(
848 dyn_interval,
849 time_resolution,
850 gs,
851 mask,
852 sc,
853 body_fixed_frame,
854 )
855 })
856 .collect()
857 })
858 .unwrap_or_default())
859 }
860}
861
862pub struct VisibilityAnalysis<'a, O: Origin, R: ReferenceFrame, E> {
875 scenario: &'a Scenario<O, R>,
876 ensemble: &'a Ensemble<AssetId, Tai, O, R>,
877 ephemeris: &'a E,
878 occulting_bodies: Vec<DynOrigin>,
879 step: TimeDelta,
880 min_pass_duration: Option<TimeDelta>,
881 inter_satellite: bool,
882 ground_space_filter: Option<GroundSpaceFilter<'a>>,
883 inter_satellite_filter: Option<InterSatelliteFilter<'a>>,
884 min_range: Option<Distance>,
885 max_range: Option<Distance>,
886}
887
888impl<'a, O, R, E> VisibilityAnalysis<'a, O, R, E>
889where
890 O: TrySpheroid + TryMeanRadius + Copy + Send + Sync + Into<DynOrigin>,
891 R: ReferenceFrame + Copy + Send + Sync + Into<DynFrame>,
892 E: Ephemeris + Send + Sync,
893 E::Error: 'static,
894 DefaultRotationProvider: TryRotation<R, DynFrame, Tai> + TryRotation<DynFrame, R, Tai>,
895 <DefaultRotationProvider as TryRotation<R, DynFrame, Tai>>::Error:
896 std::error::Error + Send + Sync + 'static,
897 <DefaultRotationProvider as TryRotation<DynFrame, R, Tai>>::Error:
898 std::error::Error + Send + Sync + 'static,
899{
900 pub fn new(
902 scenario: &'a Scenario<O, R>,
903 ensemble: &'a Ensemble<AssetId, Tai, O, R>,
904 ephemeris: &'a E,
905 ) -> Self {
906 Self {
907 scenario,
908 ensemble,
909 ephemeris,
910 occulting_bodies: Vec::new(),
911 step: TimeDelta::from_seconds(60),
912 min_pass_duration: None,
913 inter_satellite: false,
914 ground_space_filter: None,
915 inter_satellite_filter: None,
916 min_range: None,
917 max_range: None,
918 }
919 }
920
921 pub fn with_inter_satellite(mut self) -> Self {
923 self.inter_satellite = true;
924 self
925 }
926
927 pub fn with_ground_space_filter(
933 mut self,
934 filter: impl Fn(&GroundStation, &Spacecraft) -> bool + 'a,
935 ) -> Self {
936 self.ground_space_filter = Some(Box::new(filter));
937 self
938 }
939
940 pub fn with_inter_satellite_filter(
946 mut self,
947 filter: impl Fn(&Spacecraft, &Spacecraft) -> bool + 'a,
948 ) -> Self {
949 self.inter_satellite = true;
950 self.inter_satellite_filter = Some(Box::new(filter));
951 self
952 }
953
954 pub fn with_occulting_bodies(mut self, bodies: Vec<DynOrigin>) -> Self {
960 self.occulting_bodies = bodies;
961 self
962 }
963
964 pub fn with_step(mut self, step: TimeDelta) -> Self {
966 self.step = step;
967 self
968 }
969
970 pub fn with_min_pass_duration(mut self, min_pass_duration: TimeDelta) -> Self {
972 self.min_pass_duration = Some(min_pass_duration);
973 self
974 }
975
976 pub fn with_min_range(mut self, min_range: Distance) -> Self {
978 self.min_range = Some(min_range);
979 self
980 }
981
982 pub fn with_max_range(mut self, max_range: Distance) -> Self {
984 self.max_range = Some(max_range);
985 self
986 }
987
988 pub fn step(&self) -> TimeDelta {
990 self.step
991 }
992
993 pub fn compute(&self) -> Result<VisibilityResults, VisibilityError> {
995 let interval = *self.scenario.interval();
996
997 let mut intervals = HashMap::new();
998 let mut pair_types = HashMap::new();
999
1000 if !self.scenario.ground_stations().is_empty() {
1001 let gs_results = self.compute_ground_space(interval)?;
1002 let (gs_intervals, gs_pair_types) = gs_results.into_parts();
1003 intervals.extend(gs_intervals);
1004 pair_types.extend(gs_pair_types);
1005 }
1006
1007 if self.inter_satellite {
1008 let is_results = self.compute_inter_satellite(interval)?;
1009 let (is_intervals, is_pair_types) = is_results.into_parts();
1010 intervals.extend(is_intervals);
1011 pair_types.extend(is_pair_types);
1012 }
1013
1014 Ok(VisibilityResults {
1015 intervals,
1016 pair_types,
1017 })
1018 }
1019
1020 fn compute_ground_space(
1022 &self,
1023 interval: TimeInterval<Tai>,
1024 ) -> Result<VisibilityResults, VisibilityError> {
1025 let ground_stations = self.scenario.ground_stations();
1026 let spacecraft = self.scenario.spacecraft();
1027
1028 let pairs: Vec<_> = ground_stations
1030 .iter()
1031 .flat_map(|gs| spacecraft.iter().map(move |sc| (gs, sc)))
1032 .filter(|(gs, sc)| self.ground_space_filter.as_ref().is_none_or(|f| f(gs, sc)))
1033 .collect();
1034
1035 let params = ComputeParams {
1038 scenario: self.scenario,
1039 ensemble: self.ensemble,
1040 ephemeris: self.ephemeris,
1041 occulting_bodies: &self.occulting_bodies,
1042 step: self.step,
1043 min_pass_duration: self.min_pass_duration,
1044 min_range: self.min_range,
1045 max_range: self.max_range,
1046 };
1047
1048 const PARALLEL_THRESHOLD: usize = 100;
1049 let use_parallel = pairs.len() > PARALLEL_THRESHOLD;
1050
1051 let compute_one = |(gs, sc): &(&GroundStation, &Spacecraft)| {
1052 let key = (gs.id().clone(), sc.id().clone());
1053 let sc_traj = params.ensemble.get(sc.id()).expect(
1054 "trajectory not found in ensemble; did you forget to propagate this spacecraft?",
1055 );
1056 let windows = params.compute_ground_space_pair(gs, sc_traj, interval)?;
1057 Ok((key, windows))
1058 };
1059
1060 let results: Result<Vec<_>, VisibilityError> = if use_parallel {
1061 pairs.par_iter().map(compute_one).collect()
1062 } else {
1063 pairs.iter().map(compute_one).collect()
1064 };
1065
1066 let intervals: HashMap<_, _> = results?.into_iter().collect();
1067 let pair_types = intervals
1068 .keys()
1069 .map(|k| (k.clone(), PairType::GroundSpace))
1070 .collect();
1071 Ok(VisibilityResults {
1072 intervals,
1073 pair_types,
1074 })
1075 }
1076
1077 fn compute_inter_satellite(
1079 &self,
1080 interval: TimeInterval<Tai>,
1081 ) -> Result<VisibilityResults, VisibilityError> {
1082 let spacecraft = self.scenario.spacecraft();
1083 let n = spacecraft.len();
1084
1085 let mut pairs: Vec<(usize, usize)> = Vec::with_capacity(n * (n - 1) / 2);
1087 for i in 0..n {
1088 for j in (i + 1)..n {
1089 let accepted = self
1090 .inter_satellite_filter
1091 .as_ref()
1092 .is_none_or(|f| f(&spacecraft[i], &spacecraft[j]));
1093 if accepted {
1094 pairs.push((i, j));
1095 }
1096 }
1097 }
1098
1099 let params = ComputeParams {
1101 scenario: self.scenario,
1102 ensemble: self.ensemble,
1103 ephemeris: self.ephemeris,
1104 occulting_bodies: &self.occulting_bodies,
1105 step: self.step,
1106 min_pass_duration: self.min_pass_duration,
1107 min_range: self.min_range,
1108 max_range: self.max_range,
1109 };
1110
1111 let results: Result<Vec<_>, VisibilityError> = pairs
1112 .par_iter()
1113 .map(|&(i, j)| {
1114 let sc1 = &spacecraft[i];
1115 let sc2 = &spacecraft[j];
1116 let key = (sc1.id().clone(), sc2.id().clone());
1117 let traj1 = params
1118 .ensemble
1119 .get(sc1.id())
1120 .expect("trajectory not found in ensemble");
1121 let traj2 = params
1122 .ensemble
1123 .get(sc2.id())
1124 .expect("trajectory not found in ensemble");
1125 let windows =
1126 params.compute_inter_satellite_pair(sc1, sc2, traj1, traj2, interval)?;
1127 Ok((key, windows))
1128 })
1129 .collect();
1130
1131 let intervals: HashMap<_, _> = results?.into_iter().collect();
1132 let pair_types = intervals
1133 .keys()
1134 .map(|k| (k.clone(), PairType::InterSatellite))
1135 .collect();
1136 Ok(VisibilityResults {
1137 intervals,
1138 pair_types,
1139 })
1140 }
1141
1142 pub fn to_passes(
1147 &self,
1148 results: &VisibilityResults,
1149 ) -> HashMap<(AssetId, AssetId), Vec<DynPass>> {
1150 let gs_map: HashMap<&AssetId, &GroundStation> = self
1151 .scenario
1152 .ground_stations()
1153 .iter()
1154 .map(|g| (g.id(), g))
1155 .collect();
1156
1157 results
1158 .ground_space_pair_ids()
1159 .into_iter()
1160 .filter_map(|(gs_id, sc_id)| {
1161 let gs = gs_map.get(gs_id)?;
1162 let sc_traj = self.ensemble.get(sc_id)?;
1163 let intervals = results.intervals_for(gs_id, sc_id)?;
1164 let passes: Vec<DynPass> = intervals
1165 .iter()
1166 .filter_map(|interval| {
1167 let dyn_interval = TimeInterval::new(
1169 interval.start().into_dyn(),
1170 interval.end().into_dyn(),
1171 );
1172 let dyn_traj = sc_traj.clone().into_dyn();
1174 DynPass::from_interval(
1175 dyn_interval,
1176 self.step,
1177 gs.location(),
1178 gs.mask(),
1179 &dyn_traj,
1180 gs.body_fixed_frame(),
1181 )
1182 })
1183 .collect();
1184 Some(((gs_id.clone(), sc_id.clone()), passes))
1185 })
1186 .collect()
1187 }
1188}
1189
1190#[cfg(test)]
1195mod tests {
1196 use lox_bodies::{Earth, Spheroid};
1197 use lox_core::coords::LonLatAlt;
1198 use lox_core::units::Distance;
1199 use lox_ephem::spk::parser::Spk;
1200 use lox_orbits::propagators::OrbitSource;
1201 use lox_test_utils::{assert_approx_eq, data_file, read_data_file};
1202 use lox_time::time_scales::{DynTimeScale, Tai};
1203 use lox_time::utc::Utc;
1204 use std::iter::zip;
1205 use std::sync::OnceLock;
1206
1207 use super::*;
1208 use lox_frames::Icrf;
1209 use lox_orbits::ground::GroundLocation;
1210 use lox_orbits::orbits::{DynTrajectory, Trajectory};
1211
1212 fn make_scenario_and_ensemble(
1214 ground_assets: &[GroundStation],
1215 space_assets: &[Spacecraft],
1216 interval: TimeInterval<DynTimeScale>,
1217 ) -> (
1218 Scenario<DynOrigin, DynFrame>,
1219 Ensemble<AssetId, Tai, DynOrigin, DynFrame>,
1220 ) {
1221 let tai_interval =
1222 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1223 let scenario = Scenario::with_interval(tai_interval, DynOrigin::Earth, DynFrame::Icrf)
1224 .with_ground_stations(ground_assets)
1225 .with_spacecraft(space_assets);
1226 let mut map = HashMap::new();
1228 for sc in space_assets {
1229 if let OrbitSource::Trajectory(traj) = sc.orbit() {
1230 let (epoch, origin, frame, data) = traj.clone().into_parts();
1232 let typed = Trajectory::from_parts(epoch.with_scale(Tai), origin, frame, data);
1233 map.insert(sc.id().clone(), typed);
1234 }
1235 }
1236 let ensemble = Ensemble::new(map);
1237 (scenario, ensemble)
1238 }
1239
1240 #[test]
1241 fn test_line_of_sight() {
1242 let r1 = DVec3::new(0.0, -4464.696, -5102.509);
1243 let r2 = DVec3::new(0.0, 5740.323, 3189.068);
1244 let r_sun = DVec3::new(122233179.0, -76150708.0, 33016374.0);
1245 let r = Earth.equatorial_radius().to_kilometers();
1246
1247 let los = line_of_sight(r, r1, r2);
1248 let los_sun = line_of_sight(r, r1, r_sun);
1249
1250 assert!(los < 0.0);
1251 assert!(los_sun >= 0.0);
1252 }
1253
1254 #[test]
1255 fn test_line_of_sight_identical() {
1256 let r1 = DVec3::new(0.0, -4464.696, -5102.509);
1257 let r2 = DVec3::new(0.0, -4464.696, -5102.509);
1258 let r = Earth.equatorial_radius().to_kilometers();
1259
1260 let los = line_of_sight(r, r1, r2);
1261
1262 assert!(los >= 0.0);
1263 }
1264
1265 #[test]
1266 fn test_line_of_sight_trait() {
1267 let r1 = DVec3::new(0.0, -4464696.0, -5102509.0);
1268 let r2 = DVec3::new(0.0, 5740323.0, 3189068.0);
1269 let r_sun = DVec3::new(122233179e3, -76150708e3, 33016374e3);
1270
1271 let los = Earth.line_of_sight(r1, r2).unwrap();
1272 let los_sun = Earth.line_of_sight(r1, r_sun).unwrap();
1273
1274 assert!(los < 0.0);
1275 assert!(los_sun >= 0.0);
1276 }
1277
1278 #[test]
1279 fn test_elevation() {
1280 let sc = spacecraft_trajectory_dyn();
1281 let gs_traj = ground_station_trajectory();
1282 let gs = location_dyn();
1283 let mask = ElevationMask::with_fixed_elevation(0.0);
1284 let expected: Vec<f64> = read_data_file("elevation.csv")
1285 .lines()
1286 .map(|line| line.parse::<f64>().unwrap().to_radians())
1287 .collect();
1288 let (epoch, o, f, data) = sc.clone().into_parts();
1290 let typed_sc = Trajectory::from_parts(epoch.with_scale(Tai), o, f, data);
1291 let elev_fn = ElevationDetectFn {
1292 gs: &gs,
1293 mask: &mask,
1294 sc: &typed_sc,
1295 body_fixed_frame: DynFrame::Iau(DynOrigin::Earth),
1296 };
1297 let actual: Vec<f64> = gs_traj
1299 .times()
1300 .iter()
1301 .map(|t| {
1302 let tai_time = t.to_scale(Tai);
1303 elev_fn.eval(tai_time).unwrap()
1304 })
1305 .collect();
1306 assert_approx_eq!(actual, expected, atol <= 1e-1);
1307 }
1308
1309 #[test]
1310 fn test_elevation_mask() {
1311 let azimuth = vec![-PI, 0.0, PI];
1312 let elevation = vec![-2.0, 0.0, 2.0];
1313 let mask = ElevationMask::new(azimuth, elevation).unwrap();
1314 assert_eq!(mask.min_elevation(0.0), 0.0);
1315 }
1316
1317 #[test]
1318 fn test_elevation_mask_invalid_mask() {
1319 let azimuth = vec![-PI, 0.0, PI / 2.0];
1320 let elevation = vec![-2.0, 0.0, 2.0];
1321 let mask = ElevationMask::new(azimuth, elevation);
1322 assert_eq!(
1323 mask,
1324 Err(ElevationMaskError::InvalidAzimuthRange(-PI, PI / 2.0))
1325 )
1326 }
1327
1328 #[test]
1329 fn test_visibility() {
1330 let gs_loc = location_dyn();
1331 let mask = ElevationMask::with_fixed_elevation(0.0);
1332 let sc_traj = spacecraft_trajectory_dyn();
1333 let gs = GroundStation::new("cebreros", gs_loc, mask);
1334 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1335 let spk = ephemeris();
1336 let ground_assets = [gs.clone()];
1337 let space_assets = [sc.clone()];
1338 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1339 let (scenario, ensemble) =
1340 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1341 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk);
1342 let results = analysis.compute().expect("visibility");
1343 let intervals = results
1344 .intervals_for(gs.id(), sc.id())
1345 .expect("pair not found");
1346 let expected = contacts_tai();
1347 assert_eq!(intervals.len(), expected.len());
1348 assert_approx_eq!(expected, intervals.to_vec(), rtol <= 1e-4);
1349 }
1350
1351 #[test]
1352 fn test_visibility_combined() {
1353 let gs_loc = location_dyn();
1354 let mask = ElevationMask::with_fixed_elevation(0.0);
1355 let sc_traj = spacecraft_trajectory_dyn();
1356 let gs = GroundStation::new("cebreros", gs_loc, mask);
1357 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1358 let spk = ephemeris();
1359 let ground_assets = [gs.clone()];
1360 let space_assets = [sc.clone()];
1361 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1362 let (scenario, ensemble) =
1363 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1364 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1365 .with_occulting_bodies(vec![DynOrigin::Moon]);
1366 let results = analysis.compute().unwrap();
1367 let passes = analysis.to_passes(&results);
1368 let key = (gs.id().clone(), sc.id().clone());
1369 let pair_passes = &passes[&key];
1370 let expected = contacts_combined();
1371 assert_eq!(pair_passes.len(), expected.len());
1372 for (actual, expected) in zip(pair_passes, expected) {
1373 assert_approx_eq!(actual.interval().start(), expected.start(), rtol <= 1e-4);
1374 assert_approx_eq!(actual.interval().end(), expected.end(), rtol <= 1e-4);
1375 }
1376 }
1377
1378 #[test]
1379 fn test_pass_observables_above_mask() {
1380 let gs_loc = location_dyn();
1381 let mask = ElevationMask::with_fixed_elevation(10.0_f64.to_radians());
1382 let sc_traj = spacecraft_trajectory_dyn();
1383 let gs = GroundStation::new("cebreros", gs_loc, mask);
1384 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1385 let spk = ephemeris();
1386 let ground_assets = [gs.clone()];
1387 let space_assets = [sc.clone()];
1388 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1389 let (scenario, ensemble) =
1390 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1391 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk);
1392 let results = analysis.compute().unwrap();
1393 let passes = analysis.to_passes(&results);
1394 let key = (gs.id().clone(), sc.id().clone());
1395 let pair_passes = &passes[&key];
1396 let mask = gs.mask();
1397
1398 for pass in pair_passes {
1399 for obs in pass.observables() {
1400 let min_elevation = mask.min_elevation(obs.azimuth());
1401 assert!(
1402 obs.elevation() >= min_elevation,
1403 "Observable elevation {:.2}° is below mask minimum {:.2}° at azimuth {:.2}°",
1404 obs.elevation().to_degrees(),
1405 min_elevation.to_degrees(),
1406 obs.azimuth().to_degrees()
1407 );
1408 }
1409 }
1410 }
1411
1412 fn ground_station_trajectory() -> Trajectory<Tai, Earth, Icrf> {
1413 Trajectory::from_csv(&read_data_file("trajectory_cebr.csv"), Earth, Icrf).unwrap()
1414 }
1415
1416 fn spacecraft_trajectory_dyn() -> DynTrajectory {
1417 DynTrajectory::from_csv_dyn(
1418 &read_data_file("trajectory_lunar.csv"),
1419 DynOrigin::Earth,
1420 DynFrame::Icrf,
1421 )
1422 .unwrap()
1423 }
1424
1425 fn location_dyn() -> GroundLocation<DynOrigin> {
1426 let coords = LonLatAlt::from_degrees(-4.3676, 40.4527, 0.0).unwrap();
1427 GroundLocation::try_new(coords, DynOrigin::Earth).unwrap()
1428 }
1429
1430 fn contacts_tai() -> Vec<TimeInterval<Tai>> {
1431 let mut intervals = vec![];
1432 let mut reader = csv::ReaderBuilder::new()
1433 .trim(csv::Trim::All)
1434 .from_path(data_file("contacts.csv"))
1435 .unwrap();
1436 for result in reader.records() {
1437 let record = result.unwrap();
1438 let start = record[0].parse::<Utc>().unwrap().to_time();
1439 let end = record[1].parse::<Utc>().unwrap().to_time();
1440 intervals.push(TimeInterval::new(start, end));
1441 }
1442 intervals
1443 }
1444
1445 fn contacts_combined() -> Vec<TimeInterval<DynTimeScale>> {
1446 let mut intervals = vec![];
1447 let mut reader = csv::ReaderBuilder::new()
1448 .trim(csv::Trim::All)
1449 .from_path(data_file("contacts_combined.csv"))
1450 .unwrap();
1451 for result in reader.records() {
1452 let record = result.unwrap();
1453 let start = record[0].parse::<Utc>().unwrap().to_dyn_time();
1454 let end = record[1].parse::<Utc>().unwrap().to_dyn_time();
1455 intervals.push(TimeInterval::new(start, end));
1456 }
1457 intervals
1458 }
1459
1460 #[test]
1461 fn test_inter_satellite_visibility() {
1462 let sc_traj = spacecraft_trajectory_dyn();
1463 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1464 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1465 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1466 let spk = ephemeris();
1467 let space_assets = [sc1.clone(), sc2.clone()];
1468 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1469 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1470 let results = analysis.compute().unwrap();
1471 let intervals = results
1472 .intervals_for(sc1.id(), sc2.id())
1473 .expect("pair not found");
1474 assert_eq!(intervals.len(), 1);
1476 let tai_interval =
1477 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1478 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1479 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1480 }
1481
1482 #[test]
1483 fn test_inter_satellite_visibility_with_range_filter() {
1484 let sc_traj = spacecraft_trajectory_dyn();
1485 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1486 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1487 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1488 let spk = ephemeris();
1489 let space_assets = [sc1.clone(), sc2.clone()];
1490
1491 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1494 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1495 .with_inter_satellite()
1496 .with_max_range(Distance::kilometers(1000.0));
1497 let results = analysis.compute().unwrap();
1498 let intervals = results
1499 .intervals_for(sc1.id(), sc2.id())
1500 .expect("pair not found");
1501 let tai_interval =
1502 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1503 assert_eq!(intervals.len(), 1);
1504 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1505 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1506
1507 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1510 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1511 .with_inter_satellite()
1512 .with_min_range(Distance::kilometers(100.0));
1513 let results = analysis.compute().unwrap();
1514 let intervals = results
1515 .intervals_for(sc1.id(), sc2.id())
1516 .expect("pair not found");
1517 assert!(
1518 intervals.is_empty(),
1519 "expected no intervals for colocated spacecraft with min_range, got {}",
1520 intervals.len()
1521 );
1522 }
1523
1524 #[test]
1525 fn test_slew_rate_detect_fn() {
1526 let sc_traj = spacecraft_trajectory_dyn();
1528 let (epoch, origin, frame, data) = sc_traj.into_parts();
1529 let typed = Trajectory::from_parts(epoch.with_scale(Tai), origin, frame, data);
1530 let threshold = AngularRate::degrees_per_second(1.0);
1531 let detect = InterSatelliteSlewRateDetectFn {
1532 sc1: &typed,
1533 sc2: &typed,
1534 threshold,
1535 };
1536 let time = typed.start_time();
1537 let val = detect.eval(time).unwrap();
1538 assert_approx_eq!(val, threshold.to_radians_per_second(), rtol <= 1e-10);
1540 }
1541
1542 #[test]
1543 fn test_inter_satellite_visibility_with_slew_rate() {
1544 let sc_traj = spacecraft_trajectory_dyn();
1545 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1546
1547 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()))
1550 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1551 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj))
1552 .with_max_slew_rate(AngularRate::degrees_per_second(5.0));
1553 let spk = ephemeris();
1554 let space_assets = [sc1.clone(), sc2.clone()];
1555 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1556 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1557 let results = analysis.compute().unwrap();
1558 let intervals = results
1559 .intervals_for(sc1.id(), sc2.id())
1560 .expect("pair not found");
1561 let tai_interval =
1562 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1563 assert_eq!(intervals.len(), 1);
1565 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1566 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1567 }
1568
1569 #[test]
1570 fn test_space_asset_max_slew_rate() {
1571 let sc_traj = spacecraft_trajectory_dyn();
1572 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj));
1573 assert!(sc.max_slew_rate().is_none());
1574
1575 let rate = AngularRate::degrees_per_second(2.5);
1576 let sc = sc.with_max_slew_rate(rate);
1577 assert_approx_eq!(
1578 sc.max_slew_rate().unwrap().to_degrees_per_second(),
1579 2.5,
1580 rtol <= 1e-10
1581 );
1582 }
1583
1584 fn oneweb_trajectories() -> (DynTrajectory, DynTrajectory) {
1589 use lox_orbits::propagators::Propagator;
1590 use lox_orbits::propagators::sgp4::{Elements, Sgp4};
1591 use lox_time::intervals::Interval;
1592
1593 let tle1 = Elements::from_tle(
1594 Some("ONEWEB-0012".to_string()),
1595 b"1 44057U 19010A 24322.58825131 .00000088 00000+0 19693-3 0 9993",
1596 b"2 44057 87.9092 343.6767 0002420 76.7970 283.3431 13.16592150275693",
1597 )
1598 .unwrap();
1599 let tle2 = Elements::from_tle(
1600 Some("ONEWEB-0017".to_string()),
1601 b"1 45132U 20008B 24322.88240834 -.00000016 00000+0 -81930-4 0 9998",
1602 b"2 45132 87.8896 151.0343 0001369 78.1189 282.0092 13.10376984232476",
1603 )
1604 .unwrap();
1605
1606 let sgp4_1 = Sgp4::new(tle1).unwrap();
1607 let sgp4_2 = Sgp4::new(tle2).unwrap();
1608
1609 let t0 = sgp4_1.time().max(sgp4_2.time());
1611 let t1 = t0 + TimeDelta::from_hours(2);
1612 let interval = Interval::new(t0, t1);
1613
1614 let traj1 = sgp4_1
1615 .with_step(TimeDelta::from_seconds(10))
1616 .propagate(interval)
1617 .unwrap()
1618 .into_dyn();
1619 let traj2 = sgp4_2
1620 .with_step(TimeDelta::from_seconds(10))
1621 .propagate(interval)
1622 .unwrap()
1623 .into_dyn();
1624
1625 (traj1, traj2)
1626 }
1627
1628 #[test]
1629 fn test_slew_rate_trims_windows_for_crossing_orbits() {
1630 let (traj1, traj2) = oneweb_trajectories();
1631 let interval = TimeInterval::new(traj1.start_time(), traj1.end_time());
1632
1633 let spk = ephemeris();
1634
1635 let sc1_no_limit = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1.clone()));
1638 let sc2_no_limit = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2.clone()));
1639 let space_assets = [sc1_no_limit.clone(), sc2_no_limit.clone()];
1640 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1641 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1642 let results_no_limit = analysis.compute().unwrap();
1643 let intervals_no_limit = results_no_limit
1644 .intervals_for(sc1_no_limit.id(), sc2_no_limit.id())
1645 .expect("pair not found");
1646
1647 let sc1_limited = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1))
1650 .with_max_slew_rate(AngularRate::degrees_per_second(0.01));
1651 let sc2_limited = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2))
1652 .with_max_slew_rate(AngularRate::degrees_per_second(0.01));
1653 let space_assets = [sc1_limited.clone(), sc2_limited.clone()];
1654 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1655 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1656 let results_limited = analysis.compute().unwrap();
1657 let intervals_limited = results_limited
1658 .intervals_for(sc1_limited.id(), sc2_limited.id())
1659 .expect("pair not found");
1660
1661 let total_no_limit: f64 = intervals_no_limit
1663 .iter()
1664 .map(|i| (i.end() - i.start()).to_seconds().to_f64())
1665 .sum();
1666 let total_limited: f64 = intervals_limited
1667 .iter()
1668 .map(|i| (i.end() - i.start()).to_seconds().to_f64())
1669 .sum();
1670 assert!(
1671 total_limited < total_no_limit,
1672 "slew rate constraint should reduce total visibility (got {total_limited:.0}s vs {total_no_limit:.0}s)"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_inter_satellite_asymmetric_slew_rate_sc1_only() {
1678 let sc_traj = spacecraft_trajectory_dyn();
1679 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1680
1681 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()))
1683 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1684 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1685 let spk = ephemeris();
1686 let space_assets = [sc1.clone(), sc2.clone()];
1687 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1688 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1689 let results = analysis.compute().unwrap();
1690 let intervals = results
1691 .intervals_for(sc1.id(), sc2.id())
1692 .expect("pair not found");
1693 assert_eq!(intervals.len(), 1);
1695 }
1696
1697 #[test]
1698 fn test_inter_satellite_asymmetric_slew_rate_sc2_only() {
1699 let sc_traj = spacecraft_trajectory_dyn();
1700 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1701
1702 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1704 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj))
1705 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1706 let spk = ephemeris();
1707 let space_assets = [sc1.clone(), sc2.clone()];
1708 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1709 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1710 let results = analysis.compute().unwrap();
1711 let intervals = results
1712 .intervals_for(sc1.id(), sc2.id())
1713 .expect("pair not found");
1714 assert_eq!(intervals.len(), 1);
1715 }
1716
1717 #[test]
1718 fn test_inter_satellite_both_min_and_max_range() {
1719 let (traj1, traj2) = oneweb_trajectories();
1720 let interval = TimeInterval::new(traj1.start_time(), traj1.end_time());
1721 let sc1 = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1));
1722 let sc2 = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2));
1723 let spk = ephemeris();
1724 let space_assets = [sc1.clone(), sc2.clone()];
1725 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1726 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1728 .with_inter_satellite()
1729 .with_min_range(Distance::kilometers(100.0))
1730 .with_max_range(Distance::kilometers(5000.0));
1731 let results = analysis.compute().unwrap();
1732 let intervals = results
1733 .intervals_for(sc1.id(), sc2.id())
1734 .expect("pair not found");
1735 assert!(!intervals.is_empty());
1737 }
1738
1739 #[test]
1740 fn test_ground_space_filter() {
1741 let gs_loc = location_dyn();
1742 let mask = ElevationMask::with_fixed_elevation(0.0);
1743 let gs1 = GroundStation::new("cebreros", gs_loc.clone(), mask.clone());
1744 let gs2 = GroundStation::new("malargue", gs_loc, mask);
1745 let sc_traj = spacecraft_trajectory_dyn();
1746 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1747 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1748 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1749 let spk = ephemeris();
1750 let ground_assets = [gs1.clone(), gs2.clone()];
1751 let space_assets = [sc1.clone(), sc2.clone()];
1752 let (scenario, ensemble) =
1753 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1754
1755 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1757 .with_ground_space_filter(|gs, _sc| gs.id().as_str() == "cebreros");
1758 let results = analysis.compute().unwrap();
1759
1760 assert_eq!(results.num_pairs(), 2); assert!(results.intervals_for(gs1.id(), sc1.id()).is_some());
1762 assert!(results.intervals_for(gs1.id(), sc2.id()).is_some());
1763 assert!(results.intervals_for(gs2.id(), sc1.id()).is_none());
1764 assert!(results.intervals_for(gs2.id(), sc2.id()).is_none());
1765 }
1766
1767 #[test]
1768 fn test_inter_satellite_filter() {
1769 let sc_traj = spacecraft_trajectory_dyn();
1770 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1771 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1772 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj.clone()));
1773 let sc3 = Spacecraft::new("sc3", OrbitSource::Trajectory(sc_traj));
1774 let spk = ephemeris();
1775 let space_assets = [sc1.clone(), sc2.clone(), sc3.clone()];
1776 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1777
1778 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1779 .with_inter_satellite_filter(|sc_a, sc_b| {
1780 let ids = [sc_a.id().as_str(), sc_b.id().as_str()];
1781 ids.contains(&"sc1") && ids.contains(&"sc3")
1782 });
1783 let results = analysis.compute().unwrap();
1784
1785 assert_eq!(results.num_pairs(), 1);
1786 assert!(results.intervals_for(sc1.id(), sc3.id()).is_some());
1787 assert!(results.intervals_for(sc1.id(), sc2.id()).is_none());
1788 assert!(results.intervals_for(sc2.id(), sc3.id()).is_none());
1789 }
1790
1791 #[test]
1792 fn test_both_filters_combined_with_ground_space() {
1793 let gs_loc = location_dyn();
1794 let mask = ElevationMask::with_fixed_elevation(0.0);
1795 let gs1 = GroundStation::new("cebreros", gs_loc.clone(), mask.clone());
1796 let gs2 = GroundStation::new("malargue", gs_loc, mask);
1797 let sc_traj = spacecraft_trajectory_dyn();
1798 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1799 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1800 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj.clone()));
1801 let sc3 = Spacecraft::new("sc3", OrbitSource::Trajectory(sc_traj));
1802 let spk = ephemeris();
1803 let ground_assets = [gs1.clone(), gs2.clone()];
1804 let space_assets = [sc1.clone(), sc2.clone(), sc3.clone()];
1805 let (scenario, ensemble) =
1806 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1807
1808 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1809 .with_ground_space_filter(|gs, _sc| gs.id().as_str() == "cebreros")
1810 .with_inter_satellite_filter(|sc_a, sc_b| {
1811 let ids = [sc_a.id().as_str(), sc_b.id().as_str()];
1812 ids.contains(&"sc1") && ids.contains(&"sc2")
1813 });
1814 let results = analysis.compute().unwrap();
1815
1816 assert_eq!(results.num_pairs(), 4);
1818 assert!(results.intervals_for(gs1.id(), sc1.id()).is_some());
1819 assert!(results.intervals_for(gs1.id(), sc2.id()).is_some());
1820 assert!(results.intervals_for(gs1.id(), sc3.id()).is_some());
1821 assert!(results.intervals_for(gs2.id(), sc1.id()).is_none());
1822 assert!(results.intervals_for(sc1.id(), sc2.id()).is_some());
1823 assert!(results.intervals_for(sc1.id(), sc3.id()).is_none());
1824 }
1825
1826 #[test]
1827 fn test_min_pass_duration_filters_short_passes() {
1828 let gs_loc = location_dyn();
1829 let mask = ElevationMask::with_fixed_elevation(0.0);
1830 let gs = GroundStation::new("cebreros", gs_loc, mask);
1831 let sc_traj = spacecraft_trajectory_dyn();
1832 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1833 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj));
1834 let spk = ephemeris();
1835 let ground_assets = [gs];
1836 let space_assets = [sc];
1837 let (scenario, ensemble) =
1838 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1839
1840 let results_all = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1842 .compute()
1843 .unwrap();
1844 let all_count = results_all
1845 .intervals_for(ground_assets[0].id(), space_assets[0].id())
1846 .map_or(0, |v| v.len());
1847
1848 let results_filtered = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1850 .with_min_pass_duration(TimeDelta::from_hours(2))
1851 .compute()
1852 .unwrap();
1853 let filtered_count = results_filtered
1854 .intervals_for(ground_assets[0].id(), space_assets[0].id())
1855 .map_or(0, |v| v.len());
1856 assert!(filtered_count <= all_count);
1857
1858 let results_small = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1860 .with_min_pass_duration(TimeDelta::from_seconds(1))
1861 .compute()
1862 .unwrap();
1863 let small_count = results_small
1864 .intervals_for(ground_assets[0].id(), space_assets[0].id())
1865 .map_or(0, |v| v.len());
1866 assert_eq!(small_count, all_count);
1867 }
1868
1869 #[test]
1870 fn test_to_passes_rejects_inter_satellite_pair() {
1871 let sc_traj = spacecraft_trajectory_dyn();
1872 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1873 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1874 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1875 let spk = ephemeris();
1876 let space_assets = [sc1.clone(), sc2.clone()];
1877 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1878
1879 let results = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1880 .with_inter_satellite()
1881 .compute()
1882 .unwrap();
1883
1884 let gs_loc = location_dyn();
1885 let mask = ElevationMask::with_fixed_elevation(0.0);
1886 let dummy_traj = DynTrajectory::from_csv_dyn(
1887 &read_data_file("trajectory_lunar.csv"),
1888 DynOrigin::Earth,
1889 DynFrame::Icrf,
1890 )
1891 .unwrap();
1892
1893 let err = results
1894 .to_passes(
1895 sc1.id(),
1896 sc2.id(),
1897 &gs_loc,
1898 &mask,
1899 &dummy_traj,
1900 TimeDelta::from_seconds(60),
1901 DynFrame::Iau(DynOrigin::Earth),
1902 )
1903 .unwrap_err();
1904 assert!(matches!(err, PassError::InterSatellitePair(_, _)));
1905 }
1906
1907 #[test]
1908 fn test_to_passes_unknown_pair_returns_empty() {
1909 let gs_loc = location_dyn();
1910 let mask = ElevationMask::with_fixed_elevation(0.0);
1911 let gs = GroundStation::new("cebreros", gs_loc.clone(), mask.clone());
1912 let sc_traj = spacecraft_trajectory_dyn();
1913 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1914 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj));
1915 let spk = ephemeris();
1916 let (scenario, ensemble) = make_scenario_and_ensemble(&[gs], &[sc], interval);
1917
1918 let results = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1919 .compute()
1920 .unwrap();
1921
1922 let dummy_traj = DynTrajectory::from_csv_dyn(
1923 &read_data_file("trajectory_lunar.csv"),
1924 DynOrigin::Earth,
1925 DynFrame::Icrf,
1926 )
1927 .unwrap();
1928
1929 let unknown_id = AssetId::new("nonexistent");
1930 let passes = results
1931 .to_passes(
1932 &unknown_id,
1933 &unknown_id,
1934 &gs_loc,
1935 &mask,
1936 &dummy_traj,
1937 TimeDelta::from_seconds(60),
1938 DynFrame::Iau(DynOrigin::Earth),
1939 )
1940 .unwrap();
1941 assert!(passes.is_empty());
1942 }
1943
1944 #[test]
1945 fn test_combined_ground_and_inter_satellite() {
1946 let gs_loc = location_dyn();
1947 let mask = ElevationMask::with_fixed_elevation(0.0);
1948 let gs = GroundStation::new("cebreros", gs_loc, mask);
1949 let sc_traj = spacecraft_trajectory_dyn();
1950 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1951 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1952 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1953 let spk = ephemeris();
1954 let ground_assets = [gs.clone()];
1955 let space_assets = [sc1.clone(), sc2.clone()];
1956 let (scenario, ensemble) =
1957 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1958
1959 let results = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1960 .with_inter_satellite()
1961 .compute()
1962 .unwrap();
1963
1964 assert_eq!(results.num_pairs(), 3);
1966 assert!(results.intervals_for(gs.id(), sc1.id()).is_some());
1967 assert!(results.intervals_for(gs.id(), sc2.id()).is_some());
1968 assert!(results.intervals_for(sc1.id(), sc2.id()).is_some());
1969
1970 assert_eq!(
1972 results.pair_type(gs.id(), sc1.id()),
1973 Some(PairType::GroundSpace)
1974 );
1975 assert_eq!(
1976 results.pair_type(sc1.id(), sc2.id()),
1977 Some(PairType::InterSatellite)
1978 );
1979 }
1980
1981 #[test]
1986 fn test_inter_satellite_with_occulting_body() {
1987 use lox_orbits::propagators::Propagator;
1988 use lox_orbits::propagators::sgp4::{Elements, Sgp4};
1989 use lox_time::intervals::Interval;
1990
1991 let iss_tle = Elements::from_tle(
1993 Some("ISS".to_string()),
1994 b"1 25544U 98067A 22032.58348611 .00006730 00000+0 12674-3 0 9993",
1995 b"2 25544 51.6444 273.4162 0006808 335.0825 135.5682 15.49587047324581",
1996 )
1997 .unwrap();
1998 let sgp4 = Sgp4::new(iss_tle).unwrap();
1999
2000 let lunar_traj = spacecraft_trajectory_dyn();
2001
2002 let t0 = lunar_traj.start_time().max(sgp4.time().into_dyn());
2004 let t1 = t0 + TimeDelta::from_hours(24);
2005 let tai_interval = Interval::new(t0.to_scale(Tai), t1.to_scale(Tai));
2006 let iss_traj = sgp4
2007 .with_step(TimeDelta::from_seconds(30))
2008 .propagate(tai_interval)
2009 .unwrap()
2010 .into_dyn();
2011
2012 let inter_interval = TimeInterval::new(t0, t1);
2013 let sc_iss = Spacecraft::new("iss", OrbitSource::Trajectory(iss_traj));
2014 let sc_lunar = Spacecraft::new("lunar", OrbitSource::Trajectory(lunar_traj));
2015 let spk = ephemeris();
2016 let space_assets = [sc_iss.clone(), sc_lunar.clone()];
2017 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, inter_interval);
2018
2019 let results_basic = VisibilityAnalysis::new(&scenario, &ensemble, spk)
2021 .with_inter_satellite()
2022 .compute()
2023 .unwrap();
2024
2025 let results_moon = VisibilityAnalysis::new(&scenario, &ensemble, spk)
2027 .with_inter_satellite()
2028 .with_occulting_bodies(vec![DynOrigin::Moon])
2029 .compute()
2030 .unwrap();
2031
2032 let basic = results_basic
2033 .intervals_for(sc_iss.id(), sc_lunar.id())
2034 .expect("pair not found");
2035 let with_moon = results_moon
2036 .intervals_for(sc_iss.id(), sc_lunar.id())
2037 .expect("pair not found");
2038
2039 assert!(!basic.is_empty(), "ISS-lunar pair should have visibility");
2041 assert!(!with_moon.is_empty());
2042
2043 let dur_basic: f64 = basic
2045 .iter()
2046 .map(|iv| iv.duration().to_seconds().to_f64())
2047 .sum();
2048 let dur_moon: f64 = with_moon
2049 .iter()
2050 .map(|iv| iv.duration().to_seconds().to_f64())
2051 .sum();
2052 assert!(dur_moon <= dur_basic + 1e-6);
2053 }
2054
2055 fn ephemeris() -> &'static Spk {
2056 static EPHEMERIS: OnceLock<Spk> = OnceLock::new();
2057 EPHEMERIS.get_or_init(|| Spk::from_file(data_file("spice/de440s.bsp")).unwrap())
2058 }
2059}