1use crate::astro::frames::transforms::itrs_to_geodetic_compute;
9use std::f64::consts::PI;
10
11use crate::astro::time::civil;
12use crate::constants::{
13 AZIMUTH_ZENITH_EPS, C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, KM_TO_M,
14 MICROSECONDS_PER_SECOND, OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
15};
16use crate::ephemeris::BroadcastEphemeris;
17use crate::estimation::recipe::SagnacRecipe;
18use crate::id::GnssSatelliteId;
19use crate::sp3::Sp3;
20use crate::spp::EphemerisSource;
21use crate::validate;
22use crate::Error;
23use rayon::prelude::*;
24
25const FD_HALF_S: f64 = 0.5;
26
27#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct ObservableState {
30 pub position_ecef_m: [f64; 3],
32 pub clock_s: Option<f64>,
34}
35
36pub trait ObservableEphemerisSource {
38 fn observable_state_at_j2000_s(
40 &self,
41 sat: GnssSatelliteId,
42 t_j2000_s: f64,
43 ) -> Result<ObservableState, ObservablesError>;
44}
45
46impl ObservableEphemerisSource for Sp3 {
47 fn observable_state_at_j2000_s(
48 &self,
49 sat: GnssSatelliteId,
50 t_j2000_s: f64,
51 ) -> Result<ObservableState, ObservablesError> {
52 let state = self
53 .position_at_j2000_seconds(sat, t_j2000_s)
54 .map_err(ObservablesError::Ephemeris)?;
55 Ok(ObservableState {
56 position_ecef_m: state.position.as_array(),
57 clock_s: state.clock_s,
58 })
59 }
60}
61
62impl ObservableEphemerisSource for BroadcastEphemeris {
63 fn observable_state_at_j2000_s(
64 &self,
65 sat: GnssSatelliteId,
66 t_j2000_s: f64,
67 ) -> Result<ObservableState, ObservablesError> {
68 let Some((position_ecef_m, clock_s)) =
69 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
70 else {
71 return Err(ObservablesError::NoEphemeris);
72 };
73 Ok(ObservableState {
74 position_ecef_m,
75 clock_s: Some(clock_s),
76 })
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ObservablesInputErrorKind {
83 NonFinite,
85 NotPositive,
87 Negative,
89 OutOfRange,
91 Missing,
93 FloatParse,
95 IntParse,
97 InvalidCivilDate,
99 InvalidCivilTime,
101}
102
103impl core::fmt::Display for ObservablesInputErrorKind {
104 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
105 let label = match self {
106 Self::NonFinite => "not finite",
107 Self::NotPositive => "not positive",
108 Self::Negative => "negative",
109 Self::OutOfRange => "out of range",
110 Self::Missing => "missing",
111 Self::FloatParse => "invalid float",
112 Self::IntParse => "invalid integer",
113 Self::InvalidCivilDate => "invalid civil date",
114 Self::InvalidCivilTime => "invalid civil time",
115 };
116 f.write_str(label)
117 }
118}
119
120impl From<&validate::FieldError> for ObservablesInputErrorKind {
121 fn from(error: &validate::FieldError) -> Self {
122 match error {
123 validate::FieldError::Missing { .. } => Self::Missing,
124 validate::FieldError::NonFinite { .. } => Self::NonFinite,
125 validate::FieldError::NotPositive { .. } => Self::NotPositive,
126 validate::FieldError::Negative { .. } => Self::Negative,
127 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
128 validate::FieldError::FloatParse { .. } => Self::FloatParse,
129 validate::FieldError::IntParse { .. } => Self::IntParse,
130 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
131 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum ObservablesError {
139 InvalidInput {
142 field: &'static str,
144 kind: ObservablesInputErrorKind,
146 },
147 NoEphemeris,
149 Ephemeris(Error),
151}
152
153impl core::fmt::Display for ObservablesError {
154 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
155 match self {
156 Self::InvalidInput { field, kind } => {
157 write!(f, "invalid observable input {field}: {kind}")
158 }
159 Self::NoEphemeris => write!(f, "no ephemeris"),
160 Self::Ephemeris(err) => write!(f, "{err}"),
161 }
162 }
163}
164
165impl std::error::Error for ObservablesError {}
166
167#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct PredictOptions {
170 pub carrier_hz: f64,
172 pub light_time: bool,
174 pub sagnac: bool,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct TransmitTimeOptions {
181 pub light_time: bool,
183 pub sagnac: bool,
185}
186
187impl Default for TransmitTimeOptions {
188 fn default() -> Self {
189 Self {
190 light_time: true,
191 sagnac: true,
192 }
193 }
194}
195
196impl Default for PredictOptions {
197 fn default() -> Self {
198 Self {
199 carrier_hz: F_L1_HZ,
200 light_time: true,
201 sagnac: true,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct TransmitTimeSatelliteState {
215 pub signal_flight_time_s: f64,
217 pub transmit_offset_us: i64,
219 pub transmit_time_j2000_s: f64,
221 pub clock_s: Option<f64>,
223 pub transmit_position_ecef_m: [f64; 3],
225 pub position_ecef_m: [f64; 3],
227 pub velocity_m_s: [f64; 3],
229 pub geometric_range_m: f64,
231 pub los_unit: [f64; 3],
233}
234
235#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct PredictedObservables {
238 pub geometric_range_m: f64,
240 pub range_rate_m_s: f64,
242 pub doppler_hz: f64,
244 pub sat_clock_s: Option<f64>,
246 pub elevation_deg: f64,
248 pub azimuth_deg: f64,
256 pub transmit_offset_us: i64,
258 pub transmit_time_j2000_s: f64,
260 pub los_unit: [f64; 3],
262 pub sat_pos_ecef_m: [f64; 3],
264 pub sat_velocity_m_s: [f64; 3],
266}
267
268pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
270 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
271 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
272 validate::finite(
273 civil::j2000_seconds_from_split(jd_whole, jd_fraction),
274 "j2000_seconds",
275 )
276 .map_err(map_input_error)
277}
278
279pub fn transmit_time_satellite_state(
287 source: &dyn ObservableEphemerisSource,
288 sat: GnssSatelliteId,
289 receiver_ecef_m: [f64; 3],
290 t_rx_j2000_s: f64,
291 options: TransmitTimeOptions,
292) -> Result<TransmitTimeSatelliteState, ObservablesError> {
293 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
294 let predict_options = PredictOptions {
295 carrier_hz: F_L1_HZ,
296 light_time: options.light_time,
297 sagnac: options.sagnac,
298 };
299 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
300
301 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
302 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
303 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
304 let range = geometric_range_m([dx, dy, dz])?;
305 let los = [dx / range, dy / range, dz / range];
306
307 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
308 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
309 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
310
311 Ok(TransmitTimeSatelliteState {
312 signal_flight_time_s: solved.tau_s,
313 transmit_offset_us: solved.transmit_offset_us,
314 transmit_time_j2000_s: solved.transmit_time_j2000_s,
315 clock_s: solved.state.clock_s,
316 transmit_position_ecef_m: solved.state.position_ecef_m,
317 position_ecef_m: solved.sat_rot_ecef_m,
318 velocity_m_s: velocity_rot,
319 geometric_range_m: range,
320 los_unit: los,
321 })
322}
323
324pub fn predict(
326 source: &dyn ObservableEphemerisSource,
327 sat: GnssSatelliteId,
328 receiver_ecef_m: [f64; 3],
329 t_rx_j2000_s: f64,
330 options: PredictOptions,
331) -> Result<PredictedObservables, ObservablesError> {
332 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
333 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
334
335 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
336 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
337 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
338 let range = geometric_range_m([dx, dy, dz])?;
339 let los = [dx / range, dy / range, dz / range];
340
341 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
342 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
343 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
344 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
345 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
346 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
347 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
348 let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
349
350 Ok(PredictedObservables {
351 geometric_range_m: range,
352 range_rate_m_s: range_rate,
353 doppler_hz,
354 sat_clock_s: solved.state.clock_s,
355 elevation_deg,
356 azimuth_deg,
357 transmit_offset_us: solved.transmit_offset_us,
358 transmit_time_j2000_s: solved.transmit_time_j2000_s,
359 los_unit: los,
360 sat_pos_ecef_m: solved.sat_rot_ecef_m,
361 sat_velocity_m_s: velocity_rot,
362 })
363}
364
365pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
372
373pub fn predict_batch(
381 source: &dyn ObservableEphemerisSource,
382 requests: &[PredictRequest],
383 options: PredictOptions,
384) -> Vec<Result<PredictedObservables, ObservablesError>> {
385 requests
386 .iter()
387 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
388 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
389 })
390 .collect()
391}
392
393pub fn predict_batch_parallel(
403 source: &(dyn ObservableEphemerisSource + Sync),
404 requests: &[PredictRequest],
405 options: PredictOptions,
406) -> Vec<Result<PredictedObservables, ObservablesError>> {
407 requests
408 .par_iter()
409 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
410 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
411 })
412 .collect()
413}
414
415#[derive(Debug, Clone, Copy, PartialEq)]
418pub struct RangePredictionRequest {
419 pub sat: GnssSatelliteId,
421 pub receiver_ecef_m: [f64; 3],
423 pub t_rx_j2000_s: f64,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq)]
432pub struct RangePrediction {
433 pub geometric_range_m: f64,
435 pub sat_clock_s: Option<f64>,
437 pub transmit_time_j2000_s: f64,
439 pub sat_pos_ecef_m: [f64; 3],
441}
442
443pub fn predict_ranges(
459 source: &dyn ObservableEphemerisSource,
460 requests: &[RangePredictionRequest],
461 options: PredictOptions,
462 out: &mut [RangePrediction],
463) -> Result<(), ObservablesError> {
464 if out.len() != requests.len() {
465 return Err(ObservablesError::InvalidInput {
466 field: "out",
467 kind: ObservablesInputErrorKind::OutOfRange,
468 });
469 }
470 let tt_options = TransmitTimeOptions {
471 light_time: options.light_time,
472 sagnac: options.sagnac,
473 };
474 for (request, slot) in requests.iter().zip(out.iter_mut()) {
475 let state = transmit_time_satellite_state(
476 source,
477 request.sat,
478 request.receiver_ecef_m,
479 request.t_rx_j2000_s,
480 tt_options,
481 )?;
482 *slot = RangePrediction {
483 geometric_range_m: state.geometric_range_m,
484 sat_clock_s: state.clock_s,
485 transmit_time_j2000_s: state.transmit_time_j2000_s,
486 sat_pos_ecef_m: state.position_ecef_m,
487 };
488 }
489 Ok(())
490}
491
492#[derive(Debug, Clone, Copy)]
493struct SolvedTransmitTime {
494 tau_s: f64,
495 transmit_offset_us: i64,
496 transmit_time_j2000_s: f64,
497 state: ObservableState,
498 sat_rot_ecef_m: [f64; 3],
499}
500
501fn solve_transmit_time(
502 source: &dyn ObservableEphemerisSource,
503 sat: GnssSatelliteId,
504 receiver_ecef_m: [f64; 3],
505 t_rx_j2000_s: f64,
506 options: PredictOptions,
507) -> Result<SolvedTransmitTime, ObservablesError> {
508 if !options.light_time {
509 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
510 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
511 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
512 return Ok(SolvedTransmitTime {
513 tau_s: 0.0,
514 transmit_offset_us: 0,
515 transmit_time_j2000_s: t_rx_j2000_s,
516 state,
517 sat_rot_ecef_m: sat_rot,
518 });
519 }
520
521 let mut tau = 0.0;
522 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
523 let transmit_offset_us = microseconds_from_tau(tau);
524 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
525 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
526 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
527 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
528 let dx = sat_rot[0] - receiver_ecef_m[0];
529 let dy = sat_rot[1] - receiver_ecef_m[1];
530 let dz = sat_rot[2] - receiver_ecef_m[2];
531 let range = geometric_range_m([dx, dy, dz])?;
532 let new_tau = range / C_M_S;
533
534 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
535 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
536 }
537
538 tau = new_tau;
539 }
540
541 unreachable!("fixed transmit-time loop always returns on its last iteration")
542}
543
544fn finalize_transmit_time(
545 source: &dyn ObservableEphemerisSource,
546 sat: GnssSatelliteId,
547 t_rx_j2000_s: f64,
548 tau: f64,
549 sagnac: bool,
550) -> Result<SolvedTransmitTime, ObservablesError> {
551 let transmit_offset_us = microseconds_from_tau(tau);
552 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
553 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
554 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
555 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
556 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
557 Ok(SolvedTransmitTime {
558 tau_s: tau,
559 transmit_offset_us,
560 transmit_time_j2000_s: t_tx,
561 state,
562 sat_rot_ecef_m: sat_rot,
563 })
564}
565
566fn microseconds_from_tau(tau_s: f64) -> i64 {
567 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
568}
569
570fn satellite_velocity(
571 source: &dyn ObservableEphemerisSource,
572 sat: GnssSatelliteId,
573 t_tx_j2000_s: f64,
574) -> Result<[f64; 3], ObservablesError> {
575 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
576 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
577 let denom = 2.0 * FD_HALF_S;
578 let velocity = [
579 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
580 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
581 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
582 ];
583 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
584}
585
586fn validate_predict_inputs(
587 receiver_ecef_m: [f64; 3],
588 t_rx_j2000_s: f64,
589 options: PredictOptions,
590) -> Result<(), ObservablesError> {
591 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
592 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
593 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
594 Ok(())
595}
596
597fn validate_transmit_time_inputs(
598 receiver_ecef_m: [f64; 3],
599 t_rx_j2000_s: f64,
600) -> Result<(), ObservablesError> {
601 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
602 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
603 Ok(())
604}
605
606fn validated_state_at_j2000_s(
607 source: &dyn ObservableEphemerisSource,
608 sat: GnssSatelliteId,
609 t_j2000_s: f64,
610) -> Result<ObservableState, ObservablesError> {
611 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
612 validate_observable_state(&state)?;
613 Ok(state)
614}
615
616fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
617 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
618 .map_err(map_input_error)?;
619 if let Some(clock_s) = state.clock_s {
620 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
621 }
622 Ok(())
623}
624
625fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
626 let range = (delta_ecef_m[0] * delta_ecef_m[0]
627 + delta_ecef_m[1] * delta_ecef_m[1]
628 + delta_ecef_m[2] * delta_ecef_m[2])
629 .sqrt();
630 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
631}
632
633fn map_input_error(error: validate::FieldError) -> ObservablesError {
634 ObservablesError::InvalidInput {
635 field: error.field(),
636 kind: ObservablesInputErrorKind::from(&error),
637 }
638}
639
640fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
641 let sagnac = if apply {
642 SagnacRecipe::ClosedFormZRotation
643 } else {
644 SagnacRecipe::Off
645 };
646 crate::estimation::substrate::range::rotate_transmit_satellite(
647 sagnac,
648 pos,
649 tau_s,
650 OMEGA_E_DOT_RAD_S,
651 )
652}
653
654fn topocentric(
655 receiver_ecef_m: [f64; 3],
656 delta_ecef_m: [f64; 3],
657 range_m: f64,
658) -> Result<(f64, f64), ObservablesError> {
659 let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
660 receiver_ecef_m[0] / KM_TO_M,
661 receiver_ecef_m[1] / KM_TO_M,
662 receiver_ecef_m[2] / KM_TO_M,
663 )
664 .map_err(|_| ObservablesError::InvalidInput {
665 field: "receiver_ecef_m",
666 kind: ObservablesInputErrorKind::OutOfRange,
667 })?;
668 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
670 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
671
672 let sl = lat.sin();
673 let cl = lat.cos();
674 let so = lon.sin();
675 let co = lon.cos();
676
677 let dx = delta_ecef_m[0];
678 let dy = delta_ecef_m[1];
679 let dz = delta_ecef_m[2];
680
681 let e = -so * dx + co * dy;
682 let n = -sl * co * dx - sl * so * dy + cl * dz;
683 let u = cl * co * dx + cl * so * dy + sl * dz;
684
685 let horiz_sq = e * e + n * n;
690 let mut azimuth_deg = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
691 0.0
692 } else {
693 e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI
694 };
695 if azimuth_deg < 0.0 {
696 azimuth_deg += DEGREES_PER_CIRCLE;
697 }
698 let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
703 let elevation_deg = sin_elevation.asin() * DEGREES_PER_SEMICIRCLE / PI;
704
705 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
706 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
707 Ok((elevation_deg, azimuth_deg))
708}
709
710#[cfg(test)]
711mod public_api_tests {
712 use super::*;
713 use crate::{GnssSatelliteId, GnssSystem};
714
715 #[derive(Debug, Clone, Copy)]
716 struct StaticSource {
717 state: ObservableState,
718 }
719
720 impl ObservableEphemerisSource for StaticSource {
721 fn observable_state_at_j2000_s(
722 &self,
723 _sat: GnssSatelliteId,
724 _t_j2000_s: f64,
725 ) -> Result<ObservableState, ObservablesError> {
726 Ok(self.state)
727 }
728 }
729
730 #[test]
731 fn predict_ranges_matches_transmit_time_loop_bitwise() {
732 let source = StaticSource {
733 state: ObservableState {
734 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
735 clock_s: Some(1.25e-6),
736 },
737 };
738 let options = PredictOptions {
739 carrier_hz: F_L1_HZ,
740 light_time: true,
741 sagnac: true,
742 };
743 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
744 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
745 let requests = [
746 RangePredictionRequest {
747 sat: sat1,
748 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
749 t_rx_j2000_s: 646_272_000.0,
750 },
751 RangePredictionRequest {
752 sat: sat2,
753 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
754 t_rx_j2000_s: 646_272_060.0,
755 },
756 ];
757 let mut out = [RangePrediction {
758 geometric_range_m: 0.0,
759 sat_clock_s: None,
760 transmit_time_j2000_s: 0.0,
761 sat_pos_ecef_m: [0.0; 3],
762 }; 2];
763 predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
764
765 let tt_options = TransmitTimeOptions {
766 light_time: options.light_time,
767 sagnac: options.sagnac,
768 };
769 for (request, got) in requests.iter().zip(out.iter()) {
770 let single = transmit_time_satellite_state(
771 &source,
772 request.sat,
773 request.receiver_ecef_m,
774 request.t_rx_j2000_s,
775 tt_options,
776 )
777 .expect("single transmit-time state");
778 assert_eq!(
779 got.geometric_range_m.to_bits(),
780 single.geometric_range_m.to_bits()
781 );
782 assert_eq!(
783 got.transmit_time_j2000_s.to_bits(),
784 single.transmit_time_j2000_s.to_bits()
785 );
786 assert_eq!(
787 got.sat_clock_s.map(f64::to_bits),
788 single.clock_s.map(f64::to_bits)
789 );
790 assert_eq!(
791 got.sat_pos_ecef_m.map(f64::to_bits),
792 single.position_ecef_m.map(f64::to_bits)
793 );
794 }
795 }
796
797 #[test]
798 fn predict_ranges_rejects_length_mismatch() {
799 let source = StaticSource {
800 state: ObservableState {
801 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
802 clock_s: None,
803 },
804 };
805 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
806 let requests = [RangePredictionRequest {
807 sat,
808 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
809 t_rx_j2000_s: 646_272_000.0,
810 }];
811 let mut out: [RangePrediction; 0] = [];
812 let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
813 .expect_err("length mismatch must fail");
814 match err {
815 ObservablesError::InvalidInput { field, kind } => {
816 assert_eq!(field, "out");
817 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
818 }
819 other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
820 }
821 }
822
823 #[test]
824 fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
825 let rx = [
834 4_509_179.095_483_66,
835 275_556.225_682_215_9,
836 4_487_348.408_865_919,
837 ];
838 let (lat_deg, lon_deg, _h) =
839 itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
840 .expect("receiver geodetic conversion");
841 assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
842
843 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
846 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
847 let (sl, cl) = lat.sin_cos();
848 let (so, co) = lon.sin_cos();
849 let up = [cl * co, cl * so, sl];
850
851 let d = 20_000_000.0_f64;
852 let delta = [up[0] * d, up[1] * d, up[2] * d];
853 let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
854 let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
857 assert!(
858 u / range > 1.0,
859 "test geometry must overshoot the asin domain"
860 );
861
862 let (elevation_deg, _azimuth_deg) =
863 topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
864 assert!(elevation_deg.is_finite());
865 assert!((elevation_deg - 90.0).abs() < 1e-9);
866 }
867
868 #[test]
869 fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
870 let source = StaticSource {
871 state: ObservableState {
872 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
873 clock_s: Some(1.25e-6),
874 },
875 };
876 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
877 let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
878 let state = transmit_time_satellite_state(
879 &source,
880 sat,
881 rx,
882 646_272_000.0,
883 TransmitTimeOptions {
884 light_time: false,
885 sagnac: true,
886 },
887 )
888 .expect("state");
889 let prediction = predict(
890 &source,
891 sat,
892 rx,
893 646_272_000.0,
894 PredictOptions {
895 carrier_hz: F_L1_HZ,
896 light_time: false,
897 sagnac: true,
898 },
899 )
900 .expect("prediction");
901
902 assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
903 assert_eq!(state.transmit_offset_us, 0);
904 assert_eq!(
905 state.transmit_time_j2000_s.to_bits(),
906 646_272_000.0f64.to_bits()
907 );
908 assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
909 assert_eq!(
910 state.transmit_position_ecef_m.map(f64::to_bits),
911 source.state.position_ecef_m.map(f64::to_bits)
912 );
913 assert_eq!(
914 state.position_ecef_m.map(f64::to_bits),
915 prediction.sat_pos_ecef_m.map(f64::to_bits)
916 );
917 assert_eq!(
918 state.velocity_m_s.map(f64::to_bits),
919 prediction.sat_velocity_m_s.map(f64::to_bits)
920 );
921 assert_eq!(
922 state.geometric_range_m.to_bits(),
923 prediction.geometric_range_m.to_bits()
924 );
925 assert_eq!(
926 state.los_unit.map(f64::to_bits),
927 prediction.los_unit.map(f64::to_bits)
928 );
929 }
930}
931
932#[cfg(all(test, sidereon_repo_tests))]
933mod tests {
934 use super::*;
935 use crate::{GnssSatelliteId, GnssSystem};
936
937 #[derive(Debug, Clone, Copy)]
938 struct StaticSource {
939 state: ObservableState,
940 }
941
942 impl ObservableEphemerisSource for StaticSource {
943 fn observable_state_at_j2000_s(
944 &self,
945 _sat: GnssSatelliteId,
946 _t_j2000_s: f64,
947 ) -> Result<ObservableState, ObservablesError> {
948 Ok(self.state)
949 }
950 }
951
952 fn sp3_fixture() -> Sp3 {
953 let path = concat!(
954 env!("CARGO_MANIFEST_DIR"),
955 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
956 );
957 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
958 Sp3::parse(&bytes).expect("parse SP3 fixture")
959 }
960
961 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
962 StaticSource {
963 state: ObservableState {
964 position_ecef_m,
965 clock_s: Some(0.0),
966 },
967 }
968 }
969
970 fn no_light_time_options() -> PredictOptions {
971 PredictOptions {
972 carrier_hz: F_L1_HZ,
973 light_time: false,
974 sagnac: true,
975 }
976 }
977
978 fn assert_invalid_observables_input(
979 err: ObservablesError,
980 field: &'static str,
981 kind: ObservablesInputErrorKind,
982 ) {
983 match err {
984 ObservablesError::InvalidInput {
985 field: got_field,
986 kind: got_kind,
987 } => {
988 assert_eq!(got_field, field);
989 assert_eq!(got_kind, kind);
990 }
991 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
992 }
993 }
994
995 #[test]
996 fn split_julian_to_j2000_seconds_matches_orbis_time() {
997 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
998 assert_eq!(t, 646_272_000.0);
999 }
1000
1001 #[test]
1002 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
1003 for (jd_whole, jd_fraction, field) in [
1004 (f64::NAN, 0.5, "jd_whole"),
1005 (f64::INFINITY, 0.5, "jd_whole"),
1006 (2_459_024.5, f64::NAN, "jd_fraction"),
1007 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
1008 ] {
1009 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
1010 .expect_err("non-finite split Julian date part must fail");
1011 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
1012 }
1013 }
1014
1015 #[test]
1016 fn sp3_predict_reference_case() {
1017 let sp3 = sp3_fixture();
1018 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1019 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
1020 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
1021 .expect("predict observables");
1022
1023 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
1024 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
1025 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
1026 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
1027 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
1028 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
1029 assert_eq!(obs.transmit_offset_us, 69_288);
1030 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
1031 assert_eq!(
1032 obs.los_unit.map(f64::to_bits),
1033 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
1034 );
1035 assert_eq!(
1036 obs.sat_pos_ecef_m.map(f64::to_bits),
1037 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
1038 );
1039 assert_eq!(
1040 obs.sat_velocity_m_s.map(f64::to_bits),
1041 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
1042 );
1043 }
1044
1045 #[test]
1046 fn predict_rejects_invalid_entry_inputs() {
1047 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
1048 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1049
1050 let err = predict(
1051 &source,
1052 sat,
1053 [f64::NAN, 0.0, 0.0],
1054 646_272_000.0,
1055 no_light_time_options(),
1056 )
1057 .expect_err("non-finite receiver position must fail");
1058 assert_invalid_observables_input(
1059 err,
1060 "receiver_ecef_m",
1061 ObservablesInputErrorKind::NonFinite,
1062 );
1063
1064 let err = predict(
1065 &source,
1066 sat,
1067 [0.0, 0.0, 0.0],
1068 f64::INFINITY,
1069 no_light_time_options(),
1070 )
1071 .expect_err("non-finite receive time must fail");
1072 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
1073
1074 let mut options = no_light_time_options();
1075 options.carrier_hz = 0.0;
1076 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
1077 .expect_err("non-positive carrier must fail");
1078 assert_invalid_observables_input(
1079 err,
1080 "options.carrier_hz",
1081 ObservablesInputErrorKind::NotPositive,
1082 );
1083 }
1084
1085 #[test]
1086 fn predict_rejects_invalid_source_state_and_zero_range() {
1087 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1088
1089 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
1090 let err = predict(
1091 &source,
1092 sat,
1093 [0.0, 0.0, 0.0],
1094 646_272_000.0,
1095 no_light_time_options(),
1096 )
1097 .expect_err("non-finite ephemeris position must fail");
1098 assert_invalid_observables_input(
1099 err,
1100 "observable state position_ecef_m",
1101 ObservablesInputErrorKind::NonFinite,
1102 );
1103
1104 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
1105 let err = predict(
1106 &source,
1107 sat,
1108 [1_000.0, 2_000.0, 3_000.0],
1109 646_272_000.0,
1110 no_light_time_options(),
1111 )
1112 .expect_err("zero geometric range must fail");
1113 assert_invalid_observables_input(
1114 err,
1115 "geometric_range_m",
1116 ObservablesInputErrorKind::NotPositive,
1117 );
1118 }
1119
1120 #[test]
1121 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
1122 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
1123 .expect_err("invalid receiver geodetic conversion must fail");
1124
1125 assert_invalid_observables_input(
1126 err,
1127 "receiver_ecef_m",
1128 ObservablesInputErrorKind::OutOfRange,
1129 );
1130 }
1131
1132 const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
1136
1137 #[test]
1138 fn topocentric_azimuth_is_zero_at_exact_zenith() {
1139 let (elevation_deg, azimuth_deg) = topocentric(
1142 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1143 [20_000_000.0, 0.0, 0.0],
1144 20_000_000.0,
1145 )
1146 .expect("zenith topocentric must not error");
1147 assert_eq!(azimuth_deg, 0.0);
1148 assert!(azimuth_deg.is_finite());
1149 assert!((elevation_deg - 90.0).abs() < 1e-9);
1150 }
1151
1152 #[test]
1153 fn topocentric_azimuth_is_zero_just_off_zenith() {
1154 let (_, azimuth_deg) = topocentric(
1157 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1158 [20_000_000.0, 1.0e-9, 1.0e-9],
1159 20_000_000.0,
1160 )
1161 .expect("near-zenith topocentric must not error");
1162 assert_eq!(azimuth_deg, 0.0);
1163 assert!(azimuth_deg.is_finite());
1164 }
1165
1166 #[test]
1167 fn predict_azimuth_is_zero_at_exact_zenith() {
1168 let source = StaticSource {
1169 state: ObservableState {
1170 position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
1171 clock_s: None,
1172 },
1173 };
1174 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1175 let obs = predict(
1176 &source,
1177 sat,
1178 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1179 0.0,
1180 PredictOptions {
1181 carrier_hz: F_L1_HZ,
1182 light_time: false,
1183 sagnac: false,
1184 },
1185 )
1186 .expect("zenith predict must not error");
1187 assert_eq!(obs.azimuth_deg, 0.0);
1188 assert!(obs.azimuth_deg.is_finite());
1189 assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
1190 }
1191
1192 fn batch_test_requests() -> Vec<PredictRequest> {
1193 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1194 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1195 vec![
1196 (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
1197 (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
1198 (
1199 sat1,
1200 [1_130_000.0, -4_830_000.0, 3_994_000.0],
1201 646_272_060.0,
1202 ),
1203 (
1204 sat2,
1205 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1206 646_272_090.0,
1207 ),
1208 ]
1209 }
1210
1211 fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
1212 assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
1213 assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
1214 assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
1215 assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
1216 assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
1217 assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
1218 assert_eq!(
1219 a.transmit_time_j2000_s.to_bits(),
1220 b.transmit_time_j2000_s.to_bits()
1221 );
1222 for k in 0..3 {
1223 assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
1224 assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
1225 assert_eq!(
1226 a.sat_velocity_m_s[k].to_bits(),
1227 b.sat_velocity_m_s[k].to_bits()
1228 );
1229 }
1230 }
1231
1232 #[test]
1233 fn predict_batch_matches_scalar_loop_bitwise() {
1234 let source = StaticSource {
1235 state: ObservableState {
1236 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1237 clock_s: Some(1.25e-6),
1238 },
1239 };
1240 let options = PredictOptions {
1241 carrier_hz: F_L1_HZ,
1242 light_time: true,
1243 sagnac: true,
1244 };
1245 let requests = batch_test_requests();
1246 let batch = predict_batch(&source, &requests, options);
1247 assert_eq!(batch.len(), requests.len());
1248 for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
1249 let scalar = predict(&source, sat, rx, t, options);
1250 match (entry, &scalar) {
1251 (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
1252 (Err(_), Err(_)) => {}
1253 _ => panic!("batch and scalar predict disagree on Ok/Err"),
1254 }
1255 }
1256 }
1257
1258 #[test]
1259 fn predict_batch_parallel_matches_serial_bitwise() {
1260 let source = StaticSource {
1261 state: ObservableState {
1262 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1263 clock_s: Some(1.25e-6),
1264 },
1265 };
1266 let options = PredictOptions {
1267 carrier_hz: F_L1_HZ,
1268 light_time: true,
1269 sagnac: true,
1270 };
1271 let requests = batch_test_requests();
1272 let serial = predict_batch(&source, &requests, options);
1273 let parallel = predict_batch_parallel(&source, &requests, options);
1274 assert_eq!(serial.len(), parallel.len());
1275 for (s, p) in serial.iter().zip(parallel.iter()) {
1276 match (s, p) {
1277 (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
1278 (Err(_), Err(_)) => {}
1279 _ => panic!("serial and parallel batch disagree on Ok/Err"),
1280 }
1281 }
1282 }
1283}