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