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 const OBSERVABLE_STATE_MISSING_POSITION_ECEF_M: [f64; 3] = [f64::NAN; 3];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ObservableStateElementStatus {
46 Valid,
48 Gap,
50 Error,
52}
53
54#[derive(Debug, Clone, PartialEq)]
62pub struct ObservableStateBatch {
63 pub positions_ecef_m: Vec<[f64; 3]>,
65 pub clocks_s: Vec<Option<f64>>,
67 pub element_results: Vec<Result<(), ObservablesError>>,
69}
70
71pub trait ObservableEphemerisSource {
73 fn observable_state_at_j2000_s(
75 &self,
76 sat: GnssSatelliteId,
77 t_j2000_s: f64,
78 ) -> Result<ObservableState, ObservablesError>;
79
80 fn observable_states_at_j2000_s(
86 &self,
87 satellites: &[GnssSatelliteId],
88 epochs_j2000_s: &[f64],
89 ) -> Result<ObservableStateBatch, ObservablesError> {
90 if satellites.len() != epochs_j2000_s.len() {
91 return Err(ObservablesError::InvalidInput {
92 field: "epochs_j2000_s",
93 kind: ObservablesInputErrorKind::OutOfRange,
94 });
95 }
96
97 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
98 for (&sat, &epoch_j2000_s) in satellites.iter().zip(epochs_j2000_s.iter()) {
99 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
100 }
101 Ok(batch)
102 }
103
104 fn observable_states_at_shared_j2000_s(
109 &self,
110 satellites: &[GnssSatelliteId],
111 epoch_j2000_s: f64,
112 ) -> ObservableStateBatch {
113 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
114 for &sat in satellites {
115 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
116 }
117 batch
118 }
119}
120
121impl ObservableEphemerisSource for Sp3 {
122 fn observable_state_at_j2000_s(
123 &self,
124 sat: GnssSatelliteId,
125 t_j2000_s: f64,
126 ) -> Result<ObservableState, ObservablesError> {
127 let state = self
128 .position_at_j2000_seconds(sat, t_j2000_s)
129 .map_err(ObservablesError::Ephemeris)?;
130 Ok(ObservableState {
131 position_ecef_m: state.position.as_array(),
132 clock_s: state.clock_s,
133 })
134 }
135}
136
137impl ObservableEphemerisSource for BroadcastEphemeris {
138 fn observable_state_at_j2000_s(
139 &self,
140 sat: GnssSatelliteId,
141 t_j2000_s: f64,
142 ) -> Result<ObservableState, ObservablesError> {
143 let Some((position_ecef_m, clock_s)) =
144 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
145 else {
146 return Err(ObservablesError::NoEphemeris);
147 };
148 Ok(ObservableState {
149 position_ecef_m,
150 clock_s: Some(clock_s),
151 })
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ObservablesInputErrorKind {
158 NonFinite,
160 NotPositive,
162 Negative,
164 OutOfRange,
166 Missing,
168 FloatParse,
170 IntParse,
172 InvalidCivilDate,
174 InvalidCivilTime,
176}
177
178impl core::fmt::Display for ObservablesInputErrorKind {
179 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180 let label = match self {
181 Self::NonFinite => "not finite",
182 Self::NotPositive => "not positive",
183 Self::Negative => "negative",
184 Self::OutOfRange => "out of range",
185 Self::Missing => "missing",
186 Self::FloatParse => "invalid float",
187 Self::IntParse => "invalid integer",
188 Self::InvalidCivilDate => "invalid civil date",
189 Self::InvalidCivilTime => "invalid civil time",
190 };
191 f.write_str(label)
192 }
193}
194
195impl From<&validate::FieldError> for ObservablesInputErrorKind {
196 fn from(error: &validate::FieldError) -> Self {
197 match error {
198 validate::FieldError::Missing { .. } => Self::Missing,
199 validate::FieldError::NonFinite { .. } => Self::NonFinite,
200 validate::FieldError::NotPositive { .. } => Self::NotPositive,
201 validate::FieldError::Negative { .. } => Self::Negative,
202 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
203 validate::FieldError::FloatParse { .. } => Self::FloatParse,
204 validate::FieldError::IntParse { .. } => Self::IntParse,
205 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
206 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum ObservablesError {
214 InvalidInput {
217 field: &'static str,
219 kind: ObservablesInputErrorKind,
221 },
222 NoEphemeris,
224 Ephemeris(Error),
226}
227
228impl core::fmt::Display for ObservablesError {
229 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
230 match self {
231 Self::InvalidInput { field, kind } => {
232 write!(f, "invalid observable input {field}: {kind}")
233 }
234 Self::NoEphemeris => write!(f, "no ephemeris"),
235 Self::Ephemeris(err) => write!(f, "{err}"),
236 }
237 }
238}
239
240impl std::error::Error for ObservablesError {}
241
242impl ObservableStateBatch {
243 pub fn with_capacity(capacity: usize) -> Self {
245 Self {
246 positions_ecef_m: Vec::with_capacity(capacity),
247 clocks_s: Vec::with_capacity(capacity),
248 element_results: Vec::with_capacity(capacity),
249 }
250 }
251
252 pub fn len(&self) -> usize {
254 self.element_results.len()
255 }
256
257 pub fn is_empty(&self) -> bool {
259 self.element_results.is_empty()
260 }
261
262 pub fn element(&self, index: usize) -> Option<Result<ObservableState, &ObservablesError>> {
266 match self.element_results.get(index)? {
267 Ok(()) => Some(Ok(ObservableState {
268 position_ecef_m: self.positions_ecef_m[index],
269 clock_s: self.clocks_s[index],
270 })),
271 Err(error) => Some(Err(error)),
272 }
273 }
274
275 pub fn element_status(&self, index: usize) -> Option<ObservableStateElementStatus> {
279 match self.element_results.get(index)? {
280 Ok(()) => Some(ObservableStateElementStatus::Valid),
281 Err(error) if is_observable_state_gap(error) => Some(ObservableStateElementStatus::Gap),
282 Err(_) => Some(ObservableStateElementStatus::Error),
283 }
284 }
285
286 fn push_state_result(&mut self, result: Result<ObservableState, ObservablesError>) {
287 match result {
288 Ok(state) => {
289 self.positions_ecef_m.push(state.position_ecef_m);
290 self.clocks_s.push(state.clock_s);
291 self.element_results.push(Ok(()));
292 }
293 Err(error) => {
294 self.positions_ecef_m
295 .push(OBSERVABLE_STATE_MISSING_POSITION_ECEF_M);
296 self.clocks_s.push(None);
297 self.element_results.push(Err(error));
298 }
299 }
300 }
301}
302
303pub fn is_observable_state_gap(error: &ObservablesError) -> bool {
309 matches!(
310 error,
311 ObservablesError::NoEphemeris
312 | ObservablesError::Ephemeris(crate::Error::EpochOutOfRange)
313 | ObservablesError::Ephemeris(crate::Error::UnknownSatellite(_))
314 )
315}
316
317#[derive(Debug, Clone, Copy, PartialEq)]
319pub struct PredictOptions {
320 pub carrier_hz: f64,
322 pub light_time: bool,
324 pub sagnac: bool,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct TransmitTimeOptions {
331 pub light_time: bool,
333 pub sagnac: bool,
335}
336
337impl Default for TransmitTimeOptions {
338 fn default() -> Self {
339 Self {
340 light_time: true,
341 sagnac: true,
342 }
343 }
344}
345
346impl Default for PredictOptions {
347 fn default() -> Self {
348 Self {
349 carrier_hz: F_L1_HZ,
350 light_time: true,
351 sagnac: true,
352 }
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq)]
364pub struct TransmitTimeSatelliteState {
365 pub signal_flight_time_s: f64,
367 pub transmit_offset_us: i64,
369 pub transmit_time_j2000_s: f64,
371 pub clock_s: Option<f64>,
373 pub transmit_position_ecef_m: [f64; 3],
375 pub position_ecef_m: [f64; 3],
377 pub velocity_m_s: [f64; 3],
379 pub geometric_range_m: f64,
381 pub los_unit: [f64; 3],
383}
384
385#[derive(Debug, Clone, Copy, PartialEq)]
387pub struct PredictedObservables {
388 pub geometric_range_m: f64,
390 pub range_rate_m_s: f64,
392 pub doppler_hz: f64,
394 pub sat_clock_s: Option<f64>,
396 pub elevation_deg: f64,
398 pub azimuth_deg: f64,
406 pub transmit_offset_us: i64,
408 pub transmit_time_j2000_s: f64,
410 pub los_unit: [f64; 3],
412 pub sat_pos_ecef_m: [f64; 3],
414 pub sat_velocity_m_s: [f64; 3],
416}
417
418pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
420 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
421 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
422 validate::finite(
423 civil::j2000_seconds_from_split(jd_whole, jd_fraction),
424 "j2000_seconds",
425 )
426 .map_err(map_input_error)
427}
428
429pub fn observable_states_at_j2000_s(
433 source: &dyn ObservableEphemerisSource,
434 satellites: &[GnssSatelliteId],
435 epochs_j2000_s: &[f64],
436) -> Result<ObservableStateBatch, ObservablesError> {
437 source.observable_states_at_j2000_s(satellites, epochs_j2000_s)
438}
439
440pub fn observable_states_at_shared_j2000_s(
445 source: &dyn ObservableEphemerisSource,
446 satellites: &[GnssSatelliteId],
447 epoch_j2000_s: f64,
448) -> ObservableStateBatch {
449 source.observable_states_at_shared_j2000_s(satellites, epoch_j2000_s)
450}
451
452pub fn transmit_time_satellite_state(
460 source: &dyn ObservableEphemerisSource,
461 sat: GnssSatelliteId,
462 receiver_ecef_m: [f64; 3],
463 t_rx_j2000_s: f64,
464 options: TransmitTimeOptions,
465) -> Result<TransmitTimeSatelliteState, ObservablesError> {
466 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
467 let predict_options = PredictOptions {
468 carrier_hz: F_L1_HZ,
469 light_time: options.light_time,
470 sagnac: options.sagnac,
471 };
472 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
473
474 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
475 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
476 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
477 let range = geometric_range_m([dx, dy, dz])?;
478 let los = [dx / range, dy / range, dz / range];
479
480 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
481 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
482 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
483
484 Ok(TransmitTimeSatelliteState {
485 signal_flight_time_s: solved.tau_s,
486 transmit_offset_us: solved.transmit_offset_us,
487 transmit_time_j2000_s: solved.transmit_time_j2000_s,
488 clock_s: solved.state.clock_s,
489 transmit_position_ecef_m: solved.state.position_ecef_m,
490 position_ecef_m: solved.sat_rot_ecef_m,
491 velocity_m_s: velocity_rot,
492 geometric_range_m: range,
493 los_unit: los,
494 })
495}
496
497pub fn predict(
499 source: &dyn ObservableEphemerisSource,
500 sat: GnssSatelliteId,
501 receiver_ecef_m: [f64; 3],
502 t_rx_j2000_s: f64,
503 options: PredictOptions,
504) -> Result<PredictedObservables, ObservablesError> {
505 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
506 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
507
508 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
509 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
510 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
511 let range = geometric_range_m([dx, dy, dz])?;
512 let los = [dx / range, dy / range, dz / range];
513
514 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
515 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
516 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
517 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
518 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
519 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
520 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
521 let (elevation_deg, azimuth_deg) = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
522
523 Ok(PredictedObservables {
524 geometric_range_m: range,
525 range_rate_m_s: range_rate,
526 doppler_hz,
527 sat_clock_s: solved.state.clock_s,
528 elevation_deg,
529 azimuth_deg,
530 transmit_offset_us: solved.transmit_offset_us,
531 transmit_time_j2000_s: solved.transmit_time_j2000_s,
532 los_unit: los,
533 sat_pos_ecef_m: solved.sat_rot_ecef_m,
534 sat_velocity_m_s: velocity_rot,
535 })
536}
537
538pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
545
546pub fn predict_batch(
554 source: &dyn ObservableEphemerisSource,
555 requests: &[PredictRequest],
556 options: PredictOptions,
557) -> Vec<Result<PredictedObservables, ObservablesError>> {
558 requests
559 .iter()
560 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
561 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
562 })
563 .collect()
564}
565
566pub fn predict_batch_parallel(
576 source: &(dyn ObservableEphemerisSource + Sync),
577 requests: &[PredictRequest],
578 options: PredictOptions,
579) -> Vec<Result<PredictedObservables, ObservablesError>> {
580 requests
581 .par_iter()
582 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
583 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
584 })
585 .collect()
586}
587
588#[derive(Debug, Clone, Copy, PartialEq)]
591pub struct RangePredictionRequest {
592 pub sat: GnssSatelliteId,
594 pub receiver_ecef_m: [f64; 3],
596 pub t_rx_j2000_s: f64,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq)]
605pub struct RangePrediction {
606 pub geometric_range_m: f64,
608 pub sat_clock_s: Option<f64>,
610 pub transmit_time_j2000_s: f64,
612 pub sat_pos_ecef_m: [f64; 3],
614}
615
616pub fn predict_ranges(
641 source: &dyn ObservableEphemerisSource,
642 requests: &[RangePredictionRequest],
643 options: PredictOptions,
644 out: &mut [RangePrediction],
645) -> Result<(), ObservablesError> {
646 if out.len() != requests.len() {
647 return Err(ObservablesError::InvalidInput {
648 field: "out",
649 kind: ObservablesInputErrorKind::OutOfRange,
650 });
651 }
652 for (request, slot) in requests.iter().zip(out.iter_mut()) {
653 *slot = range_prediction_at_rx(
654 source,
655 request.sat,
656 request.receiver_ecef_m,
657 request.t_rx_j2000_s,
658 options,
659 )?;
660 }
661 Ok(())
662}
663
664fn range_prediction_at_rx(
673 source: &dyn ObservableEphemerisSource,
674 sat: GnssSatelliteId,
675 receiver_ecef_m: [f64; 3],
676 t_rx_j2000_s: f64,
677 options: PredictOptions,
678) -> Result<RangePrediction, ObservablesError> {
679 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
680 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
681 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
682 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
683 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
684 let range = geometric_range_m([dx, dy, dz])?;
685 Ok(RangePrediction {
686 geometric_range_m: range,
687 sat_clock_s: solved.state.clock_s,
688 transmit_time_j2000_s: solved.transmit_time_j2000_s,
689 sat_pos_ecef_m: solved.sat_rot_ecef_m,
690 })
691}
692
693#[derive(Debug, Clone, Copy)]
694struct SolvedTransmitTime {
695 tau_s: f64,
696 transmit_offset_us: i64,
697 transmit_time_j2000_s: f64,
698 state: ObservableState,
699 sat_rot_ecef_m: [f64; 3],
700}
701
702fn solve_transmit_time(
703 source: &dyn ObservableEphemerisSource,
704 sat: GnssSatelliteId,
705 receiver_ecef_m: [f64; 3],
706 t_rx_j2000_s: f64,
707 options: PredictOptions,
708) -> Result<SolvedTransmitTime, ObservablesError> {
709 if !options.light_time {
710 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
711 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
712 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
713 return Ok(SolvedTransmitTime {
714 tau_s: 0.0,
715 transmit_offset_us: 0,
716 transmit_time_j2000_s: t_rx_j2000_s,
717 state,
718 sat_rot_ecef_m: sat_rot,
719 });
720 }
721
722 let mut tau = 0.0;
723 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
724 let transmit_offset_us = microseconds_from_tau(tau);
725 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
726 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
727 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
728 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
729 let dx = sat_rot[0] - receiver_ecef_m[0];
730 let dy = sat_rot[1] - receiver_ecef_m[1];
731 let dz = sat_rot[2] - receiver_ecef_m[2];
732 let range = geometric_range_m([dx, dy, dz])?;
733 let new_tau = range / C_M_S;
734
735 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
736 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
737 }
738
739 tau = new_tau;
740 }
741
742 unreachable!("fixed transmit-time loop always returns on its last iteration")
743}
744
745fn finalize_transmit_time(
746 source: &dyn ObservableEphemerisSource,
747 sat: GnssSatelliteId,
748 t_rx_j2000_s: f64,
749 tau: f64,
750 sagnac: bool,
751) -> Result<SolvedTransmitTime, ObservablesError> {
752 let transmit_offset_us = microseconds_from_tau(tau);
753 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
754 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
755 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
756 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
757 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
758 Ok(SolvedTransmitTime {
759 tau_s: tau,
760 transmit_offset_us,
761 transmit_time_j2000_s: t_tx,
762 state,
763 sat_rot_ecef_m: sat_rot,
764 })
765}
766
767fn microseconds_from_tau(tau_s: f64) -> i64 {
768 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
769}
770
771fn satellite_velocity(
772 source: &dyn ObservableEphemerisSource,
773 sat: GnssSatelliteId,
774 t_tx_j2000_s: f64,
775) -> Result<[f64; 3], ObservablesError> {
776 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
777 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
778 let denom = 2.0 * FD_HALF_S;
779 let velocity = [
780 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
781 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
782 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
783 ];
784 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
785}
786
787fn validate_predict_inputs(
788 receiver_ecef_m: [f64; 3],
789 t_rx_j2000_s: f64,
790 options: PredictOptions,
791) -> Result<(), ObservablesError> {
792 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
793 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
794 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
795 Ok(())
796}
797
798fn validate_transmit_time_inputs(
799 receiver_ecef_m: [f64; 3],
800 t_rx_j2000_s: f64,
801) -> Result<(), ObservablesError> {
802 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
803 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
804 Ok(())
805}
806
807fn validated_state_at_j2000_s(
808 source: &dyn ObservableEphemerisSource,
809 sat: GnssSatelliteId,
810 t_j2000_s: f64,
811) -> Result<ObservableState, ObservablesError> {
812 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
813 validate_observable_state(&state)?;
814 Ok(state)
815}
816
817fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
818 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
819 .map_err(map_input_error)?;
820 if let Some(clock_s) = state.clock_s {
821 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
822 }
823 Ok(())
824}
825
826fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
827 let range = (delta_ecef_m[0] * delta_ecef_m[0]
828 + delta_ecef_m[1] * delta_ecef_m[1]
829 + delta_ecef_m[2] * delta_ecef_m[2])
830 .sqrt();
831 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
832}
833
834fn map_input_error(error: validate::FieldError) -> ObservablesError {
835 ObservablesError::InvalidInput {
836 field: error.field(),
837 kind: ObservablesInputErrorKind::from(&error),
838 }
839}
840
841fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
842 let sagnac = if apply {
843 SagnacRecipe::ClosedFormZRotation
844 } else {
845 SagnacRecipe::Off
846 };
847 crate::estimation::substrate::range::rotate_transmit_satellite(
848 sagnac,
849 pos,
850 tau_s,
851 OMEGA_E_DOT_RAD_S,
852 )
853}
854
855fn topocentric(
856 receiver_ecef_m: [f64; 3],
857 delta_ecef_m: [f64; 3],
858 range_m: f64,
859) -> Result<(f64, f64), ObservablesError> {
860 let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
861 receiver_ecef_m[0] / KM_TO_M,
862 receiver_ecef_m[1] / KM_TO_M,
863 receiver_ecef_m[2] / KM_TO_M,
864 )
865 .map_err(|_| ObservablesError::InvalidInput {
866 field: "receiver_ecef_m",
867 kind: ObservablesInputErrorKind::OutOfRange,
868 })?;
869 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
871 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
872
873 let sl = lat.sin();
874 let cl = lat.cos();
875 let so = lon.sin();
876 let co = lon.cos();
877
878 let dx = delta_ecef_m[0];
879 let dy = delta_ecef_m[1];
880 let dz = delta_ecef_m[2];
881
882 let e = -so * dx + co * dy;
883 let n = -sl * co * dx - sl * so * dy + cl * dz;
884 let u = cl * co * dx + cl * so * dy + sl * dz;
885
886 let horiz_sq = e * e + n * n;
891 let mut azimuth_deg = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
892 0.0
893 } else {
894 e.atan2(n) * DEGREES_PER_SEMICIRCLE / PI
895 };
896 if azimuth_deg < 0.0 {
897 azimuth_deg += DEGREES_PER_CIRCLE;
898 }
899 let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
904 let elevation_deg = sin_elevation.asin() * DEGREES_PER_SEMICIRCLE / PI;
905
906 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
907 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
908 Ok((elevation_deg, azimuth_deg))
909}
910
911#[cfg(test)]
912mod public_api_tests {
913 use super::*;
914 use crate::{GnssSatelliteId, GnssSystem};
915
916 #[derive(Debug, Clone, Copy)]
917 struct StaticSource {
918 state: ObservableState,
919 }
920
921 impl ObservableEphemerisSource for StaticSource {
922 fn observable_state_at_j2000_s(
923 &self,
924 _sat: GnssSatelliteId,
925 _t_j2000_s: f64,
926 ) -> Result<ObservableState, ObservablesError> {
927 Ok(self.state)
928 }
929 }
930
931 #[test]
932 fn predict_ranges_matches_transmit_time_loop_bitwise() {
933 let source = StaticSource {
934 state: ObservableState {
935 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
936 clock_s: Some(1.25e-6),
937 },
938 };
939 let options = PredictOptions {
940 carrier_hz: F_L1_HZ,
941 light_time: true,
942 sagnac: true,
943 };
944 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
945 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
946 let requests = [
947 RangePredictionRequest {
948 sat: sat1,
949 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
950 t_rx_j2000_s: 646_272_000.0,
951 },
952 RangePredictionRequest {
953 sat: sat2,
954 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
955 t_rx_j2000_s: 646_272_060.0,
956 },
957 ];
958 let mut out = [RangePrediction {
959 geometric_range_m: 0.0,
960 sat_clock_s: None,
961 transmit_time_j2000_s: 0.0,
962 sat_pos_ecef_m: [0.0; 3],
963 }; 2];
964 predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
965
966 let tt_options = TransmitTimeOptions {
967 light_time: options.light_time,
968 sagnac: options.sagnac,
969 };
970 for (request, got) in requests.iter().zip(out.iter()) {
971 let single = transmit_time_satellite_state(
972 &source,
973 request.sat,
974 request.receiver_ecef_m,
975 request.t_rx_j2000_s,
976 tt_options,
977 )
978 .expect("single transmit-time state");
979 assert_eq!(
980 got.geometric_range_m.to_bits(),
981 single.geometric_range_m.to_bits()
982 );
983 assert_eq!(
984 got.transmit_time_j2000_s.to_bits(),
985 single.transmit_time_j2000_s.to_bits()
986 );
987 assert_eq!(
988 got.sat_clock_s.map(f64::to_bits),
989 single.clock_s.map(f64::to_bits)
990 );
991 assert_eq!(
992 got.sat_pos_ecef_m.map(f64::to_bits),
993 single.position_ecef_m.map(f64::to_bits)
994 );
995 }
996 }
997
998 #[test]
999 fn predict_ranges_batch_matches_scalar_calls_bitwise() {
1000 let source = StaticSource {
1003 state: ObservableState {
1004 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1005 clock_s: Some(1.25e-6),
1006 },
1007 };
1008 let options = PredictOptions::default();
1009 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1010 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1011 let requests = [
1012 RangePredictionRequest {
1013 sat: sat1,
1014 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1015 t_rx_j2000_s: 646_272_000.0,
1016 },
1017 RangePredictionRequest {
1018 sat: sat2,
1019 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1020 t_rx_j2000_s: 646_272_060.0,
1021 },
1022 RangePredictionRequest {
1023 sat: sat1,
1024 receiver_ecef_m: [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1025 t_rx_j2000_s: 646_272_120.0,
1026 },
1027 ];
1028 let zero = RangePrediction {
1029 geometric_range_m: 0.0,
1030 sat_clock_s: None,
1031 transmit_time_j2000_s: 0.0,
1032 sat_pos_ecef_m: [0.0; 3],
1033 };
1034
1035 let mut batch = [zero; 3];
1036 predict_ranges(&source, &requests, options, &mut batch).expect("batch ranges");
1037
1038 for (i, request) in requests.iter().enumerate() {
1039 let mut single = [zero; 1];
1040 predict_ranges(&source, std::slice::from_ref(request), options, &mut single)
1041 .expect("single range");
1042 assert_eq!(
1043 batch[i].geometric_range_m.to_bits(),
1044 single[0].geometric_range_m.to_bits()
1045 );
1046 assert_eq!(
1047 batch[i].transmit_time_j2000_s.to_bits(),
1048 single[0].transmit_time_j2000_s.to_bits()
1049 );
1050 assert_eq!(
1051 batch[i].sat_clock_s.map(f64::to_bits),
1052 single[0].sat_clock_s.map(f64::to_bits)
1053 );
1054 assert_eq!(
1055 batch[i].sat_pos_ecef_m.map(f64::to_bits),
1056 single[0].sat_pos_ecef_m.map(f64::to_bits)
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn predict_ranges_rejects_length_mismatch() {
1063 let source = StaticSource {
1064 state: ObservableState {
1065 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1066 clock_s: None,
1067 },
1068 };
1069 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1070 let requests = [RangePredictionRequest {
1071 sat,
1072 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1073 t_rx_j2000_s: 646_272_000.0,
1074 }];
1075 let mut out: [RangePrediction; 0] = [];
1076 let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
1077 .expect_err("length mismatch must fail");
1078 match err {
1079 ObservablesError::InvalidInput { field, kind } => {
1080 assert_eq!(field, "out");
1081 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
1082 }
1083 other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
1084 }
1085 }
1086
1087 #[test]
1088 fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
1089 let rx = [
1098 4_509_179.095_483_66,
1099 275_556.225_682_215_9,
1100 4_487_348.408_865_919,
1101 ];
1102 let (lat_deg, lon_deg, _h) =
1103 itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
1104 .expect("receiver geodetic conversion");
1105 assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
1106
1107 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1110 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1111 let (sl, cl) = lat.sin_cos();
1112 let (so, co) = lon.sin_cos();
1113 let up = [cl * co, cl * so, sl];
1114
1115 let d = 20_000_000.0_f64;
1116 let delta = [up[0] * d, up[1] * d, up[2] * d];
1117 let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1118 let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
1121 assert!(
1122 u / range > 1.0,
1123 "test geometry must overshoot the asin domain"
1124 );
1125
1126 let (elevation_deg, _azimuth_deg) =
1127 topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
1128 assert!(elevation_deg.is_finite());
1129 assert!((elevation_deg - 90.0).abs() < 1e-9);
1130 }
1131
1132 #[test]
1133 fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
1134 let source = StaticSource {
1135 state: ObservableState {
1136 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1137 clock_s: Some(1.25e-6),
1138 },
1139 };
1140 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1141 let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
1142 let state = transmit_time_satellite_state(
1143 &source,
1144 sat,
1145 rx,
1146 646_272_000.0,
1147 TransmitTimeOptions {
1148 light_time: false,
1149 sagnac: true,
1150 },
1151 )
1152 .expect("state");
1153 let prediction = predict(
1154 &source,
1155 sat,
1156 rx,
1157 646_272_000.0,
1158 PredictOptions {
1159 carrier_hz: F_L1_HZ,
1160 light_time: false,
1161 sagnac: true,
1162 },
1163 )
1164 .expect("prediction");
1165
1166 assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
1167 assert_eq!(state.transmit_offset_us, 0);
1168 assert_eq!(
1169 state.transmit_time_j2000_s.to_bits(),
1170 646_272_000.0f64.to_bits()
1171 );
1172 assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
1173 assert_eq!(
1174 state.transmit_position_ecef_m.map(f64::to_bits),
1175 source.state.position_ecef_m.map(f64::to_bits)
1176 );
1177 assert_eq!(
1178 state.position_ecef_m.map(f64::to_bits),
1179 prediction.sat_pos_ecef_m.map(f64::to_bits)
1180 );
1181 assert_eq!(
1182 state.velocity_m_s.map(f64::to_bits),
1183 prediction.sat_velocity_m_s.map(f64::to_bits)
1184 );
1185 assert_eq!(
1186 state.geometric_range_m.to_bits(),
1187 prediction.geometric_range_m.to_bits()
1188 );
1189 assert_eq!(
1190 state.los_unit.map(f64::to_bits),
1191 prediction.los_unit.map(f64::to_bits)
1192 );
1193 }
1194}
1195
1196#[cfg(all(test, sidereon_repo_tests))]
1197mod tests {
1198 use super::*;
1199 use crate::{GnssSatelliteId, GnssSystem};
1200
1201 #[derive(Debug, Clone, Copy)]
1202 struct StaticSource {
1203 state: ObservableState,
1204 }
1205
1206 impl ObservableEphemerisSource for StaticSource {
1207 fn observable_state_at_j2000_s(
1208 &self,
1209 _sat: GnssSatelliteId,
1210 _t_j2000_s: f64,
1211 ) -> Result<ObservableState, ObservablesError> {
1212 Ok(self.state)
1213 }
1214 }
1215
1216 fn sp3_fixture() -> Sp3 {
1217 let path = concat!(
1218 env!("CARGO_MANIFEST_DIR"),
1219 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
1220 );
1221 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
1222 Sp3::parse(&bytes).expect("parse SP3 fixture")
1223 }
1224
1225 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
1226 StaticSource {
1227 state: ObservableState {
1228 position_ecef_m,
1229 clock_s: Some(0.0),
1230 },
1231 }
1232 }
1233
1234 fn no_light_time_options() -> PredictOptions {
1235 PredictOptions {
1236 carrier_hz: F_L1_HZ,
1237 light_time: false,
1238 sagnac: true,
1239 }
1240 }
1241
1242 fn assert_invalid_observables_input(
1243 err: ObservablesError,
1244 field: &'static str,
1245 kind: ObservablesInputErrorKind,
1246 ) {
1247 match err {
1248 ObservablesError::InvalidInput {
1249 field: got_field,
1250 kind: got_kind,
1251 } => {
1252 assert_eq!(got_field, field);
1253 assert_eq!(got_kind, kind);
1254 }
1255 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
1256 }
1257 }
1258
1259 #[test]
1260 fn split_julian_to_j2000_seconds_matches_orbis_time() {
1261 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
1262 assert_eq!(t, 646_272_000.0);
1263 }
1264
1265 #[test]
1266 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
1267 for (jd_whole, jd_fraction, field) in [
1268 (f64::NAN, 0.5, "jd_whole"),
1269 (f64::INFINITY, 0.5, "jd_whole"),
1270 (2_459_024.5, f64::NAN, "jd_fraction"),
1271 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
1272 ] {
1273 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
1274 .expect_err("non-finite split Julian date part must fail");
1275 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
1276 }
1277 }
1278
1279 #[test]
1280 fn sp3_predict_reference_case() {
1281 let sp3 = sp3_fixture();
1282 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1283 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
1284 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
1285 .expect("predict observables");
1286
1287 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
1288 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
1289 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
1290 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
1291 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
1292 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
1293 assert_eq!(obs.transmit_offset_us, 69_288);
1294 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
1295 assert_eq!(
1296 obs.los_unit.map(f64::to_bits),
1297 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
1298 );
1299 assert_eq!(
1300 obs.sat_pos_ecef_m.map(f64::to_bits),
1301 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
1302 );
1303 assert_eq!(
1304 obs.sat_velocity_m_s.map(f64::to_bits),
1305 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
1306 );
1307 }
1308
1309 #[test]
1310 fn predict_rejects_invalid_entry_inputs() {
1311 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
1312 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1313
1314 let err = predict(
1315 &source,
1316 sat,
1317 [f64::NAN, 0.0, 0.0],
1318 646_272_000.0,
1319 no_light_time_options(),
1320 )
1321 .expect_err("non-finite receiver position must fail");
1322 assert_invalid_observables_input(
1323 err,
1324 "receiver_ecef_m",
1325 ObservablesInputErrorKind::NonFinite,
1326 );
1327
1328 let err = predict(
1329 &source,
1330 sat,
1331 [0.0, 0.0, 0.0],
1332 f64::INFINITY,
1333 no_light_time_options(),
1334 )
1335 .expect_err("non-finite receive time must fail");
1336 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
1337
1338 let mut options = no_light_time_options();
1339 options.carrier_hz = 0.0;
1340 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
1341 .expect_err("non-positive carrier must fail");
1342 assert_invalid_observables_input(
1343 err,
1344 "options.carrier_hz",
1345 ObservablesInputErrorKind::NotPositive,
1346 );
1347 }
1348
1349 #[test]
1350 fn predict_rejects_invalid_source_state_and_zero_range() {
1351 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1352
1353 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
1354 let err = predict(
1355 &source,
1356 sat,
1357 [0.0, 0.0, 0.0],
1358 646_272_000.0,
1359 no_light_time_options(),
1360 )
1361 .expect_err("non-finite ephemeris position must fail");
1362 assert_invalid_observables_input(
1363 err,
1364 "observable state position_ecef_m",
1365 ObservablesInputErrorKind::NonFinite,
1366 );
1367
1368 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
1369 let err = predict(
1370 &source,
1371 sat,
1372 [1_000.0, 2_000.0, 3_000.0],
1373 646_272_000.0,
1374 no_light_time_options(),
1375 )
1376 .expect_err("zero geometric range must fail");
1377 assert_invalid_observables_input(
1378 err,
1379 "geometric_range_m",
1380 ObservablesInputErrorKind::NotPositive,
1381 );
1382 }
1383
1384 #[test]
1385 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
1386 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
1387 .expect_err("invalid receiver geodetic conversion must fail");
1388
1389 assert_invalid_observables_input(
1390 err,
1391 "receiver_ecef_m",
1392 ObservablesInputErrorKind::OutOfRange,
1393 );
1394 }
1395
1396 const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
1400
1401 #[test]
1402 fn topocentric_azimuth_is_zero_at_exact_zenith() {
1403 let (elevation_deg, azimuth_deg) = topocentric(
1406 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1407 [20_000_000.0, 0.0, 0.0],
1408 20_000_000.0,
1409 )
1410 .expect("zenith topocentric must not error");
1411 assert_eq!(azimuth_deg, 0.0);
1412 assert!(azimuth_deg.is_finite());
1413 assert!((elevation_deg - 90.0).abs() < 1e-9);
1414 }
1415
1416 #[test]
1417 fn topocentric_azimuth_is_zero_just_off_zenith() {
1418 let (_, azimuth_deg) = topocentric(
1421 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1422 [20_000_000.0, 1.0e-9, 1.0e-9],
1423 20_000_000.0,
1424 )
1425 .expect("near-zenith topocentric must not error");
1426 assert_eq!(azimuth_deg, 0.0);
1427 assert!(azimuth_deg.is_finite());
1428 }
1429
1430 #[test]
1431 fn predict_azimuth_is_zero_at_exact_zenith() {
1432 let source = StaticSource {
1433 state: ObservableState {
1434 position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
1435 clock_s: None,
1436 },
1437 };
1438 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1439 let obs = predict(
1440 &source,
1441 sat,
1442 [EQUATORIAL_RX_X_M, 0.0, 0.0],
1443 0.0,
1444 PredictOptions {
1445 carrier_hz: F_L1_HZ,
1446 light_time: false,
1447 sagnac: false,
1448 },
1449 )
1450 .expect("zenith predict must not error");
1451 assert_eq!(obs.azimuth_deg, 0.0);
1452 assert!(obs.azimuth_deg.is_finite());
1453 assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
1454 }
1455
1456 fn batch_test_requests() -> Vec<PredictRequest> {
1457 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1458 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1459 vec![
1460 (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
1461 (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
1462 (
1463 sat1,
1464 [1_130_000.0, -4_830_000.0, 3_994_000.0],
1465 646_272_060.0,
1466 ),
1467 (
1468 sat2,
1469 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1470 646_272_090.0,
1471 ),
1472 ]
1473 }
1474
1475 fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
1476 assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
1477 assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
1478 assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
1479 assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
1480 assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
1481 assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
1482 assert_eq!(
1483 a.transmit_time_j2000_s.to_bits(),
1484 b.transmit_time_j2000_s.to_bits()
1485 );
1486 for k in 0..3 {
1487 assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
1488 assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
1489 assert_eq!(
1490 a.sat_velocity_m_s[k].to_bits(),
1491 b.sat_velocity_m_s[k].to_bits()
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn predict_batch_matches_scalar_loop_bitwise() {
1498 let source = StaticSource {
1499 state: ObservableState {
1500 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1501 clock_s: Some(1.25e-6),
1502 },
1503 };
1504 let options = PredictOptions {
1505 carrier_hz: F_L1_HZ,
1506 light_time: true,
1507 sagnac: true,
1508 };
1509 let requests = batch_test_requests();
1510 let batch = predict_batch(&source, &requests, options);
1511 assert_eq!(batch.len(), requests.len());
1512 for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
1513 let scalar = predict(&source, sat, rx, t, options);
1514 match (entry, &scalar) {
1515 (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
1516 (Err(_), Err(_)) => {}
1517 _ => panic!("batch and scalar predict disagree on Ok/Err"),
1518 }
1519 }
1520 }
1521
1522 #[test]
1523 fn predict_batch_parallel_matches_serial_bitwise() {
1524 let source = StaticSource {
1525 state: ObservableState {
1526 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1527 clock_s: Some(1.25e-6),
1528 },
1529 };
1530 let options = PredictOptions {
1531 carrier_hz: F_L1_HZ,
1532 light_time: true,
1533 sagnac: true,
1534 };
1535 let requests = batch_test_requests();
1536 let serial = predict_batch(&source, &requests, options);
1537 let parallel = predict_batch_parallel(&source, &requests, options);
1538 assert_eq!(serial.len(), parallel.len());
1539 for (s, p) in serial.iter().zip(parallel.iter()) {
1540 match (s, p) {
1541 (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
1542 (Err(_), Err(_)) => {}
1543 _ => panic!("serial and parallel batch disagree on Ok/Err"),
1544 }
1545 }
1546 }
1547}