Skip to main content

sidereon_core/astro/
passes.rs

1//! TLE pass prediction over a ground station.
2//!
3//! This module owns the legacy Sidereon pass orchestration ([`predict_passes`]:
4//! coarse elevation sampling, horizon-crossing bisection, peak-elevation search)
5//! and the event-finder-backed pass finder ([`find_passes`]: elevation-mask
6//! crossings refined by the shared event finder, and culmination through the
7//! shared extrema finder). The
8//! low-level propagation and frame transforms stay delegated to the core SGP4
9//! and frames modules.
10
11use crate::astro::constants::units::MICROSECONDS_PER_SECOND_I64;
12use crate::astro::constants::{
13    earth::{GM_EARTH_KM3_S2, OMEGA_E_DOT_RAD_S},
14    time::{MICROSECONDS_PER_DAY_I64, SECONDS_PER_DAY, SECONDS_PER_DAY_I64},
15};
16use crate::astro::events::root::{
17    bisect_crossing_by_iterations, bisect_crossing_until, sign_change_bracketed,
18};
19use crate::astro::events::{
20    CrossingDirection, CrossingEvent, EventFinder, EventFinderError, ExtremumEvent, ExtremumKind,
21    ScalarEventPredicate,
22};
23use crate::astro::frames::transforms::{
24    gcrs_to_topocentric_compute, geodetic_to_itrs, teme_to_gcrs_compute, FrameTransformError,
25    GeodeticStationKm, TemeStateKm,
26};
27use crate::astro::sgp4::{
28    ElementSet, Error as Sgp4Error, JulianDate, OpsMode, Prediction, Satellite,
29};
30use crate::astro::time::civil::civil_from_julian_day_number;
31use crate::astro::time::scales::{julian_day_number, TimeScales};
32use crate::validate;
33use rayon::prelude::*;
34
35const UNIX_EPOCH_JDN: i64 = 2_440_588;
36
37const BISECT_ITERATIONS: usize = 20;
38const GOLDEN_ITERATIONS: usize = 30;
39const GOLDEN_RESPHI: f64 = 0.381_966_011_250_105_1;
40const ROBUST_CROSSING_FAST_SAMPLES_PER_FASTEST_REV: f64 = 360.0;
41const ROBUST_CROSSING_SLOW_SAMPLES_PER_FASTEST_REV: f64 = 48.0;
42const ROBUST_CROSSING_SLOW_ORBIT_SECONDS: f64 = 12.0 * 60.0 * 60.0;
43const ROBUST_CROSSING_MASK_DWELL_SAMPLES: f64 = 4.0;
44const EVENT_FINDER_COARSE_SAMPLE_LIMIT: i64 = 1_000_000;
45
46/// UTC instant represented as unix microseconds.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub struct UtcInstant {
49    unix_microseconds: i64,
50}
51
52impl UtcInstant {
53    /// Construct from unix microseconds.
54    pub fn from_unix_microseconds(unix_microseconds: i64) -> Self {
55        Self { unix_microseconds }
56    }
57
58    /// Construct from UTC calendar fields.
59    ///
60    /// This type stores a POSIX-like Unix microsecond count, so leap-second
61    /// labels cannot be represented distinctly from the following midnight.
62    pub fn from_utc(
63        year: i32,
64        month: i32,
65        day: i32,
66        hour: i32,
67        minute: i32,
68        second: i32,
69        microsecond: i32,
70    ) -> Option<Self> {
71        if !(0..=999_999).contains(&microsecond) {
72            return None;
73        }
74        validate::civil_datetime_with_second_policy(
75            i64::from(year),
76            i64::from(month),
77            i64::from(day),
78            i64::from(hour),
79            i64::from(minute),
80            f64::from(second),
81            validate::CivilSecondPolicy::UtcLike,
82        )
83        .ok()?;
84        if second == 60 {
85            return None;
86        }
87
88        let days = julian_day_number(year, month, day) - UNIX_EPOCH_JDN;
89        let seconds_of_day = hour as i64 * 3600 + minute as i64 * 60 + second as i64;
90        Some(Self {
91            unix_microseconds: days * MICROSECONDS_PER_DAY_I64
92                + seconds_of_day * MICROSECONDS_PER_SECOND_I64
93                + microsecond as i64,
94        })
95    }
96
97    /// Unix microseconds.
98    pub fn unix_microseconds(self) -> i64 {
99        self.unix_microseconds
100    }
101
102    // Time arithmetic saturates rather than panicking (debug) or wrapping
103    // (release). `UtcInstant` is public and constructible at the i64 extremes, so
104    // an out-of-range window must degrade gracefully; the public entry points
105    // additionally reject an unrepresentable span up front via
106    // [`validate_pass_window`]. For every in-range window these are exact, so no
107    // golden trajectory changes.
108    fn add_microseconds(self, delta: i64) -> Self {
109        Self {
110            unix_microseconds: self.unix_microseconds.saturating_add(delta),
111        }
112    }
113
114    fn diff_microseconds(self, earlier: Self) -> i64 {
115        self.unix_microseconds
116            .saturating_sub(earlier.unix_microseconds)
117    }
118
119    /// Microsecond span `self - earlier`, or `None` if it overflows `i64`.
120    fn checked_diff_microseconds(self, earlier: Self) -> Option<i64> {
121        self.unix_microseconds
122            .checked_sub(earlier.unix_microseconds)
123    }
124
125    fn diff_seconds(self, earlier: Self) -> i64 {
126        self.diff_microseconds(earlier) / MICROSECONDS_PER_SECOND_I64
127    }
128
129    fn components(self) -> UtcComponents {
130        let seconds = div_floor(self.unix_microseconds, MICROSECONDS_PER_SECOND_I64);
131        let microsecond = rem_floor(self.unix_microseconds, MICROSECONDS_PER_SECOND_I64);
132        let days = div_floor(seconds, SECONDS_PER_DAY_I64);
133        let second_of_day = seconds - days * SECONDS_PER_DAY_I64;
134        let (year, month, day) = civil_from_days(days);
135
136        UtcComponents {
137            year,
138            month,
139            day,
140            hour: (second_of_day / 3600) as i32,
141            minute: ((second_of_day % 3600) / 60) as i32,
142            second: (second_of_day % 60) as i32,
143            microsecond: microsecond as i32,
144        }
145    }
146
147    /// Resolve the precise split-Julian-date time scales (UT1/TT/TDB) for this
148    /// UTC instant, via the parity-critical [`TimeScales::from_utc`] path.
149    ///
150    /// Exposed so a Rust or Python consumer can reach the precise time scales for
151    /// an instant given only its unix-microsecond UTC stamp, without restating the
152    /// calendar breakdown. The numerics are unchanged from the internal pass-finder
153    /// use.
154    pub fn time_scales(self) -> TimeScales {
155        let c = self.components();
156        TimeScales::from_utc(
157            c.year,
158            c.month,
159            c.day,
160            c.hour,
161            c.minute,
162            c.second as f64 + c.microsecond as f64 / 1_000_000.0,
163        )
164        .expect("UtcInstant components produce a finite UTC second")
165    }
166
167    fn sgp4_julian_date(self) -> JulianDate {
168        let c = self.components();
169        let jdn = julian_day_number(c.year, c.month, c.day);
170        let jd_midnight = jdn as f64 - 0.5;
171        let frac = (c.hour as f64) / 24.0
172            + (c.minute as f64) / 1440.0
173            + (c.second as f64) / SECONDS_PER_DAY
174            + (c.microsecond as f64) / MICROSECONDS_PER_DAY_I64 as f64;
175        JulianDate(jd_midnight, frac)
176    }
177}
178
179#[derive(Debug, Clone, Copy)]
180struct UtcComponents {
181    year: i32,
182    month: i32,
183    day: i32,
184    hour: i32,
185    minute: i32,
186    second: i32,
187    microsecond: i32,
188}
189
190/// Ground-station geodetic coordinates.
191#[derive(Debug, Clone, Copy, PartialEq)]
192pub struct GroundStation {
193    pub latitude_deg: f64,
194    pub longitude_deg: f64,
195    pub altitude_m: f64,
196}
197
198/// Pass-prediction options.
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct PassPredictionOptions {
201    pub min_elevation_deg: f64,
202    pub step_seconds: i64,
203}
204
205impl Default for PassPredictionOptions {
206    fn default() -> Self {
207        Self {
208            min_elevation_deg: 0.0,
209            step_seconds: 60,
210        }
211    }
212}
213
214/// Predicted visible pass.
215#[derive(Debug, Clone, Copy, PartialEq)]
216pub struct PredictedPass {
217    pub rise: UtcInstant,
218    pub set: UtcInstant,
219    pub max_elevation_deg: f64,
220    pub max_elevation_time: UtcInstant,
221}
222
223/// Error while planning a TLE-backed pass arc.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
225pub enum PassError {
226    #[error("invalid pass input {field}: {reason}")]
227    InvalidInput {
228        field: &'static str,
229        reason: &'static str,
230    },
231}
232
233/// Topocentric look angle from a ground station to a TLE satellite.
234#[derive(Debug, Clone, Copy, PartialEq)]
235pub struct LookAngle {
236    /// Topocentric azimuth in `[0, 360)`, degrees.
237    ///
238    /// At (and arbitrarily near) the station zenith the azimuth is geometrically
239    /// undefined; it is defined here to be exactly `0.0` once the horizontal
240    /// line-of-sight projection falls below
241    /// [`crate::constants::AZIMUTH_ZENITH_EPS`], rather than returning rounding
242    /// noise or erroring.
243    pub azimuth_deg: f64,
244    pub elevation_deg: f64,
245    pub range_km: f64,
246}
247
248/// One member of a TLE-backed constellation.
249#[derive(Debug, Clone, PartialEq)]
250pub struct ConstellationMember {
251    pub catalog_number: String,
252    pub elements: ElementSet,
253}
254
255/// One satellite visible from a ground station at an instant.
256#[derive(Debug, Clone, PartialEq)]
257pub struct VisibleSatellite {
258    pub catalog_number: String,
259    pub azimuth_deg: f64,
260    pub elevation_deg: f64,
261    pub range_km: f64,
262    pub position_km: [f64; 3],
263}
264
265/// Error while computing a TLE look angle.
266#[derive(Debug, Clone, PartialEq, thiserror::Error)]
267pub enum LookAngleError {
268    #[error("invalid look-angle input {field}: {reason}")]
269    InvalidInput {
270        field: &'static str,
271        reason: &'static str,
272    },
273    #[error("SGP4 initialization failed: {0}")]
274    Init(crate::astro::sgp4::Error),
275    #[error("SGP4 propagation failed: {0}")]
276    Propagate(crate::astro::sgp4::Error),
277    #[error("look-angle frame transform failed: {0}")]
278    FrameTransform(#[from] FrameTransformError),
279}
280
281/// Propagate a pre-parsed SGP4 element set and compute its topocentric look angle.
282pub fn look_angle(
283    elements: &ElementSet,
284    ground_station: GroundStation,
285    datetime: UtcInstant,
286) -> Result<LookAngle, LookAngleError> {
287    validate_ground_station(ground_station).map_err(map_look_angle_input)?;
288    let ts = time_scales_for_look_angle(datetime)?;
289    let satellite = Satellite::from_elements_with_opsmode(elements, OpsMode::Afspc)
290        .map_err(LookAngleError::Init)?;
291    let pred = satellite
292        .propagate_jd(datetime.sgp4_julian_date())
293        .map_err(LookAngleError::Propagate)?;
294    look_angle_from_teme_prediction(&pred, &ts, ground_station)
295}
296
297/// Propagate one already-initialized SGP4 satellite to TEME position/velocity at
298/// each UTC instant.
299///
300/// The satellite (its `satrec`) is built once by the caller, so this steps the
301/// pure propagation kernel over the epoch grid without re-running `sgp4init` per
302/// epoch. Each [`Prediction`] is TEME position (km) and velocity (km/s), bit-for-bit
303/// identical to calling [`Satellite::propagate_jd`] at the same instant. The first
304/// propagation error aborts the arc.
305pub fn propagate_teme_arc(
306    satellite: &Satellite,
307    datetimes: &[UtcInstant],
308) -> Result<Vec<Prediction>, Sgp4Error> {
309    datetimes
310        .iter()
311        .map(|&datetime| satellite.propagate_jd(datetime.sgp4_julian_date()))
312        .collect()
313}
314
315/// Topocentric look angle from a ground station to one already-initialized SGP4
316/// satellite at each UTC instant.
317///
318/// The satellite is built once by the caller; this steps it over the epoch grid
319/// and runs the same TEME-to-topocentric path as [`look_angle`], so each
320/// [`LookAngle`] is bit-for-bit identical to a per-epoch [`look_angle`] call. The
321/// first propagation error aborts the arc.
322pub fn look_angle_arc(
323    satellite: &Satellite,
324    ground_station: GroundStation,
325    datetimes: &[UtcInstant],
326) -> Result<Vec<LookAngle>, LookAngleError> {
327    validate_ground_station(ground_station).map_err(map_look_angle_input)?;
328    datetimes
329        .iter()
330        .map(|&datetime| {
331            let ts = time_scales_for_look_angle(datetime)?;
332            let pred = satellite
333                .propagate_jd(datetime.sgp4_julian_date())
334                .map_err(LookAngleError::Propagate)?;
335            look_angle_from_teme_prediction(&pred, &ts, ground_station)
336        })
337        .collect()
338}
339
340/// Propagate many already-initialized SGP4 satellites over a shared epoch grid,
341/// serially.
342///
343/// Element `i` of the result is the TEME arc for `satellites[i]` over
344/// `datetimes`, exactly as [`propagate_teme_arc`] would produce it on its own
345/// (the first propagation error in a satellite's arc becomes that element's
346/// `Err`). This is the single-threaded reference the parallel
347/// [`propagate_teme_batch_parallel`] is proven bit-identical against.
348pub fn propagate_teme_batch_serial(
349    satellites: &[Satellite],
350    datetimes: &[UtcInstant],
351) -> Vec<Result<Vec<Prediction>, Sgp4Error>> {
352    satellites
353        .iter()
354        .map(|satellite| propagate_teme_arc(satellite, datetimes))
355        .collect()
356}
357
358/// Propagate many already-initialized SGP4 satellites over a shared epoch grid,
359/// fanning the independent per-satellite arcs across a rayon thread pool.
360///
361/// Each satellite's arc is computed by the same serial [`propagate_teme_arc`]
362/// kernel and the indexed parallel collect preserves input order, so element `i`
363/// is byte-for-byte identical to element `i` of
364/// [`propagate_teme_batch_serial`]: no reduction, no cross-satellite state, and
365/// no reordering inside a single SGP4 propagation. The work is embarrassingly
366/// parallel (satellites are independent), so throughput scales with cores while
367/// every value stays bit-exact.
368pub fn propagate_teme_batch_parallel(
369    satellites: &[Satellite],
370    datetimes: &[UtcInstant],
371) -> Vec<Result<Vec<Prediction>, Sgp4Error>> {
372    satellites
373        .par_iter()
374        .map(|satellite| propagate_teme_arc(satellite, datetimes))
375        .collect()
376}
377
378/// Topocentric look angles for many already-initialized SGP4 satellites over a
379/// shared epoch grid, serially.
380///
381/// Element `i` is the look-angle arc for `satellites[i]` from `ground_station`,
382/// exactly as [`look_angle_arc`] would produce it. The serial reference for
383/// [`look_angle_batch_parallel`].
384pub fn look_angle_batch_serial(
385    satellites: &[Satellite],
386    ground_station: GroundStation,
387    datetimes: &[UtcInstant],
388) -> Vec<Result<Vec<LookAngle>, LookAngleError>> {
389    satellites
390        .iter()
391        .map(|satellite| look_angle_arc(satellite, ground_station, datetimes))
392        .collect()
393}
394
395/// Topocentric look angles for many already-initialized SGP4 satellites over a
396/// shared epoch grid, fanned across a rayon thread pool.
397///
398/// Each satellite's arc is computed by the same serial [`look_angle_arc`] kernel
399/// and the indexed parallel collect preserves input order, so element `i` is
400/// byte-for-byte identical to element `i` of [`look_angle_batch_serial`].
401pub fn look_angle_batch_parallel(
402    satellites: &[Satellite],
403    ground_station: GroundStation,
404    datetimes: &[UtcInstant],
405) -> Vec<Result<Vec<LookAngle>, LookAngleError>> {
406    satellites
407        .par_iter()
408        .map(|satellite| look_angle_arc(satellite, ground_station, datetimes))
409        .collect()
410}
411
412/// Sub-satellite (ground-track) geodetic points for one already-initialized
413/// satellite over a time grid.
414///
415/// For each instant this composes the existing core transforms - propagate to
416/// TEME ([`Satellite::propagate_jd`]), TEME->GCRS ([`teme_to_gcrs_compute`]),
417/// GCRS->ITRS/ECEF ([`gcrs_to_itrs_compute`]), then ECEF->geodetic
418/// ([`itrs_to_geodetic_compute`]) - and returns the WGS84 sub-point (geodetic
419/// latitude/longitude and ellipsoidal height). No geometry is reinvented; the
420/// same TEME->GCRS path [`look_angle`] uses feeds an ECEF step and the shared
421/// geodetic reduction. Like [`look_angle_arc`], the first propagation or frame
422/// error aborts the whole arc.
423pub fn ground_track(
424    satellite: &Satellite,
425    datetimes: &[UtcInstant],
426) -> Result<Vec<crate::frame::Wgs84Geodetic>, LookAngleError> {
427    use crate::astro::frames::transforms::{gcrs_to_itrs_compute, itrs_to_geodetic_compute};
428    use crate::frame::Wgs84Geodetic;
429
430    datetimes
431        .iter()
432        .map(|&datetime| {
433            let ts = time_scales_for_look_angle(datetime)?;
434            let pred = satellite
435                .propagate_jd(datetime.sgp4_julian_date())
436                .map_err(LookAngleError::Propagate)?;
437            let (gcrs_position, _) = teme_to_gcrs_compute(
438                &TemeStateKm {
439                    position_km: pred.position,
440                    velocity_km_s: pred.velocity,
441                },
442                &ts,
443                false,
444            )?;
445            let (x_km, y_km, z_km) = gcrs_to_itrs_compute(
446                gcrs_position.0,
447                gcrs_position.1,
448                gcrs_position.2,
449                &ts,
450                false,
451            )?;
452            let (lat_deg, lon_deg, alt_km) = itrs_to_geodetic_compute(x_km, y_km, z_km)?;
453            Wgs84Geodetic::new(lat_deg.to_radians(), lon_deg.to_radians(), alt_km * 1000.0)
454                .map_err(map_frame_value_to_look_angle)
455        })
456        .collect()
457}
458
459fn map_frame_value_to_look_angle(error: crate::frame::FrameValueError) -> LookAngleError {
460    match error {
461        crate::frame::FrameValueError::InvalidInput { field, reason } => {
462            LookAngleError::InvalidInput { field, reason }
463        }
464    }
465}
466
467/// Find constellation members above an elevation threshold at one instant.
468///
469/// Invalid element sets or per-satellite propagation failures are skipped,
470/// matching the legacy Sidereon `visible_from/4` behavior through
471/// `propagate_all/2`.
472pub fn visible_from_constellation(
473    members: &[ConstellationMember],
474    ground_station: GroundStation,
475    datetime: UtcInstant,
476    min_elevation_deg: f64,
477) -> Result<Vec<VisibleSatellite>, PassError> {
478    validate_ground_station(ground_station).map_err(map_pass_input)?;
479    validate_elevation_threshold(min_elevation_deg, "min_elevation_deg").map_err(map_pass_input)?;
480    let ts = time_scales_for_pass(datetime)?;
481
482    let mut visible = Vec::new();
483
484    for member in members {
485        let satellite =
486            match Satellite::from_elements_with_opsmode(&member.elements, OpsMode::Afspc) {
487                Ok(satellite) => satellite,
488                Err(_) => continue,
489            };
490        let pred = match satellite.propagate_jd(datetime.sgp4_julian_date()) {
491            Ok(pred) => pred,
492            Err(_) => continue,
493        };
494        let look = match look_angle_from_teme_prediction(&pred, &ts, ground_station) {
495            Ok(look) => look,
496            Err(_) => continue,
497        };
498
499        if look.elevation_deg >= min_elevation_deg {
500            visible.push(VisibleSatellite {
501                catalog_number: member.catalog_number.clone(),
502                azimuth_deg: look.azimuth_deg,
503                elevation_deg: look.elevation_deg,
504                range_km: look.range_km,
505                position_km: pred.position,
506            });
507        }
508    }
509
510    visible.sort_by(|a, b| {
511        b.elevation_deg
512            .partial_cmp(&a.elevation_deg)
513            .unwrap_or(std::cmp::Ordering::Equal)
514    });
515    Ok(visible)
516}
517
518/// Find visible satellites above an elevation threshold at one instant, from
519/// already-initialized [`Satellite`]s.
520///
521/// Unlike [`visible_from_constellation`], which rebuilds each satellite with a
522/// hardcoded [`OpsMode::Afspc`] from raw element sets, this variant operates on
523/// satellites the caller built (e.g. via [`crate::astro::sgp4::parse_tle_file`]),
524/// so each satellite's own opsmode is honored end-to-end: the result for a
525/// deep-space / opsmode-sensitive object differs between an `Afspc`-built and an
526/// `Improved`-built satellite, exactly as their propagation does.
527///
528/// Identity is supplied out-of-band as a parallel `ids` slice (one id per
529/// satellite, same order); `ids[i]` becomes [`VisibleSatellite::catalog_number`]
530/// for `satellites[i]`. This keeps [`VisibleSatellite`] faithful (the caller
531/// chooses whether the id is a NORAD number, a name, or anything else) and
532/// mirrors the `&[Satellite]` convention of [`look_angle_batch_serial`]. The
533/// slices must have equal length.
534///
535/// The geometry reuses the exact single-instant TEME-to-topocentric path of
536/// [`look_angle`] / [`look_angle_arc`]. Per-satellite propagation or frame
537/// failures are skipped (matching [`visible_from_constellation`]); the result is
538/// filtered by `min_elevation_deg` and sorted by elevation descending.
539pub fn visible_from_satellites(
540    satellites: &[Satellite],
541    ids: &[String],
542    ground_station: GroundStation,
543    datetime: UtcInstant,
544    min_elevation_deg: f64,
545) -> Result<Vec<VisibleSatellite>, PassError> {
546    validate_ground_station(ground_station).map_err(map_pass_input)?;
547    validate_elevation_threshold(min_elevation_deg, "min_elevation_deg").map_err(map_pass_input)?;
548    if ids.len() != satellites.len() {
549        return Err(invalid_pass_input("ids", "must have one id per satellite"));
550    }
551    let ts = time_scales_for_pass(datetime)?;
552
553    let mut visible = Vec::new();
554
555    for (satellite, id) in satellites.iter().zip(ids) {
556        let pred = match satellite.propagate_jd(datetime.sgp4_julian_date()) {
557            Ok(pred) => pred,
558            Err(_) => continue,
559        };
560        let look = match look_angle_from_teme_prediction(&pred, &ts, ground_station) {
561            Ok(look) => look,
562            Err(_) => continue,
563        };
564
565        if look.elevation_deg >= min_elevation_deg {
566            visible.push(VisibleSatellite {
567                catalog_number: id.clone(),
568                azimuth_deg: look.azimuth_deg,
569                elevation_deg: look.elevation_deg,
570                range_km: look.range_km,
571                position_km: pred.position,
572            });
573        }
574    }
575
576    visible.sort_by(|a, b| {
577        b.elevation_deg
578            .partial_cmp(&a.elevation_deg)
579            .unwrap_or(std::cmp::Ordering::Equal)
580    });
581    Ok(visible)
582}
583
584/// Predict visible passes for a pre-parsed SGP4 element set.
585///
586/// Invalid element sets or per-sample propagation failures are treated as
587/// below-horizon samples, matching the legacy Sidereon public behavior.
588pub fn predict_passes(
589    elements: &ElementSet,
590    ground_station: GroundStation,
591    start_time: UtcInstant,
592    end_time: UtcInstant,
593    options: PassPredictionOptions,
594) -> Result<Vec<PredictedPass>, PassError> {
595    predict_passes_with_opsmode(
596        elements,
597        ground_station,
598        start_time,
599        end_time,
600        options,
601        OpsMode::Afspc,
602    )
603}
604
605/// [`predict_passes`] with an explicit SGP4 [`OpsMode`].
606///
607/// [`predict_passes`] initializes SGP4 with [`OpsMode::Afspc`] (its established
608/// default, which the committed pass goldens are pinned to). The rest of the
609/// crate (`parse_tle_file`, [`Satellite::from_elements`], `propagate_elements`)
610/// defaults to [`OpsMode::Improved`], so the same TLE can otherwise yield a
611/// different trajectory by path. Pass [`OpsMode::Improved`] here to make a pass
612/// prediction consistent with those paths.
613pub fn predict_passes_with_opsmode(
614    elements: &ElementSet,
615    ground_station: GroundStation,
616    start_time: UtcInstant,
617    end_time: UtcInstant,
618    options: PassPredictionOptions,
619    opsmode: OpsMode,
620) -> Result<Vec<PredictedPass>, PassError> {
621    validate_ground_station(ground_station).map_err(map_pass_input)?;
622    let step_seconds = validate_pass_prediction_options(options)?;
623    validate_pass_window(start_time, end_time)?;
624
625    let satellite = match Satellite::from_elements_with_opsmode(elements, opsmode) {
626        Ok(satellite) => satellite,
627        Err(_) => return Ok(Vec::new()),
628    };
629
630    let samples = coarse_scan(
631        &satellite,
632        ground_station,
633        start_time,
634        end_time,
635        step_seconds,
636    );
637
638    Ok(extract_passes(&samples, &satellite, ground_station)
639        .into_iter()
640        .filter(|pass| pass.max_elevation_deg >= options.min_elevation_deg)
641        .collect())
642}
643
644fn coarse_scan(
645    satellite: &Satellite,
646    ground_station: GroundStation,
647    start_time: UtcInstant,
648    end_time: UtcInstant,
649    step_seconds: i64,
650) -> Vec<(UtcInstant, f64)> {
651    let total_seconds = end_time.diff_seconds(start_time);
652    let num_steps = (total_seconds / step_seconds).max(0);
653
654    let mut samples: Vec<(UtcInstant, f64)> = (0..=num_steps)
655        .map(|i| {
656            let dt = start_time.add_microseconds(i * step_seconds * MICROSECONDS_PER_SECOND_I64);
657            (dt, elevation_at(satellite, dt, ground_station))
658        })
659        .collect();
660
661    if let Some(&(last_dt, _)) = samples.last() {
662        if end_time.diff_microseconds(last_dt) > 0 {
663            samples.push((end_time, elevation_at(satellite, end_time, ground_station)));
664        }
665    }
666
667    samples
668}
669
670fn extract_passes(
671    samples: &[(UtcInstant, f64)],
672    satellite: &Satellite,
673    ground_station: GroundStation,
674) -> Vec<PredictedPass> {
675    let mut rise_time = match samples.first() {
676        Some((dt, el)) if *el >= 0.0 => Some(*dt),
677        _ => None,
678    };
679    let mut passes = Vec::new();
680
681    for pair in samples.windows(2) {
682        let (dt_a, el_a) = pair[0];
683        let (dt_b, el_b) = pair[1];
684
685        if rise_time.is_none() && el_a < 0.0 && el_b >= 0.0 {
686            rise_time = Some(bisect_crossing(satellite, ground_station, dt_a, dt_b));
687        } else if let Some(rise) = rise_time {
688            if el_a >= 0.0 && el_b < 0.0 {
689                let set = bisect_crossing(satellite, ground_station, dt_a, dt_b);
690                passes.push(build_pass(satellite, ground_station, rise, set));
691                rise_time = None;
692            }
693        }
694    }
695
696    passes
697}
698
699fn bisect_crossing(
700    satellite: &Satellite,
701    ground_station: GroundStation,
702    dt_low: UtcInstant,
703    dt_high: UtcInstant,
704) -> UtcInstant {
705    bisect_crossing_by_iterations(
706        dt_low,
707        dt_high,
708        BISECT_ITERATIONS,
709        |dt| elevation_at(satellite, dt, ground_station),
710        midpoint_instant,
711    )
712    .unwrap_or(dt_low)
713}
714
715fn midpoint_instant(a: UtcInstant, b: UtcInstant) -> UtcInstant {
716    a.add_microseconds(b.diff_microseconds(a) / 2)
717}
718
719fn build_pass(
720    satellite: &Satellite,
721    ground_station: GroundStation,
722    rise: UtcInstant,
723    set: UtcInstant,
724) -> PredictedPass {
725    let (max_elevation_deg, max_elevation_time) =
726        find_max_elevation(satellite, ground_station, rise, set);
727
728    PredictedPass {
729        rise,
730        set,
731        max_elevation_deg,
732        max_elevation_time,
733    }
734}
735
736fn find_max_elevation(
737    satellite: &Satellite,
738    ground_station: GroundStation,
739    rise: UtcInstant,
740    set: UtcInstant,
741) -> (f64, UtcInstant) {
742    let total_us = set.diff_microseconds(rise);
743    let mut a = 0_i64;
744    let mut b = total_us;
745
746    for _ in 0..GOLDEN_ITERATIONS {
747        let span = b - a;
748        let x1 = (a as f64 + GOLDEN_RESPHI * span as f64).round() as i64;
749        let x2 = (b as f64 - GOLDEN_RESPHI * span as f64).round() as i64;
750
751        let dt1 = rise.add_microseconds(x1);
752        let dt2 = rise.add_microseconds(x2);
753        let el1 = elevation_at(satellite, dt1, ground_station);
754        let el2 = elevation_at(satellite, dt2, ground_station);
755
756        if el1 > el2 {
757            b = x2;
758        } else {
759            a = x1;
760        }
761    }
762
763    let best_us = (a + b) / 2;
764    let best_dt = rise.add_microseconds(best_us);
765    let best_el = elevation_at(satellite, best_dt, ground_station);
766    (best_el, best_dt)
767}
768
769fn elevation_at(satellite: &Satellite, datetime: UtcInstant, ground_station: GroundStation) -> f64 {
770    let pred = match satellite.propagate_jd(datetime.sgp4_julian_date()) {
771        Ok(pred) => pred,
772        Err(_) => return -90.0,
773    };
774    let ts = match time_scales_for_look_angle(datetime) {
775        Ok(ts) => ts,
776        Err(_) => return -90.0,
777    };
778    match look_angle_from_teme_prediction(&pred, &ts, ground_station) {
779        Ok(look) => look.elevation_deg,
780        Err(_) => -90.0,
781    }
782}
783
784fn look_angle_from_teme_prediction(
785    pred: &Prediction,
786    ts: &TimeScales,
787    ground_station: GroundStation,
788) -> Result<LookAngle, LookAngleError> {
789    let (gcrs_position, _) = teme_to_gcrs_compute(
790        &TemeStateKm {
791            position_km: [pred.position[0], pred.position[1], pred.position[2]],
792            velocity_km_s: [pred.velocity[0], pred.velocity[1], pred.velocity[2]],
793        },
794        ts,
795        false,
796    )?;
797    let (azimuth, elevation, range) = gcrs_to_topocentric_compute(
798        [gcrs_position.0, gcrs_position.1, gcrs_position.2],
799        &GeodeticStationKm {
800            latitude_deg: ground_station.latitude_deg,
801            longitude_deg: ground_station.longitude_deg,
802            altitude_km: ground_station.altitude_m / 1000.0,
803        },
804        ts,
805        false,
806    )?;
807    validate_look_angle(LookAngle {
808        azimuth_deg: azimuth,
809        elevation_deg: elevation,
810        range_km: range,
811    })
812}
813
814/// Civil `(year, month, day)` from a day count relative to 1970-01-01, via the
815/// canonical inverse Julian Day Number (the Unix epoch is JDN
816/// [`UNIX_EPOCH_JDN`]).
817fn civil_from_days(days_since_unix_epoch: i64) -> (i32, i32, i32) {
818    let (year, month, day) = civil_from_julian_day_number(days_since_unix_epoch + UNIX_EPOCH_JDN);
819    (year as i32, month as i32, day as i32)
820}
821
822fn div_floor(a: i64, b: i64) -> i64 {
823    a.div_euclid(b)
824}
825
826fn rem_floor(a: i64, b: i64) -> i64 {
827    a.rem_euclid(b)
828}
829
830/// Half-step (microseconds) of the central difference used to snap an extrema
831/// culmination to the existing pass-finder microsecond convention.
832const CULMINATION_RATE_HALF_STEP_US: i64 = 1_000_000;
833
834/// Options for the event-finder-backed pass finder [`find_passes`].
835#[derive(Debug, Clone, Copy, PartialEq)]
836pub struct PassFinderOptions {
837    /// Elevation mask (degrees). AOS/LOS are the times the satellite crosses
838    /// this elevation, not merely the geometric horizon.
839    pub elevation_mask_deg: f64,
840    /// Requested maximum sampling step (seconds) for bracketing mask crossings.
841    /// The pass finder may sub-step below this based on orbital geometry so
842    /// short or grazing passes are not skipped by a too-coarse caller step.
843    pub coarse_step_seconds: f64,
844    /// Time tolerance (seconds) to which each crossing and the culmination are
845    /// refined by bisection.
846    pub time_tolerance_seconds: f64,
847}
848
849impl Default for PassFinderOptions {
850    fn default() -> Self {
851        Self {
852            elevation_mask_deg: 0.0,
853            coarse_step_seconds: 30.0,
854            time_tolerance_seconds: 1.0e-3,
855        }
856    }
857}
858
859/// A satellite pass over a ground station: acquisition of signal (rise above the
860/// mask), loss of signal (set below the mask), the culmination (max-elevation)
861/// time, and the elevation at culmination.
862#[derive(Debug, Clone, Copy, PartialEq)]
863pub struct SatellitePass {
864    /// Acquisition of signal: the satellite rises above the elevation mask.
865    pub aos: UtcInstant,
866    /// Loss of signal: the satellite sets below the elevation mask.
867    pub los: UtcInstant,
868    /// Culmination: the time of maximum elevation within the pass.
869    pub culmination: UtcInstant,
870    /// Elevation at culmination, degrees.
871    pub max_elevation_deg: f64,
872}
873
874/// Find satellite passes over a ground station through the shared event finder.
875///
876/// The elevation function is passed to [`EventFinder::find_crossings`] across
877/// the window, and each AOS/LOS crossing is refined by the shared event finder
878/// to [`PassFinderOptions::time_tolerance_seconds`]. The culmination is the
879/// maximum elevation returned by [`EventFinder::find_extrema`] between AOS and
880/// LOS, with endpoints considered for partial passes.
881///
882/// A pass is emitted only when both its AOS and LOS fall inside the window; a
883/// satellite already above the mask at the window start has its AOS clamped to
884/// the start, mirroring [`predict_passes`]. Invalid element sets or per-sample
885/// propagation failures are treated as below-mask samples.
886pub fn find_passes(
887    elements: &ElementSet,
888    ground_station: GroundStation,
889    start_time: UtcInstant,
890    end_time: UtcInstant,
891    options: PassFinderOptions,
892) -> Result<Vec<SatellitePass>, PassError> {
893    find_passes_with_opsmode(
894        elements,
895        ground_station,
896        start_time,
897        end_time,
898        options,
899        OpsMode::Afspc,
900    )
901}
902
903/// [`find_passes`] with an explicit SGP4 [`OpsMode`].
904///
905/// [`find_passes`] initializes SGP4 with [`OpsMode::Afspc`] (the established
906/// default the committed pass goldens are pinned to); the rest of the crate
907/// defaults to [`OpsMode::Improved`]. Pass [`OpsMode::Improved`] here to make a
908/// pass search consistent with `parse_tle_file` /
909/// [`Satellite::from_elements`]. To reuse an already-initialized satellite (any
910/// opsmode), use [`find_passes_for_satellite`].
911pub fn find_passes_with_opsmode(
912    elements: &ElementSet,
913    ground_station: GroundStation,
914    start_time: UtcInstant,
915    end_time: UtcInstant,
916    options: PassFinderOptions,
917    opsmode: OpsMode,
918) -> Result<Vec<SatellitePass>, PassError> {
919    validate_ground_station(ground_station).map_err(map_pass_input)?;
920    let validated_options = validate_pass_finder_options(options)?;
921    // Validate the window before SGP4 init so an unrepresentable window is a typed
922    // error regardless of element validity, matching `predict_passes_with_opsmode`
923    // (an invalid element set otherwise short-circuits to Ok(empty) first).
924    validate_pass_window(start_time, end_time)?;
925    let satellite = match Satellite::from_elements_with_opsmode(elements, opsmode) {
926        Ok(satellite) => satellite,
927        Err(_) => return Ok(Vec::new()),
928    };
929
930    find_passes_for_satellite_validated(
931        &satellite,
932        ground_station,
933        start_time,
934        end_time,
935        validated_options,
936    )
937}
938
939/// Find satellite passes for many pre-parsed SGP4 element sets, serially.
940///
941/// Result element `i` belongs to `elements[i]`. Valid satellites are scanned
942/// independently with the same per-satellite robust step used by
943/// [`find_passes`]. Invalid element sets produce `Ok(Vec::new())`, matching
944/// [`find_passes`].
945pub fn find_passes_batch_serial(
946    elements: &[ElementSet],
947    ground_station: GroundStation,
948    start_time: UtcInstant,
949    end_time: UtcInstant,
950    options: PassFinderOptions,
951) -> Vec<Result<Vec<SatellitePass>, PassError>> {
952    find_passes_batch_serial_with_opsmode(
953        elements,
954        ground_station,
955        start_time,
956        end_time,
957        options,
958        OpsMode::Afspc,
959    )
960}
961
962/// [`find_passes_batch_serial`] with an explicit SGP4 [`OpsMode`] (see
963/// [`find_passes_with_opsmode`] for why the bare function uses
964/// [`OpsMode::Afspc`]).
965pub fn find_passes_batch_serial_with_opsmode(
966    elements: &[ElementSet],
967    ground_station: GroundStation,
968    start_time: UtcInstant,
969    end_time: UtcInstant,
970    options: PassFinderOptions,
971    opsmode: OpsMode,
972) -> Vec<Result<Vec<SatellitePass>, PassError>> {
973    find_passes_batch(
974        elements,
975        ground_station,
976        start_time,
977        end_time,
978        options,
979        PassBatchMode::Serial,
980        opsmode,
981    )
982}
983
984/// Find satellite passes for many pre-parsed SGP4 element sets in parallel.
985///
986/// This parallelizes independent per-satellite scans. Indexed collection
987/// preserves input order, so result element `i` belongs to `elements[i]` and is
988/// bit-identical to [`find_passes_batch_serial`] for the same inputs.
989pub fn find_passes_batch_parallel(
990    elements: &[ElementSet],
991    ground_station: GroundStation,
992    start_time: UtcInstant,
993    end_time: UtcInstant,
994    options: PassFinderOptions,
995) -> Vec<Result<Vec<SatellitePass>, PassError>> {
996    find_passes_batch_parallel_with_opsmode(
997        elements,
998        ground_station,
999        start_time,
1000        end_time,
1001        options,
1002        OpsMode::Afspc,
1003    )
1004}
1005
1006/// [`find_passes_batch_parallel`] with an explicit SGP4 [`OpsMode`] (see
1007/// [`find_passes_with_opsmode`] for why the bare function uses
1008/// [`OpsMode::Afspc`]).
1009pub fn find_passes_batch_parallel_with_opsmode(
1010    elements: &[ElementSet],
1011    ground_station: GroundStation,
1012    start_time: UtcInstant,
1013    end_time: UtcInstant,
1014    options: PassFinderOptions,
1015    opsmode: OpsMode,
1016) -> Vec<Result<Vec<SatellitePass>, PassError>> {
1017    find_passes_batch(
1018        elements,
1019        ground_station,
1020        start_time,
1021        end_time,
1022        options,
1023        PassBatchMode::Parallel,
1024        opsmode,
1025    )
1026}
1027
1028/// Find satellite passes for an already-initialized SGP4 satellite.
1029///
1030/// This is the same event-finder-backed pass finder as [`find_passes`], but the
1031/// caller owns SGP4 initialization. Use this when the satellite was built with
1032/// an explicit [`OpsMode`] or from a handle that must preserve its initialized
1033/// state.
1034pub fn find_passes_for_satellite(
1035    satellite: &Satellite,
1036    ground_station: GroundStation,
1037    start_time: UtcInstant,
1038    end_time: UtcInstant,
1039    options: PassFinderOptions,
1040) -> Result<Vec<SatellitePass>, PassError> {
1041    validate_ground_station(ground_station).map_err(map_pass_input)?;
1042    let validated_options = validate_pass_finder_options(options)?;
1043    find_passes_for_satellite_validated(
1044        satellite,
1045        ground_station,
1046        start_time,
1047        end_time,
1048        validated_options,
1049    )
1050}
1051
1052fn find_passes_for_satellite_validated(
1053    satellite: &Satellite,
1054    ground_station: GroundStation,
1055    start_time: UtcInstant,
1056    end_time: UtcInstant,
1057    options: ValidatedPassFinderOptions,
1058) -> Result<Vec<SatellitePass>, PassError> {
1059    if end_time <= start_time {
1060        return Ok(Vec::new());
1061    }
1062    validate_pass_window(start_time, end_time)?;
1063    let search_step_us = robust_crossing_step_us(
1064        satellite,
1065        ground_station,
1066        options.raw.elevation_mask_deg,
1067        options.step_us,
1068    );
1069    let step_seconds = search_step_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
1070    let time_tolerance_seconds = options.tol_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
1071    let search = PassSearch {
1072        satellite,
1073        ground_station,
1074        start_time,
1075        end_time,
1076        step_us: search_step_us,
1077        tol_us: options.tol_us,
1078        mask: options.raw.elevation_mask_deg,
1079    };
1080
1081    let crossings = find_mask_crossings(search, step_seconds, time_tolerance_seconds)?;
1082
1083    assemble_passes_from_crossings(search, crossings)
1084}
1085
1086fn find_mask_crossings(
1087    search: PassSearch<'_>,
1088    step_seconds: f64,
1089    time_tolerance_seconds: f64,
1090) -> Result<Vec<CrossingEvent>, PassError> {
1091    let total_us = search.end_time.diff_microseconds(search.start_time);
1092    find_segmented_crossings(
1093        search,
1094        total_us,
1095        search.step_us,
1096        step_seconds,
1097        time_tolerance_seconds,
1098    )
1099    .map_err(map_event_finder_input)
1100}
1101
1102fn find_segmented_crossings<P>(
1103    predicate: P,
1104    total_us: i64,
1105    step_us: i64,
1106    step_seconds: f64,
1107    time_tolerance_seconds: f64,
1108) -> Result<Vec<CrossingEvent>, EventFinderError>
1109where
1110    P: ScalarEventPredicate + Copy,
1111{
1112    if total_us <= 0 {
1113        return Ok(Vec::new());
1114    }
1115
1116    let max_segment_us = max_event_finder_segment_us(step_us);
1117    if total_us <= max_segment_us {
1118        return find_crossings_in_offsets(
1119            predicate,
1120            0,
1121            total_us,
1122            step_seconds,
1123            time_tolerance_seconds,
1124        );
1125    }
1126
1127    let stride_us = event_finder_segment_stride_us(step_us);
1128    let mut crossings = Vec::new();
1129    let mut segment_start_us = 0;
1130
1131    while segment_start_us < total_us {
1132        let owned_end_us = segment_start_us.saturating_add(stride_us).min(total_us);
1133        // Scan one step past the owned range so exact samples at artificial
1134        // segment boundaries keep the same left/right context as an unsegmented
1135        // EventFinder scan.
1136        let finder_end_us = if owned_end_us < total_us {
1137            owned_end_us.saturating_add(step_us).min(total_us)
1138        } else {
1139            owned_end_us
1140        };
1141        let segment_crossings = find_crossings_in_offsets(
1142            predicate,
1143            segment_start_us,
1144            finder_end_us,
1145            step_seconds,
1146            time_tolerance_seconds,
1147        )?;
1148
1149        for crossing in segment_crossings {
1150            if crossing_is_in_owned_segment(crossing, segment_start_us, owned_end_us)
1151                && crossing_preserves_truncated_plateau(
1152                    predicate,
1153                    crossing,
1154                    finder_end_us,
1155                    total_us,
1156                    step_us,
1157                )
1158            {
1159                crossings.push(crossing);
1160            }
1161        }
1162
1163        if owned_end_us >= total_us {
1164            break;
1165        }
1166        segment_start_us = owned_end_us;
1167    }
1168
1169    Ok(crossings)
1170}
1171
1172fn find_crossings_in_offsets<P>(
1173    predicate: P,
1174    start_offset_us: i64,
1175    end_offset_us: i64,
1176    step_seconds: f64,
1177    time_tolerance_seconds: f64,
1178) -> Result<Vec<CrossingEvent>, EventFinderError>
1179where
1180    P: ScalarEventPredicate,
1181{
1182    EventFinder::new(
1183        offset_seconds(start_offset_us),
1184        offset_seconds(end_offset_us),
1185        step_seconds,
1186        time_tolerance_seconds,
1187    )
1188    .and_then(|finder| finder.find_crossings(predicate, 0.0))
1189}
1190
1191fn find_segmented_extrema<P>(
1192    predicate: P,
1193    total_us: i64,
1194    step_us: i64,
1195    step_seconds: f64,
1196    time_tolerance_seconds: f64,
1197) -> Result<Vec<ExtremumEvent>, EventFinderError>
1198where
1199    P: ScalarEventPredicate + Copy,
1200{
1201    if total_us <= 0 {
1202        return Ok(Vec::new());
1203    }
1204
1205    let max_segment_us = max_event_finder_segment_us(step_us);
1206    if total_us <= max_segment_us {
1207        return find_extrema_in_offsets(
1208            predicate,
1209            0,
1210            total_us,
1211            step_seconds,
1212            time_tolerance_seconds,
1213        );
1214    }
1215
1216    let stride_us = event_finder_segment_stride_us(step_us);
1217    let mut extrema = Vec::new();
1218    let mut segment_start_us = 0;
1219
1220    while segment_start_us < total_us {
1221        let owned_end_us = segment_start_us.saturating_add(stride_us).min(total_us);
1222        let finder_end_us = if owned_end_us < total_us {
1223            owned_end_us.saturating_add(step_us).min(total_us)
1224        } else {
1225            owned_end_us
1226        };
1227        let segment_extrema = find_extrema_in_offsets(
1228            predicate,
1229            segment_start_us,
1230            finder_end_us,
1231            step_seconds,
1232            time_tolerance_seconds,
1233        )?;
1234
1235        for extremum in segment_extrema {
1236            if extremum_is_in_owned_segment(extremum, segment_start_us, owned_end_us) {
1237                extrema.push(extremum);
1238            }
1239        }
1240
1241        if owned_end_us >= total_us {
1242            break;
1243        }
1244        segment_start_us = owned_end_us;
1245    }
1246
1247    Ok(extrema)
1248}
1249
1250fn find_extrema_in_offsets<P>(
1251    predicate: P,
1252    start_offset_us: i64,
1253    end_offset_us: i64,
1254    step_seconds: f64,
1255    time_tolerance_seconds: f64,
1256) -> Result<Vec<ExtremumEvent>, EventFinderError>
1257where
1258    P: ScalarEventPredicate,
1259{
1260    EventFinder::new(
1261        offset_seconds(start_offset_us),
1262        offset_seconds(end_offset_us),
1263        step_seconds,
1264        time_tolerance_seconds,
1265    )
1266    .and_then(|finder| finder.find_extrema(predicate))
1267}
1268
1269fn max_event_finder_segment_us(step_us: i64) -> i64 {
1270    step_us.saturating_mul(EVENT_FINDER_COARSE_SAMPLE_LIMIT)
1271}
1272
1273fn event_finder_segment_stride_us(step_us: i64) -> i64 {
1274    step_us
1275        .saturating_mul(EVENT_FINDER_COARSE_SAMPLE_LIMIT - 1)
1276        .max(1)
1277}
1278
1279fn crossing_is_in_owned_segment(
1280    crossing: CrossingEvent,
1281    segment_start_us: i64,
1282    owned_end_us: i64,
1283) -> bool {
1284    let crossing_us = (crossing.time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64;
1285    let after_start = segment_start_us == 0 || crossing_us > segment_start_us;
1286    after_start && crossing_us <= owned_end_us
1287}
1288
1289fn crossing_preserves_truncated_plateau<P>(
1290    predicate: P,
1291    crossing: CrossingEvent,
1292    finder_end_us: i64,
1293    total_us: i64,
1294    step_us: i64,
1295) -> bool
1296where
1297    P: ScalarEventPredicate + Copy,
1298{
1299    if crossing.value - crossing.threshold != 0.0 || finder_end_us >= total_us {
1300        return true;
1301    }
1302
1303    let crossing_us = (crossing.time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).round() as i64;
1304    if crossing_us > finder_end_us
1305        || !coarse_zero_run_reaches_offset(
1306            predicate,
1307            crossing_us,
1308            finder_end_us,
1309            step_us,
1310            crossing.threshold,
1311        )
1312    {
1313        return true;
1314    }
1315
1316    match first_nonzero_coarse_value_after(
1317        predicate,
1318        finder_end_us,
1319        total_us,
1320        step_us,
1321        crossing.threshold,
1322    ) {
1323        Some(right) => plateau_direction_reaches_opposite_side(crossing.direction, right),
1324        None => true,
1325    }
1326}
1327
1328fn coarse_zero_run_reaches_offset<P>(
1329    predicate: P,
1330    start_us: i64,
1331    end_us: i64,
1332    step_us: i64,
1333    threshold: f64,
1334) -> bool
1335where
1336    P: ScalarEventPredicate + Copy,
1337{
1338    let mut offset_us = start_us;
1339    loop {
1340        if predicate.value_at(offset_seconds(offset_us)) - threshold != 0.0 {
1341            return false;
1342        }
1343        if offset_us >= end_us {
1344            return true;
1345        }
1346        let Some(next_offset_us) = next_coarse_sample_offset_us(offset_us, end_us, step_us) else {
1347            return true;
1348        };
1349        offset_us = next_offset_us;
1350    }
1351}
1352
1353fn first_nonzero_coarse_value_after<P>(
1354    predicate: P,
1355    start_us: i64,
1356    end_us: i64,
1357    step_us: i64,
1358    threshold: f64,
1359) -> Option<f64>
1360where
1361    P: ScalarEventPredicate + Copy,
1362{
1363    let mut offset_us = start_us;
1364    while let Some(next_offset_us) = next_coarse_sample_offset_us(offset_us, end_us, step_us) {
1365        let value = predicate.value_at(offset_seconds(next_offset_us)) - threshold;
1366        if value != 0.0 {
1367            return Some(value);
1368        }
1369        offset_us = next_offset_us;
1370    }
1371    None
1372}
1373
1374fn next_coarse_sample_offset_us(offset_us: i64, end_us: i64, step_us: i64) -> Option<i64> {
1375    if offset_us >= end_us {
1376        return None;
1377    }
1378    let next_offset_us = offset_us.saturating_add(step_us).min(end_us);
1379    (next_offset_us > offset_us).then_some(next_offset_us)
1380}
1381
1382fn plateau_direction_reaches_opposite_side(direction: CrossingDirection, right_value: f64) -> bool {
1383    match direction {
1384        CrossingDirection::Rising => right_value > 0.0,
1385        CrossingDirection::Falling => right_value < 0.0,
1386    }
1387}
1388
1389fn extremum_is_in_owned_segment(
1390    extremum: ExtremumEvent,
1391    segment_start_us: i64,
1392    owned_end_us: i64,
1393) -> bool {
1394    let extremum_us = (extremum.time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64;
1395    let after_start = segment_start_us == 0 || extremum_us > segment_start_us;
1396    after_start && extremum_us <= owned_end_us
1397}
1398
1399fn offset_seconds(offset_us: i64) -> f64 {
1400    offset_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64
1401}
1402
1403#[derive(Debug, Clone, Copy)]
1404enum PassBatchMode {
1405    Serial,
1406    Parallel,
1407}
1408
1409#[derive(Debug, Clone, Copy)]
1410struct ValidatedPassFinderOptions {
1411    raw: PassFinderOptions,
1412    step_us: i64,
1413    tol_us: i64,
1414}
1415
1416fn find_passes_batch(
1417    elements: &[ElementSet],
1418    ground_station: GroundStation,
1419    start_time: UtcInstant,
1420    end_time: UtcInstant,
1421    options: PassFinderOptions,
1422    mode: PassBatchMode,
1423    opsmode: OpsMode,
1424) -> Vec<Result<Vec<SatellitePass>, PassError>> {
1425    if elements.is_empty() {
1426        return Vec::new();
1427    }
1428    if let Err(error) = validate_ground_station(ground_station).map_err(map_pass_input) {
1429        return vec![Err(error); elements.len()];
1430    }
1431    let validated_options = match validate_pass_finder_options(options) {
1432        Ok(validated) => validated,
1433        Err(error) => return vec![Err(error); elements.len()],
1434    };
1435    // Reject an unrepresentable window up front (before SGP4 init), so the result
1436    // does not depend on which element sets happen to initialize - consistent with
1437    // the single-satellite entry points.
1438    if let Err(error) = validate_pass_window(start_time, end_time) {
1439        return vec![Err(error); elements.len()];
1440    }
1441
1442    let mut results = vec![Ok(Vec::new()); elements.len()];
1443    let mut valid_indices = Vec::new();
1444    let mut satellites = Vec::new();
1445    for (index, element_set) in elements.iter().enumerate() {
1446        if let Ok(satellite) = Satellite::from_elements_with_opsmode(element_set, opsmode) {
1447            valid_indices.push(index);
1448            satellites.push(satellite);
1449        }
1450    }
1451    if satellites.is_empty() {
1452        return results;
1453    }
1454
1455    let valid_results = find_passes_batch_for_satellites_validated(
1456        &satellites,
1457        ground_station,
1458        start_time,
1459        end_time,
1460        validated_options,
1461        mode,
1462    );
1463    for (index, result) in valid_indices.into_iter().zip(valid_results) {
1464        results[index] = result;
1465    }
1466
1467    results
1468}
1469
1470fn find_passes_batch_for_satellites_validated(
1471    satellites: &[Satellite],
1472    ground_station: GroundStation,
1473    start_time: UtcInstant,
1474    end_time: UtcInstant,
1475    options: ValidatedPassFinderOptions,
1476    mode: PassBatchMode,
1477) -> Vec<Result<Vec<SatellitePass>, PassError>> {
1478    if end_time <= start_time {
1479        return vec![Ok(Vec::new()); satellites.len()];
1480    }
1481
1482    match mode {
1483        PassBatchMode::Serial => satellites
1484            .iter()
1485            .map(|satellite| {
1486                find_passes_for_satellite_validated(
1487                    satellite,
1488                    ground_station,
1489                    start_time,
1490                    end_time,
1491                    options,
1492                )
1493            })
1494            .collect(),
1495        PassBatchMode::Parallel => satellites
1496            .par_iter()
1497            .map(|satellite| {
1498                find_passes_for_satellite_validated(
1499                    satellite,
1500                    ground_station,
1501                    start_time,
1502                    end_time,
1503                    options,
1504                )
1505            })
1506            .collect(),
1507    }
1508}
1509
1510fn assemble_passes_from_crossings(
1511    search: PassSearch<'_>,
1512    crossings: Vec<CrossingEvent>,
1513) -> Result<Vec<SatellitePass>, PassError> {
1514    let mut aos = if search.window_start_is_inside_pass(&crossings) {
1515        Some(search.start_time)
1516    } else {
1517        None
1518    };
1519    let mut passes = Vec::new();
1520
1521    for crossing in crossings {
1522        let crossing_time = search.refine_event_mask_crossing_to_utc(crossing.time_seconds);
1523        match crossing.direction {
1524            CrossingDirection::Rising => {
1525                if aos.is_none() {
1526                    aos = Some(crossing_time);
1527                }
1528            }
1529            CrossingDirection::Falling => {
1530                if let Some(rise) = aos.take() {
1531                    let set = crossing_time;
1532                    let (culmination, max_elevation_deg) = find_culmination(
1533                        search.satellite,
1534                        search.ground_station,
1535                        rise,
1536                        set,
1537                        search.step_us,
1538                        search.tol_us,
1539                    )?;
1540                    passes.push(SatellitePass {
1541                        aos: rise,
1542                        los: set,
1543                        culmination,
1544                        max_elevation_deg,
1545                    });
1546                }
1547            }
1548        }
1549    }
1550
1551    Ok(passes)
1552}
1553
1554fn validate_pass_prediction_options(options: PassPredictionOptions) -> Result<i64, PassError> {
1555    validate_elevation_threshold(options.min_elevation_deg, "min_elevation_deg")
1556        .map_err(map_pass_input)?;
1557    validate_pass_step_seconds(options.step_seconds)
1558}
1559
1560fn validate_pass_step_seconds(step_seconds: i64) -> Result<i64, PassError> {
1561    validate::positive_step(step_seconds as f64, "step_seconds").map_err(map_pass_input)?;
1562    Ok(step_seconds)
1563}
1564
1565fn validate_pass_finder_options(
1566    options: PassFinderOptions,
1567) -> Result<ValidatedPassFinderOptions, PassError> {
1568    validate_elevation_threshold(options.elevation_mask_deg, "elevation_mask_deg")
1569        .map_err(map_pass_input)?;
1570    let step_us = positive_seconds_to_microseconds(
1571        options.coarse_step_seconds,
1572        "coarse_step_seconds",
1573        false,
1574    )?;
1575    let tol_us = positive_seconds_to_microseconds(
1576        options.time_tolerance_seconds,
1577        "time_tolerance_seconds",
1578        true,
1579    )?;
1580    Ok(ValidatedPassFinderOptions {
1581        raw: options,
1582        step_us,
1583        tol_us,
1584    })
1585}
1586
1587fn robust_crossing_step_us(
1588    satellite: &Satellite,
1589    ground_station: GroundStation,
1590    elevation_mask_deg: f64,
1591    requested_step_us: i64,
1592) -> i64 {
1593    let orbit_step_us = fastest_orbit_fraction_step_us(satellite).unwrap_or(requested_step_us);
1594    let geometry_step_us = mask_geometry_step_us(satellite, ground_station, elevation_mask_deg)
1595        .unwrap_or(requested_step_us);
1596    requested_step_us
1597        .min(orbit_step_us)
1598        .min(geometry_step_us)
1599        .max(1)
1600}
1601
1602fn fastest_orbit_fraction_step_us(satellite: &Satellite) -> Option<i64> {
1603    let perigee_rate_rad_s = perigee_angular_rate_rad_s(satellite)?;
1604    let fastest_rev_seconds = std::f64::consts::TAU / perigee_rate_rad_s;
1605    let samples_per_fastest_rev = if fastest_rev_seconds >= ROBUST_CROSSING_SLOW_ORBIT_SECONDS {
1606        ROBUST_CROSSING_SLOW_SAMPLES_PER_FASTEST_REV
1607    } else {
1608        ROBUST_CROSSING_FAST_SAMPLES_PER_FASTEST_REV
1609    };
1610    let step_seconds = fastest_rev_seconds / samples_per_fastest_rev;
1611    if !(step_seconds.is_finite() && step_seconds > 0.0) {
1612        return None;
1613    }
1614
1615    Some((step_seconds * MICROSECONDS_PER_SECOND_I64 as f64).round() as i64)
1616}
1617
1618fn perigee_angular_rate_rad_s(satellite: &Satellite) -> Option<f64> {
1619    let mean_motion_rad_s = satellite.mean_motion_rad_per_min() / 60.0;
1620    let eccentricity = satellite.eccentricity();
1621    if !(mean_motion_rad_s.is_finite()
1622        && mean_motion_rad_s > 0.0
1623        && eccentricity.is_finite()
1624        && (0.0..1.0).contains(&eccentricity))
1625    {
1626        return None;
1627    }
1628
1629    let one_minus_e = 1.0 - eccentricity;
1630    let perigee_rate_rad_s =
1631        mean_motion_rad_s * (1.0 + eccentricity).sqrt() / one_minus_e.powf(1.5);
1632    if !(perigee_rate_rad_s.is_finite() && perigee_rate_rad_s > 0.0) {
1633        return None;
1634    }
1635
1636    Some(perigee_rate_rad_s)
1637}
1638
1639fn mask_geometry_step_us(
1640    satellite: &Satellite,
1641    ground_station: GroundStation,
1642    elevation_mask_deg: f64,
1643) -> Option<i64> {
1644    let station_radius_km = station_geocentric_radius_km(ground_station)?;
1645    let perigee_radius_km = orbit_perigee_radius_km(satellite)?;
1646    if perigee_radius_km <= station_radius_km {
1647        return None;
1648    }
1649
1650    let mask_rad = elevation_mask_deg.to_radians();
1651    if !(mask_rad.is_finite() && mask_rad < std::f64::consts::FRAC_PI_2) {
1652        return None;
1653    }
1654    let access_half_angle_rad =
1655        elevation_mask_central_angle_rad(station_radius_km, perigee_radius_km, mask_rad)?;
1656    let perigee_rate_rad_s = perigee_angular_rate_rad_s(satellite)?;
1657    let station_lat_rad = ground_station.latitude_deg.to_radians();
1658    if !station_lat_rad.is_finite() {
1659        return None;
1660    }
1661    let station_spin_rad_s = OMEGA_E_DOT_RAD_S * station_lat_rad.cos().abs();
1662    let relative_rate_rad_s = perigee_rate_rad_s + station_spin_rad_s;
1663    if !(relative_rate_rad_s.is_finite() && relative_rate_rad_s > 0.0) {
1664        return None;
1665    }
1666
1667    let dwell_seconds = 2.0 * access_half_angle_rad / relative_rate_rad_s;
1668    let step_seconds = dwell_seconds / ROBUST_CROSSING_MASK_DWELL_SAMPLES;
1669    if !(step_seconds.is_finite() && step_seconds > 0.0) {
1670        return None;
1671    }
1672
1673    Some(((step_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64).max(1))
1674}
1675
1676fn orbit_perigee_radius_km(satellite: &Satellite) -> Option<f64> {
1677    let mean_motion_rad_s = satellite.mean_motion_rad_per_min() / 60.0;
1678    let eccentricity = satellite.eccentricity();
1679    if !(mean_motion_rad_s.is_finite()
1680        && mean_motion_rad_s > 0.0
1681        && eccentricity.is_finite()
1682        && (0.0..1.0).contains(&eccentricity))
1683    {
1684        return None;
1685    }
1686
1687    let semi_major_axis_km = (GM_EARTH_KM3_S2 / (mean_motion_rad_s * mean_motion_rad_s)).cbrt();
1688    let perigee_radius_km = semi_major_axis_km * (1.0 - eccentricity);
1689    if !(perigee_radius_km.is_finite() && perigee_radius_km > 0.0) {
1690        return None;
1691    }
1692
1693    Some(perigee_radius_km)
1694}
1695
1696fn station_geocentric_radius_km(ground_station: GroundStation) -> Option<f64> {
1697    if !(ground_station.latitude_deg.is_finite()
1698        && ground_station.longitude_deg.is_finite()
1699        && ground_station.altitude_m.is_finite())
1700    {
1701        return None;
1702    }
1703
1704    let (x, y, z) = geodetic_to_itrs(
1705        ground_station.latitude_deg,
1706        ground_station.longitude_deg,
1707        ground_station.altitude_m / 1000.0,
1708    )
1709    .ok()?;
1710    let radius_km = (x * x + y * y + z * z).sqrt();
1711    if !(radius_km.is_finite() && radius_km > 0.0) {
1712        return None;
1713    }
1714
1715    Some(radius_km)
1716}
1717
1718fn elevation_mask_central_angle_rad(
1719    station_radius_km: f64,
1720    satellite_radius_km: f64,
1721    elevation_mask_rad: f64,
1722) -> Option<f64> {
1723    let radius_ratio = station_radius_km / satellite_radius_km;
1724    if !(radius_ratio.is_finite() && (0.0..1.0).contains(&radius_ratio)) {
1725        return None;
1726    }
1727
1728    let sin_mask = elevation_mask_rad.sin();
1729    let cos_mask = elevation_mask_rad.cos();
1730    let cos_mask_squared = cos_mask * cos_mask;
1731    let horizon_term = 1.0 - radius_ratio * radius_ratio * cos_mask_squared;
1732    if horizon_term < 0.0 {
1733        return None;
1734    }
1735
1736    let cos_central_angle = radius_ratio * cos_mask_squared + sin_mask * horizon_term.sqrt();
1737    let central_angle = cos_central_angle.clamp(-1.0, 1.0).acos();
1738    if !(central_angle.is_finite() && central_angle > 0.0) {
1739        return None;
1740    }
1741
1742    Some(central_angle)
1743}
1744
1745fn positive_seconds_to_microseconds(
1746    seconds: f64,
1747    field: &'static str,
1748    round_submicrosecond_to_one: bool,
1749) -> Result<i64, PassError> {
1750    let seconds = validate::positive_step(seconds, field).map_err(map_pass_input)?;
1751    let max_seconds = (i64::MAX / MICROSECONDS_PER_SECOND_I64) as f64;
1752    if seconds > max_seconds {
1753        return Err(invalid_pass_input(field, "out of range"));
1754    }
1755
1756    let microseconds = (seconds * MICROSECONDS_PER_SECOND_I64 as f64).round();
1757    if round_submicrosecond_to_one && microseconds < 1.0 {
1758        return Ok(1);
1759    }
1760    validate::positive_step(microseconds, field).map_err(map_pass_input)?;
1761    Ok(microseconds as i64)
1762}
1763
1764fn validate_ground_station(ground_station: GroundStation) -> Result<(), validate::FieldError> {
1765    validate::finite_in_range(
1766        ground_station.latitude_deg,
1767        -90.0,
1768        90.0,
1769        "ground_station.latitude_deg",
1770    )?;
1771    validate::finite_in_range(
1772        ground_station.longitude_deg,
1773        -180.0,
1774        180.0,
1775        "ground_station.longitude_deg",
1776    )?;
1777    validate::finite(ground_station.altitude_m, "ground_station.altitude_m")?;
1778    Ok(())
1779}
1780
1781fn validate_elevation_threshold(
1782    elevation_deg: f64,
1783    field: &'static str,
1784) -> Result<(), validate::FieldError> {
1785    validate::finite_in_range(elevation_deg, -90.0, 90.0, field)?;
1786    Ok(())
1787}
1788
1789fn time_scales_for_look_angle(datetime: UtcInstant) -> Result<TimeScales, LookAngleError> {
1790    time_scales_from_instant(datetime).ok_or(LookAngleError::InvalidInput {
1791        field: "datetime",
1792        reason: "invalid UTC instant",
1793    })
1794}
1795
1796fn time_scales_for_pass(datetime: UtcInstant) -> Result<TimeScales, PassError> {
1797    time_scales_from_instant(datetime).ok_or(PassError::InvalidInput {
1798        field: "datetime",
1799        reason: "invalid UTC instant",
1800    })
1801}
1802
1803fn time_scales_from_instant(datetime: UtcInstant) -> Option<TimeScales> {
1804    let c = datetime.components();
1805    TimeScales::from_utc(
1806        c.year,
1807        c.month,
1808        c.day,
1809        c.hour,
1810        c.minute,
1811        c.second as f64 + c.microsecond as f64 / 1_000_000.0,
1812    )
1813    .ok()
1814}
1815
1816fn validate_look_angle(look: LookAngle) -> Result<LookAngle, LookAngleError> {
1817    validate::finite(look.azimuth_deg, "azimuth_deg").map_err(map_look_angle_input)?;
1818    validate::finite(look.elevation_deg, "elevation_deg").map_err(map_look_angle_input)?;
1819    validate::finite(look.range_km, "range_km").map_err(map_look_angle_input)?;
1820    Ok(look)
1821}
1822
1823fn map_look_angle_input(error: validate::FieldError) -> LookAngleError {
1824    LookAngleError::InvalidInput {
1825        field: error.field(),
1826        reason: error.reason(),
1827    }
1828}
1829
1830fn map_pass_input(error: validate::FieldError) -> PassError {
1831    invalid_pass_input(error.field(), error.reason())
1832}
1833
1834fn invalid_pass_input(field: &'static str, reason: &'static str) -> PassError {
1835    PassError::InvalidInput { field, reason }
1836}
1837
1838/// Reject a `[start, end]` window whose microsecond span overflows `i64`.
1839///
1840/// `UtcInstant` is public and can be built at the i64 extremes, so a span of
1841/// `end - start` can overflow. The sampling/bisection arithmetic assumes a
1842/// representable span; rather than rely on that arithmetic saturating silently,
1843/// the public entry points reject an unrepresentable window with a typed error.
1844/// Every realistic window (hours to years) is far inside the representable range
1845/// (~292,000 years of microseconds), so this never fires for valid inputs.
1846fn validate_pass_window(start_time: UtcInstant, end_time: UtcInstant) -> Result<(), PassError> {
1847    if end_time.checked_diff_microseconds(start_time).is_none() {
1848        return Err(invalid_pass_input(
1849            "time window",
1850            "span between start_time and end_time exceeds the representable range",
1851        ));
1852    }
1853    Ok(())
1854}
1855
1856fn map_event_finder_input(error: EventFinderError) -> PassError {
1857    match error {
1858        EventFinderError::InvalidInput { field, reason } => {
1859            PassError::InvalidInput { field, reason }
1860        }
1861    }
1862}
1863
1864fn instant_at_offset_seconds(start_time: UtcInstant, offset_seconds: f64) -> UtcInstant {
1865    start_time
1866        .add_microseconds((offset_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64)
1867}
1868
1869#[derive(Debug, Clone, Copy)]
1870struct PassSearch<'a> {
1871    satellite: &'a Satellite,
1872    ground_station: GroundStation,
1873    start_time: UtcInstant,
1874    end_time: UtcInstant,
1875    step_us: i64,
1876    tol_us: i64,
1877    mask: f64,
1878}
1879
1880impl ScalarEventPredicate for PassSearch<'_> {
1881    fn value_at(&self, offset_seconds: f64) -> f64 {
1882        self.masked_elevation_at(instant_at_offset_seconds(self.start_time, offset_seconds))
1883    }
1884}
1885
1886impl PassSearch<'_> {
1887    fn window_start_is_inside_pass(&self, crossings: &[CrossingEvent]) -> bool {
1888        match self.masked_elevation_at(self.start_time).partial_cmp(&0.0) {
1889            Some(core::cmp::Ordering::Greater) => true,
1890            Some(core::cmp::Ordering::Equal) => crossings.first().is_some_and(|crossing| {
1891                if crossing.time_seconds == 0.0 {
1892                    crossing.direction == CrossingDirection::Rising
1893                } else {
1894                    crossing.direction == CrossingDirection::Falling
1895                }
1896            }),
1897            _ => false,
1898        }
1899    }
1900
1901    fn masked_elevation_at(&self, datetime: UtcInstant) -> f64 {
1902        elevation_at(self.satellite, datetime, self.ground_station) - self.mask
1903    }
1904
1905    fn refine_event_mask_crossing_to_utc(&self, crossing_time_seconds: f64) -> UtcInstant {
1906        let total_us = self.end_time.diff_microseconds(self.start_time);
1907        let crossing_us = ((crossing_time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor()
1908            as i64)
1909            .clamp(0, total_us);
1910        let mut low_offset = (crossing_us / self.step_us) * self.step_us;
1911        if low_offset >= total_us && total_us > 0 {
1912            low_offset = ((total_us - 1) / self.step_us) * self.step_us;
1913        }
1914        let mut high_offset = (low_offset + self.step_us).min(total_us);
1915        if high_offset == low_offset && low_offset > 0 {
1916            low_offset = low_offset.saturating_sub(self.step_us);
1917            high_offset = (low_offset + self.step_us).min(total_us);
1918        }
1919
1920        if let Some(refined) = self.refine_mask_crossing_in_offsets(low_offset, high_offset) {
1921            return refined;
1922        }
1923
1924        let previous_low = low_offset.saturating_sub(self.step_us);
1925        if previous_low < low_offset {
1926            if let Some(refined) = self.refine_mask_crossing_in_offsets(previous_low, low_offset) {
1927                return refined;
1928            }
1929        }
1930
1931        instant_at_offset_seconds(self.start_time, crossing_time_seconds)
1932    }
1933
1934    fn refine_mask_crossing_in_offsets(
1935        &self,
1936        low_offset_us: i64,
1937        high_offset_us: i64,
1938    ) -> Option<UtcInstant> {
1939        if low_offset_us == high_offset_us {
1940            return None;
1941        }
1942
1943        let low = self.start_time.add_microseconds(low_offset_us);
1944        let high = self.start_time.add_microseconds(high_offset_us);
1945        let value_low = self.masked_elevation_at(low);
1946        let value_high = self.masked_elevation_at(high);
1947        if !sign_change_bracketed(value_low, value_high).unwrap_or(false) {
1948            return None;
1949        }
1950
1951        bisect_crossing_until(
1952            low,
1953            high,
1954            |dt| self.masked_elevation_at(dt),
1955            midpoint_instant,
1956            |lo, hi| hi.diff_microseconds(lo) <= self.tol_us,
1957        )
1958        .ok()
1959    }
1960}
1961
1962/// Elevation rate (deg/s) at `dt` by central difference with half-step `h_us`.
1963fn elevation_rate<F>(elevation_at_time: &F, dt: UtcInstant, h_us: i64) -> f64
1964where
1965    F: Fn(UtcInstant) -> f64 + ?Sized,
1966{
1967    let plus = elevation_at_time(dt.add_microseconds(h_us));
1968    let minus = elevation_at_time(dt.add_microseconds(-h_us));
1969    (plus - minus) / (2.0 * h_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64)
1970}
1971
1972/// Find the maximum elevation between AOS and LOS through the shared extrema
1973/// finder. For a partial pass with no interior peak, return the higher endpoint.
1974fn find_culmination(
1975    satellite: &Satellite,
1976    ground_station: GroundStation,
1977    aos: UtcInstant,
1978    los: UtcInstant,
1979    step_us: i64,
1980    tol_us: i64,
1981) -> Result<(UtcInstant, f64), PassError> {
1982    find_culmination_with(aos, los, step_us, tol_us, |dt| {
1983        elevation_at(satellite, dt, ground_station)
1984    })
1985}
1986
1987fn find_culmination_with<F>(
1988    aos: UtcInstant,
1989    los: UtcInstant,
1990    step_us: i64,
1991    tol_us: i64,
1992    elevation_at_time: F,
1993) -> Result<(UtcInstant, f64), PassError>
1994where
1995    F: Fn(UtcInstant) -> f64,
1996{
1997    let span_us = los.diff_microseconds(aos);
1998    let aos_elevation = elevation_at_time(aos);
1999    if span_us <= 0 {
2000        return Ok((aos, aos_elevation));
2001    }
2002
2003    let los_elevation = elevation_at_time(los);
2004    let mut best = if aos_elevation >= los_elevation {
2005        (aos, aos_elevation)
2006    } else {
2007        (los, los_elevation)
2008    };
2009    let mut selected_maximum_bracket = None;
2010    let extremum_step_us = step_us.min((span_us / 4).max(1)).max(1);
2011    let extremum_step_seconds = extremum_step_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
2012    let time_tolerance_seconds = tol_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
2013
2014    let extrema = find_segmented_extrema(
2015        |offset_seconds| elevation_at_time(instant_at_offset_seconds(aos, offset_seconds)),
2016        span_us,
2017        extremum_step_us,
2018        extremum_step_seconds,
2019        time_tolerance_seconds,
2020    )
2021    .map_err(map_event_finder_input)?;
2022
2023    for extremum in extrema {
2024        if extremum.kind != ExtremumKind::Maximum {
2025            continue;
2026        }
2027        let candidate_time = instant_at_offset_seconds(aos, extremum.time_seconds);
2028        let candidate_elevation = elevation_at_time(candidate_time);
2029        if candidate_elevation > best.1 {
2030            best = (candidate_time, candidate_elevation);
2031            selected_maximum_bracket =
2032                extremum_sample_bracket(aos, span_us, extremum_step_us, extremum.time_seconds);
2033        }
2034    }
2035
2036    if let Some((low, high)) = selected_maximum_bracket {
2037        if let Some(refined) = refine_culmination_rate_zero(&elevation_at_time, aos, los, tol_us) {
2038            if low <= refined.0 && refined.0 <= high {
2039                return Ok(refined);
2040            }
2041        }
2042        if let Some(refined) = refine_culmination_rate_zero(&elevation_at_time, low, high, tol_us) {
2043            best = refined;
2044        }
2045    }
2046
2047    Ok(best)
2048}
2049
2050fn extremum_sample_bracket(
2051    aos: UtcInstant,
2052    span_us: i64,
2053    step_us: i64,
2054    time_seconds: f64,
2055) -> Option<(UtcInstant, UtcInstant)> {
2056    if !(span_us > 0 && step_us > 0 && time_seconds.is_finite()) {
2057        return None;
2058    }
2059
2060    let step_seconds = step_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
2061    let center_index = (time_seconds / step_seconds).round() as i64;
2062    let center_offset_us = center_index.saturating_mul(step_us).clamp(0, span_us);
2063    if center_offset_us == 0 || center_offset_us == span_us {
2064        return None;
2065    }
2066
2067    let low_offset_us = center_offset_us.saturating_sub(step_us);
2068    let high_offset_us = center_offset_us.saturating_add(step_us).min(span_us);
2069    if low_offset_us >= high_offset_us {
2070        return None;
2071    }
2072
2073    Some((
2074        aos.add_microseconds(low_offset_us),
2075        aos.add_microseconds(high_offset_us),
2076    ))
2077}
2078
2079fn refine_culmination_rate_zero<F>(
2080    elevation_at_time: &F,
2081    low: UtcInstant,
2082    high: UtcInstant,
2083    tol_us: i64,
2084) -> Option<(UtcInstant, f64)>
2085where
2086    F: Fn(UtcInstant) -> f64 + ?Sized,
2087{
2088    let span = high.diff_microseconds(low);
2089    if span <= 0 {
2090        return None;
2091    }
2092
2093    let h_us = CULMINATION_RATE_HALF_STEP_US.min((span / 4).max(1));
2094    let rate_low = elevation_rate(elevation_at_time, low, h_us);
2095    let rate_high = elevation_rate(elevation_at_time, high, h_us);
2096    if !(rate_low.is_finite() && rate_high.is_finite() && rate_low > 0.0 && rate_high < 0.0) {
2097        return None;
2098    }
2099
2100    let culmination = bisect_crossing_until(
2101        low,
2102        high,
2103        |dt| elevation_rate(elevation_at_time, dt, h_us),
2104        midpoint_instant,
2105        |lo, hi| hi.diff_microseconds(lo) <= tol_us,
2106    )
2107    .ok()?;
2108    Some((culmination, elevation_at_time(culmination)))
2109}
2110
2111#[cfg(test)]
2112mod tests {
2113    use crate::astro::events::{
2114        CrossingDirection, CrossingEvent, EventFinder, EventFinderError, ExtremumKind,
2115    };
2116    use crate::astro::frames::transforms::{
2117        gcrs_to_itrs_compute, itrs_to_geodetic_compute, teme_to_gcrs_compute, TemeStateKm,
2118    };
2119
2120    use super::*;
2121
2122    fn iss_2024_12_19_elements() -> ElementSet {
2123        ElementSet {
2124            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2024, 354.52609954),
2125            bstar: 0.000_370_420_000_000_000_05,
2126            mean_motion_dot: 0.00020888,
2127            mean_motion_double_dot: 0.0,
2128            eccentricity: 0.0006955,
2129            argument_of_perigee_deg: 37.7614,
2130            inclination_deg: 51.6393,
2131            mean_anomaly_deg: 87.9783,
2132            mean_motion_rev_per_day: 15.49970085,
2133            right_ascension_deg: 213.2584,
2134            catalog_number: 0,
2135        }
2136    }
2137
2138    fn iss_2024_01_01_elements() -> ElementSet {
2139        ElementSet {
2140            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2024, 1.5),
2141            bstar: 0.000_102_70,
2142            mean_motion_dot: 0.000_167_17,
2143            mean_motion_double_dot: 0.0,
2144            eccentricity: 0.000_264_4,
2145            argument_of_perigee_deg: 250.3037,
2146            inclination_deg: 51.6400,
2147            mean_anomaly_deg: 109.7782,
2148            mean_motion_rev_per_day: 15.49560812,
2149            right_ascension_deg: 208.8657,
2150            catalog_number: 25_544,
2151        }
2152    }
2153
2154    fn iss_fixture_elements() -> ElementSet {
2155        ElementSet {
2156            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2026, 95.55331950),
2157            bstar: 0.000_164_20,
2158            mean_motion_dot: 0.000_085_43,
2159            mean_motion_double_dot: 0.0,
2160            eccentricity: 0.000_635_1,
2161            argument_of_perigee_deg: 274.8255,
2162            inclination_deg: 51.6328,
2163            mean_anomaly_deg: 85.2008,
2164            mean_motion_rev_per_day: 15.4878698,
2165            right_ascension_deg: 299.5432,
2166            catalog_number: 25_544,
2167        }
2168    }
2169
2170    fn css_fixture_elements() -> ElementSet {
2171        ElementSet {
2172            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2026, 95.32454765),
2173            bstar: 0.000_372_23,
2174            mean_motion_dot: 0.000_331_73,
2175            mean_motion_double_dot: 0.0,
2176            eccentricity: 0.000_355_7,
2177            argument_of_perigee_deg: 129.2727,
2178            inclination_deg: 41.4682,
2179            mean_anomaly_deg: 230.8429,
2180            mean_motion_rev_per_day: 15.6194274,
2181            right_ascension_deg: 45.9319,
2182            catalog_number: 48_274,
2183        }
2184    }
2185
2186    fn fregat_fixture_elements() -> ElementSet {
2187        ElementSet {
2188            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2026, 95.51225242),
2189            bstar: 0.012_423,
2190            mean_motion_dot: 0.000_085_41,
2191            mean_motion_double_dot: 0.0,
2192            eccentricity: 0.095_504_7,
2193            argument_of_perigee_deg: 120.8974,
2194            inclination_deg: 51.6426,
2195            mean_anomaly_deg: 248.9327,
2196            mean_motion_rev_per_day: 12.40936816,
2197            right_ascension_deg: 220.2066,
2198            catalog_number: 49_271,
2199        }
2200    }
2201
2202    fn geo_like_fixture_elements() -> ElementSet {
2203        ElementSet {
2204            epoch: crate::astro::sgp4::sgp4_julian_date_from_day_of_year(2026, 95.0),
2205            bstar: 0.0,
2206            mean_motion_dot: 0.0,
2207            mean_motion_double_dot: 0.0,
2208            eccentricity: 0.000_1,
2209            argument_of_perigee_deg: 0.0,
2210            inclination_deg: 0.1,
2211            mean_anomaly_deg: 0.0,
2212            mean_motion_rev_per_day: 1.002_7,
2213            right_ascension_deg: 0.0,
2214            catalog_number: 99_001,
2215        }
2216    }
2217
2218    fn station_under_satellite(satellite: &Satellite, datetime: UtcInstant) -> GroundStation {
2219        let pred = satellite
2220            .propagate_jd(datetime.sgp4_julian_date())
2221            .expect("satellite propagates at subpoint instant");
2222        let ts = datetime.time_scales();
2223        let (gcrs_position, _) = teme_to_gcrs_compute(
2224            &TemeStateKm {
2225                position_km: pred.position,
2226                velocity_km_s: pred.velocity,
2227            },
2228            &ts,
2229            false,
2230        )
2231        .expect("valid TEME to GCRS transform");
2232        let (x, y, z) = gcrs_to_itrs_compute(
2233            gcrs_position.0,
2234            gcrs_position.1,
2235            gcrs_position.2,
2236            &ts,
2237            false,
2238        )
2239        .expect("valid GCRS to ITRS transform");
2240        let (latitude_deg, longitude_deg, _) =
2241            itrs_to_geodetic_compute(x, y, z).expect("valid ITRS geodetic coordinates");
2242
2243        GroundStation {
2244            latitude_deg,
2245            longitude_deg,
2246            altitude_m: 0.0,
2247        }
2248    }
2249
2250    fn instant_at_offset_seconds(start: UtcInstant, offset_seconds: f64) -> UtcInstant {
2251        start.add_microseconds((offset_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64)
2252    }
2253
2254    fn multi_stationary_elevation(dt: UtcInstant) -> f64 {
2255        let t = dt.unix_microseconds() as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
2256        if t <= 1.5 {
2257            hermite(t, 0.0, 0.0, 8.0, 1.5, 10.0, 0.0)
2258        } else if t <= 6.0 {
2259            hermite(t, 1.5, 10.0, 0.0, 6.0, -4.0, 0.0)
2260        } else if t <= 8.5 {
2261            hermite(t, 6.0, -4.0, 0.0, 8.5, 30.0, 0.0)
2262        } else {
2263            hermite(t, 8.5, 30.0, 0.0, 10.0, 0.0, -20.0)
2264        }
2265    }
2266
2267    fn hermite(t: f64, t0: f64, y0: f64, m0: f64, t1: f64, y1: f64, m1: f64) -> f64 {
2268        let span = t1 - t0;
2269        let u = (t - t0) / span;
2270        let u2 = u * u;
2271        let u3 = u2 * u;
2272        let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
2273        let h10 = u3 - 2.0 * u2 + u;
2274        let h01 = -2.0 * u3 + 3.0 * u2;
2275        let h11 = u3 - u2;
2276        h00 * y0 + h10 * span * m0 + h01 * y1 + h11 * span * m1
2277    }
2278
2279    fn synthetic_pass_wave(time_seconds: f64) -> f64 {
2280        (time_seconds * std::f64::consts::TAU / 5_400.0).sin()
2281    }
2282
2283    fn crossing_offset_us(crossing: CrossingEvent) -> i64 {
2284        (crossing.time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).floor() as i64
2285    }
2286
2287    #[test]
2288    fn utc_instant_round_trips_calendar_fields() {
2289        let instant = UtcInstant::from_utc(2024, 12, 19, 7, 3, 11, 825_435).unwrap();
2290        assert_eq!(instant.unix_microseconds(), 1_734_591_791_825_435);
2291        let c = instant.components();
2292        assert_eq!(
2293            (
2294                c.year,
2295                c.month,
2296                c.day,
2297                c.hour,
2298                c.minute,
2299                c.second,
2300                c.microsecond
2301            ),
2302            (2024, 12, 19, 7, 3, 11, 825_435)
2303        );
2304    }
2305
2306    #[test]
2307    fn utc_instant_rejects_invalid_civil_dates_and_non_leap_seconds() {
2308        assert!(UtcInstant::from_utc(2024, 2, 31, 0, 0, 0, 0).is_none());
2309        assert!(UtcInstant::from_utc(2023, 2, 29, 0, 0, 0, 0).is_none());
2310        assert!(UtcInstant::from_utc(2024, 12, 31, 23, 59, 60, 0).is_none());
2311    }
2312
2313    #[test]
2314    fn utc_instant_rejects_leap_second_labels_to_avoid_midnight_collision() {
2315        assert!(UtcInstant::from_utc(2024, 2, 29, 0, 0, 0, 0).is_some());
2316        assert!(UtcInstant::from_utc(2023, 2, 28, 23, 59, 59, 999_999).is_some());
2317
2318        assert!(UtcInstant::from_utc(2016, 12, 31, 23, 59, 60, 0).is_none());
2319        assert_eq!(
2320            UtcInstant::from_utc(2017, 1, 1, 0, 0, 0, 0)
2321                .expect("following midnight is a normal instant")
2322                .unix_microseconds(),
2323            1_483_228_800_000_000
2324        );
2325    }
2326
2327    // Cross-validated against Skyfield 1.54 (sgp4 2.25, WGS72, AFSPC opsmode
2328    // 'a', skyfield load.timescale(builtin=False) = current IERS
2329    // finals2000A.all). The same ISS element set this fixture builds is fed to
2330    // sgp4.Satrec.sgp4init and wrapped in skyfield EarthSatellite.from_satrec;
2331    // the pass events come from
2332    // EarthSatellite.find_events(station, t0, t1, altitude_degrees=0.0).
2333    // Skyfield reference (microseconds since the unix epoch, and peak elevation):
2334    //   rise = 1_734_604_991_843_315, culmination = 1_734_605_261_880_434,
2335    //   set  = 1_734_605_533_416_157, peak elevation = 12.547260311655 deg.
2336    // Measured sidereon-vs-Skyfield residuals: rise 17.88 ms, set 15.79 ms,
2337    // culmination 12.15 ms (Skyfield find_events carries its own root-finding
2338    // tolerance), peak elevation 1.14e-7 deg. The tolerances below sit a few
2339    // factors above those residuals.
2340    #[test]
2341    fn iss_london_pass_matches_skyfield() {
2342        const SKYFIELD_RISE_US: i64 = 1_734_604_991_843_315;
2343        const SKYFIELD_CULMINATION_US: i64 = 1_734_605_261_880_434;
2344        const SKYFIELD_SET_US: i64 = 1_734_605_533_416_157;
2345        const SKYFIELD_PEAK_ELEVATION_DEG: f64 = 12.547_260_311_655;
2346        const TIME_TOL_US: i64 = 50_000; // 0.05 s
2347        const ELEVATION_TOL_DEG: f64 = 1.0e-5;
2348
2349        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
2350        let end = UtcInstant::from_utc(2024, 12, 19, 12, 0, 0, 0).unwrap();
2351        let station = GroundStation {
2352            latitude_deg: 51.5074,
2353            longitude_deg: -0.1278,
2354            altitude_m: 11.0,
2355        };
2356
2357        let passes = predict_passes(
2358            &iss_2024_12_19_elements(),
2359            station,
2360            start,
2361            end,
2362            PassPredictionOptions::default(),
2363        )
2364        .expect("valid pass-prediction step");
2365
2366        assert_eq!(passes.len(), 1);
2367        let pass = passes[0];
2368        assert!(
2369            (pass.rise.unix_microseconds() - SKYFIELD_RISE_US).abs() <= TIME_TOL_US,
2370            "rise {} vs skyfield {SKYFIELD_RISE_US}",
2371            pass.rise.unix_microseconds()
2372        );
2373        assert!(
2374            (pass.set.unix_microseconds() - SKYFIELD_SET_US).abs() <= TIME_TOL_US,
2375            "set {} vs skyfield {SKYFIELD_SET_US}",
2376            pass.set.unix_microseconds()
2377        );
2378        assert!(
2379            (pass.max_elevation_time.unix_microseconds() - SKYFIELD_CULMINATION_US).abs()
2380                <= TIME_TOL_US,
2381            "culmination {} vs skyfield {SKYFIELD_CULMINATION_US}",
2382            pass.max_elevation_time.unix_microseconds()
2383        );
2384        assert!(
2385            (pass.max_elevation_deg - SKYFIELD_PEAK_ELEVATION_DEG).abs() <= ELEVATION_TOL_DEG,
2386            "peak elevation {} vs skyfield {SKYFIELD_PEAK_ELEVATION_DEG}",
2387            pass.max_elevation_deg
2388        );
2389
2390        let high = predict_passes(
2391            &iss_2024_12_19_elements(),
2392            station,
2393            start,
2394            end,
2395            PassPredictionOptions {
2396                min_elevation_deg: 30.0,
2397                step_seconds: 60,
2398            },
2399        )
2400        .expect("valid pass-prediction step");
2401        assert!(high.is_empty());
2402    }
2403
2404    #[test]
2405    fn predict_passes_includes_set_in_final_partial_interval() {
2406        let start = UtcInstant::from_utc(2024, 12, 19, 10, 42, 0, 0).unwrap();
2407        let end = UtcInstant::from_utc(2024, 12, 19, 10, 52, 30, 0).unwrap();
2408        let station = GroundStation {
2409            latitude_deg: 51.5074,
2410            longitude_deg: -0.1278,
2411            altitude_m: 11.0,
2412        };
2413
2414        let passes = predict_passes(
2415            &iss_2024_12_19_elements(),
2416            station,
2417            start,
2418            end,
2419            PassPredictionOptions {
2420                min_elevation_deg: 0.0,
2421                step_seconds: 60,
2422            },
2423        )
2424        .expect("valid pass-prediction step");
2425
2426        assert_eq!(passes.len(), 1);
2427        assert!(passes[0].rise.unix_microseconds() >= start.unix_microseconds());
2428        assert!(passes[0].set.unix_microseconds() <= end.unix_microseconds());
2429    }
2430
2431    #[test]
2432    fn predict_passes_rejects_unrepresentable_window_instead_of_panicking() {
2433        // A window spanning the full i64 microsecond range overflows the internal
2434        // span arithmetic. It must surface as a typed error, never a debug panic
2435        // or a release wrap.
2436        let station = GroundStation {
2437            latitude_deg: 51.5074,
2438            longitude_deg: -0.1278,
2439            altitude_m: 11.0,
2440        };
2441        let start = UtcInstant::from_unix_microseconds(i64::MIN);
2442        let end = UtcInstant::from_unix_microseconds(i64::MAX);
2443
2444        let predict_err = predict_passes(
2445            &iss_2024_12_19_elements(),
2446            station,
2447            start,
2448            end,
2449            PassPredictionOptions::default(),
2450        )
2451        .expect_err("an unrepresentable window must be a typed error");
2452        assert!(matches!(
2453            predict_err,
2454            PassError::InvalidInput {
2455                field: "time window",
2456                ..
2457            }
2458        ));
2459
2460        let find_err = find_passes(
2461            &iss_2024_12_19_elements(),
2462            station,
2463            start,
2464            end,
2465            PassFinderOptions::default(),
2466        )
2467        .expect_err("an unrepresentable window must be a typed error");
2468        assert!(matches!(
2469            find_err,
2470            PassError::InvalidInput {
2471                field: "time window",
2472                ..
2473            }
2474        ));
2475    }
2476
2477    #[test]
2478    fn find_passes_opsmode_is_threaded_and_consistent_across_paths() {
2479        // The element-based pass APIs default to OpsMode::Afspc (golden-pinned),
2480        // while the rest of the crate defaults to Improved. The _with_opsmode
2481        // variants thread the choice through so the same TLE no longer yields a
2482        // different trajectory by path.
2483        let elements = iss_2024_12_19_elements();
2484        let station = GroundStation {
2485            latitude_deg: 51.5074,
2486            longitude_deg: -0.1278,
2487            altitude_m: 11.0,
2488        };
2489        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
2490        let end = UtcInstant::from_utc(2024, 12, 19, 12, 0, 0, 0).unwrap();
2491        let opts = PassFinderOptions::default();
2492
2493        // The bare function and the explicit-Afspc variant are identical.
2494        let bare = find_passes(&elements, station, start, end, opts).unwrap();
2495        let afspc =
2496            find_passes_with_opsmode(&elements, station, start, end, opts, OpsMode::Afspc).unwrap();
2497        assert_eq!(bare.len(), afspc.len());
2498        assert!(!bare.is_empty(), "expected at least one pass in the window");
2499        for (a, b) in bare.iter().zip(&afspc) {
2500            assert_eq!(a.aos.unix_microseconds(), b.aos.unix_microseconds());
2501            assert_eq!(a.los.unix_microseconds(), b.los.unix_microseconds());
2502        }
2503
2504        // The Improved variant matches the caller-owns-init path built with the
2505        // crate-default (Improved) opsmode - the consistency #5 is about.
2506        let improved_sat = Satellite::from_elements(&elements).expect("Improved-built satellite");
2507        let via_sat = find_passes_for_satellite(&improved_sat, station, start, end, opts).unwrap();
2508        let improved =
2509            find_passes_with_opsmode(&elements, station, start, end, opts, OpsMode::Improved)
2510                .unwrap();
2511        assert_eq!(improved.len(), via_sat.len());
2512        for (a, b) in improved.iter().zip(&via_sat) {
2513            assert_eq!(a.aos.unix_microseconds(), b.aos.unix_microseconds());
2514            assert_eq!(a.los.unix_microseconds(), b.los.unix_microseconds());
2515            assert_eq!(
2516                a.culmination.unix_microseconds(),
2517                b.culmination.unix_microseconds()
2518            );
2519        }
2520    }
2521
2522    #[test]
2523    fn coarse_scan_does_not_duplicate_exact_end_sample() {
2524        let start = UtcInstant::from_utc(2024, 12, 19, 10, 42, 0, 0).unwrap();
2525        let end = UtcInstant::from_utc(2024, 12, 19, 10, 52, 0, 0).unwrap();
2526        let station = GroundStation {
2527            latitude_deg: 51.5074,
2528            longitude_deg: -0.1278,
2529            altitude_m: 11.0,
2530        };
2531        let satellite =
2532            Satellite::from_elements_with_opsmode(&iss_2024_12_19_elements(), OpsMode::Afspc)
2533                .unwrap();
2534
2535        let samples = coarse_scan(&satellite, station, start, end, 60);
2536
2537        assert_eq!(samples.len(), 11);
2538        assert_eq!(samples.last().expect("end sample").0, end);
2539        assert_eq!(samples.iter().filter(|(dt, _)| *dt == end).count(), 1);
2540    }
2541
2542    // Cross-validated against Skyfield 1.54 (sgp4 2.25, WGS72, AFSPC opsmode
2543    // 'a', current IERS finals2000A.all). The ISS element set is fed to
2544    // sgp4.Satrec.sgp4init, wrapped in skyfield EarthSatellite.from_satrec, and
2545    // the look angle is (sat - wgs84.latlon(51.5, -0.1, 11)).at(t).altaz() at
2546    // 2024-01-01T12:00:00Z. Skyfield reference: az = 255.645831085991 deg,
2547    // el = -37.079388752166 deg, range = 8348.734510155984 km. Measured
2548    // sidereon-vs-Skyfield residuals: az and el below 1e-12 deg, range
2549    // 1.6e-11 km. sidereon mirrors Skyfield's altaz path and this epoch's EOP
2550    // is final, so they agree to float noise; the tolerances stay well above
2551    // that while catching any real regression.
2552    #[test]
2553    fn iss_london_look_angle_matches_skyfield() {
2554        const SKYFIELD_AZIMUTH_DEG: f64 = 255.645_831_085_991;
2555        const SKYFIELD_ELEVATION_DEG: f64 = -37.079_388_752_166;
2556        const SKYFIELD_RANGE_KM: f64 = 8348.734510155984;
2557        const ANGLE_TOL_DEG: f64 = 1.0e-9;
2558        const RANGE_TOL_KM: f64 = 1.0e-6;
2559
2560        let datetime = UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).unwrap();
2561        let station = GroundStation {
2562            latitude_deg: 51.5,
2563            longitude_deg: -0.1,
2564            altitude_m: 11.0,
2565        };
2566
2567        let look = look_angle(&iss_2024_01_01_elements(), station, datetime).unwrap();
2568
2569        assert!(
2570            (look.azimuth_deg - SKYFIELD_AZIMUTH_DEG).abs() <= ANGLE_TOL_DEG,
2571            "az {} vs skyfield {SKYFIELD_AZIMUTH_DEG}",
2572            look.azimuth_deg
2573        );
2574        assert!(
2575            (look.elevation_deg - SKYFIELD_ELEVATION_DEG).abs() <= ANGLE_TOL_DEG,
2576            "el {} vs skyfield {SKYFIELD_ELEVATION_DEG}",
2577            look.elevation_deg
2578        );
2579        assert!(
2580            (look.range_km - SKYFIELD_RANGE_KM).abs() <= RANGE_TOL_KM,
2581            "range {} vs skyfield {SKYFIELD_RANGE_KM}",
2582            look.range_km
2583        );
2584    }
2585
2586    #[test]
2587    fn look_angle_rejects_out_of_range_datetime_without_panicking() {
2588        let station = GroundStation {
2589            latitude_deg: 51.5,
2590            longitude_deg: -0.1,
2591            altitude_m: 11.0,
2592        };
2593
2594        for unix_microseconds in [i64::MIN, i64::MAX] {
2595            assert_eq!(
2596                look_angle(
2597                    &iss_2024_01_01_elements(),
2598                    station,
2599                    UtcInstant::from_unix_microseconds(unix_microseconds),
2600                ),
2601                Err(LookAngleError::InvalidInput {
2602                    field: "datetime",
2603                    reason: "invalid UTC instant"
2604                })
2605            );
2606        }
2607    }
2608
2609    #[test]
2610    fn look_angle_apis_reject_invalid_station() {
2611        let datetime = UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).unwrap();
2612        let station = GroundStation {
2613            latitude_deg: f64::NAN,
2614            longitude_deg: -0.1,
2615            altitude_m: 11.0,
2616        };
2617        let elements = iss_2024_01_01_elements();
2618
2619        assert_invalid_look_angle_field(
2620            look_angle(&elements, station, datetime).unwrap_err(),
2621            "ground_station.latitude_deg",
2622            "not finite",
2623        );
2624
2625        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
2626        assert_invalid_look_angle_field(
2627            look_angle_arc(&satellite, station, &[datetime]).unwrap_err(),
2628            "ground_station.latitude_deg",
2629            "not finite",
2630        );
2631    }
2632
2633    struct SkyfieldVis {
2634        catalog: &'static str,
2635        azimuth_deg: f64,
2636        elevation_deg: f64,
2637        range_km: f64,
2638        teme_position_km: Option<[f64; 3]>,
2639    }
2640
2641    fn assert_visible_matches_skyfield(got: &VisibleSatellite, want: &SkyfieldVis) {
2642        const ANGLE_TOL_DEG: f64 = 5.0e-6;
2643        const RANGE_TOL_KM: f64 = 5.0e-4;
2644        const POSITION_TOL_KM: f64 = 5.0e-4;
2645
2646        assert_eq!(got.catalog_number, want.catalog);
2647        assert!(
2648            (got.azimuth_deg - want.azimuth_deg).abs() <= ANGLE_TOL_DEG,
2649            "{} az {} vs skyfield {}",
2650            want.catalog,
2651            got.azimuth_deg,
2652            want.azimuth_deg
2653        );
2654        assert!(
2655            (got.elevation_deg - want.elevation_deg).abs() <= ANGLE_TOL_DEG,
2656            "{} el {} vs skyfield {}",
2657            want.catalog,
2658            got.elevation_deg,
2659            want.elevation_deg
2660        );
2661        assert!(
2662            (got.range_km - want.range_km).abs() <= RANGE_TOL_KM,
2663            "{} range {} vs skyfield {}",
2664            want.catalog,
2665            got.range_km,
2666            want.range_km
2667        );
2668        if let Some(teme) = want.teme_position_km {
2669            for (axis, &component) in teme.iter().enumerate() {
2670                assert!(
2671                    (got.position_km[axis] - component).abs() <= POSITION_TOL_KM,
2672                    "{} teme[{axis}] {} vs skyfield {component}",
2673                    want.catalog,
2674                    got.position_km[axis]
2675                );
2676            }
2677        }
2678    }
2679
2680    // Cross-validated against Skyfield 1.54 (sgp4 2.25, WGS72, AFSPC opsmode
2681    // 'a', current IERS finals2000A.all) at 2026-04-05T13:16:46.804800Z from
2682    // London (51.5074, -0.1278, 11 m). Each element set is fed to
2683    // sgp4.Satrec.sgp4init and wrapped in skyfield EarthSatellite.from_satrec;
2684    // az/el/range come from (sat - station).at(t).altaz(); the TEME position
2685    // comes from sgp4.Satrec.sgp4 at the matching UTC Julian date (the frame
2686    // VisibleSatellite.position_km reports). Skyfield references (deg / km):
2687    //   49271: az 157.417716366442  el -29.164801401540  range 8438.433859058045
2688    //          teme [4122.533671989, 6040.546926999, -2484.416093432]
2689    //   25544: az 272.823261379335  el -44.228568303544  range 9483.053279562850
2690    //   48274: az 309.090826171057  el -68.048200809472  range 12241.755488755362
2691    // Measured sidereon-vs-Skyfield residuals across the three: az <= 1.6e-6 deg,
2692    // el <= 6.0e-7 deg, range <= 9.0e-5 km, TEME position <= 1.1e-4 km. The
2693    // sub-arcsec / sub-decimeter spread is dominated by polar motion (sidereon's
2694    // look-angle path omits it) at a predicted-EOP epoch; the tolerances below
2695    // sit a few factors above those residuals.
2696    #[test]
2697    fn constellation_visible_from_matches_skyfield() {
2698        let datetime = UtcInstant::from_utc(2026, 4, 5, 13, 16, 46, 804_800).unwrap();
2699        let station = GroundStation {
2700            latitude_deg: 51.5074,
2701            longitude_deg: -0.1278,
2702            altitude_m: 11.0,
2703        };
2704        let members = vec![
2705            ConstellationMember {
2706                catalog_number: "25544".to_string(),
2707                elements: iss_fixture_elements(),
2708            },
2709            ConstellationMember {
2710                catalog_number: "48274".to_string(),
2711                elements: css_fixture_elements(),
2712            },
2713            ConstellationMember {
2714                catalog_number: "49271".to_string(),
2715                elements: fregat_fixture_elements(),
2716            },
2717        ];
2718
2719        let visible =
2720            visible_from_constellation(&members, station, datetime, -90.0).expect("valid station");
2721
2722        assert_eq!(
2723            visible
2724                .iter()
2725                .map(|sat| sat.catalog_number.as_str())
2726                .collect::<Vec<_>>(),
2727            ["49271", "25544", "48274"]
2728        );
2729
2730        assert_visible_matches_skyfield(
2731            &visible[0],
2732            &SkyfieldVis {
2733                catalog: "49271",
2734                azimuth_deg: 157.417716366442,
2735                elevation_deg: -29.16480140154,
2736                range_km: 8438.433859058045,
2737                teme_position_km: Some([4122.533671989, 6040.546926999, -2484.416093432]),
2738            },
2739        );
2740        assert_visible_matches_skyfield(
2741            &visible[1],
2742            &SkyfieldVis {
2743                catalog: "25544",
2744                azimuth_deg: 272.823_261_379_335,
2745                elevation_deg: -44.228_568_303_544,
2746                range_km: 9483.05327956285,
2747                teme_position_km: None,
2748            },
2749        );
2750        assert_visible_matches_skyfield(
2751            &visible[2],
2752            &SkyfieldVis {
2753                catalog: "48274",
2754                azimuth_deg: 309.090_826_171_057,
2755                elevation_deg: -68.048_200_809_472,
2756                range_km: 12241.755488755362,
2757                teme_position_km: None,
2758            },
2759        );
2760
2761        assert!(
2762            visible_from_constellation(&members, station, datetime, -20.0)
2763                .expect("valid station")
2764                .is_empty()
2765        );
2766    }
2767
2768    #[test]
2769    fn visible_from_satellites_threads_opsmode() {
2770        let datetime = UtcInstant::from_utc(2026, 4, 5, 13, 16, 46, 804_800).unwrap();
2771        let station = GroundStation {
2772            latitude_deg: 51.5074,
2773            longitude_deg: -0.1278,
2774            altitude_m: 11.0,
2775        };
2776        // A deep-space (near-GEO) object: the only path on which Afspc vs Improved
2777        // opsmode actually changes the propagated TEME state.
2778        let elements = geo_like_fixture_elements();
2779        let afspc = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
2780        let improved = Satellite::from_elements_with_opsmode(&elements, OpsMode::Improved).unwrap();
2781        let ids = vec!["99001".to_string()];
2782
2783        let vis_afspc =
2784            visible_from_satellites(std::slice::from_ref(&afspc), &ids, station, datetime, -90.0)
2785                .unwrap();
2786        let vis_improved = visible_from_satellites(
2787            std::slice::from_ref(&improved),
2788            &ids,
2789            station,
2790            datetime,
2791            -90.0,
2792        )
2793        .unwrap();
2794
2795        assert_eq!(vis_afspc.len(), 1);
2796        assert_eq!(vis_improved.len(), 1);
2797        // Opsmode is honored end-to-end: the look angles differ between modes.
2798        assert_ne!(vis_afspc[0], vis_improved[0]);
2799        assert!(
2800            (vis_afspc[0].azimuth_deg - vis_improved[0].azimuth_deg).abs()
2801                + (vis_afspc[0].elevation_deg - vis_improved[0].elevation_deg).abs()
2802                > 1.0e-9,
2803            "afspc {:?} vs improved {:?}",
2804            vis_afspc[0],
2805            vis_improved[0]
2806        );
2807
2808        // The Afspc satellite path reproduces the element-based (Afspc-hardcoded)
2809        // path bit-for-bit.
2810        let members = vec![ConstellationMember {
2811            catalog_number: "99001".to_string(),
2812            elements: elements.clone(),
2813        }];
2814        let element_based = visible_from_constellation(&members, station, datetime, -90.0).unwrap();
2815        assert_eq!(vis_afspc, element_based);
2816    }
2817
2818    #[test]
2819    fn visible_from_satellites_filters_and_sorts() {
2820        let datetime = UtcInstant::from_utc(2026, 4, 5, 13, 16, 46, 804_800).unwrap();
2821        let station = GroundStation {
2822            latitude_deg: 51.5074,
2823            longitude_deg: -0.1278,
2824            altitude_m: 11.0,
2825        };
2826        let sats = vec![
2827            Satellite::from_elements_with_opsmode(&iss_fixture_elements(), OpsMode::Afspc).unwrap(),
2828            Satellite::from_elements_with_opsmode(&css_fixture_elements(), OpsMode::Afspc).unwrap(),
2829            Satellite::from_elements_with_opsmode(&fregat_fixture_elements(), OpsMode::Afspc)
2830                .unwrap(),
2831        ];
2832        let ids = vec![
2833            "25544".to_string(),
2834            "48274".to_string(),
2835            "49271".to_string(),
2836        ];
2837
2838        let visible = visible_from_satellites(&sats, &ids, station, datetime, -90.0).unwrap();
2839        // Sorted by elevation descending (same order the element-based path gives
2840        // for these cross-validated fixtures).
2841        assert_eq!(
2842            visible
2843                .iter()
2844                .map(|s| s.catalog_number.as_str())
2845                .collect::<Vec<_>>(),
2846            ["49271", "25544", "48274"]
2847        );
2848        for pair in visible.windows(2) {
2849            assert!(pair[0].elevation_deg >= pair[1].elevation_deg);
2850        }
2851
2852        // All three sit well below the horizon here, so a -20 deg mask drops them.
2853        assert!(
2854            visible_from_satellites(&sats, &ids, station, datetime, -20.0)
2855                .unwrap()
2856                .is_empty()
2857        );
2858
2859        // A mismatched id/satellite count is rejected.
2860        assert!(matches!(
2861            visible_from_satellites(&sats, &ids[..2], station, datetime, -90.0),
2862            Err(PassError::InvalidInput { field: "ids", .. })
2863        ));
2864    }
2865
2866    #[test]
2867    fn ground_track_subpoint_is_consistent() {
2868        let elements = iss_2024_01_01_elements();
2869        let satellite =
2870            Satellite::from_elements_with_opsmode(&elements, OpsMode::Improved).unwrap();
2871        let epochs: Vec<UtcInstant> = (0..8)
2872            .map(|i| UtcInstant::from_utc(2024, 1, 1, 12, i, 0, 0).unwrap())
2873            .collect();
2874
2875        let track = ground_track(&satellite, &epochs).unwrap();
2876        assert_eq!(track.len(), epochs.len());
2877
2878        let inclination_rad = elements.inclination_deg.to_radians();
2879        for point in &track {
2880            // Sub-point latitude is bounded by the orbital inclination (a small
2881            // margin covers nodal/oblateness wobble).
2882            assert!(
2883                point.lat_rad.abs() <= inclination_rad + 1.0e-3,
2884                "lat {} exceeds inclination {}",
2885                point.lat_rad,
2886                inclination_rad
2887            );
2888            // ISS-class altitude.
2889            let alt_km = point.height_m / 1000.0;
2890            assert!(
2891                (300.0..=600.0).contains(&alt_km),
2892                "alt {alt_km} km implausible"
2893            );
2894        }
2895
2896        // Longitude advances monotonically (single direction) over the short arc,
2897        // accounting for the +-pi wrap.
2898        let mut deltas = Vec::new();
2899        for pair in track.windows(2) {
2900            let mut dlon = pair[1].lon_rad - pair[0].lon_rad;
2901            if dlon > std::f64::consts::PI {
2902                dlon -= std::f64::consts::TAU;
2903            } else if dlon < -std::f64::consts::PI {
2904                dlon += std::f64::consts::TAU;
2905            }
2906            deltas.push(dlon);
2907        }
2908        assert!(
2909            deltas.iter().all(|&d| d > 0.0) || deltas.iter().all(|&d| d < 0.0),
2910            "longitude not monotonic: {deltas:?}"
2911        );
2912
2913        // One epoch matches a manual propagate -> TEME->GCRS -> GCRS->ITRS ->
2914        // geodetic composition bit-for-bit.
2915        let dt = epochs[3];
2916        let ts = time_scales_for_look_angle(dt).unwrap();
2917        let pred = satellite.propagate_jd(dt.sgp4_julian_date()).unwrap();
2918        let (gcrs, _) = teme_to_gcrs_compute(
2919            &TemeStateKm {
2920                position_km: pred.position,
2921                velocity_km_s: pred.velocity,
2922            },
2923            &ts,
2924            false,
2925        )
2926        .unwrap();
2927        let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).unwrap();
2928        let (lat_deg, lon_deg, alt_km) = itrs_to_geodetic_compute(x_km, y_km, z_km).unwrap();
2929        assert_eq!(track[3].lat_rad.to_bits(), lat_deg.to_radians().to_bits());
2930        assert_eq!(track[3].lon_rad.to_bits(), lon_deg.to_radians().to_bits());
2931        assert_eq!(track[3].height_m.to_bits(), (alt_km * 1000.0).to_bits());
2932    }
2933
2934    #[test]
2935    fn look_angle_arc_reproduces_single_london_golden_bits() {
2936        // The arc walker, fed the same AFSPC satellite the single-shot
2937        // `look_angle` builds internally, must reproduce the frozen London golden
2938        // bit-for-bit at the matching instant.
2939        let datetime = UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).unwrap();
2940        let station = GroundStation {
2941            latitude_deg: 51.5,
2942            longitude_deg: -0.1,
2943            altitude_m: 11.0,
2944        };
2945        let satellite =
2946            Satellite::from_elements_with_opsmode(&iss_2024_01_01_elements(), OpsMode::Afspc)
2947                .unwrap();
2948
2949        let arc = look_angle_arc(&satellite, station, &[datetime]).unwrap();
2950
2951        assert_eq!(arc.len(), 1);
2952        assert_eq!(arc[0].azimuth_deg.to_bits(), 0x406f_f4aa_a5f4_2254);
2953        assert_eq!(arc[0].elevation_deg.to_bits(), 0xc042_8a29_691f_1ca2);
2954        assert_eq!(arc[0].range_km.to_bits(), 0x40c0_4e5e_046d_c53b);
2955    }
2956
2957    #[test]
2958    fn arc_walkers_match_per_epoch_calls_bit_for_bit() {
2959        // Building the satellite once and stepping it over the grid must equal
2960        // the per-epoch single-shot calls exactly (no satrec drift).
2961        let station = GroundStation {
2962            latitude_deg: 51.5074,
2963            longitude_deg: -0.1278,
2964            altitude_m: 11.0,
2965        };
2966        let elements = iss_2024_01_01_elements();
2967        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
2968        let epochs: Vec<UtcInstant> = (0..6)
2969            .map(|i| UtcInstant::from_utc(2024, 1, 1, 12, 10 * i, 0, 0).unwrap())
2970            .collect();
2971
2972        let pos_arc = propagate_teme_arc(&satellite, &epochs).unwrap();
2973        let look_arc = look_angle_arc(&satellite, station, &epochs).unwrap();
2974
2975        for (i, &datetime) in epochs.iter().enumerate() {
2976            let single_pred = satellite.propagate_jd(datetime.sgp4_julian_date()).unwrap();
2977            let single_look = look_angle(&elements, station, datetime).unwrap();
2978            for axis in 0..3 {
2979                assert_eq!(
2980                    pos_arc[i].position[axis].to_bits(),
2981                    single_pred.position[axis].to_bits()
2982                );
2983                assert_eq!(
2984                    pos_arc[i].velocity[axis].to_bits(),
2985                    single_pred.velocity[axis].to_bits()
2986                );
2987            }
2988            assert_eq!(
2989                look_arc[i].azimuth_deg.to_bits(),
2990                single_look.azimuth_deg.to_bits()
2991            );
2992            assert_eq!(
2993                look_arc[i].elevation_deg.to_bits(),
2994                single_look.elevation_deg.to_bits()
2995            );
2996            assert_eq!(
2997                look_arc[i].range_km.to_bits(),
2998                single_look.range_km.to_bits()
2999            );
3000        }
3001    }
3002
3003    #[test]
3004    fn parallel_batch_is_bit_identical_to_serial_multi_sat() {
3005        // A real multi-satellite fixture (LEO + Tiangong + a high-eccentricity
3006        // Fregat upper stage, plus the two ISS epochs) propagated over a full
3007        // day of epochs. The rayon-parallel batch must equal the single-threaded
3008        // batch to_bits, element by element, for both the TEME states and the
3009        // topocentric look angles.
3010        let station = GroundStation {
3011            latitude_deg: 51.5074,
3012            longitude_deg: -0.1278,
3013            altitude_m: 11.0,
3014        };
3015        let element_sets = [
3016            iss_fixture_elements(),
3017            css_fixture_elements(),
3018            fregat_fixture_elements(),
3019            iss_2024_01_01_elements(),
3020            iss_2024_12_19_elements(),
3021        ];
3022        let satellites: Vec<Satellite> = element_sets
3023            .iter()
3024            .map(|e| Satellite::from_elements_with_opsmode(e, OpsMode::Afspc).unwrap())
3025            .collect();
3026        // 145 epochs spaced 10 minutes apart (a full day) so the parallel pool
3027        // has many independent arcs to interleave.
3028        let base = UtcInstant::from_utc(2026, 4, 5, 0, 0, 0, 0).unwrap();
3029        let epochs: Vec<UtcInstant> = (0..145)
3030            .map(|i| base.add_microseconds(i * 600 * MICROSECONDS_PER_SECOND_I64))
3031            .collect();
3032
3033        let pos_serial = propagate_teme_batch_serial(&satellites, &epochs);
3034        let pos_parallel = propagate_teme_batch_parallel(&satellites, &epochs);
3035        let look_serial = look_angle_batch_serial(&satellites, station, &epochs);
3036        let look_parallel = look_angle_batch_parallel(&satellites, station, &epochs);
3037
3038        assert_eq!(pos_serial.len(), satellites.len());
3039        assert_eq!(pos_parallel.len(), satellites.len());
3040        assert_eq!(look_serial.len(), satellites.len());
3041        assert_eq!(look_parallel.len(), satellites.len());
3042
3043        for sat_idx in 0..satellites.len() {
3044            let serial_arc = pos_serial[sat_idx].as_ref().expect("serial arc ok");
3045            let parallel_arc = pos_parallel[sat_idx].as_ref().expect("parallel arc ok");
3046            // Tie the batch back to the per-satellite arc walker (which is itself
3047            // pinned to the single-shot SGP4 goldens) so this is anchored to the
3048            // existing 0-ULP reference, not just serial==parallel.
3049            let reference_arc = propagate_teme_arc(&satellites[sat_idx], &epochs).unwrap();
3050            assert_eq!(serial_arc.len(), epochs.len());
3051            assert_eq!(parallel_arc.len(), epochs.len());
3052            for epoch_idx in 0..epochs.len() {
3053                for axis in 0..3 {
3054                    let s = serial_arc[epoch_idx].position[axis].to_bits();
3055                    let p = parallel_arc[epoch_idx].position[axis].to_bits();
3056                    let r = reference_arc[epoch_idx].position[axis].to_bits();
3057                    assert_eq!(
3058                        s, p,
3059                        "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
3060                    );
3061                    assert_eq!(
3062                        s, r,
3063                        "position vs arc reference sat {sat_idx} epoch {epoch_idx}"
3064                    );
3065                    let sv = serial_arc[epoch_idx].velocity[axis].to_bits();
3066                    let pv = parallel_arc[epoch_idx].velocity[axis].to_bits();
3067                    let rv = reference_arc[epoch_idx].velocity[axis].to_bits();
3068                    assert_eq!(
3069                        sv, pv,
3070                        "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
3071                    );
3072                    assert_eq!(
3073                        sv, rv,
3074                        "velocity vs arc reference sat {sat_idx} epoch {epoch_idx}"
3075                    );
3076                }
3077            }
3078
3079            let look_s = look_serial[sat_idx].as_ref().expect("serial look ok");
3080            let look_p = look_parallel[sat_idx].as_ref().expect("parallel look ok");
3081            assert_eq!(look_s.len(), epochs.len());
3082            assert_eq!(look_p.len(), epochs.len());
3083            for epoch_idx in 0..epochs.len() {
3084                assert_eq!(
3085                    look_s[epoch_idx].azimuth_deg.to_bits(),
3086                    look_p[epoch_idx].azimuth_deg.to_bits(),
3087                    "azimuth bits sat {sat_idx} epoch {epoch_idx}"
3088                );
3089                assert_eq!(
3090                    look_s[epoch_idx].elevation_deg.to_bits(),
3091                    look_p[epoch_idx].elevation_deg.to_bits(),
3092                    "elevation bits sat {sat_idx} epoch {epoch_idx}"
3093                );
3094                assert_eq!(
3095                    look_s[epoch_idx].range_km.to_bits(),
3096                    look_p[epoch_idx].range_km.to_bits(),
3097                    "range bits sat {sat_idx} epoch {epoch_idx}"
3098                );
3099            }
3100        }
3101    }
3102
3103    #[test]
3104    fn find_passes_batch_parallel_matches_serial_multi_sat() {
3105        let station = GroundStation {
3106            latitude_deg: 51.5074,
3107            longitude_deg: -0.1278,
3108            altitude_m: 11.0,
3109        };
3110        let element_sets = [
3111            iss_fixture_elements(),
3112            css_fixture_elements(),
3113            fregat_fixture_elements(),
3114            iss_2024_12_19_elements(),
3115        ];
3116        let start = UtcInstant::from_utc(2026, 4, 5, 0, 0, 0, 0).unwrap();
3117        let end = UtcInstant::from_utc(2026, 4, 6, 0, 0, 0, 0).unwrap();
3118        let options = PassFinderOptions {
3119            elevation_mask_deg: 0.0,
3120            coarse_step_seconds: 600.0,
3121            time_tolerance_seconds: 1.0e-3,
3122        };
3123
3124        let serial = find_passes_batch_serial(&element_sets, station, start, end, options);
3125        let parallel = find_passes_batch_parallel(&element_sets, station, start, end, options);
3126
3127        assert_eq!(parallel, serial);
3128        assert_eq!(serial.len(), element_sets.len());
3129        assert!(serial
3130            .iter()
3131            .all(|result| result.as_ref().is_ok_and(|passes| {
3132                passes
3133                    .iter()
3134                    .all(|pass| pass.aos <= pass.culmination && pass.culmination <= pass.los)
3135            })));
3136        assert!(serial
3137            .iter()
3138            .any(|result| result.as_ref().is_ok_and(|passes| !passes.is_empty())));
3139
3140        let single = find_passes(&element_sets[0], station, start, end, options)
3141            .expect("single pass finder succeeds");
3142        let batch_first = serial[0].as_ref().expect("serial batch element succeeds");
3143        assert_eq!(batch_first.len(), single.len());
3144        for (batch_pass, single_pass) in batch_first.iter().zip(&single) {
3145            assert_pass_close(batch_pass, single_pass, 1_000, 1.0e-6);
3146        }
3147    }
3148
3149    #[test]
3150    fn segmented_crossings_long_window_match_under_cap_event_finder() {
3151        let total_us = 181 * SECONDS_PER_DAY_I64 * MICROSECONDS_PER_SECOND_I64;
3152        let step_us = 15 * MICROSECONDS_PER_SECOND_I64;
3153        let step_seconds = step_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3154        let time_tolerance_seconds = 1.0e-3;
3155        let total_seconds = offset_seconds(total_us);
3156        assert!(
3157            (total_seconds / step_seconds).ceil() > EVENT_FINDER_COARSE_SAMPLE_LIMIT as f64,
3158            "181-day LEO-rate scan should exceed one EventFinder coarse budget"
3159        );
3160        assert_eq!(
3161            EventFinder::new(0.0, total_seconds, step_seconds, time_tolerance_seconds)
3162                .expect("valid long event window")
3163                .find_crossings(synthetic_pass_wave, 0.0)
3164                .unwrap_err(),
3165            EventFinderError::InvalidInput {
3166                field: "step_seconds",
3167                reason: "too many samples",
3168            }
3169        );
3170
3171        let segmented = find_segmented_crossings(
3172            synthetic_pass_wave,
3173            total_us,
3174            step_us,
3175            step_seconds,
3176            time_tolerance_seconds,
3177        )
3178        .expect("segmented long-window crossings succeed");
3179        assert!(!segmented.is_empty());
3180
3181        let boundary_us = event_finder_segment_stride_us(step_us);
3182        assert!(0 < boundary_us && boundary_us < total_us);
3183        let reference_start_us = boundary_us - 12 * 60 * 60 * MICROSECONDS_PER_SECOND_I64;
3184        let reference_end_us = boundary_us + 12 * 60 * 60 * MICROSECONDS_PER_SECOND_I64;
3185        let reference = EventFinder::new(
3186            offset_seconds(reference_start_us),
3187            offset_seconds(reference_end_us),
3188            step_seconds,
3189            time_tolerance_seconds,
3190        )
3191        .expect("valid under-cap reference window")
3192        .find_crossings(synthetic_pass_wave, 0.0)
3193        .expect("under-cap reference crossings succeed");
3194        let segmented_boundary: Vec<_> = segmented
3195            .iter()
3196            .copied()
3197            .filter(|crossing| {
3198                let crossing_us = crossing_offset_us(*crossing);
3199                reference_start_us <= crossing_us && crossing_us <= reference_end_us
3200            })
3201            .collect();
3202
3203        assert!(!reference.is_empty());
3204        assert_eq!(segmented_boundary.len(), reference.len());
3205        for (actual, expected) in segmented_boundary.iter().zip(&reference) {
3206            assert_eq!(actual.direction, expected.direction);
3207            assert!(
3208                (actual.time_seconds - expected.time_seconds).abs() <= time_tolerance_seconds,
3209                "segmented event time diverged from under-cap EventFinder reference"
3210            );
3211        }
3212    }
3213
3214    #[test]
3215    fn segmented_crossings_boundary_plateaus_match_under_cap_event_finder() {
3216        let step_us = 10;
3217        let step_seconds = offset_seconds(step_us);
3218        let time_tolerance_seconds = 1.0e-12;
3219        let total_us = max_event_finder_segment_us(step_us) + 1_000;
3220        let boundary_us = event_finder_segment_stride_us(step_us);
3221        let reference_start_us = boundary_us - 2 * step_us;
3222        let reference_end_us = boundary_us + 4 * step_us;
3223        let plateau = |right_value: f64| {
3224            move |time_seconds: f64| {
3225                let offset_us = (time_seconds * MICROSECONDS_PER_SECOND_I64 as f64).round() as i64;
3226                if boundary_us <= offset_us && offset_us <= boundary_us + 2 * step_us {
3227                    0.0
3228                } else if offset_us < boundary_us {
3229                    -1.0
3230                } else {
3231                    right_value
3232                }
3233            }
3234        };
3235
3236        let same_side = plateau(-1.0);
3237        let same_reference = EventFinder::new(
3238            offset_seconds(reference_start_us),
3239            offset_seconds(reference_end_us),
3240            step_seconds,
3241            time_tolerance_seconds,
3242        )
3243        .expect("valid same-side reference window")
3244        .find_crossings(same_side, 0.0)
3245        .expect("same-side reference scan succeeds");
3246        let same_segmented = find_segmented_crossings(
3247            same_side,
3248            total_us,
3249            step_us,
3250            step_seconds,
3251            time_tolerance_seconds,
3252        )
3253        .expect("same-side segmented scan succeeds");
3254        let same_segmented_boundary: Vec<_> = same_segmented
3255            .iter()
3256            .copied()
3257            .filter(|crossing| {
3258                let crossing_us = crossing_offset_us(*crossing);
3259                reference_start_us <= crossing_us && crossing_us <= reference_end_us
3260            })
3261            .collect();
3262
3263        assert!(same_reference.is_empty());
3264        assert_eq!(same_segmented_boundary, same_reference);
3265
3266        let opposite_side = plateau(1.0);
3267        let opposite_reference = EventFinder::new(
3268            offset_seconds(reference_start_us),
3269            offset_seconds(reference_end_us),
3270            step_seconds,
3271            time_tolerance_seconds,
3272        )
3273        .expect("valid opposite-side reference window")
3274        .find_crossings(opposite_side, 0.0)
3275        .expect("opposite-side reference scan succeeds");
3276        let opposite_segmented = find_segmented_crossings(
3277            opposite_side,
3278            total_us,
3279            step_us,
3280            step_seconds,
3281            time_tolerance_seconds,
3282        )
3283        .expect("opposite-side segmented scan succeeds");
3284        let opposite_segmented_boundary: Vec<_> = opposite_segmented
3285            .iter()
3286            .copied()
3287            .filter(|crossing| {
3288                let crossing_us = crossing_offset_us(*crossing);
3289                reference_start_us <= crossing_us && crossing_us <= reference_end_us
3290            })
3291            .collect();
3292
3293        assert_eq!(opposite_reference.len(), 1);
3294        assert_eq!(opposite_segmented_boundary.len(), opposite_reference.len());
3295        for (actual, expected) in opposite_segmented_boundary.iter().zip(&opposite_reference) {
3296            assert_eq!(actual.direction, expected.direction);
3297            assert!(
3298                (crossing_offset_us(*actual) - crossing_offset_us(*expected)).abs() <= 1,
3299                "segmented plateau crossing time diverged from under-cap reference"
3300            );
3301        }
3302    }
3303
3304    #[test]
3305    fn segmented_culmination_long_window_stays_under_event_sample_cap() {
3306        let start = UtcInstant::from_unix_microseconds(0);
3307        let step_us = 10;
3308        let total_us = max_event_finder_segment_us(step_us) + 1_000;
3309        let peak_offset_us = event_finder_segment_stride_us(step_us) + 500;
3310        let end = start.add_microseconds(total_us);
3311        let peak_time = start.add_microseconds(peak_offset_us);
3312        let step_seconds = offset_seconds(step_us);
3313        let time_tolerance_seconds = 1.0e-6;
3314        let elevation = |dt: UtcInstant| {
3315            let centered_us = (dt.diff_microseconds(start) - peak_offset_us) as f64;
3316            42.0 - (centered_us / 10_000.0).powi(2)
3317        };
3318
3319        assert!(peak_offset_us > max_event_finder_segment_us(step_us));
3320        assert_eq!(
3321            EventFinder::new(
3322                0.0,
3323                offset_seconds(total_us),
3324                step_seconds,
3325                time_tolerance_seconds
3326            )
3327            .expect("valid long event window")
3328            .find_extrema(|offset_seconds| elevation(instant_at_offset_seconds(
3329                start,
3330                offset_seconds
3331            )))
3332            .unwrap_err(),
3333            EventFinderError::InvalidInput {
3334                field: "step_seconds",
3335                reason: "too many samples",
3336            }
3337        );
3338
3339        let (culmination, max_elevation) = find_culmination_with(start, end, step_us, 1, elevation)
3340            .expect("segmented long-window culmination succeeds");
3341
3342        assert!(
3343            (culmination.unix_microseconds() - peak_time.unix_microseconds()).abs() <= 1,
3344            "culmination should stay on the synthetic peak"
3345        );
3346        assert!(
3347            (max_elevation - elevation(peak_time)).abs() <= 1.0e-12,
3348            "max elevation should come from the synthetic peak"
3349        );
3350    }
3351
3352    #[test]
3353    fn segmented_extrema_short_window_matches_direct_event_finder() {
3354        let start = UtcInstant::from_unix_microseconds(0);
3355        let total_us = 10 * MICROSECONDS_PER_SECOND_I64;
3356        let step_us = 500_000;
3357        let step_seconds = offset_seconds(step_us);
3358        let time_tolerance_seconds = 1.0e-4;
3359        let elevation = |offset_seconds| {
3360            multi_stationary_elevation(instant_at_offset_seconds(start, offset_seconds))
3361        };
3362
3363        let direct = EventFinder::new(
3364            0.0,
3365            offset_seconds(total_us),
3366            step_seconds,
3367            time_tolerance_seconds,
3368        )
3369        .expect("valid short event window")
3370        .find_extrema(elevation)
3371        .expect("direct short-window extrema succeeds");
3372        let segmented = find_segmented_extrema(
3373            elevation,
3374            total_us,
3375            step_us,
3376            step_seconds,
3377            time_tolerance_seconds,
3378        )
3379        .expect("segmented short-window extrema succeeds");
3380
3381        assert_eq!(segmented, direct);
3382    }
3383
3384    #[test]
3385    fn find_passes_matches_predict_passes_and_resists_coarse_drop() {
3386        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
3387        let end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
3388        let station = GroundStation {
3389            latitude_deg: 51.5074,
3390            longitude_deg: -0.1278,
3391            altitude_m: 11.0,
3392        };
3393        let elements = iss_2024_12_19_elements();
3394
3395        // Existing reference path at a fine step.
3396        let reference = predict_passes(
3397            &elements,
3398            station,
3399            start,
3400            end,
3401            PassPredictionOptions {
3402                min_elevation_deg: 0.0,
3403                step_seconds: 10,
3404            },
3405        )
3406        .expect("valid pass-prediction step");
3407        // Dense finder at mask 0 must reproduce it.
3408        let mine = find_passes(
3409            &elements,
3410            station,
3411            start,
3412            end,
3413            PassFinderOptions {
3414                elevation_mask_deg: 0.0,
3415                coarse_step_seconds: 10.0,
3416                time_tolerance_seconds: 1.0e-3,
3417            },
3418        )
3419        .expect("valid pass-finder step");
3420        // The same reference path at a coarse step drops passes.
3421        let coarse = predict_passes(
3422            &elements,
3423            station,
3424            start,
3425            end,
3426            PassPredictionOptions {
3427                min_elevation_deg: 0.0,
3428                step_seconds: 900,
3429            },
3430        )
3431        .expect("valid pass-prediction step");
3432
3433        assert!(reference.len() >= 2);
3434        assert_eq!(mine.len(), reference.len());
3435        assert!(coarse.len() < reference.len());
3436
3437        for (m, r) in mine.iter().zip(reference.iter()) {
3438            assert!(
3439                (m.aos.unix_microseconds() - r.rise.unix_microseconds()).abs() < 1_000,
3440                "AOS within 1 ms of reference rise"
3441            );
3442            assert!(
3443                (m.los.unix_microseconds() - r.set.unix_microseconds()).abs() < 1_000,
3444                "LOS within 1 ms of reference set"
3445            );
3446            assert!(
3447                (m.culmination.unix_microseconds() - r.max_elevation_time.unix_microseconds())
3448                    .abs()
3449                    < 1_000,
3450                "culmination within 1 ms of reference peak time"
3451            );
3452            assert!(
3453                (m.max_elevation_deg - r.max_elevation_deg).abs() < 1.0e-7,
3454                "max elevation within 1e-7 deg of reference"
3455            );
3456        }
3457    }
3458
3459    #[test]
3460    fn find_passes_extrema_culmination_matches_predict_passes_peak() {
3461        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
3462        let end = UtcInstant::from_utc(2024, 12, 19, 12, 0, 0, 0).unwrap();
3463        let station = GroundStation {
3464            latitude_deg: 51.5074,
3465            longitude_deg: -0.1278,
3466            altitude_m: 11.0,
3467        };
3468        let elements = iss_2024_12_19_elements();
3469        let reference = predict_passes(
3470            &elements,
3471            station,
3472            start,
3473            end,
3474            PassPredictionOptions {
3475                min_elevation_deg: 0.0,
3476                step_seconds: 10,
3477            },
3478        )
3479        .expect("valid pass-prediction step");
3480        let found = find_passes(
3481            &elements,
3482            station,
3483            start,
3484            end,
3485            PassFinderOptions {
3486                elevation_mask_deg: 0.0,
3487                coarse_step_seconds: 10.0,
3488                time_tolerance_seconds: 1.0e-4,
3489            },
3490        )
3491        .expect("valid pass-finder step");
3492
3493        assert_eq!(reference.len(), 1);
3494        assert_eq!(found.len(), reference.len());
3495        let expected = reference[0];
3496        let actual = found[0];
3497        assert!(
3498            (actual.culmination.unix_microseconds()
3499                - expected.max_elevation_time.unix_microseconds())
3500            .abs()
3501                < 1_000,
3502            "culmination within 1 ms of legacy peak time"
3503        );
3504        assert!(
3505            (actual.max_elevation_deg - expected.max_elevation_deg).abs() < 1.0e-7,
3506            "max elevation within 1e-7 deg of legacy peak"
3507        );
3508
3509        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
3510        let span_seconds =
3511            actual.los.diff_microseconds(actual.aos) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3512        let extrema = EventFinder::new(0.0, span_seconds, 10.0, 1.0e-4)
3513            .expect("valid event-finder window")
3514            .find_extrema(|offset_seconds| {
3515                elevation_at(
3516                    &satellite,
3517                    instant_at_offset_seconds(actual.aos, offset_seconds),
3518                    station,
3519                )
3520            })
3521            .expect("finite elevation predicate");
3522        let peak = extrema
3523            .iter()
3524            .filter(|event| event.kind == ExtremumKind::Maximum)
3525            .max_by(|a, b| {
3526                a.value
3527                    .partial_cmp(&b.value)
3528                    .unwrap_or(std::cmp::Ordering::Equal)
3529            })
3530            .expect("pass has an interior maximum");
3531        let peak_time = instant_at_offset_seconds(actual.aos, peak.time_seconds);
3532
3533        assert!(
3534            (actual.culmination.unix_microseconds() - peak_time.unix_microseconds()).abs() <= 1_000,
3535            "pass culmination follows the shared extrema finder"
3536        );
3537        assert!(
3538            (actual.max_elevation_deg - elevation_at(&satellite, peak_time, station)).abs()
3539                < 1.0e-7,
3540            "pass max elevation follows the shared extrema finder"
3541        );
3542    }
3543
3544    #[test]
3545    fn robust_crossing_step_honors_requested_step_for_geo_like_orbit() {
3546        let station = GroundStation {
3547            latitude_deg: 51.5074,
3548            longitude_deg: -0.1278,
3549            altitude_m: 11.0,
3550        };
3551        let satellite =
3552            Satellite::from_elements_with_opsmode(&geo_like_fixture_elements(), OpsMode::Afspc)
3553                .expect("GEO-like fixture initializes");
3554        let requested_step_us = 900 * MICROSECONDS_PER_SECOND_I64;
3555        let orbit_step_us = fastest_orbit_fraction_step_us(&satellite)
3556            .expect("GEO-like orbit has a finite safe step");
3557
3558        assert!(orbit_step_us >= requested_step_us);
3559        assert_eq!(
3560            robust_crossing_step_us(&satellite, station, 0.0, requested_step_us),
3561            requested_step_us
3562        );
3563    }
3564
3565    #[test]
3566    fn robust_crossing_step_substeps_fast_orbit_by_orbit_bound() {
3567        let station = GroundStation {
3568            latitude_deg: 51.5074,
3569            longitude_deg: -0.1278,
3570            altitude_m: 11.0,
3571        };
3572        let satellite =
3573            Satellite::from_elements_with_opsmode(&iss_2024_12_19_elements(), OpsMode::Afspc)
3574                .expect("ISS fixture initializes");
3575        let requested_step_us = 900 * MICROSECONDS_PER_SECOND_I64;
3576        let orbit_step_us =
3577            fastest_orbit_fraction_step_us(&satellite).expect("ISS orbit has a finite safe step");
3578
3579        assert!(orbit_step_us < requested_step_us);
3580        assert_eq!(
3581            robust_crossing_step_us(&satellite, station, 0.0, requested_step_us),
3582            orbit_step_us
3583        );
3584    }
3585
3586    #[test]
3587    fn find_passes_geometry_step_finds_high_mask_pass_period_step_skips() {
3588        let elements = iss_2024_12_19_elements();
3589        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc)
3590            .expect("ISS fixture initializes");
3591        let peak_seed = UtcInstant::from_utc(2024, 12, 19, 12, 37, 35, 0).unwrap();
3592        let station = station_under_satellite(&satellite, peak_seed);
3593        let orbit_step_us =
3594            fastest_orbit_fraction_step_us(&satellite).expect("ISS orbit has finite step");
3595        let requested_step_us = 900 * MICROSECONDS_PER_SECOND_I64;
3596
3597        let reference = find_passes_for_satellite(
3598            &satellite,
3599            station,
3600            peak_seed.add_microseconds(-30 * 60 * MICROSECONDS_PER_SECOND_I64),
3601            peak_seed.add_microseconds(30 * 60 * MICROSECONDS_PER_SECOND_I64),
3602            PassFinderOptions {
3603                elevation_mask_deg: 0.0,
3604                coarse_step_seconds: 1.0,
3605                time_tolerance_seconds: 1.0e-3,
3606            },
3607        )
3608        .expect("fine overhead reference succeeds");
3609        let reference_pass = reference
3610            .iter()
3611            .max_by(|a, b| {
3612                a.max_elevation_deg
3613                    .partial_cmp(&b.max_elevation_deg)
3614                    .unwrap_or(std::cmp::Ordering::Equal)
3615            })
3616            .expect("overhead station has a visible ISS pass");
3617        let mask = 85.0;
3618        assert!(
3619            reference_pass.max_elevation_deg > mask,
3620            "subpoint pass should clear the high mask"
3621        );
3622
3623        let half_phase_us = orbit_step_us / 2;
3624        let side_us = 20 * orbit_step_us + half_phase_us;
3625        let start = reference_pass.culmination.add_microseconds(-side_us);
3626        let end = reference_pass.culmination.add_microseconds(side_us);
3627        let span_seconds = end.diff_microseconds(start) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3628        let orbit_step_seconds = orbit_step_us as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3629
3630        let period_only_crossings = EventFinder::new(0.0, span_seconds, orbit_step_seconds, 1.0e-3)
3631            .expect("valid period-only window")
3632            .find_crossings(
3633                PassSearch {
3634                    satellite: &satellite,
3635                    ground_station: station,
3636                    start_time: start,
3637                    end_time: end,
3638                    step_us: orbit_step_us,
3639                    tol_us: 1_000,
3640                    mask,
3641                },
3642                0.0,
3643            )
3644            .expect("finite period-only masked elevation predicate");
3645        assert!(
3646            period_only_crossings.is_empty(),
3647            "period-only robust step should skip this narrow high-mask pass"
3648        );
3649
3650        let geometry_step_us =
3651            robust_crossing_step_us(&satellite, station, mask, requested_step_us);
3652        assert!(
3653            geometry_step_us < orbit_step_us,
3654            "high-mask geometry step should refine below the orbital step"
3655        );
3656
3657        let fine = find_passes_for_satellite(
3658            &satellite,
3659            station,
3660            start,
3661            end,
3662            PassFinderOptions {
3663                elevation_mask_deg: mask,
3664                coarse_step_seconds: 0.5,
3665                time_tolerance_seconds: 1.0e-3,
3666            },
3667        )
3668        .expect("fine high-mask pass search succeeds");
3669        let robust = find_passes_for_satellite(
3670            &satellite,
3671            station,
3672            start,
3673            end,
3674            PassFinderOptions {
3675                elevation_mask_deg: mask,
3676                coarse_step_seconds: 900.0,
3677                time_tolerance_seconds: 1.0e-3,
3678            },
3679        )
3680        .expect("geometry-aware high-mask pass search succeeds");
3681
3682        assert_eq!(fine.len(), 1);
3683        assert_eq!(robust.len(), fine.len());
3684        assert_pass_close(&robust[0], &fine[0], 1_000, 1.0e-3);
3685        assert!(robust[0].max_elevation_deg > mask);
3686    }
3687
3688    #[test]
3689    fn culmination_refines_selected_maximum_not_first_rate_zero() {
3690        let start = UtcInstant::from_unix_microseconds(0);
3691        let end = start.add_microseconds(10 * MICROSECONDS_PER_SECOND_I64);
3692
3693        let whole_window =
3694            refine_culmination_rate_zero(&multi_stationary_elevation, start, end, 100)
3695                .expect("whole-window rate signs bracket some stationary point");
3696        let (culmination, max_elevation) =
3697            find_culmination_with(start, end, 500_000, 100, multi_stationary_elevation)
3698                .expect("synthetic pass culmination should resolve");
3699
3700        let whole_window_offset =
3701            whole_window.0.diff_microseconds(start) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3702        let selected_offset =
3703            culmination.diff_microseconds(start) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3704
3705        assert!(
3706            whole_window_offset < 3.0,
3707            "whole-window rate bisection locked onto offset {whole_window_offset}"
3708        );
3709        assert!(
3710            (selected_offset - 8.5).abs() < 0.05,
3711            "selected maximum should refine near 8.5 s, got {selected_offset}"
3712        );
3713        assert!(
3714            max_elevation > whole_window.1 + 15.0,
3715            "selected culmination should beat the earlier stationary point"
3716        );
3717        assert!(
3718            max_elevation > 25.0,
3719            "selected maximum elevation should stay on the high synthetic peak"
3720        );
3721    }
3722
3723    #[test]
3724    fn event_finder_elevation_crossings_match_predict_passes() {
3725        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
3726        let end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
3727        let station = GroundStation {
3728            latitude_deg: 51.5074,
3729            longitude_deg: -0.1278,
3730            altitude_m: 11.0,
3731        };
3732        let elements = iss_2024_12_19_elements();
3733        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
3734
3735        let reference = predict_passes(
3736            &elements,
3737            station,
3738            start,
3739            end,
3740            PassPredictionOptions {
3741                min_elevation_deg: 0.0,
3742                step_seconds: 10,
3743            },
3744        )
3745        .expect("valid pass-prediction step");
3746        assert!(reference.len() >= 2);
3747
3748        let span_seconds = end.diff_microseconds(start) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
3749        let crossings = EventFinder::new(0.0, span_seconds, 10.0, 1.0e-4)
3750            .expect("valid event-finder window")
3751            .find_crossings(
3752                |offset_seconds| {
3753                    elevation_at(
3754                        &satellite,
3755                        instant_at_offset_seconds(start, offset_seconds),
3756                        station,
3757                    )
3758                },
3759                0.0,
3760            )
3761            .expect("finite elevation predicate");
3762        let found = find_passes(
3763            &elements,
3764            station,
3765            start,
3766            end,
3767            PassFinderOptions {
3768                elevation_mask_deg: 0.0,
3769                coarse_step_seconds: 10.0,
3770                time_tolerance_seconds: 1.0e-4,
3771            },
3772        )
3773        .expect("valid pass-finder step");
3774
3775        assert_eq!(crossings.len(), reference.len() * 2);
3776        assert_eq!(found.len(), reference.len());
3777
3778        for (pass_index, pass) in reference.iter().enumerate() {
3779            let rise = crossings[pass_index * 2];
3780            let set = crossings[pass_index * 2 + 1];
3781            assert_eq!(rise.direction, CrossingDirection::Rising);
3782            assert_eq!(set.direction, CrossingDirection::Falling);
3783
3784            let rise_time = instant_at_offset_seconds(start, rise.time_seconds);
3785            let set_time = instant_at_offset_seconds(start, set.time_seconds);
3786            assert!(
3787                (rise_time.unix_microseconds() - pass.rise.unix_microseconds()).abs() < 1_000,
3788                "event finder rise within 1 ms of predict_passes"
3789            );
3790            assert!(
3791                (set_time.unix_microseconds() - pass.set.unix_microseconds()).abs() < 1_000,
3792                "event finder set within 1 ms of predict_passes"
3793            );
3794            assert!(
3795                (found[pass_index].aos.unix_microseconds() - rise_time.unix_microseconds()).abs()
3796                    <= 100,
3797                "pass finder AOS follows event-finder rise within snap tolerance"
3798            );
3799            assert!(
3800                (found[pass_index].los.unix_microseconds() - set_time.unix_microseconds()).abs()
3801                    <= 100,
3802                "pass finder LOS follows event-finder set within snap tolerance"
3803            );
3804        }
3805    }
3806
3807    #[test]
3808    fn event_finder_pass_peak_equal_to_mask_emits_no_crossing() {
3809        let day_start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
3810        let day_end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
3811        let station = GroundStation {
3812            latitude_deg: 51.5074,
3813            longitude_deg: -0.1278,
3814            altitude_m: 11.0,
3815        };
3816        let elements = iss_2024_12_19_elements();
3817        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
3818        let reference = find_passes(
3819            &elements,
3820            station,
3821            day_start,
3822            day_end,
3823            PassFinderOptions {
3824                elevation_mask_deg: 0.0,
3825                coarse_step_seconds: 10.0,
3826                time_tolerance_seconds: 1.0e-3,
3827            },
3828        )
3829        .expect("valid pass-finder step");
3830        assert!(!reference.is_empty());
3831
3832        let peak_time = reference[0].culmination;
3833        let mask = elevation_at(&satellite, peak_time, station);
3834        let step_us = 10 * MICROSECONDS_PER_SECOND_I64;
3835        let start = peak_time.add_microseconds(-step_us);
3836        let end = peak_time.add_microseconds(step_us);
3837        let search = PassSearch {
3838            satellite: &satellite,
3839            ground_station: station,
3840            start_time: start,
3841            end_time: end,
3842            step_us,
3843            tol_us: 1_000,
3844            mask,
3845        };
3846        assert!(search.masked_elevation_at(start) < 0.0);
3847        assert_eq!(
3848            search.masked_elevation_at(peak_time).to_bits(),
3849            0.0_f64.to_bits()
3850        );
3851        assert!(search.masked_elevation_at(end) < 0.0);
3852
3853        let crossings = EventFinder::new(0.0, 20.0, 10.0, 1.0e-3)
3854            .expect("valid event-finder window")
3855            .find_crossings(search, 0.0)
3856            .expect("finite masked elevation predicate");
3857        assert!(
3858            crossings.is_empty(),
3859            "peak tangent at the mask must not emit AOS/LOS crossings"
3860        );
3861
3862        let found = find_passes(
3863            &elements,
3864            station,
3865            start,
3866            end,
3867            PassFinderOptions {
3868                elevation_mask_deg: mask,
3869                coarse_step_seconds: 10.0,
3870                time_tolerance_seconds: 1.0e-3,
3871            },
3872        )
3873        .expect("valid pass-finder step");
3874        assert!(found.is_empty());
3875    }
3876
3877    #[test]
3878    fn assemble_passes_seeds_exact_aos_but_not_exact_los_window_start() {
3879        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
3880        let end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
3881        let station = GroundStation {
3882            latitude_deg: 51.5074,
3883            longitude_deg: -0.1278,
3884            altitude_m: 11.0,
3885        };
3886        let elements = iss_2024_12_19_elements();
3887        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
3888        let options = PassFinderOptions {
3889            elevation_mask_deg: 0.0,
3890            coarse_step_seconds: 10.0,
3891            time_tolerance_seconds: 1.0e-3,
3892        };
3893
3894        let reference =
3895            find_passes(&elements, station, start, end, options).expect("valid pass-finder step");
3896        assert!(reference.len() >= 2);
3897        let search_step_us =
3898            robust_crossing_step_us(&satellite, station, 0.0, 10 * MICROSECONDS_PER_SECOND_I64);
3899
3900        let aos_at_window_start = reference[0].aos;
3901        let aos_mask = elevation_at(&satellite, aos_at_window_start, station);
3902        assert!(
3903            elevation_at(
3904                &satellite,
3905                aos_at_window_start.add_microseconds(-MICROSECONDS_PER_SECOND_I64),
3906                station,
3907            ) < aos_mask
3908        );
3909        assert!(
3910            elevation_at(
3911                &satellite,
3912                aos_at_window_start.add_microseconds(MICROSECONDS_PER_SECOND_I64),
3913                station,
3914            ) > aos_mask
3915        );
3916
3917        let aos_search = PassSearch {
3918            satellite: &satellite,
3919            ground_station: station,
3920            start_time: aos_at_window_start,
3921            end_time: end,
3922            step_us: search_step_us,
3923            tol_us: 1_000,
3924            mask: aos_mask,
3925        };
3926        let first_los_offset_seconds = reference[0].los.diff_microseconds(aos_at_window_start)
3927            as f64
3928            / MICROSECONDS_PER_SECOND_I64 as f64;
3929        let aos_found = assemble_passes_from_crossings(
3930            aos_search,
3931            vec![CrossingEvent {
3932                time_seconds: first_los_offset_seconds,
3933                value: 0.0,
3934                threshold: 0.0,
3935                direction: CrossingDirection::Falling,
3936            }],
3937        )
3938        .expect("exact AOS assembly succeeds");
3939
3940        assert_eq!(aos_found.len(), 1);
3941        assert_eq!(aos_found[0].aos, aos_at_window_start);
3942        assert!(aos_found[0].los > aos_at_window_start);
3943
3944        let los_at_window_start = reference[0].los;
3945        let los_mask = elevation_at(&satellite, los_at_window_start, station);
3946        assert!(
3947            elevation_at(
3948                &satellite,
3949                los_at_window_start.add_microseconds(-MICROSECONDS_PER_SECOND_I64),
3950                station,
3951            ) > los_mask
3952        );
3953        assert!(
3954            elevation_at(
3955                &satellite,
3956                los_at_window_start.add_microseconds(MICROSECONDS_PER_SECOND_I64),
3957                station,
3958            ) < los_mask
3959        );
3960
3961        let los_search = PassSearch {
3962            satellite: &satellite,
3963            ground_station: station,
3964            start_time: los_at_window_start,
3965            end_time: end,
3966            step_us: search_step_us,
3967            tol_us: 1_000,
3968            mask: los_mask,
3969        };
3970        let next_aos_offset_seconds = reference[1].aos.diff_microseconds(los_at_window_start)
3971            as f64
3972            / MICROSECONDS_PER_SECOND_I64 as f64;
3973        let next_los_offset_seconds = reference[1].los.diff_microseconds(los_at_window_start)
3974            as f64
3975            / MICROSECONDS_PER_SECOND_I64 as f64;
3976        let los_found = assemble_passes_from_crossings(
3977            los_search,
3978            vec![
3979                CrossingEvent {
3980                    time_seconds: next_aos_offset_seconds,
3981                    value: 0.0,
3982                    threshold: 0.0,
3983                    direction: CrossingDirection::Rising,
3984                },
3985                CrossingEvent {
3986                    time_seconds: next_los_offset_seconds,
3987                    value: 0.0,
3988                    threshold: 0.0,
3989                    direction: CrossingDirection::Falling,
3990                },
3991            ],
3992        )
3993        .expect("exact LOS assembly succeeds");
3994
3995        assert_eq!(los_found.len(), 1);
3996        assert!(los_found[0].aos > los_at_window_start);
3997        assert!(los_found[0].aos < los_found[0].los);
3998    }
3999
4000    #[test]
4001    fn find_passes_does_not_seed_at_exact_los_window_start() {
4002        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4003        let end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
4004        let station = GroundStation {
4005            latitude_deg: 51.5074,
4006            longitude_deg: -0.1278,
4007            altitude_m: 11.0,
4008        };
4009        let elements = iss_2024_12_19_elements();
4010        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
4011        let options = PassFinderOptions {
4012            elevation_mask_deg: 0.0,
4013            coarse_step_seconds: 10.0,
4014            time_tolerance_seconds: 1.0e-3,
4015        };
4016
4017        let reference =
4018            find_passes(&elements, station, start, end, options).expect("valid pass-finder step");
4019        assert!(reference.len() >= 2);
4020        let los_at_window_start = reference[0].los;
4021        let mask = elevation_at(&satellite, los_at_window_start, station);
4022        assert!(
4023            elevation_at(
4024                &satellite,
4025                los_at_window_start.add_microseconds(-MICROSECONDS_PER_SECOND_I64),
4026                station,
4027            ) > mask
4028        );
4029        assert!(
4030            elevation_at(
4031                &satellite,
4032                los_at_window_start.add_microseconds(MICROSECONDS_PER_SECOND_I64),
4033                station,
4034            ) < mask
4035        );
4036
4037        let found = find_passes(
4038            &elements,
4039            station,
4040            los_at_window_start,
4041            end,
4042            PassFinderOptions {
4043                elevation_mask_deg: mask,
4044                ..options
4045            },
4046        )
4047        .expect("valid pass-finder step");
4048
4049        assert!(
4050            !found.is_empty(),
4051            "later pass after a real rising crossing should still be found"
4052        );
4053        assert!(
4054            found.iter().all(|pass| pass.aos > los_at_window_start),
4055            "window-start LOS must not be seeded as a phantom AOS"
4056        );
4057        assert!(found.iter().all(|pass| pass.aos < pass.los));
4058    }
4059
4060    #[test]
4061    fn find_passes_substeps_coarse_scan_for_short_masked_pass() {
4062        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4063        let end = UtcInstant::from_utc(2024, 12, 19, 12, 0, 0, 0).unwrap();
4064        let station = GroundStation {
4065            latitude_deg: 51.5074,
4066            longitude_deg: -0.1278,
4067            altitude_m: 11.0,
4068        };
4069        let elements = iss_2024_12_19_elements();
4070        let satellite = Satellite::from_elements_with_opsmode(&elements, OpsMode::Afspc).unwrap();
4071        let mask = 12.0;
4072        let span_seconds = end.diff_microseconds(start) as f64 / MICROSECONDS_PER_SECOND_I64 as f64;
4073
4074        let naive_crossings = EventFinder::new(0.0, span_seconds, 900.0, 1.0e-3)
4075            .expect("valid event-finder window")
4076            .find_crossings(
4077                |offset_seconds| {
4078                    elevation_at(
4079                        &satellite,
4080                        instant_at_offset_seconds(start, offset_seconds),
4081                        station,
4082                    ) - mask
4083                },
4084                0.0,
4085            )
4086            .expect("finite masked elevation predicate");
4087        assert!(
4088            naive_crossings.is_empty(),
4089            "900-second naive scan should skip this short masked pass"
4090        );
4091
4092        let reference = find_passes(
4093            &elements,
4094            station,
4095            start,
4096            end,
4097            PassFinderOptions {
4098                elevation_mask_deg: mask,
4099                coarse_step_seconds: 10.0,
4100                time_tolerance_seconds: 1.0e-3,
4101            },
4102        )
4103        .expect("valid pass-finder step");
4104        let robust = find_passes(
4105            &elements,
4106            station,
4107            start,
4108            end,
4109            PassFinderOptions {
4110                elevation_mask_deg: mask,
4111                coarse_step_seconds: 900.0,
4112                time_tolerance_seconds: 1.0e-3,
4113            },
4114        )
4115        .expect("valid pass-finder step");
4116
4117        assert_eq!(reference.len(), 1);
4118        assert_eq!(robust.len(), reference.len());
4119        assert_pass_close(&robust[0], &reference[0], 1_000, 1.0e-7);
4120        assert!(robust[0].max_elevation_deg > mask);
4121    }
4122
4123    #[test]
4124    fn find_passes_applies_elevation_mask() {
4125        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4126        let end = UtcInstant::from_utc(2024, 12, 20, 0, 0, 0, 0).unwrap();
4127        let station = GroundStation {
4128            latitude_deg: 51.5074,
4129            longitude_deg: -0.1278,
4130            altitude_m: 11.0,
4131        };
4132        let elements = iss_2024_12_19_elements();
4133
4134        let opts = |mask: f64| PassFinderOptions {
4135            elevation_mask_deg: mask,
4136            coarse_step_seconds: 10.0,
4137            time_tolerance_seconds: 1.0e-3,
4138        };
4139
4140        let all =
4141            find_passes(&elements, station, start, end, opts(0.0)).expect("valid pass-finder step");
4142        let high = find_passes(&elements, station, start, end, opts(40.0))
4143            .expect("valid pass-finder step");
4144
4145        // A higher mask admits no more passes than a lower one, and every pass
4146        // it keeps culminates above the mask.
4147        assert!(high.len() <= all.len());
4148        for pass in &high {
4149            assert!(pass.max_elevation_deg >= 40.0);
4150            // AOS precedes culmination precedes LOS.
4151            assert!(pass.aos.unix_microseconds() <= pass.culmination.unix_microseconds());
4152            assert!(pass.culmination.unix_microseconds() <= pass.los.unix_microseconds());
4153        }
4154    }
4155
4156    #[test]
4157    fn pass_and_visibility_apis_reject_invalid_station_and_thresholds() {
4158        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4159        let end = UtcInstant::from_utc(2024, 12, 19, 1, 0, 0, 0).unwrap();
4160        let datetime = UtcInstant::from_utc(2024, 1, 1, 12, 0, 0, 0).unwrap();
4161        let station = GroundStation {
4162            latitude_deg: 51.5074,
4163            longitude_deg: -0.1278,
4164            altitude_m: 11.0,
4165        };
4166        let invalid_station = GroundStation {
4167            latitude_deg: 91.0,
4168            ..station
4169        };
4170        let elements = iss_2024_12_19_elements();
4171
4172        assert_invalid_pass_field_with_reason(
4173            predict_passes(
4174                &elements,
4175                invalid_station,
4176                start,
4177                end,
4178                PassPredictionOptions::default(),
4179            )
4180            .unwrap_err(),
4181            "ground_station.latitude_deg",
4182            "out of range",
4183        );
4184        assert_invalid_pass_field_with_reason(
4185            predict_passes(
4186                &elements,
4187                station,
4188                start,
4189                end,
4190                PassPredictionOptions {
4191                    min_elevation_deg: f64::INFINITY,
4192                    step_seconds: 60,
4193                },
4194            )
4195            .unwrap_err(),
4196            "min_elevation_deg",
4197            "not finite",
4198        );
4199        assert_invalid_pass_field_with_reason(
4200            find_passes(
4201                &elements,
4202                station,
4203                start,
4204                end,
4205                PassFinderOptions {
4206                    elevation_mask_deg: 91.0,
4207                    coarse_step_seconds: 10.0,
4208                    time_tolerance_seconds: 1.0e-3,
4209                },
4210            )
4211            .unwrap_err(),
4212            "elevation_mask_deg",
4213            "out of range",
4214        );
4215        assert_invalid_pass_field_with_reason(
4216            visible_from_constellation(&[], station, datetime, f64::NAN).unwrap_err(),
4217            "min_elevation_deg",
4218            "not finite",
4219        );
4220    }
4221
4222    #[test]
4223    fn pass_planners_reject_zero_steps() {
4224        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4225        let end = UtcInstant::from_utc(2024, 12, 19, 1, 0, 0, 0).unwrap();
4226        let station = GroundStation {
4227            latitude_deg: 51.5074,
4228            longitude_deg: -0.1278,
4229            altitude_m: 11.0,
4230        };
4231        let elements = iss_2024_12_19_elements();
4232
4233        assert_invalid_pass_field(
4234            predict_passes(
4235                &elements,
4236                station,
4237                start,
4238                end,
4239                PassPredictionOptions {
4240                    min_elevation_deg: 0.0,
4241                    step_seconds: 0,
4242                },
4243            )
4244            .unwrap_err(),
4245            "step_seconds",
4246        );
4247        assert_invalid_pass_field(
4248            find_passes(
4249                &elements,
4250                station,
4251                start,
4252                end,
4253                PassFinderOptions {
4254                    elevation_mask_deg: 0.0,
4255                    coarse_step_seconds: 0.0,
4256                    time_tolerance_seconds: 1.0e-3,
4257                },
4258            )
4259            .unwrap_err(),
4260            "coarse_step_seconds",
4261        );
4262        assert_invalid_pass_field(
4263            find_passes(
4264                &elements,
4265                station,
4266                start,
4267                end,
4268                PassFinderOptions {
4269                    elevation_mask_deg: 0.0,
4270                    coarse_step_seconds: 10.0,
4271                    time_tolerance_seconds: 0.0,
4272                },
4273            )
4274            .unwrap_err(),
4275            "time_tolerance_seconds",
4276        );
4277    }
4278
4279    fn assert_pass_close(
4280        actual: &SatellitePass,
4281        expected: &SatellitePass,
4282        time_tolerance_us: i64,
4283        elevation_tolerance_deg: f64,
4284    ) {
4285        assert!(
4286            (actual.aos.unix_microseconds() - expected.aos.unix_microseconds()).abs()
4287                <= time_tolerance_us,
4288            "AOS differs by more than {time_tolerance_us} us"
4289        );
4290        assert!(
4291            (actual.los.unix_microseconds() - expected.los.unix_microseconds()).abs()
4292                <= time_tolerance_us,
4293            "LOS differs by more than {time_tolerance_us} us"
4294        );
4295        assert!(
4296            (actual.culmination.unix_microseconds() - expected.culmination.unix_microseconds())
4297                .abs()
4298                <= time_tolerance_us,
4299            "culmination differs by more than {time_tolerance_us} us"
4300        );
4301        assert!(
4302            (actual.max_elevation_deg - expected.max_elevation_deg).abs()
4303                <= elevation_tolerance_deg,
4304            "max elevation differs by more than {elevation_tolerance_deg} deg"
4305        );
4306    }
4307
4308    #[test]
4309    fn pass_finder_rejects_overflowing_coarse_step() {
4310        let start = UtcInstant::from_utc(2024, 12, 19, 0, 0, 0, 0).unwrap();
4311        let end = UtcInstant::from_utc(2024, 12, 19, 1, 0, 0, 0).unwrap();
4312        let station = GroundStation {
4313            latitude_deg: 51.5074,
4314            longitude_deg: -0.1278,
4315            altitude_m: 11.0,
4316        };
4317        let elements = iss_2024_12_19_elements();
4318
4319        assert_invalid_pass_field_with_reason(
4320            find_passes(
4321                &elements,
4322                station,
4323                start,
4324                end,
4325                PassFinderOptions {
4326                    elevation_mask_deg: 0.0,
4327                    coarse_step_seconds: 1.0e20,
4328                    time_tolerance_seconds: 1.0e-3,
4329                },
4330            )
4331            .unwrap_err(),
4332            "coarse_step_seconds",
4333            "out of range",
4334        );
4335    }
4336
4337    fn assert_invalid_pass_field(error: PassError, expected: &'static str) {
4338        assert_invalid_pass_field_with_reason(error, expected, "not positive");
4339    }
4340
4341    fn assert_invalid_look_angle_field(
4342        error: LookAngleError,
4343        expected: &'static str,
4344        expected_reason: &'static str,
4345    ) {
4346        match error {
4347            LookAngleError::InvalidInput { field, reason } => {
4348                assert_eq!(field, expected);
4349                assert_eq!(reason, expected_reason);
4350            }
4351            other => panic!("expected invalid look-angle input for {expected}, got {other:?}"),
4352        }
4353    }
4354
4355    fn assert_invalid_pass_field_with_reason(
4356        error: PassError,
4357        expected: &'static str,
4358        expected_reason: &'static str,
4359    ) {
4360        let PassError::InvalidInput { field, reason } = error;
4361        assert_eq!(field, expected);
4362        assert_eq!(reason, expected_reason);
4363    }
4364}