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