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