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