1use std::collections::HashMap;
6
7use glam::DVec3;
8use lox_bodies::{DynOrigin, Origin, TryMeanRadius, TrySpheroid, UndefinedOriginPropertyError};
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::time_scales::{DynTimeScale, Tai, Tdb, TimeScale};
18use rayon::prelude::*;
19use std::f64::consts::PI;
20use thiserror::Error;
21
22use lox_core::units::{AngularRate, Distance};
23
24use crate::assets::{AssetId, GroundStation, Scenario, Spacecraft};
25use lox_orbits::events::{
26 DetectError, DetectFn, EventsToIntervals, IntervalDetector, IntervalDetectorExt,
27 RootFindingDetector,
28};
29use lox_orbits::ground::{DynGroundLocation, Observables};
30use lox_orbits::orbits::{Ensemble, Trajectory};
31
32pub fn line_of_sight(radius: f64, r1: DVec3, r2: DVec3) -> f64 {
45 let r1n = r1.length();
46 let r2n = r2.length();
47 let theta1 = radius / r1n;
48 let theta2 = radius / r2n;
49 let theta = (r1.dot(r2) / r1n / r2n).clamp(-1.0, 1.0);
51 theta1.acos() + theta2.acos() - theta.acos()
52}
53
54pub fn line_of_sight_spheroid(
57 mean_radius: f64,
58 radius_eq: f64,
59 radius_p: f64,
60 r1: DVec3,
61 r2: DVec3,
62) -> f64 {
63 let eps = (1.0 - radius_p.powi(2) / radius_eq.powi(2)).sqrt();
64 let scale = (1.0 - eps.powi(2)).sqrt();
65 let r1 = DVec3::new(r1.x, r1.y, r1.z / scale);
66 let r2 = DVec3::new(r2.x, r2.y, r2.z / scale);
67 line_of_sight(mean_radius, r1, r2)
68}
69
70pub trait LineOfSight: TrySpheroid + TryMeanRadius {
73 fn line_of_sight(&self, r1: DVec3, r2: DVec3) -> Result<f64, UndefinedOriginPropertyError> {
75 let mean_radius = self.try_mean_radius()?.to_meters();
76 if let (Ok(r_eq), Ok(r_p)) = (self.try_equatorial_radius(), self.try_polar_radius()) {
77 return Ok(line_of_sight_spheroid(
78 mean_radius,
79 r_eq.to_meters(),
80 r_p.to_meters(),
81 r1,
82 r2,
83 ));
84 }
85 Ok(line_of_sight(mean_radius, r1, r2))
86 }
87}
88
89impl<T: TrySpheroid + TryMeanRadius> LineOfSight for T {}
90
91#[derive(Debug, Clone, Error, PartialEq)]
97pub enum ElevationMaskError {
98 #[error("invalid azimuth range: {}..{}", .0.to_degrees(), .1.to_degrees())]
100 InvalidAzimuthRange(f64, f64),
101 #[error("series error")]
103 SeriesError(#[from] SeriesError),
104}
105
106#[derive(Debug, Clone, PartialEq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112pub enum ElevationMask {
113 Fixed(f64),
115 Variable(Series),
117}
118
119impl ElevationMask {
120 pub fn new(azimuth: Vec<f64>, elevation: Vec<f64>) -> Result<Self, ElevationMaskError> {
122 if !azimuth.is_empty() {
123 let az_min = *azimuth.iter().min_by(|a, b| a.total_cmp(b)).unwrap();
124 let az_max = *azimuth.iter().max_by(|a, b| a.total_cmp(b)).unwrap();
125 if az_min != -PI || az_max != PI {
126 return Err(ElevationMaskError::InvalidAzimuthRange(az_min, az_max));
127 }
128 }
129 Ok(Self::Variable(Series::try_new(
130 azimuth,
131 elevation,
132 InterpolationType::Linear,
133 )?))
134 }
135
136 pub fn with_fixed_elevation(elevation: f64) -> Self {
138 Self::Fixed(elevation)
139 }
140
141 pub fn min_elevation(&self, azimuth: f64) -> f64 {
143 match self {
144 ElevationMask::Fixed(min_elevation) => *min_elevation,
145 ElevationMask::Variable(series) => series.interpolate(azimuth),
146 }
147 }
148}
149
150#[derive(Debug, Error)]
156pub enum VisibilityError {
157 #[error(transparent)]
159 Detect(#[from] DetectError),
160 #[error(transparent)]
162 Series(#[from] SeriesError),
163}
164
165#[derive(Debug, Error)]
167pub enum PassError {
168 #[error(
169 "passes are not supported for inter-satellite pair ({0}, {1}): use intervals() instead"
170 )]
171 InterSatellitePair(String, String),
173}
174
175#[derive(Debug, Clone)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct Pass<T: TimeScale> {
186 interval: TimeInterval<T>,
187 times: Vec<Time<T>>,
188 observables: Vec<Observables>,
189 azimuth_series: Series,
190 elevation_series: Series,
191 range_series: Series,
192 range_rate_series: Series,
193}
194
195pub type DynPass = Pass<DynTimeScale>;
197
198impl DynPass {
199 pub fn from_interval(
204 interval: TimeInterval<DynTimeScale>,
205 time_resolution: TimeDelta,
206 gs: &DynGroundLocation,
207 mask: &ElevationMask,
208 sc: &lox_orbits::orbits::DynTrajectory,
209 body_fixed_frame: DynFrame,
210 ) -> Option<DynPass> {
211 let mut pass_times = Vec::new();
212 let mut pass_observables = Vec::new();
213
214 for current_time in interval.step_by(time_resolution) {
215 let state = sc.interpolate_at(current_time);
216 let state_bf = state
217 .try_to_frame(body_fixed_frame, &DefaultRotationProvider)
218 .unwrap();
219 let obs = gs.observables_dyn(state_bf);
220
221 let min_elev = mask.min_elevation(obs.azimuth());
222 if obs.elevation() >= min_elev {
223 pass_times.push(current_time);
224 pass_observables.push(obs);
225 }
226 }
227
228 if pass_times.is_empty() {
229 return None;
230 }
231
232 Pass::try_new(interval, pass_times, pass_observables).ok()
233 }
234}
235
236impl<T: TimeScale> Pass<T> {
237 pub fn try_new(
242 interval: TimeInterval<T>,
243 times: Vec<Time<T>>,
244 observables: Vec<Observables>,
245 ) -> Result<Self, SeriesError>
246 where
247 T: Copy,
248 {
249 if times.len() < 2 {
250 return Err(SeriesError::InsufficientPoints(times.len()));
251 }
252
253 let time_seconds: Vec<f64> = times
254 .iter()
255 .map(|t| (*t - interval.start()).to_seconds().to_f64())
256 .collect();
257 let azimuths: Vec<f64> = observables.iter().map(|o| o.azimuth()).collect();
258 let elevations: Vec<f64> = observables.iter().map(|o| o.elevation()).collect();
259 let ranges: Vec<f64> = observables.iter().map(|o| o.range()).collect();
260 let range_rates: Vec<f64> = observables.iter().map(|o| o.range_rate()).collect();
261
262 let azimuth_series =
263 Series::try_new(time_seconds.clone(), azimuths, InterpolationType::Linear)?;
264 let elevation_series =
265 Series::try_new(time_seconds.clone(), elevations, InterpolationType::Linear)?;
266 let range_series =
267 Series::try_new(time_seconds.clone(), ranges, InterpolationType::Linear)?;
268 let range_rate_series =
269 Series::try_new(time_seconds, range_rates, InterpolationType::Linear)?;
270
271 Ok(Pass {
272 interval,
273 times,
274 observables,
275 azimuth_series,
276 elevation_series,
277 range_series,
278 range_rate_series,
279 })
280 }
281
282 pub fn interval(&self) -> &TimeInterval<T> {
284 &self.interval
285 }
286
287 pub fn times(&self) -> &[Time<T>] {
289 &self.times
290 }
291
292 pub fn observables(&self) -> &[Observables] {
294 &self.observables
295 }
296
297 pub fn interpolate(&self, time: Time<T>) -> Option<Observables>
299 where
300 T: Copy + PartialOrd,
301 {
302 if time < self.interval.start() || time > self.interval.end() {
303 return None;
304 }
305
306 if self.times.is_empty() {
307 return None;
308 }
309
310 let target_seconds = (time - self.interval.start()).to_seconds().to_f64();
311
312 let azimuth = self.azimuth_series.interpolate(target_seconds);
313 let elevation = self.elevation_series.interpolate(target_seconds);
314 let range = self.range_series.interpolate(target_seconds);
315 let range_rate = self.range_rate_series.interpolate(target_seconds);
316
317 Some(Observables::new(azimuth, elevation, range, range_rate))
318 }
319}
320
321#[derive(Debug, Error)]
327pub enum EvalError {
328 #[error("rotation error: {0}")]
330 Rotation(Box<dyn std::error::Error + Send + Sync>),
331 #[error(transparent)]
333 UndefinedProperty(#[from] UndefinedOriginPropertyError),
334 #[error("ephemeris error: {0}")]
336 Ephemeris(Box<dyn std::error::Error + Send + Sync>),
337}
338
339impl From<DynRotationError> for EvalError {
340 fn from(e: DynRotationError) -> Self {
341 EvalError::Rotation(Box::new(e))
342 }
343}
344
345impl From<RotationError> for EvalError {
346 fn from(e: RotationError) -> Self {
347 EvalError::Rotation(Box::new(e))
348 }
349}
350
351struct ElevationDetectFn<'a, O: Origin, R: ReferenceFrame> {
363 gs: &'a DynGroundLocation,
364 mask: &'a ElevationMask,
365 sc: &'a Trajectory<Tai, O, R>,
366 body_fixed_frame: DynFrame,
367}
368
369impl<O, R> DetectFn<Tai> for ElevationDetectFn<'_, O, R>
370where
371 O: TrySpheroid + Copy,
372 R: ReferenceFrame + Copy,
373 DefaultRotationProvider: TryRotation<R, DynFrame, Tai>,
374 <DefaultRotationProvider as TryRotation<R, DynFrame, Tai>>::Error:
375 std::error::Error + Send + Sync + 'static,
376{
377 type Error = EvalError;
378
379 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
380 let sc = self.sc.interpolate_at(time);
381 let sc = sc
382 .try_to_frame(self.body_fixed_frame, &DefaultRotationProvider)
383 .map_err(|e| EvalError::Rotation(Box::new(e)))?;
384 let obs = self.gs.compute_observables(sc.position(), sc.velocity());
385 Ok(obs.elevation() - self.mask.min_elevation(obs.azimuth()))
386 }
387}
388
389struct LineOfSightDetectFn<'a, O: Origin, R: ReferenceFrame, E> {
392 gs: &'a DynGroundLocation,
393 sc: &'a Trajectory<Tai, O, R>,
394 body: DynOrigin,
395 ephemeris: &'a E,
396 body_fixed_frame: DynFrame,
397}
398
399impl<O, R, E: Ephemeris> DetectFn<Tai> for LineOfSightDetectFn<'_, O, R, E>
400where
401 O: TrySpheroid + Copy,
402 R: ReferenceFrame + Copy,
403 E::Error: 'static,
404 DefaultRotationProvider: TryRotation<DynFrame, R, Tai>,
405 <DefaultRotationProvider as TryRotation<DynFrame, R, Tai>>::Error:
406 std::error::Error + Send + Sync + 'static,
407{
408 type Error = EvalError;
409
410 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
411 let tdb = time.to_scale(Tdb);
413 let r_body = self
414 .ephemeris
415 .position(tdb, self.sc.origin(), self.body)
416 .map_err(|e| EvalError::Ephemeris(Box::new(e)))?;
417 let r_sc = self.sc.interpolate_at(time).position() - r_body;
418 let rot = DefaultRotationProvider
421 .try_rotation(self.body_fixed_frame, self.sc.reference_frame(), time)
422 .map_err(|e| EvalError::Rotation(Box::new(e)))?;
423 let (r_gs_frame, _) = rot.rotate_state(self.gs.body_fixed_position(), DVec3::ZERO);
424 let r_gs = r_gs_frame - r_body;
425 Ok(self.body.line_of_sight(r_gs, r_sc)?)
426 }
427}
428
429struct InterSatelliteLosDetectFn<'a, O: Origin, R: ReferenceFrame, E> {
431 sc1: &'a Trajectory<Tai, O, R>,
432 sc2: &'a Trajectory<Tai, O, R>,
433 body: DynOrigin,
434 ephemeris: &'a E,
435}
436
437impl<O, R, E: Ephemeris> DetectFn<Tai> for InterSatelliteLosDetectFn<'_, O, R, E>
438where
439 O: Origin + Copy,
440 R: ReferenceFrame + Copy,
441 E::Error: 'static,
442{
443 type Error = EvalError;
444
445 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
446 let tdb = time.to_scale(Tdb);
447 let r_body = self
448 .ephemeris
449 .position(tdb, self.sc1.origin(), self.body)
450 .map_err(|e| EvalError::Ephemeris(Box::new(e)))?;
451 let r_sc1 = self.sc1.interpolate_at(time).position() - r_body;
452 let r_sc2 = self.sc2.interpolate_at(time).position() - r_body;
453 Ok(self.body.line_of_sight(r_sc1, r_sc2)?)
454 }
455}
456
457enum RangeDirection {
459 Max,
461 Min,
463}
464
465struct InterSatelliteRangeDetectFn<'a, O: Origin, R: ReferenceFrame> {
467 sc1: &'a Trajectory<Tai, O, R>,
468 sc2: &'a Trajectory<Tai, O, R>,
469 threshold: Distance,
470 direction: RangeDirection,
471}
472
473impl<O, R> DetectFn<Tai> for InterSatelliteRangeDetectFn<'_, O, R>
474where
475 O: Origin + Copy,
476 R: ReferenceFrame + Copy,
477{
478 type Error = EvalError;
479
480 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
481 let r1 = self.sc1.interpolate_at(time).position();
482 let r2 = self.sc2.interpolate_at(time).position();
483 let range = (r1 - r2).length();
484 let threshold = self.threshold.to_meters();
485 Ok(match self.direction {
486 RangeDirection::Max => threshold - range,
487 RangeDirection::Min => range - threshold,
488 })
489 }
490}
491
492struct InterSatelliteSlewRateDetectFn<'a, O: Origin, R: ReferenceFrame> {
498 sc1: &'a Trajectory<Tai, O, R>,
499 sc2: &'a Trajectory<Tai, O, R>,
500 threshold: AngularRate,
501}
502
503impl<O, R> DetectFn<Tai> for InterSatelliteSlewRateDetectFn<'_, O, R>
504where
505 O: Origin + Copy,
506 R: ReferenceFrame + Copy,
507{
508 type Error = EvalError;
509
510 fn eval(&self, time: Time<Tai>) -> Result<f64, Self::Error> {
511 let s1 = self.sc1.interpolate_at(time);
512 let s2 = self.sc2.interpolate_at(time);
513 let r = s2.position() - s1.position();
514 let v = s2.velocity() - s1.velocity();
515 let r_len_sq = r.length_squared();
516 let omega = if r_len_sq > 0.0 {
517 r.cross(v).length() / r_len_sq
518 } else {
519 0.0
520 };
521 Ok(self.threshold.to_radians_per_second() - omega)
522 }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
531pub enum PairType {
532 GroundSpace,
534 InterSatellite,
536}
537
538type IntervalMap = HashMap<(AssetId, AssetId), Vec<TimeInterval<Tai>>>;
539type PairTypeMap = HashMap<(AssetId, AssetId), PairType>;
540
541pub struct VisibilityResults {
547 intervals: IntervalMap,
548 pair_types: PairTypeMap,
549}
550
551impl VisibilityResults {
552 pub fn intervals_for(&self, id1: &AssetId, id2: &AssetId) -> Option<&[TimeInterval<Tai>]> {
554 let key = (id1.clone(), id2.clone());
555 self.intervals.get(&key).map(|v| v.as_slice())
556 }
557
558 pub fn all_intervals(&self) -> &IntervalMap {
560 &self.intervals
561 }
562
563 pub fn pair_ids(&self) -> impl Iterator<Item = &(AssetId, AssetId)> {
565 self.intervals.keys()
566 }
567
568 pub fn pair_type(&self, id1: &AssetId, id2: &AssetId) -> Option<PairType> {
570 self.pair_types.get(&(id1.clone(), id2.clone())).copied()
571 }
572
573 pub fn ground_space_pair_ids(&self) -> Vec<&(AssetId, AssetId)> {
575 self.pair_types
576 .iter()
577 .filter(|&(_, &pt)| pt == PairType::GroundSpace)
578 .map(|(k, _)| k)
579 .collect()
580 }
581
582 pub fn inter_satellite_pair_ids(&self) -> Vec<&(AssetId, AssetId)> {
584 self.pair_types
585 .iter()
586 .filter(|&(_, &pt)| pt == PairType::InterSatellite)
587 .map(|(k, _)| k)
588 .collect()
589 }
590
591 pub fn is_empty(&self) -> bool {
593 self.intervals.is_empty()
594 }
595
596 pub fn num_pairs(&self) -> usize {
598 self.intervals.len()
599 }
600
601 pub fn total_intervals(&self) -> usize {
603 self.intervals.values().map(|v| v.len()).sum()
604 }
605
606 pub fn into_parts(self) -> (IntervalMap, PairTypeMap) {
608 (self.intervals, self.pair_types)
609 }
610
611 #[allow(clippy::too_many_arguments)]
617 pub fn to_passes(
618 &self,
619 ground_id: &AssetId,
620 space_id: &AssetId,
621 gs: &DynGroundLocation,
622 mask: &ElevationMask,
623 sc: &lox_orbits::orbits::DynTrajectory,
624 time_resolution: TimeDelta,
625 body_fixed_frame: DynFrame,
626 ) -> Result<Vec<DynPass>, PassError> {
627 let key = (ground_id.clone(), space_id.clone());
628 if self.pair_types.get(&key) == Some(&PairType::InterSatellite) {
629 return Err(PassError::InterSatellitePair(
630 ground_id.as_str().to_string(),
631 space_id.as_str().to_string(),
632 ));
633 }
634 Ok(self
635 .intervals
636 .get(&key)
637 .map(|intervals| {
638 intervals
639 .iter()
640 .filter_map(|interval| {
641 let dyn_interval = TimeInterval::new(
642 interval.start().into_dyn(),
643 interval.end().into_dyn(),
644 );
645 DynPass::from_interval(
646 dyn_interval,
647 time_resolution,
648 gs,
649 mask,
650 sc,
651 body_fixed_frame,
652 )
653 })
654 .collect()
655 })
656 .unwrap_or_default())
657 }
658}
659
660pub struct VisibilityAnalysis<'a, O: Origin, R: ReferenceFrame, E> {
673 scenario: &'a Scenario<O, R>,
674 ensemble: &'a Ensemble<AssetId, Tai, O, R>,
675 ephemeris: &'a E,
676 occulting_bodies: Vec<DynOrigin>,
677 step: TimeDelta,
678 min_pass_duration: Option<TimeDelta>,
679 inter_satellite: bool,
680 min_range: Option<Distance>,
681 max_range: Option<Distance>,
682}
683
684impl<'a, O, R, E> VisibilityAnalysis<'a, O, R, E>
685where
686 O: TrySpheroid + TryMeanRadius + Copy + Send + Sync + Into<DynOrigin>,
687 R: ReferenceFrame + Copy + Send + Sync + Into<DynFrame>,
688 E: Ephemeris + Send + Sync,
689 E::Error: 'static,
690 DefaultRotationProvider: TryRotation<R, DynFrame, Tai> + TryRotation<DynFrame, R, Tai>,
691 <DefaultRotationProvider as TryRotation<R, DynFrame, Tai>>::Error:
692 std::error::Error + Send + Sync + 'static,
693 <DefaultRotationProvider as TryRotation<DynFrame, R, Tai>>::Error:
694 std::error::Error + Send + Sync + 'static,
695{
696 pub fn new(
698 scenario: &'a Scenario<O, R>,
699 ensemble: &'a Ensemble<AssetId, Tai, O, R>,
700 ephemeris: &'a E,
701 ) -> Self {
702 Self {
703 scenario,
704 ensemble,
705 ephemeris,
706 occulting_bodies: Vec::new(),
707 step: TimeDelta::from_seconds(60),
708 min_pass_duration: None,
709 inter_satellite: false,
710 min_range: None,
711 max_range: None,
712 }
713 }
714
715 pub fn with_inter_satellite(mut self) -> Self {
717 self.inter_satellite = true;
718 self
719 }
720
721 pub fn with_occulting_bodies(mut self, bodies: Vec<DynOrigin>) -> Self {
723 self.occulting_bodies = bodies;
724 self
725 }
726
727 pub fn with_step(mut self, step: TimeDelta) -> Self {
729 self.step = step;
730 self
731 }
732
733 pub fn with_min_pass_duration(mut self, min_pass_duration: TimeDelta) -> Self {
735 self.min_pass_duration = Some(min_pass_duration);
736 self
737 }
738
739 pub fn with_min_range(mut self, min_range: Distance) -> Self {
741 self.min_range = Some(min_range);
742 self
743 }
744
745 pub fn with_max_range(mut self, max_range: Distance) -> Self {
747 self.max_range = Some(max_range);
748 self
749 }
750
751 pub fn step(&self) -> TimeDelta {
753 self.step
754 }
755
756 fn apply_coarse_step<F>(&self, det: RootFindingDetector<F>) -> RootFindingDetector<F> {
758 match self.min_pass_duration {
759 Some(d) => {
760 let coarse = TimeDelta::from_seconds_f64(d.to_seconds().to_f64() / 2.0);
761 if coarse > self.step {
762 det.with_coarse_step(coarse)
763 } else {
764 det
765 }
766 }
767 None => det,
768 }
769 }
770
771 fn compute_pair(
773 &self,
774 gs: &GroundStation,
775 sc_traj: &Trajectory<Tai, O, R>,
776 interval: TimeInterval<Tai>,
777 ) -> Result<Vec<TimeInterval<Tai>>, VisibilityError> {
778 let body_fixed_frame = gs.body_fixed_frame();
779
780 let make_elev = || {
781 let det = RootFindingDetector::new(
782 ElevationDetectFn {
783 gs: gs.location(),
784 mask: gs.mask(),
785 sc: sc_traj,
786 body_fixed_frame,
787 },
788 self.step,
789 );
790 EventsToIntervals::new(self.apply_coarse_step(det))
791 };
792
793 if self.occulting_bodies.is_empty() {
794 return Ok(make_elev().detect(interval)?);
795 }
796
797 let make_los = |body: DynOrigin| {
798 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
799 LineOfSightDetectFn {
800 gs: gs.location(),
801 sc: sc_traj,
802 body,
803 ephemeris: self.ephemeris,
804 body_fixed_frame,
805 },
806 self.step,
807 )))
808 };
809
810 let mut los: Box<dyn IntervalDetector<Tai> + '_> =
811 Box::new(make_los(self.occulting_bodies[0]));
812 for &body in &self.occulting_bodies[1..] {
813 los = Box::new(los.intersect(make_los(body)));
814 }
815
816 Ok(make_elev().chain(los).detect(interval)?)
817 }
818
819 fn compute_inter_satellite_pair(
822 &self,
823 sc1: &Spacecraft,
824 sc2: &Spacecraft,
825 traj1: &Trajectory<Tai, O, R>,
826 traj2: &Trajectory<Tai, O, R>,
827 interval: TimeInterval<Tai>,
828 ) -> Result<Vec<TimeInterval<Tai>>, VisibilityError> {
829 let has_range = self.min_range.is_some() || self.max_range.is_some();
830 let has_los = !self.occulting_bodies.is_empty();
831
832 let effective_slew_rate = match (sc1.max_slew_rate(), sc2.max_slew_rate()) {
834 (Some(a), Some(b)) => Some(if a.to_radians_per_second() < b.to_radians_per_second() {
835 a
836 } else {
837 b
838 }),
839 (Some(a), None) => Some(a),
840 (None, Some(b)) => Some(b),
841 (None, None) => None,
842 };
843 let has_slew_rate = effective_slew_rate.is_some();
844
845 if !has_range && !has_slew_rate && !has_los {
846 return Ok(vec![interval]);
847 }
848
849 let make_range = |threshold: Distance, direction: RangeDirection| {
850 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
851 InterSatelliteRangeDetectFn {
852 sc1: traj1,
853 sc2: traj2,
854 threshold,
855 direction,
856 },
857 self.step,
858 )))
859 };
860
861 let make_los = |body: DynOrigin| {
862 EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
863 InterSatelliteLosDetectFn {
864 sc1: traj1,
865 sc2: traj2,
866 body,
867 ephemeris: self.ephemeris,
868 },
869 self.step,
870 )))
871 };
872
873 let mut detector: Option<Box<dyn IntervalDetector<Tai> + '_>> = None;
875
876 if let Some(max) = self.max_range {
877 detector = Some(Box::new(make_range(max, RangeDirection::Max)));
878 }
879 if let Some(min) = self.min_range {
880 let min_det = make_range(min, RangeDirection::Min);
881 detector = Some(match detector {
882 Some(d) => Box::new(d.intersect(min_det)),
883 None => Box::new(min_det),
884 });
885 }
886
887 if let Some(threshold) = effective_slew_rate {
889 let slew = EventsToIntervals::new(self.apply_coarse_step(RootFindingDetector::new(
890 InterSatelliteSlewRateDetectFn {
891 sc1: traj1,
892 sc2: traj2,
893 threshold,
894 },
895 self.step,
896 )));
897 detector = Some(match detector {
898 Some(d) => Box::new(d.chain(slew)),
899 None => Box::new(slew),
900 });
901 }
902
903 for &body in &self.occulting_bodies {
905 let los = make_los(body);
906 detector = Some(match detector {
907 Some(d) => Box::new(d.chain(los)),
908 None => Box::new(los),
909 });
910 }
911
912 Ok(detector.unwrap().detect(interval)?)
913 }
914
915 pub fn compute(&self) -> Result<VisibilityResults, VisibilityError> {
917 let interval = *self.scenario.interval();
918
919 let mut intervals = HashMap::new();
920 let mut pair_types = HashMap::new();
921
922 if !self.scenario.ground_stations().is_empty() {
923 let gs_results = self.compute_ground_space(interval)?;
924 let (gs_intervals, gs_pair_types) = gs_results.into_parts();
925 intervals.extend(gs_intervals);
926 pair_types.extend(gs_pair_types);
927 }
928
929 if self.inter_satellite {
930 let is_results = self.compute_inter_satellite(interval)?;
931 let (is_intervals, is_pair_types) = is_results.into_parts();
932 intervals.extend(is_intervals);
933 pair_types.extend(is_pair_types);
934 }
935
936 Ok(VisibilityResults {
937 intervals,
938 pair_types,
939 })
940 }
941
942 fn compute_ground_space(
944 &self,
945 interval: TimeInterval<Tai>,
946 ) -> Result<VisibilityResults, VisibilityError> {
947 let ground_stations = self.scenario.ground_stations();
948 let spacecraft = self.scenario.spacecraft();
949
950 let pairs: Vec<_> = ground_stations
951 .iter()
952 .flat_map(|gs| spacecraft.iter().map(move |sc| (gs, sc)))
953 .collect();
954
955 const PARALLEL_THRESHOLD: usize = 100;
956 let use_parallel = pairs.len() > PARALLEL_THRESHOLD;
957
958 let compute_one = |(gs, sc): &(&GroundStation, &Spacecraft)| {
959 let key = (gs.id().clone(), sc.id().clone());
960 let sc_traj = self.ensemble.get(sc.id()).expect(
961 "trajectory not found in ensemble; did you forget to propagate this spacecraft?",
962 );
963 let windows = self.compute_pair(gs, sc_traj, interval)?;
964 Ok((key, windows))
965 };
966
967 let results: Result<Vec<_>, VisibilityError> = if use_parallel {
968 pairs.par_iter().map(compute_one).collect()
969 } else {
970 pairs.iter().map(compute_one).collect()
971 };
972
973 let intervals: HashMap<_, _> = results?.into_iter().collect();
974 let pair_types = intervals
975 .keys()
976 .map(|k| (k.clone(), PairType::GroundSpace))
977 .collect();
978 Ok(VisibilityResults {
979 intervals,
980 pair_types,
981 })
982 }
983
984 fn compute_inter_satellite(
986 &self,
987 interval: TimeInterval<Tai>,
988 ) -> Result<VisibilityResults, VisibilityError> {
989 let spacecraft = self.scenario.spacecraft();
990 let n = spacecraft.len();
991 let mut pairs: Vec<(usize, usize)> = Vec::with_capacity(n * (n - 1) / 2);
992 for i in 0..n {
993 for j in (i + 1)..n {
994 pairs.push((i, j));
995 }
996 }
997
998 let results: Result<Vec<_>, VisibilityError> = pairs
999 .par_iter()
1000 .map(|&(i, j)| {
1001 let sc1 = &spacecraft[i];
1002 let sc2 = &spacecraft[j];
1003 let key = (sc1.id().clone(), sc2.id().clone());
1004 let traj1 = self
1005 .ensemble
1006 .get(sc1.id())
1007 .expect("trajectory not found in ensemble");
1008 let traj2 = self
1009 .ensemble
1010 .get(sc2.id())
1011 .expect("trajectory not found in ensemble");
1012 let windows =
1013 self.compute_inter_satellite_pair(sc1, sc2, traj1, traj2, interval)?;
1014 Ok((key, windows))
1015 })
1016 .collect();
1017
1018 let intervals: HashMap<_, _> = results?.into_iter().collect();
1019 let pair_types = intervals
1020 .keys()
1021 .map(|k| (k.clone(), PairType::InterSatellite))
1022 .collect();
1023 Ok(VisibilityResults {
1024 intervals,
1025 pair_types,
1026 })
1027 }
1028
1029 pub fn to_passes(
1034 &self,
1035 results: &VisibilityResults,
1036 ) -> HashMap<(AssetId, AssetId), Vec<DynPass>> {
1037 let gs_map: HashMap<&AssetId, &GroundStation> = self
1038 .scenario
1039 .ground_stations()
1040 .iter()
1041 .map(|g| (g.id(), g))
1042 .collect();
1043
1044 results
1045 .ground_space_pair_ids()
1046 .into_iter()
1047 .filter_map(|(gs_id, sc_id)| {
1048 let gs = gs_map.get(gs_id)?;
1049 let sc_traj = self.ensemble.get(sc_id)?;
1050 let intervals = results.intervals_for(gs_id, sc_id)?;
1051 let passes: Vec<DynPass> = intervals
1052 .iter()
1053 .filter_map(|interval| {
1054 let dyn_interval = TimeInterval::new(
1056 interval.start().into_dyn(),
1057 interval.end().into_dyn(),
1058 );
1059 let dyn_traj = sc_traj.clone().into_dyn();
1061 DynPass::from_interval(
1062 dyn_interval,
1063 self.step,
1064 gs.location(),
1065 gs.mask(),
1066 &dyn_traj,
1067 gs.body_fixed_frame(),
1068 )
1069 })
1070 .collect();
1071 Some(((gs_id.clone(), sc_id.clone()), passes))
1072 })
1073 .collect()
1074 }
1075}
1076
1077#[cfg(test)]
1082mod tests {
1083 use lox_bodies::{Earth, Spheroid};
1084 use lox_core::coords::LonLatAlt;
1085 use lox_core::units::Distance;
1086 use lox_ephem::spk::parser::Spk;
1087 use lox_orbits::propagators::OrbitSource;
1088 use lox_test_utils::{assert_approx_eq, data_file, read_data_file};
1089 use lox_time::time_scales::{DynTimeScale, Tai};
1090 use lox_time::utc::Utc;
1091 use std::iter::zip;
1092 use std::sync::OnceLock;
1093
1094 use super::*;
1095 use lox_frames::Icrf;
1096 use lox_orbits::ground::GroundLocation;
1097 use lox_orbits::orbits::{DynTrajectory, Trajectory};
1098
1099 fn make_scenario_and_ensemble(
1101 ground_assets: &[GroundStation],
1102 space_assets: &[Spacecraft],
1103 interval: TimeInterval<DynTimeScale>,
1104 ) -> (
1105 Scenario<DynOrigin, DynFrame>,
1106 Ensemble<AssetId, Tai, DynOrigin, DynFrame>,
1107 ) {
1108 let tai_interval =
1109 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1110 let scenario = Scenario::with_interval(tai_interval, DynOrigin::Earth, DynFrame::Icrf)
1111 .with_ground_stations(ground_assets)
1112 .with_spacecraft(space_assets);
1113 let mut map = HashMap::new();
1115 for sc in space_assets {
1116 if let OrbitSource::Trajectory(traj) = sc.orbit() {
1117 let (epoch, origin, frame, data) = traj.clone().into_parts();
1119 let typed = Trajectory::from_parts(epoch.with_scale(Tai), origin, frame, data);
1120 map.insert(sc.id().clone(), typed);
1121 }
1122 }
1123 let ensemble = Ensemble::new(map);
1124 (scenario, ensemble)
1125 }
1126
1127 #[test]
1128 fn test_line_of_sight() {
1129 let r1 = DVec3::new(0.0, -4464.696, -5102.509);
1130 let r2 = DVec3::new(0.0, 5740.323, 3189.068);
1131 let r_sun = DVec3::new(122233179.0, -76150708.0, 33016374.0);
1132 let r = Earth.equatorial_radius().to_kilometers();
1133
1134 let los = line_of_sight(r, r1, r2);
1135 let los_sun = line_of_sight(r, r1, r_sun);
1136
1137 assert!(los < 0.0);
1138 assert!(los_sun >= 0.0);
1139 }
1140
1141 #[test]
1142 fn test_line_of_sight_identical() {
1143 let r1 = DVec3::new(0.0, -4464.696, -5102.509);
1144 let r2 = DVec3::new(0.0, -4464.696, -5102.509);
1145 let r = Earth.equatorial_radius().to_kilometers();
1146
1147 let los = line_of_sight(r, r1, r2);
1148
1149 assert!(los >= 0.0);
1150 }
1151
1152 #[test]
1153 fn test_line_of_sight_trait() {
1154 let r1 = DVec3::new(0.0, -4464696.0, -5102509.0);
1155 let r2 = DVec3::new(0.0, 5740323.0, 3189068.0);
1156 let r_sun = DVec3::new(122233179e3, -76150708e3, 33016374e3);
1157
1158 let los = Earth.line_of_sight(r1, r2).unwrap();
1159 let los_sun = Earth.line_of_sight(r1, r_sun).unwrap();
1160
1161 assert!(los < 0.0);
1162 assert!(los_sun >= 0.0);
1163 }
1164
1165 #[test]
1166 fn test_elevation() {
1167 let sc = spacecraft_trajectory_dyn();
1168 let gs_traj = ground_station_trajectory();
1169 let gs = location_dyn();
1170 let mask = ElevationMask::with_fixed_elevation(0.0);
1171 let expected: Vec<f64> = read_data_file("elevation.csv")
1172 .lines()
1173 .map(|line| line.parse::<f64>().unwrap().to_radians())
1174 .collect();
1175 let (epoch, o, f, data) = sc.clone().into_parts();
1177 let typed_sc = Trajectory::from_parts(epoch.with_scale(Tai), o, f, data);
1178 let elev_fn = ElevationDetectFn {
1179 gs: &gs,
1180 mask: &mask,
1181 sc: &typed_sc,
1182 body_fixed_frame: DynFrame::Iau(DynOrigin::Earth),
1183 };
1184 let actual: Vec<f64> = gs_traj
1186 .times()
1187 .iter()
1188 .map(|t| {
1189 let tai_time = t.to_scale(Tai);
1190 elev_fn.eval(tai_time).unwrap()
1191 })
1192 .collect();
1193 assert_approx_eq!(actual, expected, atol <= 1e-1);
1194 }
1195
1196 #[test]
1197 fn test_elevation_mask() {
1198 let azimuth = vec![-PI, 0.0, PI];
1199 let elevation = vec![-2.0, 0.0, 2.0];
1200 let mask = ElevationMask::new(azimuth, elevation).unwrap();
1201 assert_eq!(mask.min_elevation(0.0), 0.0);
1202 }
1203
1204 #[test]
1205 fn test_elevation_mask_invalid_mask() {
1206 let azimuth = vec![-PI, 0.0, PI / 2.0];
1207 let elevation = vec![-2.0, 0.0, 2.0];
1208 let mask = ElevationMask::new(azimuth, elevation);
1209 assert_eq!(
1210 mask,
1211 Err(ElevationMaskError::InvalidAzimuthRange(-PI, PI / 2.0))
1212 )
1213 }
1214
1215 #[test]
1216 fn test_visibility() {
1217 let gs_loc = location_dyn();
1218 let mask = ElevationMask::with_fixed_elevation(0.0);
1219 let sc_traj = spacecraft_trajectory_dyn();
1220 let gs = GroundStation::new("cebreros", gs_loc, mask);
1221 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1222 let spk = ephemeris();
1223 let ground_assets = [gs.clone()];
1224 let space_assets = [sc.clone()];
1225 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1226 let (scenario, ensemble) =
1227 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1228 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk);
1229 let results = analysis.compute().expect("visibility");
1230 let intervals = results
1231 .intervals_for(gs.id(), sc.id())
1232 .expect("pair not found");
1233 let expected = contacts_tai();
1234 assert_eq!(intervals.len(), expected.len());
1235 assert_approx_eq!(expected, intervals.to_vec(), rtol <= 1e-4);
1236 }
1237
1238 #[test]
1239 fn test_visibility_combined() {
1240 let gs_loc = location_dyn();
1241 let mask = ElevationMask::with_fixed_elevation(0.0);
1242 let sc_traj = spacecraft_trajectory_dyn();
1243 let gs = GroundStation::new("cebreros", gs_loc, mask);
1244 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1245 let spk = ephemeris();
1246 let ground_assets = [gs.clone()];
1247 let space_assets = [sc.clone()];
1248 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1249 let (scenario, ensemble) =
1250 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1251 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1252 .with_occulting_bodies(vec![DynOrigin::Moon]);
1253 let results = analysis.compute().unwrap();
1254 let passes = analysis.to_passes(&results);
1255 let key = (gs.id().clone(), sc.id().clone());
1256 let pair_passes = &passes[&key];
1257 let expected = contacts_combined();
1258 assert_eq!(pair_passes.len(), expected.len());
1259 for (actual, expected) in zip(pair_passes, expected) {
1260 assert_approx_eq!(actual.interval().start(), expected.start(), rtol <= 1e-4);
1261 assert_approx_eq!(actual.interval().end(), expected.end(), rtol <= 1e-4);
1262 }
1263 }
1264
1265 #[test]
1266 fn test_pass_observables_above_mask() {
1267 let gs_loc = location_dyn();
1268 let mask = ElevationMask::with_fixed_elevation(10.0_f64.to_radians());
1269 let sc_traj = spacecraft_trajectory_dyn();
1270 let gs = GroundStation::new("cebreros", gs_loc, mask);
1271 let sc = Spacecraft::new("lunar", OrbitSource::Trajectory(sc_traj.clone()));
1272 let spk = ephemeris();
1273 let ground_assets = [gs.clone()];
1274 let space_assets = [sc.clone()];
1275 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1276 let (scenario, ensemble) =
1277 make_scenario_and_ensemble(&ground_assets, &space_assets, interval);
1278 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk);
1279 let results = analysis.compute().unwrap();
1280 let passes = analysis.to_passes(&results);
1281 let key = (gs.id().clone(), sc.id().clone());
1282 let pair_passes = &passes[&key];
1283 let mask = gs.mask();
1284
1285 for pass in pair_passes {
1286 for obs in pass.observables() {
1287 let min_elevation = mask.min_elevation(obs.azimuth());
1288 assert!(
1289 obs.elevation() >= min_elevation,
1290 "Observable elevation {:.2}° is below mask minimum {:.2}° at azimuth {:.2}°",
1291 obs.elevation().to_degrees(),
1292 min_elevation.to_degrees(),
1293 obs.azimuth().to_degrees()
1294 );
1295 }
1296 }
1297 }
1298
1299 fn ground_station_trajectory() -> Trajectory<Tai, Earth, Icrf> {
1300 Trajectory::from_csv(&read_data_file("trajectory_cebr.csv"), Earth, Icrf).unwrap()
1301 }
1302
1303 fn spacecraft_trajectory_dyn() -> DynTrajectory {
1304 DynTrajectory::from_csv_dyn(
1305 &read_data_file("trajectory_lunar.csv"),
1306 DynOrigin::Earth,
1307 DynFrame::Icrf,
1308 )
1309 .unwrap()
1310 }
1311
1312 fn location_dyn() -> GroundLocation<DynOrigin> {
1313 let coords = LonLatAlt::from_degrees(-4.3676, 40.4527, 0.0).unwrap();
1314 GroundLocation::try_new(coords, DynOrigin::Earth).unwrap()
1315 }
1316
1317 fn contacts_tai() -> Vec<TimeInterval<Tai>> {
1318 let mut intervals = vec![];
1319 let mut reader = csv::Reader::from_path(data_file("contacts.csv")).unwrap();
1320 for result in reader.records() {
1321 let record = result.unwrap();
1322 let start = record[0].parse::<Utc>().unwrap().to_time();
1323 let end = record[1].parse::<Utc>().unwrap().to_time();
1324 intervals.push(TimeInterval::new(start, end));
1325 }
1326 intervals
1327 }
1328
1329 fn contacts_combined() -> Vec<TimeInterval<DynTimeScale>> {
1330 let mut intervals = vec![];
1331 let mut reader = csv::Reader::from_path(data_file("contacts_combined.csv")).unwrap();
1332 for result in reader.records() {
1333 let record = result.unwrap();
1334 let start = record[0].parse::<Utc>().unwrap().to_dyn_time();
1335 let end = record[1].parse::<Utc>().unwrap().to_dyn_time();
1336 intervals.push(TimeInterval::new(start, end));
1337 }
1338 intervals
1339 }
1340
1341 #[test]
1342 fn test_inter_satellite_visibility() {
1343 let sc_traj = spacecraft_trajectory_dyn();
1344 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1345 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1346 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1347 let spk = ephemeris();
1348 let space_assets = [sc1.clone(), sc2.clone()];
1349 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1350 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1351 .with_inter_satellite()
1352 .with_occulting_bodies(vec![DynOrigin::Earth]);
1353 let results = analysis.compute().unwrap();
1354 let intervals = results
1355 .intervals_for(sc1.id(), sc2.id())
1356 .expect("pair not found");
1357 assert_eq!(intervals.len(), 1);
1359 let tai_interval =
1360 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1361 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1362 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1363 }
1364
1365 #[test]
1366 fn test_inter_satellite_visibility_with_range_filter() {
1367 let sc_traj = spacecraft_trajectory_dyn();
1368 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1369 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1370 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1371 let spk = ephemeris();
1372 let space_assets = [sc1.clone(), sc2.clone()];
1373
1374 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1377 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1378 .with_inter_satellite()
1379 .with_max_range(Distance::kilometers(1000.0));
1380 let results = analysis.compute().unwrap();
1381 let intervals = results
1382 .intervals_for(sc1.id(), sc2.id())
1383 .expect("pair not found");
1384 let tai_interval =
1385 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1386 assert_eq!(intervals.len(), 1);
1387 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1388 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1389
1390 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1393 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1394 .with_inter_satellite()
1395 .with_min_range(Distance::kilometers(100.0));
1396 let results = analysis.compute().unwrap();
1397 let intervals = results
1398 .intervals_for(sc1.id(), sc2.id())
1399 .expect("pair not found");
1400 assert!(
1401 intervals.is_empty(),
1402 "expected no intervals for colocated spacecraft with min_range, got {}",
1403 intervals.len()
1404 );
1405 }
1406
1407 #[test]
1408 fn test_slew_rate_detect_fn() {
1409 let sc_traj = spacecraft_trajectory_dyn();
1411 let (epoch, origin, frame, data) = sc_traj.into_parts();
1412 let typed = Trajectory::from_parts(epoch.with_scale(Tai), origin, frame, data);
1413 let threshold = AngularRate::degrees_per_second(1.0);
1414 let detect = InterSatelliteSlewRateDetectFn {
1415 sc1: &typed,
1416 sc2: &typed,
1417 threshold,
1418 };
1419 let time = typed.start_time();
1420 let val = detect.eval(time).unwrap();
1421 assert_approx_eq!(val, threshold.to_radians_per_second(), rtol <= 1e-10);
1423 }
1424
1425 #[test]
1426 fn test_inter_satellite_visibility_with_slew_rate() {
1427 let sc_traj = spacecraft_trajectory_dyn();
1428 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1429
1430 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()))
1433 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1434 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj))
1435 .with_max_slew_rate(AngularRate::degrees_per_second(5.0));
1436 let spk = ephemeris();
1437 let space_assets = [sc1.clone(), sc2.clone()];
1438 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1439 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1440 let results = analysis.compute().unwrap();
1441 let intervals = results
1442 .intervals_for(sc1.id(), sc2.id())
1443 .expect("pair not found");
1444 let tai_interval =
1445 TimeInterval::new(interval.start().to_scale(Tai), interval.end().to_scale(Tai));
1446 assert_eq!(intervals.len(), 1);
1448 assert_approx_eq!(intervals[0].start(), tai_interval.start(), rtol <= 1e-10);
1449 assert_approx_eq!(intervals[0].end(), tai_interval.end(), rtol <= 1e-10);
1450 }
1451
1452 #[test]
1453 fn test_space_asset_max_slew_rate() {
1454 let sc_traj = spacecraft_trajectory_dyn();
1455 let sc = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj));
1456 assert!(sc.max_slew_rate().is_none());
1457
1458 let rate = AngularRate::degrees_per_second(2.5);
1459 let sc = sc.with_max_slew_rate(rate);
1460 assert_approx_eq!(
1461 sc.max_slew_rate().unwrap().to_degrees_per_second(),
1462 2.5,
1463 rtol <= 1e-10
1464 );
1465 }
1466
1467 fn oneweb_trajectories() -> (DynTrajectory, DynTrajectory) {
1472 use lox_orbits::propagators::Propagator;
1473 use lox_orbits::propagators::sgp4::{Elements, Sgp4};
1474 use lox_time::intervals::Interval;
1475
1476 let tle1 = Elements::from_tle(
1477 Some("ONEWEB-0012".to_string()),
1478 b"1 44057U 19010A 24322.58825131 .00000088 00000+0 19693-3 0 9993",
1479 b"2 44057 87.9092 343.6767 0002420 76.7970 283.3431 13.16592150275693",
1480 )
1481 .unwrap();
1482 let tle2 = Elements::from_tle(
1483 Some("ONEWEB-0017".to_string()),
1484 b"1 45132U 20008B 24322.88240834 -.00000016 00000+0 -81930-4 0 9998",
1485 b"2 45132 87.8896 151.0343 0001369 78.1189 282.0092 13.10376984232476",
1486 )
1487 .unwrap();
1488
1489 let sgp4_1 = Sgp4::new(tle1).unwrap();
1490 let sgp4_2 = Sgp4::new(tle2).unwrap();
1491
1492 let t0 = sgp4_1.time().max(sgp4_2.time());
1494 let t1 = t0 + TimeDelta::from_hours(2);
1495 let interval = Interval::new(t0, t1);
1496
1497 let traj1 = sgp4_1
1498 .with_step(TimeDelta::from_seconds(10))
1499 .propagate(interval)
1500 .unwrap()
1501 .into_dyn();
1502 let traj2 = sgp4_2
1503 .with_step(TimeDelta::from_seconds(10))
1504 .propagate(interval)
1505 .unwrap()
1506 .into_dyn();
1507
1508 (traj1, traj2)
1509 }
1510
1511 #[test]
1512 fn test_slew_rate_trims_windows_for_crossing_orbits() {
1513 let (traj1, traj2) = oneweb_trajectories();
1514 let interval = TimeInterval::new(traj1.start_time(), traj1.end_time());
1515
1516 let spk = ephemeris();
1517
1518 let sc1_no_limit = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1.clone()));
1521 let sc2_no_limit = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2.clone()));
1522 let space_assets = [sc1_no_limit.clone(), sc2_no_limit.clone()];
1523 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1524 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1525 let results_no_limit = analysis.compute().unwrap();
1526 let intervals_no_limit = results_no_limit
1527 .intervals_for(sc1_no_limit.id(), sc2_no_limit.id())
1528 .expect("pair not found");
1529
1530 let sc1_limited = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1))
1533 .with_max_slew_rate(AngularRate::degrees_per_second(0.01));
1534 let sc2_limited = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2))
1535 .with_max_slew_rate(AngularRate::degrees_per_second(0.01));
1536 let space_assets = [sc1_limited.clone(), sc2_limited.clone()];
1537 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1538 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1539 let results_limited = analysis.compute().unwrap();
1540 let intervals_limited = results_limited
1541 .intervals_for(sc1_limited.id(), sc2_limited.id())
1542 .expect("pair not found");
1543
1544 let total_no_limit: f64 = intervals_no_limit
1546 .iter()
1547 .map(|i| (i.end() - i.start()).to_seconds().to_f64())
1548 .sum();
1549 let total_limited: f64 = intervals_limited
1550 .iter()
1551 .map(|i| (i.end() - i.start()).to_seconds().to_f64())
1552 .sum();
1553 assert!(
1554 total_limited < total_no_limit,
1555 "slew rate constraint should reduce total visibility (got {total_limited:.0}s vs {total_no_limit:.0}s)"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_inter_satellite_asymmetric_slew_rate_sc1_only() {
1561 let sc_traj = spacecraft_trajectory_dyn();
1562 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1563
1564 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()))
1566 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1567 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj));
1568 let spk = ephemeris();
1569 let space_assets = [sc1.clone(), sc2.clone()];
1570 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1571 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1572 let results = analysis.compute().unwrap();
1573 let intervals = results
1574 .intervals_for(sc1.id(), sc2.id())
1575 .expect("pair not found");
1576 assert_eq!(intervals.len(), 1);
1578 }
1579
1580 #[test]
1581 fn test_inter_satellite_asymmetric_slew_rate_sc2_only() {
1582 let sc_traj = spacecraft_trajectory_dyn();
1583 let interval = TimeInterval::new(sc_traj.start_time(), sc_traj.end_time());
1584
1585 let sc1 = Spacecraft::new("sc1", OrbitSource::Trajectory(sc_traj.clone()));
1587 let sc2 = Spacecraft::new("sc2", OrbitSource::Trajectory(sc_traj))
1588 .with_max_slew_rate(AngularRate::degrees_per_second(10.0));
1589 let spk = ephemeris();
1590 let space_assets = [sc1.clone(), sc2.clone()];
1591 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1592 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk).with_inter_satellite();
1593 let results = analysis.compute().unwrap();
1594 let intervals = results
1595 .intervals_for(sc1.id(), sc2.id())
1596 .expect("pair not found");
1597 assert_eq!(intervals.len(), 1);
1598 }
1599
1600 #[test]
1601 fn test_inter_satellite_both_min_and_max_range() {
1602 let (traj1, traj2) = oneweb_trajectories();
1603 let interval = TimeInterval::new(traj1.start_time(), traj1.end_time());
1604 let sc1 = Spacecraft::new("ow12", OrbitSource::Trajectory(traj1));
1605 let sc2 = Spacecraft::new("ow17", OrbitSource::Trajectory(traj2));
1606 let spk = ephemeris();
1607 let space_assets = [sc1.clone(), sc2.clone()];
1608 let (scenario, ensemble) = make_scenario_and_ensemble(&[], &space_assets, interval);
1609 let analysis = VisibilityAnalysis::new(&scenario, &ensemble, spk)
1611 .with_inter_satellite()
1612 .with_min_range(Distance::kilometers(100.0))
1613 .with_max_range(Distance::kilometers(5000.0));
1614 let results = analysis.compute().unwrap();
1615 let intervals = results
1616 .intervals_for(sc1.id(), sc2.id())
1617 .expect("pair not found");
1618 assert!(!intervals.is_empty());
1620 }
1621
1622 fn ephemeris() -> &'static Spk {
1623 static EPHEMERIS: OnceLock<Spk> = OnceLock::new();
1624 EPHEMERIS.get_or_init(|| Spk::from_file(data_file("spice/de440s.bsp")).unwrap())
1625 }
1626}