1use crate::astro::frames::transforms::itrs_to_geodetic_compute;
9use std::f64::consts::PI;
10
11use crate::astro::time::civil;
12use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
13use crate::constants::{
14 AZIMUTH_ZENITH_EPS, C_M_S, DEGREES_PER_CIRCLE, DEGREES_PER_SEMICIRCLE, F_L1_HZ, J2000_JD,
15 KM_TO_M, MICROSECONDS_PER_SECOND, OBSERVABLE_TRANSMIT_TIME_ITERATIONS, OMEGA_E_DOT_RAD_S,
16 SECONDS_PER_DAY,
17};
18use crate::ephemeris::BroadcastEphemeris;
19use crate::estimation::recipe::SagnacRecipe;
20use crate::frame::Wgs84Geodetic;
21use crate::id::GnssSatelliteId;
22use crate::ionex::{ionex_slant_delay, ionosphere_delay, Ionex, IonoModel};
23use crate::sp3::Sp3;
24use crate::spp::EphemerisSource;
25use crate::tropo::{tropo_mapping, tropo_zenith, MappingModel, Met, TropoModel};
26use crate::validate;
27use crate::Error;
28use rayon::prelude::*;
29
30const FD_HALF_S: f64 = 0.5;
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct ObservableState {
35 pub position_ecef_m: [f64; 3],
37 pub clock_s: Option<f64>,
39}
40
41pub const OBSERVABLE_STATE_MISSING_POSITION_ECEF_M: [f64; 3] = [f64::NAN; 3];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ObservableStateElementStatus {
51 Valid,
53 Gap,
55 Error,
57}
58
59#[derive(Debug, Clone, PartialEq)]
67pub struct ObservableStateBatch {
68 pub positions_ecef_m: Vec<[f64; 3]>,
70 pub clocks_s: Vec<Option<f64>>,
72 pub element_results: Vec<Result<(), ObservablesError>>,
74}
75
76pub trait ObservableEphemerisSource {
78 fn observable_state_at_j2000_s(
80 &self,
81 sat: GnssSatelliteId,
82 t_j2000_s: f64,
83 ) -> Result<ObservableState, ObservablesError>;
84
85 fn observable_states_at_j2000_s(
91 &self,
92 satellites: &[GnssSatelliteId],
93 epochs_j2000_s: &[f64],
94 ) -> Result<ObservableStateBatch, ObservablesError> {
95 if satellites.len() != epochs_j2000_s.len() {
96 return Err(ObservablesError::InvalidInput {
97 field: "epochs_j2000_s",
98 kind: ObservablesInputErrorKind::OutOfRange,
99 });
100 }
101
102 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
103 for (&sat, &epoch_j2000_s) in satellites.iter().zip(epochs_j2000_s.iter()) {
104 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
105 }
106 Ok(batch)
107 }
108
109 fn observable_states_at_shared_j2000_s(
114 &self,
115 satellites: &[GnssSatelliteId],
116 epoch_j2000_s: f64,
117 ) -> ObservableStateBatch {
118 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
119 for &sat in satellites {
120 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
121 }
122 batch
123 }
124}
125
126impl ObservableEphemerisSource for Sp3 {
127 fn observable_state_at_j2000_s(
128 &self,
129 sat: GnssSatelliteId,
130 t_j2000_s: f64,
131 ) -> Result<ObservableState, ObservablesError> {
132 let state = self
133 .position_at_j2000_seconds(sat, t_j2000_s)
134 .map_err(ObservablesError::Ephemeris)?;
135 Ok(ObservableState {
136 position_ecef_m: state.position.as_array(),
137 clock_s: state.clock_s,
138 })
139 }
140}
141
142impl ObservableEphemerisSource for BroadcastEphemeris {
143 fn observable_state_at_j2000_s(
144 &self,
145 sat: GnssSatelliteId,
146 t_j2000_s: f64,
147 ) -> Result<ObservableState, ObservablesError> {
148 let Some((position_ecef_m, clock_s)) =
149 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
150 else {
151 return Err(ObservablesError::NoEphemeris);
152 };
153 Ok(ObservableState {
154 position_ecef_m,
155 clock_s: Some(clock_s),
156 })
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum ObservablesInputErrorKind {
163 NonFinite,
165 NotPositive,
167 Negative,
169 OutOfRange,
171 Missing,
173 FloatParse,
175 IntParse,
177 InvalidCivilDate,
179 InvalidCivilTime,
181}
182
183impl core::fmt::Display for ObservablesInputErrorKind {
184 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
185 let label = match self {
186 Self::NonFinite => "not finite",
187 Self::NotPositive => "not positive",
188 Self::Negative => "negative",
189 Self::OutOfRange => "out of range",
190 Self::Missing => "missing",
191 Self::FloatParse => "invalid float",
192 Self::IntParse => "invalid integer",
193 Self::InvalidCivilDate => "invalid civil date",
194 Self::InvalidCivilTime => "invalid civil time",
195 };
196 f.write_str(label)
197 }
198}
199
200impl From<&validate::FieldError> for ObservablesInputErrorKind {
201 fn from(error: &validate::FieldError) -> Self {
202 match error {
203 validate::FieldError::Missing { .. } => Self::Missing,
204 validate::FieldError::NonFinite { .. } => Self::NonFinite,
205 validate::FieldError::NotPositive { .. } => Self::NotPositive,
206 validate::FieldError::Negative { .. } => Self::Negative,
207 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
208 validate::FieldError::FloatParse { .. } => Self::FloatParse,
209 validate::FieldError::IntParse { .. } => Self::IntParse,
210 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
211 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum ObservablesError {
219 InvalidInput {
222 field: &'static str,
224 kind: ObservablesInputErrorKind,
226 },
227 NoEphemeris,
229 Ephemeris(Error),
231}
232
233impl core::fmt::Display for ObservablesError {
234 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
235 match self {
236 Self::InvalidInput { field, kind } => {
237 write!(f, "invalid observable input {field}: {kind}")
238 }
239 Self::NoEphemeris => write!(f, "no ephemeris"),
240 Self::Ephemeris(err) => write!(f, "{err}"),
241 }
242 }
243}
244
245impl std::error::Error for ObservablesError {}
246
247impl ObservableStateBatch {
248 pub fn with_capacity(capacity: usize) -> Self {
250 Self {
251 positions_ecef_m: Vec::with_capacity(capacity),
252 clocks_s: Vec::with_capacity(capacity),
253 element_results: Vec::with_capacity(capacity),
254 }
255 }
256
257 pub fn len(&self) -> usize {
259 self.element_results.len()
260 }
261
262 pub fn is_empty(&self) -> bool {
264 self.element_results.is_empty()
265 }
266
267 pub fn element(&self, index: usize) -> Option<Result<ObservableState, &ObservablesError>> {
271 match self.element_results.get(index)? {
272 Ok(()) => Some(Ok(ObservableState {
273 position_ecef_m: self.positions_ecef_m[index],
274 clock_s: self.clocks_s[index],
275 })),
276 Err(error) => Some(Err(error)),
277 }
278 }
279
280 pub fn element_status(&self, index: usize) -> Option<ObservableStateElementStatus> {
284 match self.element_results.get(index)? {
285 Ok(()) => Some(ObservableStateElementStatus::Valid),
286 Err(error) if is_observable_state_gap(error) => Some(ObservableStateElementStatus::Gap),
287 Err(_) => Some(ObservableStateElementStatus::Error),
288 }
289 }
290
291 fn push_state_result(&mut self, result: Result<ObservableState, ObservablesError>) {
292 match result {
293 Ok(state) => {
294 self.positions_ecef_m.push(state.position_ecef_m);
295 self.clocks_s.push(state.clock_s);
296 self.element_results.push(Ok(()));
297 }
298 Err(error) => {
299 self.positions_ecef_m
300 .push(OBSERVABLE_STATE_MISSING_POSITION_ECEF_M);
301 self.clocks_s.push(None);
302 self.element_results.push(Err(error));
303 }
304 }
305 }
306}
307
308pub fn is_observable_state_gap(error: &ObservablesError) -> bool {
314 matches!(
315 error,
316 ObservablesError::NoEphemeris
317 | ObservablesError::Ephemeris(crate::Error::EpochOutOfRange)
318 | ObservablesError::Ephemeris(crate::Error::UnknownSatellite(_))
319 )
320}
321
322#[derive(Debug, Clone, Copy, PartialEq)]
324pub struct PredictOptions {
325 pub carrier_hz: f64,
327 pub light_time: bool,
329 pub sagnac: bool,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub struct TransmitTimeOptions {
336 pub light_time: bool,
338 pub sagnac: bool,
340}
341
342impl Default for TransmitTimeOptions {
343 fn default() -> Self {
344 Self {
345 light_time: true,
346 sagnac: true,
347 }
348 }
349}
350
351impl Default for PredictOptions {
352 fn default() -> Self {
353 Self {
354 carrier_hz: F_L1_HZ,
355 light_time: true,
356 sagnac: true,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq)]
363pub struct ObservableTroposphereCorrection {
364 pub met: Met,
366 pub mapping: MappingModel,
368}
369
370impl Default for ObservableTroposphereCorrection {
371 fn default() -> Self {
372 Self {
373 met: Met::new_unchecked(1013.25, 288.15, 0.5),
374 mapping: MappingModel::Niell,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq)]
381pub enum ObservableIonosphereCorrection<'a> {
382 Broadcast(IonoModel),
384 Ionex(&'a Ionex),
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Default)]
390pub struct ObservableMediaOptions<'a> {
391 pub troposphere: Option<ObservableTroposphereCorrection>,
393 pub ionosphere: Option<ObservableIonosphereCorrection<'a>>,
395}
396
397impl ObservableMediaOptions<'_> {
398 fn is_disabled(self) -> bool {
399 self.troposphere.is_none() && self.ionosphere.is_none()
400 }
401
402 fn needs_instant(self) -> bool {
403 self.troposphere.is_some()
404 || matches!(
405 self.ionosphere,
406 Some(ObservableIonosphereCorrection::Broadcast(_))
407 )
408 }
409
410 fn needs_carrier(self) -> bool {
411 self.ionosphere.is_some()
412 }
413
414 fn needs_ionex_epoch(self) -> bool {
415 matches!(
416 self.ionosphere,
417 Some(ObservableIonosphereCorrection::Ionex(_))
418 )
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Default)]
424pub struct MediaPredictOptions<'a> {
425 pub prediction: PredictOptions,
427 pub media: ObservableMediaOptions<'a>,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq)]
433pub struct AppliedMediaCorrections {
434 pub troposphere_m: f64,
436 pub ionosphere_m: f64,
438 pub total_m: f64,
440}
441
442impl Default for AppliedMediaCorrections {
443 fn default() -> Self {
444 Self {
445 troposphere_m: 0.0,
446 ionosphere_m: 0.0,
447 total_m: 0.0,
448 }
449 }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq)]
454pub struct MediaPredictedObservables {
455 pub prediction: PredictedObservables,
457 pub range_m: f64,
459 pub media: AppliedMediaCorrections,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq)]
471pub struct TransmitTimeSatelliteState {
472 pub signal_flight_time_s: f64,
474 pub transmit_offset_us: i64,
476 pub transmit_time_j2000_s: f64,
478 pub clock_s: Option<f64>,
480 pub transmit_position_ecef_m: [f64; 3],
482 pub position_ecef_m: [f64; 3],
484 pub velocity_m_s: [f64; 3],
486 pub geometric_range_m: f64,
488 pub los_unit: [f64; 3],
490}
491
492#[derive(Debug, Clone, Copy, PartialEq)]
494pub struct PredictedObservables {
495 pub geometric_range_m: f64,
497 pub range_rate_m_s: f64,
499 pub doppler_hz: f64,
501 pub sat_clock_s: Option<f64>,
503 pub elevation_deg: f64,
505 pub azimuth_deg: f64,
513 pub transmit_offset_us: i64,
515 pub transmit_time_j2000_s: f64,
517 pub los_unit: [f64; 3],
519 pub sat_pos_ecef_m: [f64; 3],
521 pub sat_velocity_m_s: [f64; 3],
523}
524
525pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
527 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
528 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
529 validate::finite(
530 civil::j2000_seconds_from_split(jd_whole, jd_fraction),
531 "j2000_seconds",
532 )
533 .map_err(map_input_error)
534}
535
536pub fn observable_media_corrections(
543 receiver: Wgs84Geodetic,
544 elevation_rad: f64,
545 azimuth_rad: f64,
546 t_rx_j2000_s: f64,
547 carrier_hz: f64,
548 options: ObservableMediaOptions<'_>,
549) -> Result<AppliedMediaCorrections, ObservablesError> {
550 if options.is_disabled() {
551 return Ok(AppliedMediaCorrections::default());
552 }
553 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
554 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
555 if options.needs_carrier() {
556 validate::finite_positive(carrier_hz, "carrier_hz").map_err(map_input_error)?;
557 }
558 let epoch = if options.needs_instant() {
559 Some(media_instant(t_rx_j2000_s)?)
560 } else {
561 None
562 };
563 let ionex_epoch_j2000_s = if options.needs_ionex_epoch() {
564 Some(rounded_j2000_seconds(t_rx_j2000_s)?)
565 } else {
566 None
567 };
568
569 let troposphere_m = match options.troposphere {
570 Some(troposphere) => {
571 let epoch = epoch.expect("troposphere media requires an epoch");
572 let zenith = tropo_zenith(TropoModel::Saastamoinen, receiver, troposphere.met)
573 .map_err(map_media_error)?;
574 let mapping = tropo_mapping(troposphere.mapping, elevation_rad, receiver, epoch)
575 .map_err(map_media_error)?;
576 let delay_m = zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet;
577 validate::finite(delay_m, "media.troposphere_m").map_err(map_input_error)?;
578 delay_m
579 }
580 None => 0.0,
581 };
582
583 let ionosphere_m = match options.ionosphere {
584 Some(ObservableIonosphereCorrection::Broadcast(model)) => {
585 let epoch = epoch.expect("broadcast ionosphere media requires an epoch");
586 let delay_m = ionosphere_delay(
587 receiver,
588 elevation_rad,
589 azimuth_rad,
590 epoch,
591 carrier_hz,
592 &model,
593 )
594 .map_err(map_media_error)?;
595 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
596 delay_m
597 }
598 Some(ObservableIonosphereCorrection::Ionex(ionex)) => {
599 let ionex_epoch_j2000_s =
600 ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
601 let delay_m = ionex_slant_delay(
602 ionex,
603 receiver,
604 elevation_rad,
605 azimuth_rad,
606 ionex_epoch_j2000_s,
607 carrier_hz,
608 )
609 .map_err(map_media_error)?;
610 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
611 delay_m
612 }
613 None => 0.0,
614 };
615
616 let total_m = troposphere_m + ionosphere_m;
617 validate::finite(total_m, "media.total_m").map_err(map_input_error)?;
618 Ok(AppliedMediaCorrections {
619 troposphere_m,
620 ionosphere_m,
621 total_m,
622 })
623}
624
625pub fn observable_states_at_j2000_s(
629 source: &dyn ObservableEphemerisSource,
630 satellites: &[GnssSatelliteId],
631 epochs_j2000_s: &[f64],
632) -> Result<ObservableStateBatch, ObservablesError> {
633 source.observable_states_at_j2000_s(satellites, epochs_j2000_s)
634}
635
636pub fn observable_states_at_shared_j2000_s(
641 source: &dyn ObservableEphemerisSource,
642 satellites: &[GnssSatelliteId],
643 epoch_j2000_s: f64,
644) -> ObservableStateBatch {
645 source.observable_states_at_shared_j2000_s(satellites, epoch_j2000_s)
646}
647
648pub fn transmit_time_satellite_state(
656 source: &dyn ObservableEphemerisSource,
657 sat: GnssSatelliteId,
658 receiver_ecef_m: [f64; 3],
659 t_rx_j2000_s: f64,
660 options: TransmitTimeOptions,
661) -> Result<TransmitTimeSatelliteState, ObservablesError> {
662 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
663 let predict_options = PredictOptions {
664 carrier_hz: F_L1_HZ,
665 light_time: options.light_time,
666 sagnac: options.sagnac,
667 };
668 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
669
670 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
671 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
672 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
673 let range = geometric_range_m([dx, dy, dz])?;
674 let los = [dx / range, dy / range, dz / range];
675
676 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
677 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
678 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
679
680 Ok(TransmitTimeSatelliteState {
681 signal_flight_time_s: solved.tau_s,
682 transmit_offset_us: solved.transmit_offset_us,
683 transmit_time_j2000_s: solved.transmit_time_j2000_s,
684 clock_s: solved.state.clock_s,
685 transmit_position_ecef_m: solved.state.position_ecef_m,
686 position_ecef_m: solved.sat_rot_ecef_m,
687 velocity_m_s: velocity_rot,
688 geometric_range_m: range,
689 los_unit: los,
690 })
691}
692
693pub fn predict(
695 source: &dyn ObservableEphemerisSource,
696 sat: GnssSatelliteId,
697 receiver_ecef_m: [f64; 3],
698 t_rx_j2000_s: f64,
699 options: PredictOptions,
700) -> Result<PredictedObservables, ObservablesError> {
701 let (prediction, _) = predict_core(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
702 Ok(prediction)
703}
704
705pub fn predict_with_media(
713 source: &dyn ObservableEphemerisSource,
714 sat: GnssSatelliteId,
715 receiver_ecef_m: [f64; 3],
716 t_rx_j2000_s: f64,
717 options: MediaPredictOptions<'_>,
718) -> Result<MediaPredictedObservables, ObservablesError> {
719 let (prediction, topocentric) = predict_core(
720 source,
721 sat,
722 receiver_ecef_m,
723 t_rx_j2000_s,
724 options.prediction,
725 )?;
726 if options.media.is_disabled() {
727 return Ok(MediaPredictedObservables {
728 range_m: prediction.geometric_range_m,
729 prediction,
730 media: AppliedMediaCorrections::default(),
731 });
732 }
733 let media = observable_media_corrections(
734 topocentric.receiver,
735 topocentric.elevation_rad,
736 topocentric.azimuth_rad,
737 t_rx_j2000_s,
738 options.prediction.carrier_hz,
739 options.media,
740 )?;
741 let range_m = prediction.geometric_range_m + media.total_m;
742 validate::finite(range_m, "range_m").map_err(map_input_error)?;
743 Ok(MediaPredictedObservables {
744 prediction,
745 range_m,
746 media,
747 })
748}
749
750fn predict_core(
751 source: &dyn ObservableEphemerisSource,
752 sat: GnssSatelliteId,
753 receiver_ecef_m: [f64; 3],
754 t_rx_j2000_s: f64,
755 options: PredictOptions,
756) -> Result<(PredictedObservables, TopocentricGeometry), ObservablesError> {
757 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
758 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
759
760 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
761 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
762 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
763 let range = geometric_range_m([dx, dy, dz])?;
764 let los = [dx / range, dy / range, dz / range];
765
766 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
767 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
768 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
769 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
770 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
771 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
772 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
773 let topocentric = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
774
775 Ok((
776 PredictedObservables {
777 geometric_range_m: range,
778 range_rate_m_s: range_rate,
779 doppler_hz,
780 sat_clock_s: solved.state.clock_s,
781 elevation_deg: topocentric.elevation_deg,
782 azimuth_deg: topocentric.azimuth_deg,
783 transmit_offset_us: solved.transmit_offset_us,
784 transmit_time_j2000_s: solved.transmit_time_j2000_s,
785 los_unit: los,
786 sat_pos_ecef_m: solved.sat_rot_ecef_m,
787 sat_velocity_m_s: velocity_rot,
788 },
789 topocentric,
790 ))
791}
792
793pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
800
801pub fn predict_batch(
809 source: &dyn ObservableEphemerisSource,
810 requests: &[PredictRequest],
811 options: PredictOptions,
812) -> Vec<Result<PredictedObservables, ObservablesError>> {
813 requests
814 .iter()
815 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
816 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
817 })
818 .collect()
819}
820
821pub fn predict_batch_with_media(
826 source: &dyn ObservableEphemerisSource,
827 requests: &[PredictRequest],
828 options: MediaPredictOptions<'_>,
829) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
830 requests
831 .iter()
832 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
833 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
834 })
835 .collect()
836}
837
838pub fn predict_batch_parallel(
848 source: &(dyn ObservableEphemerisSource + Sync),
849 requests: &[PredictRequest],
850 options: PredictOptions,
851) -> Vec<Result<PredictedObservables, ObservablesError>> {
852 requests
853 .par_iter()
854 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
855 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
856 })
857 .collect()
858}
859
860pub fn predict_batch_with_media_parallel(
865 source: &(dyn ObservableEphemerisSource + Sync),
866 requests: &[PredictRequest],
867 options: MediaPredictOptions<'_>,
868) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
869 requests
870 .par_iter()
871 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
872 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
873 })
874 .collect()
875}
876
877#[derive(Debug, Clone, Copy, PartialEq)]
880pub struct RangePredictionRequest {
881 pub sat: GnssSatelliteId,
883 pub receiver_ecef_m: [f64; 3],
885 pub t_rx_j2000_s: f64,
887}
888
889#[derive(Debug, Clone, Copy, PartialEq)]
894pub struct RangePrediction {
895 pub geometric_range_m: f64,
897 pub sat_clock_s: Option<f64>,
899 pub transmit_time_j2000_s: f64,
901 pub sat_pos_ecef_m: [f64; 3],
903}
904
905#[derive(Debug, Clone, Copy, PartialEq)]
907pub struct MediaRangePrediction {
908 pub prediction: RangePrediction,
910 pub range_m: f64,
912 pub media: AppliedMediaCorrections,
914}
915
916pub fn predict_ranges(
941 source: &dyn ObservableEphemerisSource,
942 requests: &[RangePredictionRequest],
943 options: PredictOptions,
944 out: &mut [RangePrediction],
945) -> Result<(), ObservablesError> {
946 if out.len() != requests.len() {
947 return Err(ObservablesError::InvalidInput {
948 field: "out",
949 kind: ObservablesInputErrorKind::OutOfRange,
950 });
951 }
952 for (request, slot) in requests.iter().zip(out.iter_mut()) {
953 *slot = range_prediction_at_rx(
954 source,
955 request.sat,
956 request.receiver_ecef_m,
957 request.t_rx_j2000_s,
958 options,
959 )?;
960 }
961 Ok(())
962}
963
964pub fn predict_ranges_with_media(
970 source: &dyn ObservableEphemerisSource,
971 requests: &[RangePredictionRequest],
972 options: MediaPredictOptions<'_>,
973 out: &mut [MediaRangePrediction],
974) -> Result<(), ObservablesError> {
975 if out.len() != requests.len() {
976 return Err(ObservablesError::InvalidInput {
977 field: "out",
978 kind: ObservablesInputErrorKind::OutOfRange,
979 });
980 }
981 for (request, slot) in requests.iter().zip(out.iter_mut()) {
982 if options.media.is_disabled() {
983 let prediction = range_prediction_at_rx(
984 source,
985 request.sat,
986 request.receiver_ecef_m,
987 request.t_rx_j2000_s,
988 options.prediction,
989 )?;
990 *slot = MediaRangePrediction {
991 range_m: prediction.geometric_range_m,
992 prediction,
993 media: AppliedMediaCorrections::default(),
994 };
995 continue;
996 }
997 let (prediction, topocentric) = range_prediction_core(
998 source,
999 request.sat,
1000 request.receiver_ecef_m,
1001 request.t_rx_j2000_s,
1002 options.prediction,
1003 )?;
1004 let media = observable_media_corrections(
1005 topocentric.receiver,
1006 topocentric.elevation_rad,
1007 topocentric.azimuth_rad,
1008 request.t_rx_j2000_s,
1009 options.prediction.carrier_hz,
1010 options.media,
1011 )?;
1012 let range_m = prediction.geometric_range_m + media.total_m;
1013 validate::finite(range_m, "range_m").map_err(map_input_error)?;
1014 *slot = MediaRangePrediction {
1015 prediction,
1016 range_m,
1017 media,
1018 };
1019 }
1020 Ok(())
1021}
1022
1023fn range_prediction_at_rx(
1032 source: &dyn ObservableEphemerisSource,
1033 sat: GnssSatelliteId,
1034 receiver_ecef_m: [f64; 3],
1035 t_rx_j2000_s: f64,
1036 options: PredictOptions,
1037) -> Result<RangePrediction, ObservablesError> {
1038 let (prediction, _, _) =
1039 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1040 Ok(prediction)
1041}
1042
1043fn range_prediction_core(
1044 source: &dyn ObservableEphemerisSource,
1045 sat: GnssSatelliteId,
1046 receiver_ecef_m: [f64; 3],
1047 t_rx_j2000_s: f64,
1048 options: PredictOptions,
1049) -> Result<(RangePrediction, TopocentricGeometry), ObservablesError> {
1050 let (prediction, line_of_sight_m, range) =
1051 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1052 let topocentric = topocentric(receiver_ecef_m, line_of_sight_m, range)?;
1053 Ok((prediction, topocentric))
1054}
1055
1056fn range_prediction_state(
1057 source: &dyn ObservableEphemerisSource,
1058 sat: GnssSatelliteId,
1059 receiver_ecef_m: [f64; 3],
1060 t_rx_j2000_s: f64,
1061 options: PredictOptions,
1062) -> Result<(RangePrediction, [f64; 3], f64), ObservablesError> {
1063 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
1064 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1065 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1066 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1067 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1068 let line_of_sight_m = [dx, dy, dz];
1069 let range = geometric_range_m([dx, dy, dz])?;
1070 Ok((
1071 RangePrediction {
1072 geometric_range_m: range,
1073 sat_clock_s: solved.state.clock_s,
1074 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1075 sat_pos_ecef_m: solved.sat_rot_ecef_m,
1076 },
1077 line_of_sight_m,
1078 range,
1079 ))
1080}
1081
1082#[derive(Debug, Clone, Copy)]
1083struct SolvedTransmitTime {
1084 tau_s: f64,
1085 transmit_offset_us: i64,
1086 transmit_time_j2000_s: f64,
1087 state: ObservableState,
1088 sat_rot_ecef_m: [f64; 3],
1089}
1090
1091fn solve_transmit_time(
1092 source: &dyn ObservableEphemerisSource,
1093 sat: GnssSatelliteId,
1094 receiver_ecef_m: [f64; 3],
1095 t_rx_j2000_s: f64,
1096 options: PredictOptions,
1097) -> Result<SolvedTransmitTime, ObservablesError> {
1098 if !options.light_time {
1099 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
1100 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
1101 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1102 return Ok(SolvedTransmitTime {
1103 tau_s: 0.0,
1104 transmit_offset_us: 0,
1105 transmit_time_j2000_s: t_rx_j2000_s,
1106 state,
1107 sat_rot_ecef_m: sat_rot,
1108 });
1109 }
1110
1111 let mut tau = 0.0;
1112 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1113 let transmit_offset_us = microseconds_from_tau(tau);
1114 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1115 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1116 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
1117 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1118 let dx = sat_rot[0] - receiver_ecef_m[0];
1119 let dy = sat_rot[1] - receiver_ecef_m[1];
1120 let dz = sat_rot[2] - receiver_ecef_m[2];
1121 let range = geometric_range_m([dx, dy, dz])?;
1122 let new_tau = range / C_M_S;
1123
1124 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1125 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
1126 }
1127
1128 tau = new_tau;
1129 }
1130
1131 unreachable!("fixed transmit-time loop always returns on its last iteration")
1132}
1133
1134fn finalize_transmit_time(
1135 source: &dyn ObservableEphemerisSource,
1136 sat: GnssSatelliteId,
1137 t_rx_j2000_s: f64,
1138 tau: f64,
1139 sagnac: bool,
1140) -> Result<SolvedTransmitTime, ObservablesError> {
1141 let transmit_offset_us = microseconds_from_tau(tau);
1142 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1143 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
1144 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1145 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
1146 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1147 Ok(SolvedTransmitTime {
1148 tau_s: tau,
1149 transmit_offset_us,
1150 transmit_time_j2000_s: t_tx,
1151 state,
1152 sat_rot_ecef_m: sat_rot,
1153 })
1154}
1155
1156fn microseconds_from_tau(tau_s: f64) -> i64 {
1157 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
1158}
1159
1160fn satellite_velocity(
1161 source: &dyn ObservableEphemerisSource,
1162 sat: GnssSatelliteId,
1163 t_tx_j2000_s: f64,
1164) -> Result<[f64; 3], ObservablesError> {
1165 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
1166 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
1167 let denom = 2.0 * FD_HALF_S;
1168 let velocity = [
1169 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
1170 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
1171 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
1172 ];
1173 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
1174}
1175
1176fn validate_predict_inputs(
1177 receiver_ecef_m: [f64; 3],
1178 t_rx_j2000_s: f64,
1179 options: PredictOptions,
1180) -> Result<(), ObservablesError> {
1181 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1182 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1183 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
1184 Ok(())
1185}
1186
1187fn validate_transmit_time_inputs(
1188 receiver_ecef_m: [f64; 3],
1189 t_rx_j2000_s: f64,
1190) -> Result<(), ObservablesError> {
1191 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1192 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1193 Ok(())
1194}
1195
1196fn validated_state_at_j2000_s(
1197 source: &dyn ObservableEphemerisSource,
1198 sat: GnssSatelliteId,
1199 t_j2000_s: f64,
1200) -> Result<ObservableState, ObservablesError> {
1201 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
1202 validate_observable_state(&state)?;
1203 Ok(state)
1204}
1205
1206fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
1207 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
1208 .map_err(map_input_error)?;
1209 if let Some(clock_s) = state.clock_s {
1210 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
1211 }
1212 Ok(())
1213}
1214
1215fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
1216 let range = (delta_ecef_m[0] * delta_ecef_m[0]
1217 + delta_ecef_m[1] * delta_ecef_m[1]
1218 + delta_ecef_m[2] * delta_ecef_m[2])
1219 .sqrt();
1220 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
1221}
1222
1223fn map_input_error(error: validate::FieldError) -> ObservablesError {
1224 ObservablesError::InvalidInput {
1225 field: error.field(),
1226 kind: ObservablesInputErrorKind::from(&error),
1227 }
1228}
1229
1230fn invalid_observable_input(
1231 field: &'static str,
1232 kind: ObservablesInputErrorKind,
1233) -> ObservablesError {
1234 ObservablesError::InvalidInput { field, kind }
1235}
1236
1237fn media_instant(t_rx_j2000_s: f64) -> Result<Instant, ObservablesError> {
1238 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1239 let days = (t_rx_j2000_s / SECONDS_PER_DAY).floor();
1240 let seconds_into_day = t_rx_j2000_s - days * SECONDS_PER_DAY;
1241 let fraction = seconds_into_day / SECONDS_PER_DAY;
1242 let split = JulianDateSplit::new(J2000_JD + days, fraction).map_err(|_| {
1243 invalid_observable_input("t_rx_j2000_s", ObservablesInputErrorKind::OutOfRange)
1244 })?;
1245 Ok(Instant::from_julian_date(TimeScale::Gpst, split))
1246}
1247
1248fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ObservablesError> {
1249 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1250 let rounded = t_rx_j2000_s.round();
1251 if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
1252 return Err(invalid_observable_input(
1253 "t_rx_j2000_s",
1254 ObservablesInputErrorKind::OutOfRange,
1255 ));
1256 }
1257 Ok(rounded as i64)
1258}
1259
1260fn map_media_error(error: Error) -> ObservablesError {
1261 match error {
1262 Error::InvalidInput(message) => map_media_invalid_input(&message),
1263 _ => invalid_observable_input("media", ObservablesInputErrorKind::OutOfRange),
1264 }
1265}
1266
1267fn map_media_invalid_input(message: &str) -> ObservablesError {
1268 let kind = if message.ends_with("not finite") {
1269 ObservablesInputErrorKind::NonFinite
1270 } else if message.ends_with("not positive") {
1271 ObservablesInputErrorKind::NotPositive
1272 } else if message.ends_with("negative") {
1273 ObservablesInputErrorKind::Negative
1274 } else {
1275 ObservablesInputErrorKind::OutOfRange
1276 };
1277 let field = if message.starts_with("elevation_rad ") {
1278 "media.elevation_rad"
1279 } else if message.starts_with("receiver.lat_rad ") {
1280 "media.receiver.lat_rad"
1281 } else if message.starts_with("receiver.lon_rad ") {
1282 "media.receiver.lon_rad"
1283 } else if message.starts_with("receiver.height_m ") {
1284 "media.receiver.height_m"
1285 } else if message.starts_with("pressure_hpa ") {
1286 "media.pressure_hpa"
1287 } else if message.starts_with("temperature_k ") {
1288 "media.temperature_k"
1289 } else if message.starts_with("relative_humidity ") {
1290 "media.relative_humidity"
1291 } else if message.starts_with("frequency_hz ") {
1292 "media.carrier_hz"
1293 } else if message.starts_with("azimuth_rad ") {
1294 "media.azimuth_rad"
1295 } else {
1296 "media"
1297 };
1298 invalid_observable_input(field, kind)
1299}
1300
1301fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
1302 let sagnac = if apply {
1303 SagnacRecipe::ClosedFormZRotation
1304 } else {
1305 SagnacRecipe::Off
1306 };
1307 crate::estimation::substrate::range::rotate_transmit_satellite(
1308 sagnac,
1309 pos,
1310 tau_s,
1311 OMEGA_E_DOT_RAD_S,
1312 )
1313}
1314
1315#[derive(Debug, Clone, Copy, PartialEq)]
1316struct TopocentricGeometry {
1317 receiver: Wgs84Geodetic,
1318 elevation_rad: f64,
1319 azimuth_rad: f64,
1320 elevation_deg: f64,
1321 azimuth_deg: f64,
1322}
1323
1324fn topocentric(
1325 receiver_ecef_m: [f64; 3],
1326 delta_ecef_m: [f64; 3],
1327 range_m: f64,
1328) -> Result<TopocentricGeometry, ObservablesError> {
1329 let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
1330 receiver_ecef_m[0] / KM_TO_M,
1331 receiver_ecef_m[1] / KM_TO_M,
1332 receiver_ecef_m[2] / KM_TO_M,
1333 )
1334 .map_err(|_| ObservablesError::InvalidInput {
1335 field: "receiver_ecef_m",
1336 kind: ObservablesInputErrorKind::OutOfRange,
1337 })?;
1338 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1340 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1341 let receiver = Wgs84Geodetic::new(lat, lon, height_km * KM_TO_M).map_err(|_| {
1342 ObservablesError::InvalidInput {
1343 field: "receiver_ecef_m",
1344 kind: ObservablesInputErrorKind::OutOfRange,
1345 }
1346 })?;
1347
1348 let sl = lat.sin();
1349 let cl = lat.cos();
1350 let so = lon.sin();
1351 let co = lon.cos();
1352
1353 let dx = delta_ecef_m[0];
1354 let dy = delta_ecef_m[1];
1355 let dz = delta_ecef_m[2];
1356
1357 let e = -so * dx + co * dy;
1358 let n = -sl * co * dx - sl * so * dy + cl * dz;
1359 let u = cl * co * dx + cl * so * dy + sl * dz;
1360
1361 let horiz_sq = e * e + n * n;
1366 let (azimuth_rad, mut azimuth_deg) = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
1367 (0.0, 0.0)
1368 } else {
1369 let raw_azimuth_rad = e.atan2(n);
1370 (
1371 if raw_azimuth_rad < 0.0 {
1372 raw_azimuth_rad + 2.0 * PI
1373 } else {
1374 raw_azimuth_rad
1375 },
1376 raw_azimuth_rad * DEGREES_PER_SEMICIRCLE / PI,
1377 )
1378 };
1379 if azimuth_deg < 0.0 {
1380 azimuth_deg += DEGREES_PER_CIRCLE;
1381 }
1382 let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
1387 let elevation_rad = sin_elevation.asin();
1388 let elevation_deg = elevation_rad * DEGREES_PER_SEMICIRCLE / PI;
1389
1390 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
1391 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
1392 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
1393 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
1394 Ok(TopocentricGeometry {
1395 receiver,
1396 elevation_rad,
1397 azimuth_rad,
1398 elevation_deg,
1399 azimuth_deg,
1400 })
1401}
1402
1403#[cfg(test)]
1404mod public_api_tests {
1405 use super::*;
1406 use crate::{GnssSatelliteId, GnssSystem};
1407
1408 #[derive(Debug, Clone, Copy)]
1409 struct StaticSource {
1410 state: ObservableState,
1411 }
1412
1413 impl ObservableEphemerisSource for StaticSource {
1414 fn observable_state_at_j2000_s(
1415 &self,
1416 _sat: GnssSatelliteId,
1417 _t_j2000_s: f64,
1418 ) -> Result<ObservableState, ObservablesError> {
1419 Ok(self.state)
1420 }
1421 }
1422
1423 #[test]
1424 fn predict_ranges_matches_transmit_time_loop_bitwise() {
1425 let source = StaticSource {
1426 state: ObservableState {
1427 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1428 clock_s: Some(1.25e-6),
1429 },
1430 };
1431 let options = PredictOptions {
1432 carrier_hz: F_L1_HZ,
1433 light_time: true,
1434 sagnac: true,
1435 };
1436 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1437 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1438 let requests = [
1439 RangePredictionRequest {
1440 sat: sat1,
1441 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1442 t_rx_j2000_s: 646_272_000.0,
1443 },
1444 RangePredictionRequest {
1445 sat: sat2,
1446 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1447 t_rx_j2000_s: 646_272_060.0,
1448 },
1449 ];
1450 let mut out = [RangePrediction {
1451 geometric_range_m: 0.0,
1452 sat_clock_s: None,
1453 transmit_time_j2000_s: 0.0,
1454 sat_pos_ecef_m: [0.0; 3],
1455 }; 2];
1456 predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
1457
1458 let tt_options = TransmitTimeOptions {
1459 light_time: options.light_time,
1460 sagnac: options.sagnac,
1461 };
1462 for (request, got) in requests.iter().zip(out.iter()) {
1463 let single = transmit_time_satellite_state(
1464 &source,
1465 request.sat,
1466 request.receiver_ecef_m,
1467 request.t_rx_j2000_s,
1468 tt_options,
1469 )
1470 .expect("single transmit-time state");
1471 assert_eq!(
1472 got.geometric_range_m.to_bits(),
1473 single.geometric_range_m.to_bits()
1474 );
1475 assert_eq!(
1476 got.transmit_time_j2000_s.to_bits(),
1477 single.transmit_time_j2000_s.to_bits()
1478 );
1479 assert_eq!(
1480 got.sat_clock_s.map(f64::to_bits),
1481 single.clock_s.map(f64::to_bits)
1482 );
1483 assert_eq!(
1484 got.sat_pos_ecef_m.map(f64::to_bits),
1485 single.position_ecef_m.map(f64::to_bits)
1486 );
1487 }
1488 }
1489
1490 #[test]
1491 fn predict_ranges_batch_matches_scalar_calls_bitwise() {
1492 let source = StaticSource {
1495 state: ObservableState {
1496 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1497 clock_s: Some(1.25e-6),
1498 },
1499 };
1500 let options = PredictOptions::default();
1501 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1502 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1503 let requests = [
1504 RangePredictionRequest {
1505 sat: sat1,
1506 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1507 t_rx_j2000_s: 646_272_000.0,
1508 },
1509 RangePredictionRequest {
1510 sat: sat2,
1511 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1512 t_rx_j2000_s: 646_272_060.0,
1513 },
1514 RangePredictionRequest {
1515 sat: sat1,
1516 receiver_ecef_m: [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1517 t_rx_j2000_s: 646_272_120.0,
1518 },
1519 ];
1520 let zero = RangePrediction {
1521 geometric_range_m: 0.0,
1522 sat_clock_s: None,
1523 transmit_time_j2000_s: 0.0,
1524 sat_pos_ecef_m: [0.0; 3],
1525 };
1526
1527 let mut batch = [zero; 3];
1528 predict_ranges(&source, &requests, options, &mut batch).expect("batch ranges");
1529
1530 for (i, request) in requests.iter().enumerate() {
1531 let mut single = [zero; 1];
1532 predict_ranges(&source, std::slice::from_ref(request), options, &mut single)
1533 .expect("single range");
1534 assert_eq!(
1535 batch[i].geometric_range_m.to_bits(),
1536 single[0].geometric_range_m.to_bits()
1537 );
1538 assert_eq!(
1539 batch[i].transmit_time_j2000_s.to_bits(),
1540 single[0].transmit_time_j2000_s.to_bits()
1541 );
1542 assert_eq!(
1543 batch[i].sat_clock_s.map(f64::to_bits),
1544 single[0].sat_clock_s.map(f64::to_bits)
1545 );
1546 assert_eq!(
1547 batch[i].sat_pos_ecef_m.map(f64::to_bits),
1548 single[0].sat_pos_ecef_m.map(f64::to_bits)
1549 );
1550 }
1551 }
1552
1553 #[test]
1554 fn predict_ranges_rejects_length_mismatch() {
1555 let source = StaticSource {
1556 state: ObservableState {
1557 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1558 clock_s: None,
1559 },
1560 };
1561 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1562 let requests = [RangePredictionRequest {
1563 sat,
1564 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1565 t_rx_j2000_s: 646_272_000.0,
1566 }];
1567 let mut out: [RangePrediction; 0] = [];
1568 let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
1569 .expect_err("length mismatch must fail");
1570 match err {
1571 ObservablesError::InvalidInput { field, kind } => {
1572 assert_eq!(field, "out");
1573 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
1574 }
1575 other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
1576 }
1577 }
1578
1579 #[test]
1580 fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
1581 let rx = [
1590 4_509_179.095_483_66,
1591 275_556.225_682_215_9,
1592 4_487_348.408_865_919,
1593 ];
1594 let (lat_deg, lon_deg, _h) =
1595 itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
1596 .expect("receiver geodetic conversion");
1597 assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
1598
1599 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1602 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1603 let (sl, cl) = lat.sin_cos();
1604 let (so, co) = lon.sin_cos();
1605 let up = [cl * co, cl * so, sl];
1606
1607 let d = 20_000_000.0_f64;
1608 let delta = [up[0] * d, up[1] * d, up[2] * d];
1609 let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1610 let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
1613 assert!(
1614 u / range > 1.0,
1615 "test geometry must overshoot the asin domain"
1616 );
1617
1618 let geometry = topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
1619 assert!(geometry.elevation_deg.is_finite());
1620 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
1621 }
1622
1623 #[test]
1624 fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
1625 let source = StaticSource {
1626 state: ObservableState {
1627 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1628 clock_s: Some(1.25e-6),
1629 },
1630 };
1631 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1632 let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
1633 let state = transmit_time_satellite_state(
1634 &source,
1635 sat,
1636 rx,
1637 646_272_000.0,
1638 TransmitTimeOptions {
1639 light_time: false,
1640 sagnac: true,
1641 },
1642 )
1643 .expect("state");
1644 let prediction = predict(
1645 &source,
1646 sat,
1647 rx,
1648 646_272_000.0,
1649 PredictOptions {
1650 carrier_hz: F_L1_HZ,
1651 light_time: false,
1652 sagnac: true,
1653 },
1654 )
1655 .expect("prediction");
1656
1657 assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
1658 assert_eq!(state.transmit_offset_us, 0);
1659 assert_eq!(
1660 state.transmit_time_j2000_s.to_bits(),
1661 646_272_000.0f64.to_bits()
1662 );
1663 assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
1664 assert_eq!(
1665 state.transmit_position_ecef_m.map(f64::to_bits),
1666 source.state.position_ecef_m.map(f64::to_bits)
1667 );
1668 assert_eq!(
1669 state.position_ecef_m.map(f64::to_bits),
1670 prediction.sat_pos_ecef_m.map(f64::to_bits)
1671 );
1672 assert_eq!(
1673 state.velocity_m_s.map(f64::to_bits),
1674 prediction.sat_velocity_m_s.map(f64::to_bits)
1675 );
1676 assert_eq!(
1677 state.geometric_range_m.to_bits(),
1678 prediction.geometric_range_m.to_bits()
1679 );
1680 assert_eq!(
1681 state.los_unit.map(f64::to_bits),
1682 prediction.los_unit.map(f64::to_bits)
1683 );
1684 }
1685}
1686
1687#[cfg(test)]
1688mod media_validation_tests {
1689 use super::*;
1695 use crate::astro::time::civil::split_julian_date_from_j2000_seconds;
1696 use crate::ionex::TecGridSamples;
1697 use crate::GnssSystem;
1698
1699 const T_RX_J2000_S: f64 = 646_272_000.0;
1700 const T_RX_J2000_I64: i64 = 646_272_000;
1701
1702 #[derive(Debug, Clone, Copy)]
1703 struct StaticSource {
1704 state: ObservableState,
1705 }
1706
1707 impl ObservableEphemerisSource for StaticSource {
1708 fn observable_state_at_j2000_s(
1709 &self,
1710 _sat: GnssSatelliteId,
1711 _t_j2000_s: f64,
1712 ) -> Result<ObservableState, ObservablesError> {
1713 Ok(self.state)
1714 }
1715 }
1716
1717 fn epoch() -> Instant {
1718 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(T_RX_J2000_I64);
1719 Instant::from_julian_date(
1720 TimeScale::Gpst,
1721 JulianDateSplit::new(jd_whole, fraction).expect("valid media epoch"),
1722 )
1723 }
1724
1725 fn receiver() -> Wgs84Geodetic {
1726 Wgs84Geodetic::new(0.0, 0.0, 0.0).expect("valid receiver")
1727 }
1728
1729 fn met() -> Met {
1730 Met::new(1013.25, 288.15, 0.5).expect("valid met")
1731 }
1732
1733 fn klobuchar_model() -> IonoModel {
1734 IonoModel::Klobuchar(crate::ionex::KlobucharParams {
1735 alpha: [0.0; 4],
1736 beta: [0.0; 4],
1737 })
1738 }
1739
1740 fn ionex() -> Ionex {
1741 let map = vec![
1742 vec![12.0, 12.0, 12.0],
1743 vec![12.0, 12.0, 12.0],
1744 vec![12.0, 12.0, 12.0],
1745 ];
1746 Ionex::from_samples(TecGridSamples {
1747 map_epochs: vec![epoch()],
1748 lat_nodes_deg: vec![90.0, 0.0, -90.0],
1749 lon_nodes_deg: vec![-180.0, 0.0, 180.0],
1750 dlat_deg: -90.0,
1751 dlon_deg: 180.0,
1752 shell_height_km: 450.0,
1753 base_radius_km: 6371.0,
1754 exponent: 0,
1755 tec_maps: vec![map],
1756 rms_maps: Vec::new(),
1757 })
1758 .expect("valid IONEX samples")
1759 }
1760
1761 fn direct_troposphere(elevation_rad: f64) -> f64 {
1762 let zenith =
1763 tropo_zenith(TropoModel::Saastamoinen, receiver(), met()).expect("zenith delay");
1764 let mapping = tropo_mapping(MappingModel::Niell, elevation_rad, receiver(), epoch())
1765 .expect("mapping");
1766 zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
1767 }
1768
1769 fn assert_bits_eq(label: &str, got: f64, expected: f64) {
1770 assert_eq!(
1771 got.to_bits(),
1772 expected.to_bits(),
1773 "{label}: got {got:?}, expected {expected:?}"
1774 );
1775 }
1776
1777 fn assert_prediction_bits_eq(got: &PredictedObservables, expected: &PredictedObservables) {
1778 assert_bits_eq(
1779 "geometric range",
1780 got.geometric_range_m,
1781 expected.geometric_range_m,
1782 );
1783 assert_bits_eq("range-rate", got.range_rate_m_s, expected.range_rate_m_s);
1784 assert_bits_eq("Doppler", got.doppler_hz, expected.doppler_hz);
1785 assert_eq!(
1786 got.sat_clock_s.map(f64::to_bits),
1787 expected.sat_clock_s.map(f64::to_bits)
1788 );
1789 assert_bits_eq("elevation", got.elevation_deg, expected.elevation_deg);
1790 assert_bits_eq("azimuth", got.azimuth_deg, expected.azimuth_deg);
1791 assert_eq!(got.transmit_offset_us, expected.transmit_offset_us);
1792 assert_bits_eq(
1793 "transmit time",
1794 got.transmit_time_j2000_s,
1795 expected.transmit_time_j2000_s,
1796 );
1797 for k in 0..3 {
1798 assert_bits_eq("los", got.los_unit[k], expected.los_unit[k]);
1799 assert_bits_eq(
1800 "satellite position",
1801 got.sat_pos_ecef_m[k],
1802 expected.sat_pos_ecef_m[k],
1803 );
1804 assert_bits_eq(
1805 "satellite velocity",
1806 got.sat_velocity_m_s[k],
1807 expected.sat_velocity_m_s[k],
1808 );
1809 }
1810 }
1811
1812 fn assert_range_prediction_bits_eq(got: &RangePrediction, expected: &RangePrediction) {
1813 assert_bits_eq(
1814 "range geometric",
1815 got.geometric_range_m,
1816 expected.geometric_range_m,
1817 );
1818 assert_eq!(
1819 got.sat_clock_s.map(f64::to_bits),
1820 expected.sat_clock_s.map(f64::to_bits)
1821 );
1822 assert_bits_eq(
1823 "range transmit time",
1824 got.transmit_time_j2000_s,
1825 expected.transmit_time_j2000_s,
1826 );
1827 for k in 0..3 {
1828 assert_bits_eq(
1829 "range satellite position",
1830 got.sat_pos_ecef_m[k],
1831 expected.sat_pos_ecef_m[k],
1832 );
1833 }
1834 }
1835
1836 #[test]
1837 fn media_corrections_match_direct_tropo_and_klobuchar_bits() {
1838 for elevation_deg in [5.0_f64, 15.0, 90.0] {
1839 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
1840 let azimuth_rad = 0.25;
1841 let options = ObservableMediaOptions {
1842 troposphere: Some(ObservableTroposphereCorrection {
1843 met: met(),
1844 mapping: MappingModel::Niell,
1845 }),
1846 ionosphere: Some(ObservableIonosphereCorrection::Broadcast(klobuchar_model())),
1847 };
1848 let got = observable_media_corrections(
1849 receiver(),
1850 elevation_rad,
1851 azimuth_rad,
1852 T_RX_J2000_S,
1853 F_L1_HZ,
1854 options,
1855 )
1856 .expect("media corrections");
1857 let expected_tropo = direct_troposphere(elevation_rad);
1858 let expected_iono = ionosphere_delay(
1859 receiver(),
1860 elevation_rad,
1861 azimuth_rad,
1862 epoch(),
1863 F_L1_HZ,
1864 &klobuchar_model(),
1865 )
1866 .expect("direct Klobuchar");
1867
1868 assert_bits_eq("troposphere", got.troposphere_m, expected_tropo);
1869 assert_bits_eq("Klobuchar", got.ionosphere_m, expected_iono);
1870 assert_bits_eq("total", got.total_m, expected_tropo + expected_iono);
1871 }
1872 }
1873
1874 #[test]
1875 fn media_corrections_match_direct_ionex_bits() {
1876 let ionex = ionex();
1877 for elevation_deg in [5.0_f64, 15.0, 90.0] {
1878 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
1879 let azimuth_rad = 1.0;
1880 let got = observable_media_corrections(
1881 receiver(),
1882 elevation_rad,
1883 azimuth_rad,
1884 T_RX_J2000_S,
1885 F_L1_HZ,
1886 ObservableMediaOptions {
1887 troposphere: None,
1888 ionosphere: Some(ObservableIonosphereCorrection::Ionex(&ionex)),
1889 },
1890 )
1891 .expect("IONEX media correction");
1892 let expected = ionex_slant_delay(
1893 &ionex,
1894 receiver(),
1895 elevation_rad,
1896 azimuth_rad,
1897 T_RX_J2000_I64,
1898 F_L1_HZ,
1899 )
1900 .expect("direct IONEX");
1901
1902 assert_bits_eq("IONEX", got.ionosphere_m, expected);
1903 assert_bits_eq("IONEX total", got.total_m, expected);
1904 }
1905 }
1906
1907 #[test]
1908 fn default_media_prediction_matches_predict_bits() {
1909 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1910 let rx = [6_378_137.0, 0.0, 0.0];
1911 let source = StaticSource {
1912 state: ObservableState {
1913 position_ecef_m: [26_378_137.0, 0.0, 0.0],
1914 clock_s: Some(0.0),
1915 },
1916 };
1917 let options = PredictOptions {
1918 carrier_hz: F_L1_HZ,
1919 light_time: false,
1920 sagnac: false,
1921 };
1922 let plain = predict(&source, sat, rx, T_RX_J2000_S, options).expect("plain predict");
1923 let media = predict_with_media(
1924 &source,
1925 sat,
1926 rx,
1927 T_RX_J2000_S,
1928 MediaPredictOptions {
1929 prediction: options,
1930 media: ObservableMediaOptions::default(),
1931 },
1932 )
1933 .expect("default media predict");
1934
1935 assert_prediction_bits_eq(&media.prediction, &plain);
1936 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
1937 assert_eq!(media.media, AppliedMediaCorrections::default());
1938 }
1939
1940 #[test]
1941 fn default_media_prediction_skips_media_epoch_for_large_epoch() {
1942 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
1943 let rx = [6_378_137.0, 0.0, 0.0];
1944 let source = StaticSource {
1945 state: ObservableState {
1946 position_ecef_m: [26_378_137.0, 0.0, 0.0],
1947 clock_s: Some(0.0),
1948 },
1949 };
1950 let options = PredictOptions {
1951 carrier_hz: F_L1_HZ,
1952 light_time: false,
1953 sagnac: false,
1954 };
1955 let t_rx = 1.0e20;
1956 let plain = predict(&source, sat, rx, t_rx, options).expect("plain predict");
1957 let media = predict_with_media(
1958 &source,
1959 sat,
1960 rx,
1961 t_rx,
1962 MediaPredictOptions {
1963 prediction: options,
1964 media: ObservableMediaOptions::default(),
1965 },
1966 )
1967 .expect("default media predict");
1968
1969 assert_prediction_bits_eq(&media.prediction, &plain);
1970 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
1971 assert_eq!(media.media, AppliedMediaCorrections::default());
1972 }
1973
1974 #[test]
1975 fn below_troposphere_validity_returns_typed_error() {
1976 let err = observable_media_corrections(
1977 receiver(),
1978 2.0 * PI / DEGREES_PER_SEMICIRCLE,
1979 0.0,
1980 T_RX_J2000_S,
1981 F_L1_HZ,
1982 ObservableMediaOptions {
1983 troposphere: Some(ObservableTroposphereCorrection::default()),
1984 ionosphere: None,
1985 },
1986 )
1987 .expect_err("below mapping validity must fail");
1988
1989 match err {
1990 ObservablesError::InvalidInput { field, kind } => {
1991 assert_eq!(field, "media.elevation_rad");
1992 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
1993 }
1994 other => panic!("expected typed InvalidInput, got {other:?}"),
1995 }
1996 }
1997
1998 #[test]
1999 fn range_media_prediction_adds_direct_troposphere_bits() {
2000 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2001 let rx = [6_378_137.0, 0.0, 0.0];
2002 let elevation_rad = core::f64::consts::FRAC_PI_2;
2003 let range_m = 20_000_000.0;
2004 let delta = [range_m, 0.0, 0.0];
2005 let source = StaticSource {
2006 state: ObservableState {
2007 position_ecef_m: [rx[0] + delta[0], rx[1] + delta[1], rx[2] + delta[2]],
2008 clock_s: Some(0.0),
2009 },
2010 };
2011 let options = MediaPredictOptions {
2012 prediction: PredictOptions {
2013 carrier_hz: f64::NAN,
2014 light_time: false,
2015 sagnac: false,
2016 },
2017 media: ObservableMediaOptions {
2018 troposphere: Some(ObservableTroposphereCorrection::default()),
2019 ionosphere: None,
2020 },
2021 };
2022 let request = [RangePredictionRequest {
2023 sat,
2024 receiver_ecef_m: rx,
2025 t_rx_j2000_s: T_RX_J2000_S,
2026 }];
2027 let zero_prediction = RangePrediction {
2028 geometric_range_m: 0.0,
2029 sat_clock_s: None,
2030 transmit_time_j2000_s: 0.0,
2031 sat_pos_ecef_m: [0.0; 3],
2032 };
2033 let mut out = [MediaRangePrediction {
2034 prediction: zero_prediction,
2035 range_m: 0.0,
2036 media: AppliedMediaCorrections::default(),
2037 }];
2038 predict_ranges_with_media(&source, &request, options, &mut out)
2039 .expect("range media prediction");
2040 let got = out[0];
2041 let expected = got.prediction.geometric_range_m + direct_troposphere(elevation_rad);
2042 assert_bits_eq("corrected range", got.range_m, expected);
2043 }
2044
2045 #[test]
2046 fn default_range_media_prediction_matches_range_bits_with_unused_carrier() {
2047 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2048 let rx = [6_378_137.0, 0.0, 0.0];
2049 let source = StaticSource {
2050 state: ObservableState {
2051 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2052 clock_s: Some(0.0),
2053 },
2054 };
2055 let options = PredictOptions {
2056 carrier_hz: f64::NAN,
2057 light_time: false,
2058 sagnac: false,
2059 };
2060 let request = [RangePredictionRequest {
2061 sat,
2062 receiver_ecef_m: rx,
2063 t_rx_j2000_s: T_RX_J2000_S,
2064 }];
2065 let zero_prediction = RangePrediction {
2066 geometric_range_m: 0.0,
2067 sat_clock_s: None,
2068 transmit_time_j2000_s: 0.0,
2069 sat_pos_ecef_m: [0.0; 3],
2070 };
2071 let mut plain = [zero_prediction];
2072 predict_ranges(&source, &request, options, &mut plain).expect("plain range");
2073 let mut media = [MediaRangePrediction {
2074 prediction: zero_prediction,
2075 range_m: 0.0,
2076 media: AppliedMediaCorrections::default(),
2077 }];
2078 predict_ranges_with_media(
2079 &source,
2080 &request,
2081 MediaPredictOptions {
2082 prediction: options,
2083 media: ObservableMediaOptions::default(),
2084 },
2085 &mut media,
2086 )
2087 .expect("default media range");
2088
2089 assert_range_prediction_bits_eq(&media[0].prediction, &plain[0]);
2090 assert_bits_eq(
2091 "default range",
2092 media[0].range_m,
2093 plain[0].geometric_range_m,
2094 );
2095 assert_eq!(media[0].media, AppliedMediaCorrections::default());
2096 }
2097}
2098
2099#[cfg(all(test, sidereon_repo_tests))]
2100mod tests {
2101 use super::*;
2102 use crate::{GnssSatelliteId, GnssSystem};
2103
2104 #[derive(Debug, Clone, Copy)]
2105 struct StaticSource {
2106 state: ObservableState,
2107 }
2108
2109 impl ObservableEphemerisSource for StaticSource {
2110 fn observable_state_at_j2000_s(
2111 &self,
2112 _sat: GnssSatelliteId,
2113 _t_j2000_s: f64,
2114 ) -> Result<ObservableState, ObservablesError> {
2115 Ok(self.state)
2116 }
2117 }
2118
2119 fn sp3_fixture() -> Sp3 {
2120 let path = concat!(
2121 env!("CARGO_MANIFEST_DIR"),
2122 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
2123 );
2124 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
2125 Sp3::parse(&bytes).expect("parse SP3 fixture")
2126 }
2127
2128 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
2129 StaticSource {
2130 state: ObservableState {
2131 position_ecef_m,
2132 clock_s: Some(0.0),
2133 },
2134 }
2135 }
2136
2137 fn no_light_time_options() -> PredictOptions {
2138 PredictOptions {
2139 carrier_hz: F_L1_HZ,
2140 light_time: false,
2141 sagnac: true,
2142 }
2143 }
2144
2145 fn assert_invalid_observables_input(
2146 err: ObservablesError,
2147 field: &'static str,
2148 kind: ObservablesInputErrorKind,
2149 ) {
2150 match err {
2151 ObservablesError::InvalidInput {
2152 field: got_field,
2153 kind: got_kind,
2154 } => {
2155 assert_eq!(got_field, field);
2156 assert_eq!(got_kind, kind);
2157 }
2158 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
2159 }
2160 }
2161
2162 #[test]
2163 fn split_julian_to_j2000_seconds_matches_orbis_time() {
2164 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
2165 assert_eq!(t, 646_272_000.0);
2166 }
2167
2168 #[test]
2169 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
2170 for (jd_whole, jd_fraction, field) in [
2171 (f64::NAN, 0.5, "jd_whole"),
2172 (f64::INFINITY, 0.5, "jd_whole"),
2173 (2_459_024.5, f64::NAN, "jd_fraction"),
2174 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
2175 ] {
2176 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
2177 .expect_err("non-finite split Julian date part must fail");
2178 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
2179 }
2180 }
2181
2182 #[test]
2183 fn sp3_predict_reference_case() {
2184 let sp3 = sp3_fixture();
2185 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2186 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
2187 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
2188 .expect("predict observables");
2189
2190 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
2191 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
2192 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
2193 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
2194 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
2195 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
2196 assert_eq!(obs.transmit_offset_us, 69_288);
2197 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
2198 assert_eq!(
2199 obs.los_unit.map(f64::to_bits),
2200 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
2201 );
2202 assert_eq!(
2203 obs.sat_pos_ecef_m.map(f64::to_bits),
2204 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
2205 );
2206 assert_eq!(
2207 obs.sat_velocity_m_s.map(f64::to_bits),
2208 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
2209 );
2210 }
2211
2212 #[test]
2213 fn predict_rejects_invalid_entry_inputs() {
2214 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
2215 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2216
2217 let err = predict(
2218 &source,
2219 sat,
2220 [f64::NAN, 0.0, 0.0],
2221 646_272_000.0,
2222 no_light_time_options(),
2223 )
2224 .expect_err("non-finite receiver position must fail");
2225 assert_invalid_observables_input(
2226 err,
2227 "receiver_ecef_m",
2228 ObservablesInputErrorKind::NonFinite,
2229 );
2230
2231 let err = predict(
2232 &source,
2233 sat,
2234 [0.0, 0.0, 0.0],
2235 f64::INFINITY,
2236 no_light_time_options(),
2237 )
2238 .expect_err("non-finite receive time must fail");
2239 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
2240
2241 let mut options = no_light_time_options();
2242 options.carrier_hz = 0.0;
2243 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
2244 .expect_err("non-positive carrier must fail");
2245 assert_invalid_observables_input(
2246 err,
2247 "options.carrier_hz",
2248 ObservablesInputErrorKind::NotPositive,
2249 );
2250 }
2251
2252 #[test]
2253 fn predict_rejects_invalid_source_state_and_zero_range() {
2254 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2255
2256 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
2257 let err = predict(
2258 &source,
2259 sat,
2260 [0.0, 0.0, 0.0],
2261 646_272_000.0,
2262 no_light_time_options(),
2263 )
2264 .expect_err("non-finite ephemeris position must fail");
2265 assert_invalid_observables_input(
2266 err,
2267 "observable state position_ecef_m",
2268 ObservablesInputErrorKind::NonFinite,
2269 );
2270
2271 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
2272 let err = predict(
2273 &source,
2274 sat,
2275 [1_000.0, 2_000.0, 3_000.0],
2276 646_272_000.0,
2277 no_light_time_options(),
2278 )
2279 .expect_err("zero geometric range must fail");
2280 assert_invalid_observables_input(
2281 err,
2282 "geometric_range_m",
2283 ObservablesInputErrorKind::NotPositive,
2284 );
2285 }
2286
2287 #[test]
2288 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
2289 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
2290 .expect_err("invalid receiver geodetic conversion must fail");
2291
2292 assert_invalid_observables_input(
2293 err,
2294 "receiver_ecef_m",
2295 ObservablesInputErrorKind::OutOfRange,
2296 );
2297 }
2298
2299 const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
2303
2304 #[test]
2305 fn topocentric_azimuth_is_zero_at_exact_zenith() {
2306 let geometry = topocentric(
2309 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2310 [20_000_000.0, 0.0, 0.0],
2311 20_000_000.0,
2312 )
2313 .expect("zenith topocentric must not error");
2314 assert_eq!(geometry.azimuth_deg, 0.0);
2315 assert!(geometry.azimuth_deg.is_finite());
2316 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
2317 }
2318
2319 #[test]
2320 fn topocentric_azimuth_is_zero_just_off_zenith() {
2321 let geometry = topocentric(
2324 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2325 [20_000_000.0, 1.0e-9, 1.0e-9],
2326 20_000_000.0,
2327 )
2328 .expect("near-zenith topocentric must not error");
2329 assert_eq!(geometry.azimuth_deg, 0.0);
2330 assert!(geometry.azimuth_deg.is_finite());
2331 }
2332
2333 #[test]
2334 fn predict_azimuth_is_zero_at_exact_zenith() {
2335 let source = StaticSource {
2336 state: ObservableState {
2337 position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
2338 clock_s: None,
2339 },
2340 };
2341 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2342 let obs = predict(
2343 &source,
2344 sat,
2345 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2346 0.0,
2347 PredictOptions {
2348 carrier_hz: F_L1_HZ,
2349 light_time: false,
2350 sagnac: false,
2351 },
2352 )
2353 .expect("zenith predict must not error");
2354 assert_eq!(obs.azimuth_deg, 0.0);
2355 assert!(obs.azimuth_deg.is_finite());
2356 assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
2357 }
2358
2359 fn batch_test_requests() -> Vec<PredictRequest> {
2360 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2361 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
2362 vec![
2363 (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
2364 (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
2365 (
2366 sat1,
2367 [1_130_000.0, -4_830_000.0, 3_994_000.0],
2368 646_272_060.0,
2369 ),
2370 (
2371 sat2,
2372 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
2373 646_272_090.0,
2374 ),
2375 ]
2376 }
2377
2378 fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
2379 assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
2380 assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
2381 assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
2382 assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
2383 assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
2384 assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
2385 assert_eq!(
2386 a.transmit_time_j2000_s.to_bits(),
2387 b.transmit_time_j2000_s.to_bits()
2388 );
2389 for k in 0..3 {
2390 assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
2391 assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
2392 assert_eq!(
2393 a.sat_velocity_m_s[k].to_bits(),
2394 b.sat_velocity_m_s[k].to_bits()
2395 );
2396 }
2397 }
2398
2399 #[test]
2400 fn predict_batch_matches_scalar_loop_bitwise() {
2401 let source = StaticSource {
2402 state: ObservableState {
2403 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2404 clock_s: Some(1.25e-6),
2405 },
2406 };
2407 let options = PredictOptions {
2408 carrier_hz: F_L1_HZ,
2409 light_time: true,
2410 sagnac: true,
2411 };
2412 let requests = batch_test_requests();
2413 let batch = predict_batch(&source, &requests, options);
2414 assert_eq!(batch.len(), requests.len());
2415 for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
2416 let scalar = predict(&source, sat, rx, t, options);
2417 match (entry, &scalar) {
2418 (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
2419 (Err(_), Err(_)) => {}
2420 _ => panic!("batch and scalar predict disagree on Ok/Err"),
2421 }
2422 }
2423 }
2424
2425 #[test]
2426 fn predict_batch_parallel_matches_serial_bitwise() {
2427 let source = StaticSource {
2428 state: ObservableState {
2429 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2430 clock_s: Some(1.25e-6),
2431 },
2432 };
2433 let options = PredictOptions {
2434 carrier_hz: F_L1_HZ,
2435 light_time: true,
2436 sagnac: true,
2437 };
2438 let requests = batch_test_requests();
2439 let serial = predict_batch(&source, &requests, options);
2440 let parallel = predict_batch_parallel(&source, &requests, options);
2441 assert_eq!(serial.len(), parallel.len());
2442 for (s, p) in serial.iter().zip(parallel.iter()) {
2443 match (s, p) {
2444 (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
2445 (Err(_), Err(_)) => {}
2446 _ => panic!("serial and parallel batch disagree on Ok/Err"),
2447 }
2448 }
2449 }
2450}