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::{
23 ionex_slant_delay, ionex_slant_delay_with_policy, ionosphere_delay, Ionex, IonexCoveragePolicy,
24 IonoModel,
25};
26use crate::sp3::Sp3;
27use crate::spp::EphemerisSource;
28use crate::tropo::{tropo_mapping, tropo_zenith, MappingModel, Met, TropoModel};
29use crate::validate;
30use crate::Error;
31use rayon::prelude::*;
32
33const FD_HALF_S: f64 = 0.5;
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct ObservableState {
38 pub position_ecef_m: [f64; 3],
40 pub clock_s: Option<f64>,
42}
43
44pub const OBSERVABLE_STATE_MISSING_POSITION_ECEF_M: [f64; 3] = [f64::NAN; 3];
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ObservableStateElementStatus {
54 Valid,
56 Gap,
58 Error,
60}
61
62#[derive(Debug, Clone, PartialEq)]
70pub struct ObservableStateBatch {
71 pub positions_ecef_m: Vec<[f64; 3]>,
73 pub clocks_s: Vec<Option<f64>>,
75 pub element_results: Vec<Result<(), ObservablesError>>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EmissionMediaStatus {
82 Valid,
84 Gap,
86 BelowElevationCutoff,
88 Error,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct EmissionMediaBatchOptions<'a> {
95 pub carrier_hz: f64,
97 pub media: ObservableMediaOptions<'a>,
99 pub min_elevation_rad: Option<f64>,
104}
105
106impl Default for EmissionMediaBatchOptions<'_> {
107 fn default() -> Self {
108 Self {
109 carrier_hz: F_L1_HZ,
110 media: ObservableMediaOptions::default(),
111 min_elevation_rad: None,
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq)]
124pub struct EmissionMediaBatch {
125 pub positions_ecef_m: Vec<Option<[f64; 3]>>,
127 pub clocks_s: Vec<Option<f64>>,
129 pub ionosphere_slant_delays_m: Vec<Option<f64>>,
131 pub troposphere_delays_m: Vec<Option<f64>>,
133 pub statuses: Vec<EmissionMediaStatus>,
135 pub element_errors: Vec<Option<ObservablesError>>,
137}
138
139pub trait ObservableEphemerisSource {
141 fn observable_state_at_j2000_s(
143 &self,
144 sat: GnssSatelliteId,
145 t_j2000_s: f64,
146 ) -> Result<ObservableState, ObservablesError>;
147
148 fn observable_states_at_j2000_s(
154 &self,
155 satellites: &[GnssSatelliteId],
156 epochs_j2000_s: &[f64],
157 ) -> Result<ObservableStateBatch, ObservablesError> {
158 if satellites.len() != epochs_j2000_s.len() {
159 return Err(ObservablesError::InvalidInput {
160 field: "epochs_j2000_s",
161 kind: ObservablesInputErrorKind::OutOfRange,
162 });
163 }
164
165 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
166 for (&sat, &epoch_j2000_s) in satellites.iter().zip(epochs_j2000_s.iter()) {
167 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
168 }
169 Ok(batch)
170 }
171
172 fn observable_states_at_shared_j2000_s(
177 &self,
178 satellites: &[GnssSatelliteId],
179 epoch_j2000_s: f64,
180 ) -> ObservableStateBatch {
181 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
182 for &sat in satellites {
183 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
184 }
185 batch
186 }
187}
188
189impl ObservableEphemerisSource for Sp3 {
190 fn observable_state_at_j2000_s(
191 &self,
192 sat: GnssSatelliteId,
193 t_j2000_s: f64,
194 ) -> Result<ObservableState, ObservablesError> {
195 let state = self
196 .position_at_j2000_seconds(sat, t_j2000_s)
197 .map_err(ObservablesError::Ephemeris)?;
198 Ok(ObservableState {
199 position_ecef_m: state.position.as_array(),
200 clock_s: state.clock_s,
201 })
202 }
203}
204
205impl ObservableEphemerisSource for BroadcastEphemeris {
206 fn observable_state_at_j2000_s(
207 &self,
208 sat: GnssSatelliteId,
209 t_j2000_s: f64,
210 ) -> Result<ObservableState, ObservablesError> {
211 let Some((position_ecef_m, clock_s)) =
212 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
213 else {
214 return Err(ObservablesError::NoEphemeris);
215 };
216 Ok(ObservableState {
217 position_ecef_m,
218 clock_s: Some(clock_s),
219 })
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum ObservablesInputErrorKind {
226 NonFinite,
228 NotPositive,
230 Negative,
232 OutOfRange,
234 Missing,
236 FloatParse,
238 IntParse,
240 InvalidCivilDate,
242 InvalidCivilTime,
244}
245
246impl core::fmt::Display for ObservablesInputErrorKind {
247 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248 let label = match self {
249 Self::NonFinite => "not finite",
250 Self::NotPositive => "not positive",
251 Self::Negative => "negative",
252 Self::OutOfRange => "out of range",
253 Self::Missing => "missing",
254 Self::FloatParse => "invalid float",
255 Self::IntParse => "invalid integer",
256 Self::InvalidCivilDate => "invalid civil date",
257 Self::InvalidCivilTime => "invalid civil time",
258 };
259 f.write_str(label)
260 }
261}
262
263impl From<&validate::FieldError> for ObservablesInputErrorKind {
264 fn from(error: &validate::FieldError) -> Self {
265 match error {
266 validate::FieldError::Missing { .. } => Self::Missing,
267 validate::FieldError::NonFinite { .. } => Self::NonFinite,
268 validate::FieldError::NotPositive { .. } => Self::NotPositive,
269 validate::FieldError::Negative { .. } => Self::Negative,
270 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
271 validate::FieldError::FloatParse { .. } => Self::FloatParse,
272 validate::FieldError::IntParse { .. } => Self::IntParse,
273 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
274 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum ObservablesError {
282 InvalidInput {
285 field: &'static str,
287 kind: ObservablesInputErrorKind,
289 },
290 NoEphemeris,
292 Ephemeris(Error),
294 Media(Error),
296}
297
298impl core::fmt::Display for ObservablesError {
299 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
300 match self {
301 Self::InvalidInput { field, kind } => {
302 write!(f, "invalid observable input {field}: {kind}")
303 }
304 Self::NoEphemeris => write!(f, "no ephemeris"),
305 Self::Ephemeris(err) => write!(f, "{err}"),
306 Self::Media(err) => write!(f, "{err}"),
307 }
308 }
309}
310
311impl std::error::Error for ObservablesError {}
312
313impl ObservableStateBatch {
314 pub fn with_capacity(capacity: usize) -> Self {
316 Self {
317 positions_ecef_m: Vec::with_capacity(capacity),
318 clocks_s: Vec::with_capacity(capacity),
319 element_results: Vec::with_capacity(capacity),
320 }
321 }
322
323 pub fn len(&self) -> usize {
325 self.element_results.len()
326 }
327
328 pub fn is_empty(&self) -> bool {
330 self.element_results.is_empty()
331 }
332
333 pub fn element(&self, index: usize) -> Option<Result<ObservableState, &ObservablesError>> {
337 match self.element_results.get(index)? {
338 Ok(()) => Some(Ok(ObservableState {
339 position_ecef_m: self.positions_ecef_m[index],
340 clock_s: self.clocks_s[index],
341 })),
342 Err(error) => Some(Err(error)),
343 }
344 }
345
346 pub fn element_status(&self, index: usize) -> Option<ObservableStateElementStatus> {
350 match self.element_results.get(index)? {
351 Ok(()) => Some(ObservableStateElementStatus::Valid),
352 Err(error) if is_observable_state_gap(error) => Some(ObservableStateElementStatus::Gap),
353 Err(_) => Some(ObservableStateElementStatus::Error),
354 }
355 }
356
357 fn push_state_result(&mut self, result: Result<ObservableState, ObservablesError>) {
358 match result {
359 Ok(state) => {
360 self.positions_ecef_m.push(state.position_ecef_m);
361 self.clocks_s.push(state.clock_s);
362 self.element_results.push(Ok(()));
363 }
364 Err(error) => {
365 self.positions_ecef_m
366 .push(OBSERVABLE_STATE_MISSING_POSITION_ECEF_M);
367 self.clocks_s.push(None);
368 self.element_results.push(Err(error));
369 }
370 }
371 }
372}
373
374impl EmissionMediaBatch {
375 pub fn with_capacity(capacity: usize) -> Self {
377 Self {
378 positions_ecef_m: Vec::with_capacity(capacity),
379 clocks_s: Vec::with_capacity(capacity),
380 ionosphere_slant_delays_m: Vec::with_capacity(capacity),
381 troposphere_delays_m: Vec::with_capacity(capacity),
382 statuses: Vec::with_capacity(capacity),
383 element_errors: Vec::with_capacity(capacity),
384 }
385 }
386
387 pub fn len(&self) -> usize {
389 self.statuses.len()
390 }
391
392 pub fn is_empty(&self) -> bool {
394 self.statuses.is_empty()
395 }
396
397 pub fn element_status(&self, index: usize) -> Option<EmissionMediaStatus> {
401 self.statuses.get(index).copied()
402 }
403
404 fn push_valid(&mut self, state: ObservableState, media: AppliedMediaCorrections) {
405 self.positions_ecef_m.push(Some(state.position_ecef_m));
406 self.clocks_s.push(state.clock_s);
407 self.ionosphere_slant_delays_m
408 .push(Some(media.ionosphere_m));
409 self.troposphere_delays_m.push(Some(media.troposphere_m));
410 self.statuses.push(EmissionMediaStatus::Valid);
411 self.element_errors.push(None);
412 }
413
414 fn push_gap(&mut self, error: ObservablesError) {
415 self.positions_ecef_m.push(None);
416 self.clocks_s.push(None);
417 self.ionosphere_slant_delays_m.push(None);
418 self.troposphere_delays_m.push(None);
419 self.statuses.push(EmissionMediaStatus::Gap);
420 self.element_errors.push(Some(error));
421 }
422
423 fn push_below_cutoff(&mut self, state: ObservableState) {
424 self.positions_ecef_m.push(Some(state.position_ecef_m));
425 self.clocks_s.push(state.clock_s);
426 self.ionosphere_slant_delays_m.push(None);
427 self.troposphere_delays_m.push(None);
428 self.statuses
429 .push(EmissionMediaStatus::BelowElevationCutoff);
430 self.element_errors.push(None);
431 }
432
433 fn push_error(&mut self, state: Option<ObservableState>, error: ObservablesError) {
434 self.positions_ecef_m
435 .push(state.map(|state| state.position_ecef_m));
436 self.clocks_s.push(state.and_then(|state| state.clock_s));
437 self.ionosphere_slant_delays_m.push(None);
438 self.troposphere_delays_m.push(None);
439 self.statuses.push(EmissionMediaStatus::Error);
440 self.element_errors.push(Some(error));
441 }
442}
443
444pub fn is_observable_state_gap(error: &ObservablesError) -> bool {
450 matches!(
451 error,
452 ObservablesError::NoEphemeris
453 | ObservablesError::Ephemeris(crate::Error::EpochOutOfRange)
454 | ObservablesError::Ephemeris(crate::Error::UnknownSatellite(_))
455 )
456}
457
458#[derive(Debug, Clone, Copy, PartialEq)]
460pub struct PredictOptions {
461 pub carrier_hz: f64,
463 pub light_time: bool,
465 pub sagnac: bool,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub struct TransmitTimeOptions {
472 pub light_time: bool,
474 pub sagnac: bool,
476}
477
478impl Default for TransmitTimeOptions {
479 fn default() -> Self {
480 Self {
481 light_time: true,
482 sagnac: true,
483 }
484 }
485}
486
487impl Default for PredictOptions {
488 fn default() -> Self {
489 Self {
490 carrier_hz: F_L1_HZ,
491 light_time: true,
492 sagnac: true,
493 }
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq)]
499pub struct ObservableTroposphereCorrection {
500 pub met: Met,
502 pub mapping: MappingModel,
504}
505
506impl Default for ObservableTroposphereCorrection {
507 fn default() -> Self {
508 Self {
509 met: Met::new_unchecked(1013.25, 288.15, 0.5),
510 mapping: MappingModel::Niell,
511 }
512 }
513}
514
515#[derive(Debug, Clone, Copy, PartialEq)]
517pub enum ObservableIonosphereCorrection<'a> {
518 Broadcast(IonoModel),
520 Ionex(&'a Ionex),
522 IonexWithPolicy(&'a Ionex, IonexCoveragePolicy),
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Default)]
528pub struct ObservableMediaOptions<'a> {
529 pub troposphere: Option<ObservableTroposphereCorrection>,
531 pub ionosphere: Option<ObservableIonosphereCorrection<'a>>,
533}
534
535impl ObservableMediaOptions<'_> {
536 fn is_disabled(self) -> bool {
537 self.troposphere.is_none() && self.ionosphere.is_none()
538 }
539
540 fn needs_instant(self) -> bool {
541 self.troposphere.is_some()
542 || matches!(
543 self.ionosphere,
544 Some(ObservableIonosphereCorrection::Broadcast(_))
545 )
546 }
547
548 fn needs_carrier(self) -> bool {
549 self.ionosphere.is_some()
550 }
551
552 fn needs_ionex_epoch(self) -> bool {
553 matches!(
554 self.ionosphere,
555 Some(
556 ObservableIonosphereCorrection::Ionex(_)
557 | ObservableIonosphereCorrection::IonexWithPolicy(_, _)
558 )
559 )
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Default)]
565pub struct MediaPredictOptions<'a> {
566 pub prediction: PredictOptions,
568 pub media: ObservableMediaOptions<'a>,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq)]
574pub struct AppliedMediaCorrections {
575 pub troposphere_m: f64,
577 pub ionosphere_m: f64,
579 pub total_m: f64,
581}
582
583impl Default for AppliedMediaCorrections {
584 fn default() -> Self {
585 Self {
586 troposphere_m: 0.0,
587 ionosphere_m: 0.0,
588 total_m: 0.0,
589 }
590 }
591}
592
593#[derive(Debug, Clone, Copy, PartialEq)]
595pub struct MediaPredictedObservables {
596 pub prediction: PredictedObservables,
598 pub range_m: f64,
600 pub media: AppliedMediaCorrections,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq)]
612pub struct TransmitTimeSatelliteState {
613 pub signal_flight_time_s: f64,
615 pub transmit_offset_us: i64,
617 pub transmit_time_j2000_s: f64,
619 pub clock_s: Option<f64>,
621 pub transmit_position_ecef_m: [f64; 3],
623 pub position_ecef_m: [f64; 3],
625 pub velocity_m_s: [f64; 3],
627 pub geometric_range_m: f64,
629 pub los_unit: [f64; 3],
631}
632
633#[derive(Debug, Clone, Copy, PartialEq)]
635pub struct PredictedObservables {
636 pub geometric_range_m: f64,
638 pub range_rate_m_s: f64,
640 pub doppler_hz: f64,
642 pub sat_clock_s: Option<f64>,
644 pub elevation_deg: f64,
646 pub azimuth_deg: f64,
654 pub transmit_offset_us: i64,
656 pub transmit_time_j2000_s: f64,
658 pub los_unit: [f64; 3],
660 pub sat_pos_ecef_m: [f64; 3],
662 pub sat_velocity_m_s: [f64; 3],
664}
665
666pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
668 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
669 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
670 validate::finite(
671 civil::j2000_seconds_from_split(jd_whole, jd_fraction),
672 "j2000_seconds",
673 )
674 .map_err(map_input_error)
675}
676
677pub fn observable_media_corrections(
684 receiver: Wgs84Geodetic,
685 elevation_rad: f64,
686 azimuth_rad: f64,
687 t_rx_j2000_s: f64,
688 carrier_hz: f64,
689 options: ObservableMediaOptions<'_>,
690) -> Result<AppliedMediaCorrections, ObservablesError> {
691 if options.is_disabled() {
692 return Ok(AppliedMediaCorrections::default());
693 }
694 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
695 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
696 if options.needs_carrier() {
697 validate::finite_positive(carrier_hz, "carrier_hz").map_err(map_input_error)?;
698 }
699 let epoch = if options.needs_instant() {
700 Some(media_instant(t_rx_j2000_s)?)
701 } else {
702 None
703 };
704 let ionex_epoch_j2000_s = if options.needs_ionex_epoch() {
705 Some(rounded_j2000_seconds(t_rx_j2000_s)?)
706 } else {
707 None
708 };
709
710 let troposphere_m = match options.troposphere {
711 Some(troposphere) => {
712 let epoch = epoch.expect("troposphere media requires an epoch");
713 let zenith = tropo_zenith(TropoModel::Saastamoinen, receiver, troposphere.met)
714 .map_err(map_media_error)?;
715 let mapping = tropo_mapping(troposphere.mapping, elevation_rad, receiver, epoch)
716 .map_err(map_media_error)?;
717 let delay_m = zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet;
718 validate::finite(delay_m, "media.troposphere_m").map_err(map_input_error)?;
719 delay_m
720 }
721 None => 0.0,
722 };
723
724 let ionosphere_m = match options.ionosphere {
725 Some(ObservableIonosphereCorrection::Broadcast(model)) => {
726 let epoch = epoch.expect("broadcast ionosphere media requires an epoch");
727 let delay_m = ionosphere_delay(
728 receiver,
729 elevation_rad,
730 azimuth_rad,
731 epoch,
732 carrier_hz,
733 &model,
734 )
735 .map_err(map_media_error)?;
736 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
737 delay_m
738 }
739 Some(ObservableIonosphereCorrection::Ionex(ionex)) => {
740 let ionex_epoch_j2000_s =
741 ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
742 let delay_m = ionex_slant_delay(
743 ionex,
744 receiver,
745 elevation_rad,
746 azimuth_rad,
747 ionex_epoch_j2000_s,
748 carrier_hz,
749 )
750 .map_err(map_media_error)?;
751 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
752 delay_m
753 }
754 Some(ObservableIonosphereCorrection::IonexWithPolicy(ionex, policy)) => {
755 let ionex_epoch_j2000_s =
756 ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
757 let delay_m = ionex_slant_delay_with_policy(
758 ionex,
759 receiver,
760 elevation_rad,
761 azimuth_rad,
762 ionex_epoch_j2000_s,
763 carrier_hz,
764 policy,
765 )
766 .map_err(map_media_error)?
767 .delay_m;
768 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
769 delay_m
770 }
771 None => 0.0,
772 };
773
774 let total_m = troposphere_m + ionosphere_m;
775 validate::finite(total_m, "media.total_m").map_err(map_input_error)?;
776 Ok(AppliedMediaCorrections {
777 troposphere_m,
778 ionosphere_m,
779 total_m,
780 })
781}
782
783pub fn observable_states_at_j2000_s(
787 source: &dyn ObservableEphemerisSource,
788 satellites: &[GnssSatelliteId],
789 epochs_j2000_s: &[f64],
790) -> Result<ObservableStateBatch, ObservablesError> {
791 source.observable_states_at_j2000_s(satellites, epochs_j2000_s)
792}
793
794pub fn observable_states_at_shared_j2000_s(
799 source: &dyn ObservableEphemerisSource,
800 satellites: &[GnssSatelliteId],
801 epoch_j2000_s: f64,
802) -> ObservableStateBatch {
803 source.observable_states_at_shared_j2000_s(satellites, epoch_j2000_s)
804}
805
806pub fn emission_media_batch_at_j2000_s(
821 source: &dyn ObservableEphemerisSource,
822 satellites: &[GnssSatelliteId],
823 emission_epochs_j2000_s: &[f64],
824 receiver_ecef_m: [f64; 3],
825 options: EmissionMediaBatchOptions<'_>,
826) -> Result<EmissionMediaBatch, ObservablesError> {
827 if satellites.len() != emission_epochs_j2000_s.len() {
828 return Err(ObservablesError::InvalidInput {
829 field: "emission_epochs_j2000_s",
830 kind: ObservablesInputErrorKind::OutOfRange,
831 });
832 }
833 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
834 validate_emission_media_batch_options(options)?;
835
836 let mut batch = EmissionMediaBatch::with_capacity(satellites.len());
837 for (&sat, &emission_epoch_j2000_s) in satellites.iter().zip(emission_epochs_j2000_s.iter()) {
838 let state = match source.observable_state_at_j2000_s(sat, emission_epoch_j2000_s) {
839 Ok(state) => state,
840 Err(error) if is_observable_state_gap(&error) => {
841 batch.push_gap(error);
842 continue;
843 }
844 Err(error) => {
845 batch.push_error(None, error);
846 continue;
847 }
848 };
849
850 if let Err(error) = validate_observable_state(&state) {
851 batch.push_error(Some(state), error);
852 continue;
853 }
854
855 let dx = state.position_ecef_m[0] - receiver_ecef_m[0];
856 let dy = state.position_ecef_m[1] - receiver_ecef_m[1];
857 let dz = state.position_ecef_m[2] - receiver_ecef_m[2];
858 let line_of_sight_m = [dx, dy, dz];
859 let range = match geometric_range_m(line_of_sight_m) {
860 Ok(range) => range,
861 Err(error) => {
862 batch.push_error(Some(state), error);
863 continue;
864 }
865 };
866 let topocentric = match topocentric(receiver_ecef_m, line_of_sight_m, range) {
867 Ok(topocentric) => topocentric,
868 Err(error) => {
869 batch.push_error(Some(state), error);
870 continue;
871 }
872 };
873
874 if options
875 .min_elevation_rad
876 .is_some_and(|cutoff| topocentric.elevation_rad < cutoff)
877 {
878 batch.push_below_cutoff(state);
879 continue;
880 }
881
882 let media = observable_media_corrections(
883 topocentric.receiver,
884 topocentric.elevation_rad,
885 topocentric.azimuth_rad,
886 emission_epoch_j2000_s,
887 options.carrier_hz,
888 options.media,
889 );
890 match media {
891 Ok(media) => batch.push_valid(state, media),
892 Err(error) => batch.push_error(Some(state), error),
893 }
894 }
895 Ok(batch)
896}
897
898pub fn transmit_time_satellite_state(
906 source: &dyn ObservableEphemerisSource,
907 sat: GnssSatelliteId,
908 receiver_ecef_m: [f64; 3],
909 t_rx_j2000_s: f64,
910 options: TransmitTimeOptions,
911) -> Result<TransmitTimeSatelliteState, ObservablesError> {
912 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
913 let predict_options = PredictOptions {
914 carrier_hz: F_L1_HZ,
915 light_time: options.light_time,
916 sagnac: options.sagnac,
917 };
918 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
919
920 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
921 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
922 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
923 let range = geometric_range_m([dx, dy, dz])?;
924 let los = [dx / range, dy / range, dz / range];
925
926 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
927 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
928 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
929
930 Ok(TransmitTimeSatelliteState {
931 signal_flight_time_s: solved.tau_s,
932 transmit_offset_us: solved.transmit_offset_us,
933 transmit_time_j2000_s: solved.transmit_time_j2000_s,
934 clock_s: solved.state.clock_s,
935 transmit_position_ecef_m: solved.state.position_ecef_m,
936 position_ecef_m: solved.sat_rot_ecef_m,
937 velocity_m_s: velocity_rot,
938 geometric_range_m: range,
939 los_unit: los,
940 })
941}
942
943pub fn predict(
945 source: &dyn ObservableEphemerisSource,
946 sat: GnssSatelliteId,
947 receiver_ecef_m: [f64; 3],
948 t_rx_j2000_s: f64,
949 options: PredictOptions,
950) -> Result<PredictedObservables, ObservablesError> {
951 let (prediction, _) = predict_core(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
952 Ok(prediction)
953}
954
955pub fn predict_with_media(
963 source: &dyn ObservableEphemerisSource,
964 sat: GnssSatelliteId,
965 receiver_ecef_m: [f64; 3],
966 t_rx_j2000_s: f64,
967 options: MediaPredictOptions<'_>,
968) -> Result<MediaPredictedObservables, ObservablesError> {
969 let (prediction, topocentric) = predict_core(
970 source,
971 sat,
972 receiver_ecef_m,
973 t_rx_j2000_s,
974 options.prediction,
975 )?;
976 if options.media.is_disabled() {
977 return Ok(MediaPredictedObservables {
978 range_m: prediction.geometric_range_m,
979 prediction,
980 media: AppliedMediaCorrections::default(),
981 });
982 }
983 let media = observable_media_corrections(
984 topocentric.receiver,
985 topocentric.elevation_rad,
986 topocentric.azimuth_rad,
987 t_rx_j2000_s,
988 options.prediction.carrier_hz,
989 options.media,
990 )?;
991 let range_m = prediction.geometric_range_m + media.total_m;
992 validate::finite(range_m, "range_m").map_err(map_input_error)?;
993 Ok(MediaPredictedObservables {
994 prediction,
995 range_m,
996 media,
997 })
998}
999
1000fn predict_core(
1001 source: &dyn ObservableEphemerisSource,
1002 sat: GnssSatelliteId,
1003 receiver_ecef_m: [f64; 3],
1004 t_rx_j2000_s: f64,
1005 options: PredictOptions,
1006) -> Result<(PredictedObservables, TopocentricGeometry), ObservablesError> {
1007 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
1008 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1009
1010 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1011 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1012 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1013 let range = geometric_range_m([dx, dy, dz])?;
1014 let los = [dx / range, dy / range, dz / range];
1015
1016 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
1017 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
1018 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
1019 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
1020 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
1021 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
1022 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
1023 let topocentric = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
1024
1025 Ok((
1026 PredictedObservables {
1027 geometric_range_m: range,
1028 range_rate_m_s: range_rate,
1029 doppler_hz,
1030 sat_clock_s: solved.state.clock_s,
1031 elevation_deg: topocentric.elevation_deg,
1032 azimuth_deg: topocentric.azimuth_deg,
1033 transmit_offset_us: solved.transmit_offset_us,
1034 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1035 los_unit: los,
1036 sat_pos_ecef_m: solved.sat_rot_ecef_m,
1037 sat_velocity_m_s: velocity_rot,
1038 },
1039 topocentric,
1040 ))
1041}
1042
1043pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
1050
1051pub fn predict_batch(
1059 source: &dyn ObservableEphemerisSource,
1060 requests: &[PredictRequest],
1061 options: PredictOptions,
1062) -> Vec<Result<PredictedObservables, ObservablesError>> {
1063 requests
1064 .iter()
1065 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1066 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1067 })
1068 .collect()
1069}
1070
1071pub fn predict_batch_with_media(
1076 source: &dyn ObservableEphemerisSource,
1077 requests: &[PredictRequest],
1078 options: MediaPredictOptions<'_>,
1079) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1080 requests
1081 .iter()
1082 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1083 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1084 })
1085 .collect()
1086}
1087
1088pub fn predict_batch_parallel(
1098 source: &(dyn ObservableEphemerisSource + Sync),
1099 requests: &[PredictRequest],
1100 options: PredictOptions,
1101) -> Vec<Result<PredictedObservables, ObservablesError>> {
1102 requests
1103 .par_iter()
1104 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1105 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1106 })
1107 .collect()
1108}
1109
1110pub fn predict_batch_with_media_parallel(
1115 source: &(dyn ObservableEphemerisSource + Sync),
1116 requests: &[PredictRequest],
1117 options: MediaPredictOptions<'_>,
1118) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1119 requests
1120 .par_iter()
1121 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1122 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1123 })
1124 .collect()
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq)]
1130pub struct RangePredictionRequest {
1131 pub sat: GnssSatelliteId,
1133 pub receiver_ecef_m: [f64; 3],
1135 pub t_rx_j2000_s: f64,
1137}
1138
1139#[derive(Debug, Clone, Copy, PartialEq)]
1144pub struct RangePrediction {
1145 pub geometric_range_m: f64,
1147 pub sat_clock_s: Option<f64>,
1149 pub transmit_time_j2000_s: f64,
1151 pub sat_pos_ecef_m: [f64; 3],
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq)]
1157pub struct MediaRangePrediction {
1158 pub prediction: RangePrediction,
1160 pub range_m: f64,
1162 pub media: AppliedMediaCorrections,
1164}
1165
1166pub fn predict_ranges(
1191 source: &dyn ObservableEphemerisSource,
1192 requests: &[RangePredictionRequest],
1193 options: PredictOptions,
1194 out: &mut [RangePrediction],
1195) -> Result<(), ObservablesError> {
1196 if out.len() != requests.len() {
1197 return Err(ObservablesError::InvalidInput {
1198 field: "out",
1199 kind: ObservablesInputErrorKind::OutOfRange,
1200 });
1201 }
1202 for (request, slot) in requests.iter().zip(out.iter_mut()) {
1203 *slot = range_prediction_at_rx(
1204 source,
1205 request.sat,
1206 request.receiver_ecef_m,
1207 request.t_rx_j2000_s,
1208 options,
1209 )?;
1210 }
1211 Ok(())
1212}
1213
1214pub fn predict_ranges_with_media(
1220 source: &dyn ObservableEphemerisSource,
1221 requests: &[RangePredictionRequest],
1222 options: MediaPredictOptions<'_>,
1223 out: &mut [MediaRangePrediction],
1224) -> Result<(), ObservablesError> {
1225 if out.len() != requests.len() {
1226 return Err(ObservablesError::InvalidInput {
1227 field: "out",
1228 kind: ObservablesInputErrorKind::OutOfRange,
1229 });
1230 }
1231 for (request, slot) in requests.iter().zip(out.iter_mut()) {
1232 if options.media.is_disabled() {
1233 let prediction = range_prediction_at_rx(
1234 source,
1235 request.sat,
1236 request.receiver_ecef_m,
1237 request.t_rx_j2000_s,
1238 options.prediction,
1239 )?;
1240 *slot = MediaRangePrediction {
1241 range_m: prediction.geometric_range_m,
1242 prediction,
1243 media: AppliedMediaCorrections::default(),
1244 };
1245 continue;
1246 }
1247 let (prediction, topocentric) = range_prediction_core(
1248 source,
1249 request.sat,
1250 request.receiver_ecef_m,
1251 request.t_rx_j2000_s,
1252 options.prediction,
1253 )?;
1254 let media = observable_media_corrections(
1255 topocentric.receiver,
1256 topocentric.elevation_rad,
1257 topocentric.azimuth_rad,
1258 request.t_rx_j2000_s,
1259 options.prediction.carrier_hz,
1260 options.media,
1261 )?;
1262 let range_m = prediction.geometric_range_m + media.total_m;
1263 validate::finite(range_m, "range_m").map_err(map_input_error)?;
1264 *slot = MediaRangePrediction {
1265 prediction,
1266 range_m,
1267 media,
1268 };
1269 }
1270 Ok(())
1271}
1272
1273fn range_prediction_at_rx(
1282 source: &dyn ObservableEphemerisSource,
1283 sat: GnssSatelliteId,
1284 receiver_ecef_m: [f64; 3],
1285 t_rx_j2000_s: f64,
1286 options: PredictOptions,
1287) -> Result<RangePrediction, ObservablesError> {
1288 let (prediction, _, _) =
1289 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1290 Ok(prediction)
1291}
1292
1293fn range_prediction_core(
1294 source: &dyn ObservableEphemerisSource,
1295 sat: GnssSatelliteId,
1296 receiver_ecef_m: [f64; 3],
1297 t_rx_j2000_s: f64,
1298 options: PredictOptions,
1299) -> Result<(RangePrediction, TopocentricGeometry), ObservablesError> {
1300 let (prediction, line_of_sight_m, range) =
1301 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1302 let topocentric = topocentric(receiver_ecef_m, line_of_sight_m, range)?;
1303 Ok((prediction, topocentric))
1304}
1305
1306fn range_prediction_state(
1307 source: &dyn ObservableEphemerisSource,
1308 sat: GnssSatelliteId,
1309 receiver_ecef_m: [f64; 3],
1310 t_rx_j2000_s: f64,
1311 options: PredictOptions,
1312) -> Result<(RangePrediction, [f64; 3], f64), ObservablesError> {
1313 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
1314 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1315 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1316 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1317 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1318 let line_of_sight_m = [dx, dy, dz];
1319 let range = geometric_range_m([dx, dy, dz])?;
1320 Ok((
1321 RangePrediction {
1322 geometric_range_m: range,
1323 sat_clock_s: solved.state.clock_s,
1324 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1325 sat_pos_ecef_m: solved.sat_rot_ecef_m,
1326 },
1327 line_of_sight_m,
1328 range,
1329 ))
1330}
1331
1332#[derive(Debug, Clone, Copy)]
1333struct SolvedTransmitTime {
1334 tau_s: f64,
1335 transmit_offset_us: i64,
1336 transmit_time_j2000_s: f64,
1337 state: ObservableState,
1338 sat_rot_ecef_m: [f64; 3],
1339}
1340
1341fn solve_transmit_time(
1342 source: &dyn ObservableEphemerisSource,
1343 sat: GnssSatelliteId,
1344 receiver_ecef_m: [f64; 3],
1345 t_rx_j2000_s: f64,
1346 options: PredictOptions,
1347) -> Result<SolvedTransmitTime, ObservablesError> {
1348 if !options.light_time {
1349 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
1350 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
1351 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1352 return Ok(SolvedTransmitTime {
1353 tau_s: 0.0,
1354 transmit_offset_us: 0,
1355 transmit_time_j2000_s: t_rx_j2000_s,
1356 state,
1357 sat_rot_ecef_m: sat_rot,
1358 });
1359 }
1360
1361 let mut tau = 0.0;
1362 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1363 let transmit_offset_us = microseconds_from_tau(tau);
1364 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1365 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1366 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
1367 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1368 let dx = sat_rot[0] - receiver_ecef_m[0];
1369 let dy = sat_rot[1] - receiver_ecef_m[1];
1370 let dz = sat_rot[2] - receiver_ecef_m[2];
1371 let range = geometric_range_m([dx, dy, dz])?;
1372 let new_tau = range / C_M_S;
1373
1374 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1375 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
1376 }
1377
1378 tau = new_tau;
1379 }
1380
1381 unreachable!("fixed transmit-time loop always returns on its last iteration")
1382}
1383
1384fn finalize_transmit_time(
1385 source: &dyn ObservableEphemerisSource,
1386 sat: GnssSatelliteId,
1387 t_rx_j2000_s: f64,
1388 tau: f64,
1389 sagnac: bool,
1390) -> Result<SolvedTransmitTime, ObservablesError> {
1391 let transmit_offset_us = microseconds_from_tau(tau);
1392 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1393 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
1394 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1395 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
1396 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1397 Ok(SolvedTransmitTime {
1398 tau_s: tau,
1399 transmit_offset_us,
1400 transmit_time_j2000_s: t_tx,
1401 state,
1402 sat_rot_ecef_m: sat_rot,
1403 })
1404}
1405
1406fn microseconds_from_tau(tau_s: f64) -> i64 {
1407 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
1408}
1409
1410fn satellite_velocity(
1411 source: &dyn ObservableEphemerisSource,
1412 sat: GnssSatelliteId,
1413 t_tx_j2000_s: f64,
1414) -> Result<[f64; 3], ObservablesError> {
1415 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
1416 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
1417 let denom = 2.0 * FD_HALF_S;
1418 let velocity = [
1419 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
1420 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
1421 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
1422 ];
1423 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
1424}
1425
1426fn validate_predict_inputs(
1427 receiver_ecef_m: [f64; 3],
1428 t_rx_j2000_s: f64,
1429 options: PredictOptions,
1430) -> Result<(), ObservablesError> {
1431 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1432 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1433 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
1434 Ok(())
1435}
1436
1437fn validate_transmit_time_inputs(
1438 receiver_ecef_m: [f64; 3],
1439 t_rx_j2000_s: f64,
1440) -> Result<(), ObservablesError> {
1441 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1442 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1443 Ok(())
1444}
1445
1446fn validate_emission_media_batch_options(
1447 options: EmissionMediaBatchOptions<'_>,
1448) -> Result<(), ObservablesError> {
1449 if options.media.needs_carrier() {
1450 validate::finite_positive(options.carrier_hz, "options.carrier_hz")
1451 .map_err(map_input_error)?;
1452 }
1453 if let Some(cutoff) = options.min_elevation_rad {
1454 validate::finite(cutoff, "options.min_elevation_rad").map_err(map_input_error)?;
1455 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&cutoff) {
1456 return Err(invalid_observable_input(
1457 "options.min_elevation_rad",
1458 ObservablesInputErrorKind::OutOfRange,
1459 ));
1460 }
1461 }
1462 Ok(())
1463}
1464
1465fn validated_state_at_j2000_s(
1466 source: &dyn ObservableEphemerisSource,
1467 sat: GnssSatelliteId,
1468 t_j2000_s: f64,
1469) -> Result<ObservableState, ObservablesError> {
1470 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
1471 validate_observable_state(&state)?;
1472 Ok(state)
1473}
1474
1475fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
1476 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
1477 .map_err(map_input_error)?;
1478 if let Some(clock_s) = state.clock_s {
1479 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
1480 }
1481 Ok(())
1482}
1483
1484fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
1485 let range = (delta_ecef_m[0] * delta_ecef_m[0]
1486 + delta_ecef_m[1] * delta_ecef_m[1]
1487 + delta_ecef_m[2] * delta_ecef_m[2])
1488 .sqrt();
1489 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
1490}
1491
1492fn map_input_error(error: validate::FieldError) -> ObservablesError {
1493 ObservablesError::InvalidInput {
1494 field: error.field(),
1495 kind: ObservablesInputErrorKind::from(&error),
1496 }
1497}
1498
1499fn invalid_observable_input(
1500 field: &'static str,
1501 kind: ObservablesInputErrorKind,
1502) -> ObservablesError {
1503 ObservablesError::InvalidInput { field, kind }
1504}
1505
1506fn media_instant(t_rx_j2000_s: f64) -> Result<Instant, ObservablesError> {
1507 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1508 let days = (t_rx_j2000_s / SECONDS_PER_DAY).floor();
1509 let seconds_into_day = t_rx_j2000_s - days * SECONDS_PER_DAY;
1510 let fraction = seconds_into_day / SECONDS_PER_DAY;
1511 let split = JulianDateSplit::new(J2000_JD + days, fraction).map_err(|_| {
1512 invalid_observable_input("t_rx_j2000_s", ObservablesInputErrorKind::OutOfRange)
1513 })?;
1514 Ok(Instant::from_julian_date(TimeScale::Gpst, split))
1515}
1516
1517fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ObservablesError> {
1518 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1519 let rounded = t_rx_j2000_s.round();
1520 if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
1521 return Err(invalid_observable_input(
1522 "t_rx_j2000_s",
1523 ObservablesInputErrorKind::OutOfRange,
1524 ));
1525 }
1526 Ok(rounded as i64)
1527}
1528
1529fn map_media_error(error: Error) -> ObservablesError {
1530 match error {
1531 Error::InvalidInput(message) => map_media_invalid_input(&message),
1532 Error::IonexOutOfCoverage(_) => ObservablesError::Media(error),
1533 _ => invalid_observable_input("media", ObservablesInputErrorKind::OutOfRange),
1534 }
1535}
1536
1537fn map_media_invalid_input(message: &str) -> ObservablesError {
1538 let kind = if message.ends_with("not finite") {
1539 ObservablesInputErrorKind::NonFinite
1540 } else if message.ends_with("not positive") {
1541 ObservablesInputErrorKind::NotPositive
1542 } else if message.ends_with("negative") {
1543 ObservablesInputErrorKind::Negative
1544 } else {
1545 ObservablesInputErrorKind::OutOfRange
1546 };
1547 let field = if message.starts_with("elevation_rad ") {
1548 "media.elevation_rad"
1549 } else if message.starts_with("receiver.lat_rad ") {
1550 "media.receiver.lat_rad"
1551 } else if message.starts_with("receiver.lon_rad ") {
1552 "media.receiver.lon_rad"
1553 } else if message.starts_with("receiver.height_m ") {
1554 "media.receiver.height_m"
1555 } else if message.starts_with("pressure_hpa ") {
1556 "media.pressure_hpa"
1557 } else if message.starts_with("temperature_k ") {
1558 "media.temperature_k"
1559 } else if message.starts_with("relative_humidity ") {
1560 "media.relative_humidity"
1561 } else if message.starts_with("frequency_hz ") {
1562 "media.carrier_hz"
1563 } else if message.starts_with("azimuth_rad ") {
1564 "media.azimuth_rad"
1565 } else {
1566 "media"
1567 };
1568 invalid_observable_input(field, kind)
1569}
1570
1571fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
1572 let sagnac = if apply {
1573 SagnacRecipe::ClosedFormZRotation
1574 } else {
1575 SagnacRecipe::Off
1576 };
1577 crate::estimation::substrate::range::rotate_transmit_satellite(
1578 sagnac,
1579 pos,
1580 tau_s,
1581 OMEGA_E_DOT_RAD_S,
1582 )
1583}
1584
1585#[derive(Debug, Clone, Copy, PartialEq)]
1586struct TopocentricGeometry {
1587 receiver: Wgs84Geodetic,
1588 elevation_rad: f64,
1589 azimuth_rad: f64,
1590 elevation_deg: f64,
1591 azimuth_deg: f64,
1592}
1593
1594fn topocentric(
1595 receiver_ecef_m: [f64; 3],
1596 delta_ecef_m: [f64; 3],
1597 range_m: f64,
1598) -> Result<TopocentricGeometry, ObservablesError> {
1599 let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
1600 receiver_ecef_m[0] / KM_TO_M,
1601 receiver_ecef_m[1] / KM_TO_M,
1602 receiver_ecef_m[2] / KM_TO_M,
1603 )
1604 .map_err(|_| ObservablesError::InvalidInput {
1605 field: "receiver_ecef_m",
1606 kind: ObservablesInputErrorKind::OutOfRange,
1607 })?;
1608 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1610 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1611 let receiver = Wgs84Geodetic::new(lat, lon, height_km * KM_TO_M).map_err(|_| {
1612 ObservablesError::InvalidInput {
1613 field: "receiver_ecef_m",
1614 kind: ObservablesInputErrorKind::OutOfRange,
1615 }
1616 })?;
1617
1618 let sl = lat.sin();
1619 let cl = lat.cos();
1620 let so = lon.sin();
1621 let co = lon.cos();
1622
1623 let dx = delta_ecef_m[0];
1624 let dy = delta_ecef_m[1];
1625 let dz = delta_ecef_m[2];
1626
1627 let e = -so * dx + co * dy;
1628 let n = -sl * co * dx - sl * so * dy + cl * dz;
1629 let u = cl * co * dx + cl * so * dy + sl * dz;
1630
1631 let horiz_sq = e * e + n * n;
1636 let (azimuth_rad, mut azimuth_deg) = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
1637 (0.0, 0.0)
1638 } else {
1639 let raw_azimuth_rad = e.atan2(n);
1640 (
1641 if raw_azimuth_rad < 0.0 {
1642 raw_azimuth_rad + 2.0 * PI
1643 } else {
1644 raw_azimuth_rad
1645 },
1646 raw_azimuth_rad * DEGREES_PER_SEMICIRCLE / PI,
1647 )
1648 };
1649 if azimuth_deg < 0.0 {
1650 azimuth_deg += DEGREES_PER_CIRCLE;
1651 }
1652 let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
1657 let elevation_rad = sin_elevation.asin();
1658 let elevation_deg = elevation_rad * DEGREES_PER_SEMICIRCLE / PI;
1659
1660 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
1661 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
1662 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
1663 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
1664 Ok(TopocentricGeometry {
1665 receiver,
1666 elevation_rad,
1667 azimuth_rad,
1668 elevation_deg,
1669 azimuth_deg,
1670 })
1671}
1672
1673#[cfg(test)]
1674mod public_api_tests {
1675 use super::*;
1676 use crate::{GnssSatelliteId, GnssSystem};
1677
1678 #[derive(Debug, Clone, Copy)]
1679 struct StaticSource {
1680 state: ObservableState,
1681 }
1682
1683 impl ObservableEphemerisSource for StaticSource {
1684 fn observable_state_at_j2000_s(
1685 &self,
1686 _sat: GnssSatelliteId,
1687 _t_j2000_s: f64,
1688 ) -> Result<ObservableState, ObservablesError> {
1689 Ok(self.state)
1690 }
1691 }
1692
1693 #[test]
1694 fn predict_ranges_matches_transmit_time_loop_bitwise() {
1695 let source = StaticSource {
1696 state: ObservableState {
1697 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1698 clock_s: Some(1.25e-6),
1699 },
1700 };
1701 let options = PredictOptions {
1702 carrier_hz: F_L1_HZ,
1703 light_time: true,
1704 sagnac: true,
1705 };
1706 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1707 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1708 let requests = [
1709 RangePredictionRequest {
1710 sat: sat1,
1711 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1712 t_rx_j2000_s: 646_272_000.0,
1713 },
1714 RangePredictionRequest {
1715 sat: sat2,
1716 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1717 t_rx_j2000_s: 646_272_060.0,
1718 },
1719 ];
1720 let mut out = [RangePrediction {
1721 geometric_range_m: 0.0,
1722 sat_clock_s: None,
1723 transmit_time_j2000_s: 0.0,
1724 sat_pos_ecef_m: [0.0; 3],
1725 }; 2];
1726 predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
1727
1728 let tt_options = TransmitTimeOptions {
1729 light_time: options.light_time,
1730 sagnac: options.sagnac,
1731 };
1732 for (request, got) in requests.iter().zip(out.iter()) {
1733 let single = transmit_time_satellite_state(
1734 &source,
1735 request.sat,
1736 request.receiver_ecef_m,
1737 request.t_rx_j2000_s,
1738 tt_options,
1739 )
1740 .expect("single transmit-time state");
1741 assert_eq!(
1742 got.geometric_range_m.to_bits(),
1743 single.geometric_range_m.to_bits()
1744 );
1745 assert_eq!(
1746 got.transmit_time_j2000_s.to_bits(),
1747 single.transmit_time_j2000_s.to_bits()
1748 );
1749 assert_eq!(
1750 got.sat_clock_s.map(f64::to_bits),
1751 single.clock_s.map(f64::to_bits)
1752 );
1753 assert_eq!(
1754 got.sat_pos_ecef_m.map(f64::to_bits),
1755 single.position_ecef_m.map(f64::to_bits)
1756 );
1757 }
1758 }
1759
1760 #[test]
1761 fn predict_ranges_batch_matches_scalar_calls_bitwise() {
1762 let source = StaticSource {
1765 state: ObservableState {
1766 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1767 clock_s: Some(1.25e-6),
1768 },
1769 };
1770 let options = PredictOptions::default();
1771 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1772 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1773 let requests = [
1774 RangePredictionRequest {
1775 sat: sat1,
1776 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1777 t_rx_j2000_s: 646_272_000.0,
1778 },
1779 RangePredictionRequest {
1780 sat: sat2,
1781 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1782 t_rx_j2000_s: 646_272_060.0,
1783 },
1784 RangePredictionRequest {
1785 sat: sat1,
1786 receiver_ecef_m: [-2_700_000.0, -4_290_000.0, 3_855_000.0],
1787 t_rx_j2000_s: 646_272_120.0,
1788 },
1789 ];
1790 let zero = RangePrediction {
1791 geometric_range_m: 0.0,
1792 sat_clock_s: None,
1793 transmit_time_j2000_s: 0.0,
1794 sat_pos_ecef_m: [0.0; 3],
1795 };
1796
1797 let mut batch = [zero; 3];
1798 predict_ranges(&source, &requests, options, &mut batch).expect("batch ranges");
1799
1800 for (i, request) in requests.iter().enumerate() {
1801 let mut single = [zero; 1];
1802 predict_ranges(&source, std::slice::from_ref(request), options, &mut single)
1803 .expect("single range");
1804 assert_eq!(
1805 batch[i].geometric_range_m.to_bits(),
1806 single[0].geometric_range_m.to_bits()
1807 );
1808 assert_eq!(
1809 batch[i].transmit_time_j2000_s.to_bits(),
1810 single[0].transmit_time_j2000_s.to_bits()
1811 );
1812 assert_eq!(
1813 batch[i].sat_clock_s.map(f64::to_bits),
1814 single[0].sat_clock_s.map(f64::to_bits)
1815 );
1816 assert_eq!(
1817 batch[i].sat_pos_ecef_m.map(f64::to_bits),
1818 single[0].sat_pos_ecef_m.map(f64::to_bits)
1819 );
1820 }
1821 }
1822
1823 #[test]
1824 fn predict_ranges_rejects_length_mismatch() {
1825 let source = StaticSource {
1826 state: ObservableState {
1827 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1828 clock_s: None,
1829 },
1830 };
1831 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1832 let requests = [RangePredictionRequest {
1833 sat,
1834 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1835 t_rx_j2000_s: 646_272_000.0,
1836 }];
1837 let mut out: [RangePrediction; 0] = [];
1838 let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
1839 .expect_err("length mismatch must fail");
1840 match err {
1841 ObservablesError::InvalidInput { field, kind } => {
1842 assert_eq!(field, "out");
1843 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
1844 }
1845 other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
1846 }
1847 }
1848
1849 #[test]
1850 fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
1851 let rx = [
1860 4_509_179.095_483_66,
1861 275_556.225_682_215_9,
1862 4_487_348.408_865_919,
1863 ];
1864 let (lat_deg, lon_deg, _h) =
1865 itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
1866 .expect("receiver geodetic conversion");
1867 assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
1868
1869 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1872 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1873 let (sl, cl) = lat.sin_cos();
1874 let (so, co) = lon.sin_cos();
1875 let up = [cl * co, cl * so, sl];
1876
1877 let d = 20_000_000.0_f64;
1878 let delta = [up[0] * d, up[1] * d, up[2] * d];
1879 let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1880 let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
1883 assert!(
1884 u / range > 1.0,
1885 "test geometry must overshoot the asin domain"
1886 );
1887
1888 let geometry = topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
1889 assert!(geometry.elevation_deg.is_finite());
1890 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
1891 }
1892
1893 #[test]
1894 fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
1895 let source = StaticSource {
1896 state: ObservableState {
1897 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1898 clock_s: Some(1.25e-6),
1899 },
1900 };
1901 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1902 let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
1903 let state = transmit_time_satellite_state(
1904 &source,
1905 sat,
1906 rx,
1907 646_272_000.0,
1908 TransmitTimeOptions {
1909 light_time: false,
1910 sagnac: true,
1911 },
1912 )
1913 .expect("state");
1914 let prediction = predict(
1915 &source,
1916 sat,
1917 rx,
1918 646_272_000.0,
1919 PredictOptions {
1920 carrier_hz: F_L1_HZ,
1921 light_time: false,
1922 sagnac: true,
1923 },
1924 )
1925 .expect("prediction");
1926
1927 assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
1928 assert_eq!(state.transmit_offset_us, 0);
1929 assert_eq!(
1930 state.transmit_time_j2000_s.to_bits(),
1931 646_272_000.0f64.to_bits()
1932 );
1933 assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
1934 assert_eq!(
1935 state.transmit_position_ecef_m.map(f64::to_bits),
1936 source.state.position_ecef_m.map(f64::to_bits)
1937 );
1938 assert_eq!(
1939 state.position_ecef_m.map(f64::to_bits),
1940 prediction.sat_pos_ecef_m.map(f64::to_bits)
1941 );
1942 assert_eq!(
1943 state.velocity_m_s.map(f64::to_bits),
1944 prediction.sat_velocity_m_s.map(f64::to_bits)
1945 );
1946 assert_eq!(
1947 state.geometric_range_m.to_bits(),
1948 prediction.geometric_range_m.to_bits()
1949 );
1950 assert_eq!(
1951 state.los_unit.map(f64::to_bits),
1952 prediction.los_unit.map(f64::to_bits)
1953 );
1954 }
1955}
1956
1957#[cfg(test)]
1958mod media_validation_tests {
1959 use super::*;
1965 use crate::astro::time::civil::split_julian_date_from_j2000_seconds;
1966 use crate::ionex::TecGridSamples;
1967 use crate::GnssSystem;
1968
1969 const T_RX_J2000_S: f64 = 646_272_000.0;
1970 const T_RX_J2000_I64: i64 = 646_272_000;
1971
1972 #[derive(Debug, Clone, Copy)]
1973 struct StaticSource {
1974 state: ObservableState,
1975 }
1976
1977 impl ObservableEphemerisSource for StaticSource {
1978 fn observable_state_at_j2000_s(
1979 &self,
1980 _sat: GnssSatelliteId,
1981 _t_j2000_s: f64,
1982 ) -> Result<ObservableState, ObservablesError> {
1983 Ok(self.state)
1984 }
1985 }
1986
1987 fn epoch() -> Instant {
1988 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(T_RX_J2000_I64);
1989 Instant::from_julian_date(
1990 TimeScale::Gpst,
1991 JulianDateSplit::new(jd_whole, fraction).expect("valid media epoch"),
1992 )
1993 }
1994
1995 fn receiver() -> Wgs84Geodetic {
1996 Wgs84Geodetic::new(0.0, 0.0, 0.0).expect("valid receiver")
1997 }
1998
1999 fn met() -> Met {
2000 Met::new(1013.25, 288.15, 0.5).expect("valid met")
2001 }
2002
2003 fn klobuchar_model() -> IonoModel {
2004 IonoModel::Klobuchar(crate::ionex::KlobucharParams {
2005 alpha: [0.0; 4],
2006 beta: [0.0; 4],
2007 })
2008 }
2009
2010 fn ionex() -> Ionex {
2011 let map = vec![
2012 vec![12.0, 12.0, 12.0],
2013 vec![12.0, 12.0, 12.0],
2014 vec![12.0, 12.0, 12.0],
2015 ];
2016 Ionex::from_samples(TecGridSamples {
2017 map_epochs: vec![epoch()],
2018 lat_nodes_deg: vec![90.0, 0.0, -90.0],
2019 lon_nodes_deg: vec![-180.0, 0.0, 180.0],
2020 dlat_deg: -90.0,
2021 dlon_deg: 180.0,
2022 shell_height_km: 450.0,
2023 base_radius_km: 6371.0,
2024 exponent: 0,
2025 tec_maps: vec![map],
2026 rms_maps: Vec::new(),
2027 })
2028 .expect("valid IONEX samples")
2029 }
2030
2031 fn direct_troposphere(elevation_rad: f64) -> f64 {
2032 let zenith =
2033 tropo_zenith(TropoModel::Saastamoinen, receiver(), met()).expect("zenith delay");
2034 let mapping = tropo_mapping(MappingModel::Niell, elevation_rad, receiver(), epoch())
2035 .expect("mapping");
2036 zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
2037 }
2038
2039 fn assert_bits_eq(label: &str, got: f64, expected: f64) {
2040 assert_eq!(
2041 got.to_bits(),
2042 expected.to_bits(),
2043 "{label}: got {got:?}, expected {expected:?}"
2044 );
2045 }
2046
2047 fn assert_prediction_bits_eq(got: &PredictedObservables, expected: &PredictedObservables) {
2048 assert_bits_eq(
2049 "geometric range",
2050 got.geometric_range_m,
2051 expected.geometric_range_m,
2052 );
2053 assert_bits_eq("range-rate", got.range_rate_m_s, expected.range_rate_m_s);
2054 assert_bits_eq("Doppler", got.doppler_hz, expected.doppler_hz);
2055 assert_eq!(
2056 got.sat_clock_s.map(f64::to_bits),
2057 expected.sat_clock_s.map(f64::to_bits)
2058 );
2059 assert_bits_eq("elevation", got.elevation_deg, expected.elevation_deg);
2060 assert_bits_eq("azimuth", got.azimuth_deg, expected.azimuth_deg);
2061 assert_eq!(got.transmit_offset_us, expected.transmit_offset_us);
2062 assert_bits_eq(
2063 "transmit time",
2064 got.transmit_time_j2000_s,
2065 expected.transmit_time_j2000_s,
2066 );
2067 for k in 0..3 {
2068 assert_bits_eq("los", got.los_unit[k], expected.los_unit[k]);
2069 assert_bits_eq(
2070 "satellite position",
2071 got.sat_pos_ecef_m[k],
2072 expected.sat_pos_ecef_m[k],
2073 );
2074 assert_bits_eq(
2075 "satellite velocity",
2076 got.sat_velocity_m_s[k],
2077 expected.sat_velocity_m_s[k],
2078 );
2079 }
2080 }
2081
2082 fn assert_range_prediction_bits_eq(got: &RangePrediction, expected: &RangePrediction) {
2083 assert_bits_eq(
2084 "range geometric",
2085 got.geometric_range_m,
2086 expected.geometric_range_m,
2087 );
2088 assert_eq!(
2089 got.sat_clock_s.map(f64::to_bits),
2090 expected.sat_clock_s.map(f64::to_bits)
2091 );
2092 assert_bits_eq(
2093 "range transmit time",
2094 got.transmit_time_j2000_s,
2095 expected.transmit_time_j2000_s,
2096 );
2097 for k in 0..3 {
2098 assert_bits_eq(
2099 "range satellite position",
2100 got.sat_pos_ecef_m[k],
2101 expected.sat_pos_ecef_m[k],
2102 );
2103 }
2104 }
2105
2106 #[test]
2107 fn media_corrections_match_direct_tropo_and_klobuchar_bits() {
2108 for elevation_deg in [5.0_f64, 15.0, 90.0] {
2109 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2110 let azimuth_rad = 0.25;
2111 let options = ObservableMediaOptions {
2112 troposphere: Some(ObservableTroposphereCorrection {
2113 met: met(),
2114 mapping: MappingModel::Niell,
2115 }),
2116 ionosphere: Some(ObservableIonosphereCorrection::Broadcast(klobuchar_model())),
2117 };
2118 let got = observable_media_corrections(
2119 receiver(),
2120 elevation_rad,
2121 azimuth_rad,
2122 T_RX_J2000_S,
2123 F_L1_HZ,
2124 options,
2125 )
2126 .expect("media corrections");
2127 let expected_tropo = direct_troposphere(elevation_rad);
2128 let expected_iono = ionosphere_delay(
2129 receiver(),
2130 elevation_rad,
2131 azimuth_rad,
2132 epoch(),
2133 F_L1_HZ,
2134 &klobuchar_model(),
2135 )
2136 .expect("direct Klobuchar");
2137
2138 assert_bits_eq("troposphere", got.troposphere_m, expected_tropo);
2139 assert_bits_eq("Klobuchar", got.ionosphere_m, expected_iono);
2140 assert_bits_eq("total", got.total_m, expected_tropo + expected_iono);
2141 }
2142 }
2143
2144 #[test]
2145 fn media_corrections_match_direct_ionex_bits() {
2146 let ionex = ionex();
2147 for elevation_deg in [5.0_f64, 15.0, 90.0] {
2148 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2149 let azimuth_rad = 1.0;
2150 let got = observable_media_corrections(
2151 receiver(),
2152 elevation_rad,
2153 azimuth_rad,
2154 T_RX_J2000_S,
2155 F_L1_HZ,
2156 ObservableMediaOptions {
2157 troposphere: None,
2158 ionosphere: Some(ObservableIonosphereCorrection::Ionex(&ionex)),
2159 },
2160 )
2161 .expect("IONEX media correction");
2162 let expected = ionex_slant_delay(
2163 &ionex,
2164 receiver(),
2165 elevation_rad,
2166 azimuth_rad,
2167 T_RX_J2000_I64,
2168 F_L1_HZ,
2169 )
2170 .expect("direct IONEX");
2171
2172 assert_bits_eq("IONEX", got.ionosphere_m, expected);
2173 assert_bits_eq("IONEX total", got.total_m, expected);
2174 }
2175 }
2176
2177 #[test]
2178 fn default_media_prediction_matches_predict_bits() {
2179 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2180 let rx = [6_378_137.0, 0.0, 0.0];
2181 let source = StaticSource {
2182 state: ObservableState {
2183 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2184 clock_s: Some(0.0),
2185 },
2186 };
2187 let options = PredictOptions {
2188 carrier_hz: F_L1_HZ,
2189 light_time: false,
2190 sagnac: false,
2191 };
2192 let plain = predict(&source, sat, rx, T_RX_J2000_S, options).expect("plain predict");
2193 let media = predict_with_media(
2194 &source,
2195 sat,
2196 rx,
2197 T_RX_J2000_S,
2198 MediaPredictOptions {
2199 prediction: options,
2200 media: ObservableMediaOptions::default(),
2201 },
2202 )
2203 .expect("default media predict");
2204
2205 assert_prediction_bits_eq(&media.prediction, &plain);
2206 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2207 assert_eq!(media.media, AppliedMediaCorrections::default());
2208 }
2209
2210 #[test]
2211 fn default_media_prediction_skips_media_epoch_for_large_epoch() {
2212 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2213 let rx = [6_378_137.0, 0.0, 0.0];
2214 let source = StaticSource {
2215 state: ObservableState {
2216 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2217 clock_s: Some(0.0),
2218 },
2219 };
2220 let options = PredictOptions {
2221 carrier_hz: F_L1_HZ,
2222 light_time: false,
2223 sagnac: false,
2224 };
2225 let t_rx = 1.0e20;
2226 let plain = predict(&source, sat, rx, t_rx, options).expect("plain predict");
2227 let media = predict_with_media(
2228 &source,
2229 sat,
2230 rx,
2231 t_rx,
2232 MediaPredictOptions {
2233 prediction: options,
2234 media: ObservableMediaOptions::default(),
2235 },
2236 )
2237 .expect("default media predict");
2238
2239 assert_prediction_bits_eq(&media.prediction, &plain);
2240 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2241 assert_eq!(media.media, AppliedMediaCorrections::default());
2242 }
2243
2244 #[test]
2245 fn below_troposphere_validity_returns_typed_error() {
2246 let err = observable_media_corrections(
2247 receiver(),
2248 2.0 * PI / DEGREES_PER_SEMICIRCLE,
2249 0.0,
2250 T_RX_J2000_S,
2251 F_L1_HZ,
2252 ObservableMediaOptions {
2253 troposphere: Some(ObservableTroposphereCorrection::default()),
2254 ionosphere: None,
2255 },
2256 )
2257 .expect_err("below mapping validity must fail");
2258
2259 match err {
2260 ObservablesError::InvalidInput { field, kind } => {
2261 assert_eq!(field, "media.elevation_rad");
2262 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
2263 }
2264 other => panic!("expected typed InvalidInput, got {other:?}"),
2265 }
2266 }
2267
2268 #[test]
2269 fn range_media_prediction_adds_direct_troposphere_bits() {
2270 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2271 let rx = [6_378_137.0, 0.0, 0.0];
2272 let elevation_rad = core::f64::consts::FRAC_PI_2;
2273 let range_m = 20_000_000.0;
2274 let delta = [range_m, 0.0, 0.0];
2275 let source = StaticSource {
2276 state: ObservableState {
2277 position_ecef_m: [rx[0] + delta[0], rx[1] + delta[1], rx[2] + delta[2]],
2278 clock_s: Some(0.0),
2279 },
2280 };
2281 let options = MediaPredictOptions {
2282 prediction: PredictOptions {
2283 carrier_hz: f64::NAN,
2284 light_time: false,
2285 sagnac: false,
2286 },
2287 media: ObservableMediaOptions {
2288 troposphere: Some(ObservableTroposphereCorrection::default()),
2289 ionosphere: None,
2290 },
2291 };
2292 let request = [RangePredictionRequest {
2293 sat,
2294 receiver_ecef_m: rx,
2295 t_rx_j2000_s: T_RX_J2000_S,
2296 }];
2297 let zero_prediction = RangePrediction {
2298 geometric_range_m: 0.0,
2299 sat_clock_s: None,
2300 transmit_time_j2000_s: 0.0,
2301 sat_pos_ecef_m: [0.0; 3],
2302 };
2303 let mut out = [MediaRangePrediction {
2304 prediction: zero_prediction,
2305 range_m: 0.0,
2306 media: AppliedMediaCorrections::default(),
2307 }];
2308 predict_ranges_with_media(&source, &request, options, &mut out)
2309 .expect("range media prediction");
2310 let got = out[0];
2311 let expected = got.prediction.geometric_range_m + direct_troposphere(elevation_rad);
2312 assert_bits_eq("corrected range", got.range_m, expected);
2313 }
2314
2315 #[test]
2316 fn default_range_media_prediction_matches_range_bits_with_unused_carrier() {
2317 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2318 let rx = [6_378_137.0, 0.0, 0.0];
2319 let source = StaticSource {
2320 state: ObservableState {
2321 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2322 clock_s: Some(0.0),
2323 },
2324 };
2325 let options = PredictOptions {
2326 carrier_hz: f64::NAN,
2327 light_time: false,
2328 sagnac: false,
2329 };
2330 let request = [RangePredictionRequest {
2331 sat,
2332 receiver_ecef_m: rx,
2333 t_rx_j2000_s: T_RX_J2000_S,
2334 }];
2335 let zero_prediction = RangePrediction {
2336 geometric_range_m: 0.0,
2337 sat_clock_s: None,
2338 transmit_time_j2000_s: 0.0,
2339 sat_pos_ecef_m: [0.0; 3],
2340 };
2341 let mut plain = [zero_prediction];
2342 predict_ranges(&source, &request, options, &mut plain).expect("plain range");
2343 let mut media = [MediaRangePrediction {
2344 prediction: zero_prediction,
2345 range_m: 0.0,
2346 media: AppliedMediaCorrections::default(),
2347 }];
2348 predict_ranges_with_media(
2349 &source,
2350 &request,
2351 MediaPredictOptions {
2352 prediction: options,
2353 media: ObservableMediaOptions::default(),
2354 },
2355 &mut media,
2356 )
2357 .expect("default media range");
2358
2359 assert_range_prediction_bits_eq(&media[0].prediction, &plain[0]);
2360 assert_bits_eq(
2361 "default range",
2362 media[0].range_m,
2363 plain[0].geometric_range_m,
2364 );
2365 assert_eq!(media[0].media, AppliedMediaCorrections::default());
2366 }
2367}
2368
2369#[cfg(all(test, sidereon_repo_tests))]
2370mod tests {
2371 use super::*;
2372 use crate::{GnssSatelliteId, GnssSystem};
2373
2374 #[derive(Debug, Clone, Copy)]
2375 struct StaticSource {
2376 state: ObservableState,
2377 }
2378
2379 impl ObservableEphemerisSource for StaticSource {
2380 fn observable_state_at_j2000_s(
2381 &self,
2382 _sat: GnssSatelliteId,
2383 _t_j2000_s: f64,
2384 ) -> Result<ObservableState, ObservablesError> {
2385 Ok(self.state)
2386 }
2387 }
2388
2389 fn sp3_fixture() -> Sp3 {
2390 let path = concat!(
2391 env!("CARGO_MANIFEST_DIR"),
2392 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
2393 );
2394 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
2395 Sp3::parse(&bytes).expect("parse SP3 fixture")
2396 }
2397
2398 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
2399 StaticSource {
2400 state: ObservableState {
2401 position_ecef_m,
2402 clock_s: Some(0.0),
2403 },
2404 }
2405 }
2406
2407 fn no_light_time_options() -> PredictOptions {
2408 PredictOptions {
2409 carrier_hz: F_L1_HZ,
2410 light_time: false,
2411 sagnac: true,
2412 }
2413 }
2414
2415 fn assert_invalid_observables_input(
2416 err: ObservablesError,
2417 field: &'static str,
2418 kind: ObservablesInputErrorKind,
2419 ) {
2420 match err {
2421 ObservablesError::InvalidInput {
2422 field: got_field,
2423 kind: got_kind,
2424 } => {
2425 assert_eq!(got_field, field);
2426 assert_eq!(got_kind, kind);
2427 }
2428 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
2429 }
2430 }
2431
2432 #[test]
2433 fn split_julian_to_j2000_seconds_matches_orbis_time() {
2434 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
2435 assert_eq!(t, 646_272_000.0);
2436 }
2437
2438 #[test]
2439 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
2440 for (jd_whole, jd_fraction, field) in [
2441 (f64::NAN, 0.5, "jd_whole"),
2442 (f64::INFINITY, 0.5, "jd_whole"),
2443 (2_459_024.5, f64::NAN, "jd_fraction"),
2444 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
2445 ] {
2446 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
2447 .expect_err("non-finite split Julian date part must fail");
2448 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
2449 }
2450 }
2451
2452 #[test]
2453 fn sp3_predict_reference_case() {
2454 let sp3 = sp3_fixture();
2455 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2456 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
2457 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
2458 .expect("predict observables");
2459
2460 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
2461 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
2462 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
2463 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
2464 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
2465 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
2466 assert_eq!(obs.transmit_offset_us, 69_288);
2467 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
2468 assert_eq!(
2469 obs.los_unit.map(f64::to_bits),
2470 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
2471 );
2472 assert_eq!(
2473 obs.sat_pos_ecef_m.map(f64::to_bits),
2474 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
2475 );
2476 assert_eq!(
2477 obs.sat_velocity_m_s.map(f64::to_bits),
2478 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
2479 );
2480 }
2481
2482 #[test]
2483 fn predict_rejects_invalid_entry_inputs() {
2484 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
2485 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2486
2487 let err = predict(
2488 &source,
2489 sat,
2490 [f64::NAN, 0.0, 0.0],
2491 646_272_000.0,
2492 no_light_time_options(),
2493 )
2494 .expect_err("non-finite receiver position must fail");
2495 assert_invalid_observables_input(
2496 err,
2497 "receiver_ecef_m",
2498 ObservablesInputErrorKind::NonFinite,
2499 );
2500
2501 let err = predict(
2502 &source,
2503 sat,
2504 [0.0, 0.0, 0.0],
2505 f64::INFINITY,
2506 no_light_time_options(),
2507 )
2508 .expect_err("non-finite receive time must fail");
2509 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
2510
2511 let mut options = no_light_time_options();
2512 options.carrier_hz = 0.0;
2513 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
2514 .expect_err("non-positive carrier must fail");
2515 assert_invalid_observables_input(
2516 err,
2517 "options.carrier_hz",
2518 ObservablesInputErrorKind::NotPositive,
2519 );
2520 }
2521
2522 #[test]
2523 fn predict_rejects_invalid_source_state_and_zero_range() {
2524 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2525
2526 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
2527 let err = predict(
2528 &source,
2529 sat,
2530 [0.0, 0.0, 0.0],
2531 646_272_000.0,
2532 no_light_time_options(),
2533 )
2534 .expect_err("non-finite ephemeris position must fail");
2535 assert_invalid_observables_input(
2536 err,
2537 "observable state position_ecef_m",
2538 ObservablesInputErrorKind::NonFinite,
2539 );
2540
2541 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
2542 let err = predict(
2543 &source,
2544 sat,
2545 [1_000.0, 2_000.0, 3_000.0],
2546 646_272_000.0,
2547 no_light_time_options(),
2548 )
2549 .expect_err("zero geometric range must fail");
2550 assert_invalid_observables_input(
2551 err,
2552 "geometric_range_m",
2553 ObservablesInputErrorKind::NotPositive,
2554 );
2555 }
2556
2557 #[test]
2558 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
2559 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
2560 .expect_err("invalid receiver geodetic conversion must fail");
2561
2562 assert_invalid_observables_input(
2563 err,
2564 "receiver_ecef_m",
2565 ObservablesInputErrorKind::OutOfRange,
2566 );
2567 }
2568
2569 const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
2573
2574 #[test]
2575 fn topocentric_azimuth_is_zero_at_exact_zenith() {
2576 let geometry = topocentric(
2579 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2580 [20_000_000.0, 0.0, 0.0],
2581 20_000_000.0,
2582 )
2583 .expect("zenith topocentric must not error");
2584 assert_eq!(geometry.azimuth_deg, 0.0);
2585 assert!(geometry.azimuth_deg.is_finite());
2586 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
2587 }
2588
2589 #[test]
2590 fn topocentric_azimuth_is_zero_just_off_zenith() {
2591 let geometry = topocentric(
2594 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2595 [20_000_000.0, 1.0e-9, 1.0e-9],
2596 20_000_000.0,
2597 )
2598 .expect("near-zenith topocentric must not error");
2599 assert_eq!(geometry.azimuth_deg, 0.0);
2600 assert!(geometry.azimuth_deg.is_finite());
2601 }
2602
2603 #[test]
2604 fn predict_azimuth_is_zero_at_exact_zenith() {
2605 let source = StaticSource {
2606 state: ObservableState {
2607 position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
2608 clock_s: None,
2609 },
2610 };
2611 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2612 let obs = predict(
2613 &source,
2614 sat,
2615 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2616 0.0,
2617 PredictOptions {
2618 carrier_hz: F_L1_HZ,
2619 light_time: false,
2620 sagnac: false,
2621 },
2622 )
2623 .expect("zenith predict must not error");
2624 assert_eq!(obs.azimuth_deg, 0.0);
2625 assert!(obs.azimuth_deg.is_finite());
2626 assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
2627 }
2628
2629 fn batch_test_requests() -> Vec<PredictRequest> {
2630 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2631 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
2632 vec![
2633 (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
2634 (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
2635 (
2636 sat1,
2637 [1_130_000.0, -4_830_000.0, 3_994_000.0],
2638 646_272_060.0,
2639 ),
2640 (
2641 sat2,
2642 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
2643 646_272_090.0,
2644 ),
2645 ]
2646 }
2647
2648 fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
2649 assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
2650 assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
2651 assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
2652 assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
2653 assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
2654 assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
2655 assert_eq!(
2656 a.transmit_time_j2000_s.to_bits(),
2657 b.transmit_time_j2000_s.to_bits()
2658 );
2659 for k in 0..3 {
2660 assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
2661 assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
2662 assert_eq!(
2663 a.sat_velocity_m_s[k].to_bits(),
2664 b.sat_velocity_m_s[k].to_bits()
2665 );
2666 }
2667 }
2668
2669 #[test]
2670 fn predict_batch_matches_scalar_loop_bitwise() {
2671 let source = StaticSource {
2672 state: ObservableState {
2673 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2674 clock_s: Some(1.25e-6),
2675 },
2676 };
2677 let options = PredictOptions {
2678 carrier_hz: F_L1_HZ,
2679 light_time: true,
2680 sagnac: true,
2681 };
2682 let requests = batch_test_requests();
2683 let batch = predict_batch(&source, &requests, options);
2684 assert_eq!(batch.len(), requests.len());
2685 for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
2686 let scalar = predict(&source, sat, rx, t, options);
2687 match (entry, &scalar) {
2688 (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
2689 (Err(_), Err(_)) => {}
2690 _ => panic!("batch and scalar predict disagree on Ok/Err"),
2691 }
2692 }
2693 }
2694
2695 #[test]
2696 fn predict_batch_parallel_matches_serial_bitwise() {
2697 let source = StaticSource {
2698 state: ObservableState {
2699 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2700 clock_s: Some(1.25e-6),
2701 },
2702 };
2703 let options = PredictOptions {
2704 carrier_hz: F_L1_HZ,
2705 light_time: true,
2706 sagnac: true,
2707 };
2708 let requests = batch_test_requests();
2709 let serial = predict_batch(&source, &requests, options);
2710 let parallel = predict_batch_parallel(&source, &requests, options);
2711 assert_eq!(serial.len(), parallel.len());
2712 for (s, p) in serial.iter().zip(parallel.iter()) {
2713 match (s, p) {
2714 (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
2715 (Err(_), Err(_)) => {}
2716 _ => panic!("serial and parallel batch disagree on Ok/Err"),
2717 }
2718 }
2719 }
2720}