Skip to main content

lox_analysis/
visibility.rs

1// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5use 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
32// ---------------------------------------------------------------------------
33// Line-of-sight geometry
34// ---------------------------------------------------------------------------
35
36// Salvatore Alfano, David Negron, Jr., and Jennifer L. Moore
37// Rapid Determination of Satellite Visibility Periods
38// The Journal of the Astronautical Sciences. Vol. 40, No. 2, April-June 1992, pp. 281-296
39
40/// Computes the line-of-sight angle for a spherical body with the given `radius`.
41///
42/// Returns a positive value when the two position vectors `r1` and `r2` have
43/// mutual line of sight, and a negative value when they are occluded.
44pub 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    // Clamp to the domain of `acos` to avoid floating point errors when `r1 == r2`.
50    let theta = (r1.dot(r2) / r1n / r2n).clamp(-1.0, 1.0);
51    theta1.acos() + theta2.acos() - theta.acos()
52}
53
54/// Computes the line-of-sight angle for a spheroid body, scaling the z-axis
55/// to account for oblateness before delegating to [`line_of_sight`].
56pub 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
70/// Extension trait for computing line-of-sight between two position vectors
71/// around a body that implements [`TrySpheroid`] and [`TryMeanRadius`].
72pub trait LineOfSight: TrySpheroid + TryMeanRadius {
73    /// Computes the line-of-sight angle, using a spheroid model when available.
74    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// ---------------------------------------------------------------------------
92// Elevation mask
93// ---------------------------------------------------------------------------
94
95/// Errors from constructing an [`ElevationMask`].
96#[derive(Debug, Clone, Error, PartialEq)]
97pub enum ElevationMaskError {
98    /// The azimuth range does not span \[-π, π\].
99    #[error("invalid azimuth range: {}..{}", .0.to_degrees(), .1.to_degrees())]
100    InvalidAzimuthRange(f64, f64),
101    /// Failed to construct the interpolation series.
102    #[error("series error")]
103    SeriesError(#[from] SeriesError),
104}
105
106/// Minimum elevation angle as a function of azimuth.
107///
108/// Can be either a constant angle ([`Fixed`](Self::Fixed)) or an
109/// azimuth-dependent piecewise-linear profile ([`Variable`](Self::Variable)).
110#[derive(Debug, Clone, PartialEq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112pub enum ElevationMask {
113    /// Constant minimum elevation angle (radians).
114    Fixed(f64),
115    /// Azimuth-dependent minimum elevation angle (interpolated series).
116    Variable(Series),
117}
118
119impl ElevationMask {
120    /// Creates a variable elevation mask from paired azimuth/elevation vectors (radians).
121    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    /// Creates a fixed elevation mask with a constant minimum elevation (radians).
137    pub fn with_fixed_elevation(elevation: f64) -> Self {
138        Self::Fixed(elevation)
139    }
140
141    /// Returns the minimum elevation angle (radians) at the given azimuth.
142    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// ---------------------------------------------------------------------------
151// Error types
152// ---------------------------------------------------------------------------
153
154/// Errors from visibility interval computation.
155#[derive(Debug, Error)]
156pub enum VisibilityError {
157    /// Event detection failed.
158    #[error(transparent)]
159    Detect(#[from] DetectError),
160    /// Series interpolation failed.
161    #[error(transparent)]
162    Series(#[from] SeriesError),
163}
164
165/// Error returned when computing passes for an invalid pair type.
166#[derive(Debug, Error)]
167pub enum PassError {
168    #[error(
169        "passes are not supported for inter-satellite pair ({0}, {1}): use intervals() instead"
170    )]
171    /// Passes are not supported for inter-satellite pairs; use intervals instead.
172    InterSatellitePair(String, String),
173}
174
175// ---------------------------------------------------------------------------
176// Pass
177// ---------------------------------------------------------------------------
178
179/// A visibility pass between a ground station and spacecraft.
180///
181/// Stores the time interval, sampled times, observables, and `Series` for
182/// each observable channel to support interpolation.
183#[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
195/// A visibility pass using a dynamic time scale.
196pub type DynPass = Pass<DynTimeScale>;
197
198impl DynPass {
199    /// Create a Pass from an interval, calculating observables for times when
200    /// the satellite is above the elevation mask.
201    ///
202    /// Returns `None` if the satellite is never above the mask within the interval.
203    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    /// Create a new Pass with Series-based interpolation.
238    ///
239    /// Requires at least 2 data points so that the observables can be
240    /// interpolated. Returns `Err(SeriesError::InsufficientPoints)` otherwise.
241    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    /// Returns the time interval of this pass.
283    pub fn interval(&self) -> &TimeInterval<T> {
284        &self.interval
285    }
286
287    /// Returns the sampled time points within the pass.
288    pub fn times(&self) -> &[Time<T>] {
289        &self.times
290    }
291
292    /// Returns the sampled observables at each time point.
293    pub fn observables(&self) -> &[Observables] {
294        &self.observables
295    }
296
297    /// Interpolates observables at the given time, or `None` if outside the pass interval.
298    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// ---------------------------------------------------------------------------
322// DetectFn error type
323// ---------------------------------------------------------------------------
324
325/// Errors from detect function evaluation.
326#[derive(Debug, Error)]
327pub enum EvalError {
328    /// Frame rotation failed.
329    #[error("rotation error: {0}")]
330    Rotation(Box<dyn std::error::Error + Send + Sync>),
331    /// A required origin property (e.g. mean radius) is undefined.
332    #[error(transparent)]
333    UndefinedProperty(#[from] UndefinedOriginPropertyError),
334    /// Ephemeris lookup failed.
335    #[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
351// ---------------------------------------------------------------------------
352// DetectFn implementations
353// ---------------------------------------------------------------------------
354
355/// Elevation above mask for a ground station / spacecraft pair.
356///
357/// Generic over origin `O` and frame `R`. The detect function:
358/// 1. Interpolates the spacecraft trajectory at the given time
359/// 2. Rotates the state into the body-fixed frame via `TryRotation<R, DynFrame, Tai>`
360/// 3. Computes observables (azimuth, elevation, range, range rate)
361/// 4. Returns elevation minus minimum elevation from the mask
362struct 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
389/// Line-of-sight between a ground station and spacecraft, relative to an
390/// occulting body.
391struct 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        // Convert Tai → Tdb for ephemeris lookup (infallible via DefaultOffsetProvider).
412        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        // Compute ground station position in the scenario frame R by rotating
419        // from body-fixed → R.
420        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
429/// Line-of-sight between two spacecraft, relative to an occulting body.
430struct 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
457/// Direction for inter-satellite range threshold comparison.
458enum RangeDirection {
459    /// Positive when range < threshold (i.e. `threshold - range`).
460    Max,
461    /// Positive when range > threshold (i.e. `range - threshold`).
462    Min,
463}
464
465/// Range threshold detector for inter-satellite pairs.
466struct 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
492/// Slew rate (angular rate) threshold detector for inter-satellite pairs.
493///
494/// The angular rate ω = |r × v| / |r|² is symmetric between the two
495/// spacecraft.  The detector returns `threshold - ω`, positive when the
496/// angular rate is within the limit.
497struct 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// ---------------------------------------------------------------------------
526// VisibilityResults
527// ---------------------------------------------------------------------------
528
529/// Distinguishes ground-to-space from inter-satellite visibility pairs.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
531pub enum PairType {
532    /// Ground station to spacecraft pair.
533    GroundSpace,
534    /// Spacecraft to spacecraft pair.
535    InterSatellite,
536}
537
538type IntervalMap = HashMap<(AssetId, AssetId), Vec<TimeInterval<Tai>>>;
539type PairTypeMap = HashMap<(AssetId, AssetId), PairType>;
540
541/// Stores raw visibility intervals per asset pair.
542///
543/// This is the primary result type for visibility analysis. Intervals are
544/// cheap to compute; conversion to [`Pass`] (with observables) happens
545/// separately and on demand.
546pub struct VisibilityResults {
547    intervals: IntervalMap,
548    pair_types: PairTypeMap,
549}
550
551impl VisibilityResults {
552    /// Return all intervals for a specific pair.
553    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    /// Return all intervals keyed by pair ids.
559    pub fn all_intervals(&self) -> &IntervalMap {
560        &self.intervals
561    }
562
563    /// Iterate over all pair keys.
564    pub fn pair_ids(&self) -> impl Iterator<Item = &(AssetId, AssetId)> {
565        self.intervals.keys()
566    }
567
568    /// Return the [`PairType`] for a given pair, if present.
569    pub fn pair_type(&self, id1: &AssetId, id2: &AssetId) -> Option<PairType> {
570        self.pair_types.get(&(id1.clone(), id2.clone())).copied()
571    }
572
573    /// Return pair ids for ground-to-space pairs only.
574    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    /// Return pair ids for inter-satellite pairs only.
583    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    /// Returns `true` if no visibility intervals were found.
592    pub fn is_empty(&self) -> bool {
593        self.intervals.is_empty()
594    }
595
596    /// Returns the number of asset pairs with visibility data.
597    pub fn num_pairs(&self) -> usize {
598        self.intervals.len()
599    }
600
601    /// Total number of visibility intervals across all pairs.
602    pub fn total_intervals(&self) -> usize {
603        self.intervals.values().map(|v| v.len()).sum()
604    }
605
606    /// Consume self and return the inner intervals and pair types maps.
607    pub fn into_parts(self) -> (IntervalMap, PairTypeMap) {
608        (self.intervals, self.pair_types)
609    }
610
611    /// Convert intervals for a specific ground-space pair to visibility passes.
612    ///
613    /// Returns an error if the pair is an inter-satellite pair, since passes
614    /// with ground-station observables are not meaningful for such pairs.
615    /// Returns an empty vec if the pair is not found.
616    #[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
660// ---------------------------------------------------------------------------
661// VisibilityAnalysis
662// ---------------------------------------------------------------------------
663
664/// Computes ground-station-to-spacecraft and inter-satellite visibility.
665///
666/// Generic over origin `O`, reference frame `R`, and ephemeris `E`.
667/// Ground-to-space pairs are always computed when ground assets are present.
668/// Inter-satellite pairs are additionally computed when enabled via
669/// [`with_inter_satellite`](Self::with_inter_satellite).
670///
671/// Trajectories are looked up from a pre-computed [`Ensemble`] by asset id.
672pub 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    /// Creates a new visibility analysis for the given scenario, ensemble, and ephemeris.
697    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    /// Enables inter-satellite visibility computation.
716    pub fn with_inter_satellite(mut self) -> Self {
717        self.inter_satellite = true;
718        self
719    }
720
721    /// Sets bodies that may occlude the line of sight.
722    pub fn with_occulting_bodies(mut self, bodies: Vec<DynOrigin>) -> Self {
723        self.occulting_bodies = bodies;
724        self
725    }
726
727    /// Sets the time step for event detection sampling.
728    pub fn with_step(mut self, step: TimeDelta) -> Self {
729        self.step = step;
730        self
731    }
732
733    /// Sets the minimum pass duration; shorter passes will be discarded.
734    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    /// Sets the minimum range filter for inter-satellite links.
740    pub fn with_min_range(mut self, min_range: Distance) -> Self {
741        self.min_range = Some(min_range);
742        self
743    }
744
745    /// Sets the maximum range filter for inter-satellite links.
746    pub fn with_max_range(mut self, max_range: Distance) -> Self {
747        self.max_range = Some(max_range);
748        self
749    }
750
751    /// Returns the current time step.
752    pub fn step(&self) -> TimeDelta {
753        self.step
754    }
755
756    /// Apply `min_pass_duration` → `coarse_step` conversion to a detector.
757    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    /// Compute visibility intervals for a single (ground, space) pair.
772    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    /// Compute LOS intervals for a single inter-satellite pair,
820    /// optionally filtered by min/max range constraints.
821    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        // Resolve per-pair slew rate limit: min of both assets' limits.
833        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        // Start with range constraints (cheapest: position-only).
874        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        // Slew rate constraint (medium cost: position + velocity).
888        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        // Chain LOS detectors onto previous windows (most expensive: requires ephemeris).
904        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    /// Compute visibility intervals for all pairs.
916    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    /// Compute ground-to-space visibility for all (ground, space) pairs.
943    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    /// Compute LOS visibility for all unique spacecraft pairs (i, j) where i < j.
985    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    /// Convert all ground-space intervals in a [`VisibilityResults`] to passes.
1030    ///
1031    /// Inter-satellite pairs are skipped since passes with ground-station
1032    /// observables are not meaningful for them.
1033    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                        // Convert Tai interval to DynTimeScale for DynPass::from_interval
1055                        let dyn_interval = TimeInterval::new(
1056                            interval.start().into_dyn(),
1057                            interval.end().into_dyn(),
1058                        );
1059                        // Convert typed trajectory to DynTrajectory for pass computation
1060                        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// ---------------------------------------------------------------------------
1078// Tests
1079// ---------------------------------------------------------------------------
1080
1081#[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    /// Build a DynScenario + DynEnsemble from ground/space assets and a DynTimeScale interval.
1100    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        // Build ensemble from OrbitSource::Trajectory entries
1114        let mut map = HashMap::new();
1115        for sc in space_assets {
1116            if let OrbitSource::Trajectory(traj) = sc.orbit() {
1117                // Re-tag DynTrajectory as Ensemble<Tai, DynOrigin, DynFrame>
1118                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        // Build a typed trajectory for the ElevationDetectFn
1176        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        // Use the ground station trajectory times converted to Tai
1185        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        // Colocated spacecraft are always visible to each other.
1358        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        // Colocated spacecraft have range = 0. A max_range filter with a large
1375        // threshold should still return the full interval.
1376        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        // A min_range filter with a positive threshold should exclude colocated
1391        // spacecraft entirely (range = 0 < threshold at all times).
1392        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        // Two colocated trajectories have zero angular rate → always within limit.
1410        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        // ω = 0 for colocated → threshold - 0 = threshold
1422        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        // Colocated spacecraft have ω = 0. A generous slew rate limit should
1431        // keep the full interval.
1432        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        // ω = 0 everywhere, so full interval should be returned.
1447        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    // Two OneWeb satellites in different orbital planes (~192° RAAN separation).
1468    // ONEWEB-0012: RAAN 343.68°, ONEWEB-0017: RAAN 151.03°
1469    // Their crossing orbits produce high angular rates during close approaches.
1470
1471    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        // Use the later epoch as start so both TLEs are valid.
1493        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        // Without slew rate constraint: should have visibility (no other
1519        // constraints → full interval returned).
1520        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        // With a tight slew rate constraint (0.01 deg/s): should trim windows
1531        // compared to the unconstrained case.
1532        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        // The constrained intervals should be strictly shorter in total duration.
1545        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        // Only sc1 has a slew rate limit — exercises the (Some(a), None) branch.
1565        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        // Colocated → ω = 0, full interval returned.
1577        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        // Only sc2 has a slew rate limit — exercises the (None, Some(b)) branch.
1586        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        // Set both min and max range to exercise the intersection branch (line 835).
1610        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        // Should have some visibility windows within the range band.
1619        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}