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