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