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
139#[derive(Debug, Clone, PartialEq)]
146pub struct EmissionMediaReceiverContext {
147 topocentric: TopocentricReceiver,
148}
149
150impl EmissionMediaReceiverContext {
151 pub fn new(receiver_ecef_m: [f64; 3]) -> Result<Self, ObservablesError> {
153 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
154 Ok(Self {
155 topocentric: topocentric_receiver(receiver_ecef_m)?,
156 })
157 }
158
159 pub fn receiver_ecef_m(&self) -> [f64; 3] {
161 self.topocentric.ecef_m
162 }
163
164 pub fn receiver_geodetic(&self) -> Wgs84Geodetic {
166 self.topocentric.geodetic
167 }
168}
169
170pub trait ObservableEphemerisSource {
172 fn observable_state_at_j2000_s(
174 &self,
175 sat: GnssSatelliteId,
176 t_j2000_s: f64,
177 ) -> Result<ObservableState, ObservablesError>;
178
179 fn observable_states_at_j2000_s(
185 &self,
186 satellites: &[GnssSatelliteId],
187 epochs_j2000_s: &[f64],
188 ) -> Result<ObservableStateBatch, ObservablesError> {
189 if satellites.len() != epochs_j2000_s.len() {
190 return Err(ObservablesError::InvalidInput {
191 field: "epochs_j2000_s",
192 kind: ObservablesInputErrorKind::OutOfRange,
193 });
194 }
195
196 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
197 for (&sat, &epoch_j2000_s) in satellites.iter().zip(epochs_j2000_s.iter()) {
198 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
199 }
200 Ok(batch)
201 }
202
203 fn observable_states_at_shared_j2000_s(
208 &self,
209 satellites: &[GnssSatelliteId],
210 epoch_j2000_s: f64,
211 ) -> ObservableStateBatch {
212 let mut batch = ObservableStateBatch::with_capacity(satellites.len());
213 for &sat in satellites {
214 batch.push_state_result(self.observable_state_at_j2000_s(sat, epoch_j2000_s));
215 }
216 batch
217 }
218}
219
220impl ObservableEphemerisSource for Sp3 {
221 fn observable_state_at_j2000_s(
222 &self,
223 sat: GnssSatelliteId,
224 t_j2000_s: f64,
225 ) -> Result<ObservableState, ObservablesError> {
226 let state = self
227 .position_at_j2000_seconds(sat, t_j2000_s)
228 .map_err(ObservablesError::Ephemeris)?;
229 Ok(ObservableState {
230 position_ecef_m: state.position.as_array(),
231 clock_s: state.clock_s,
232 })
233 }
234}
235
236impl ObservableEphemerisSource for BroadcastEphemeris {
237 fn observable_state_at_j2000_s(
238 &self,
239 sat: GnssSatelliteId,
240 t_j2000_s: f64,
241 ) -> Result<ObservableState, ObservablesError> {
242 let Some((position_ecef_m, clock_s)) =
243 EphemerisSource::position_clock_at_j2000_s(self, sat, t_j2000_s)
244 else {
245 return Err(ObservablesError::NoEphemeris);
246 };
247 Ok(ObservableState {
248 position_ecef_m,
249 clock_s: Some(clock_s),
250 })
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum ObservablesInputErrorKind {
257 NonFinite,
259 NotPositive,
261 Negative,
263 OutOfRange,
265 Missing,
267 FloatParse,
269 IntParse,
271 InvalidCivilDate,
273 InvalidCivilTime,
275}
276
277impl core::fmt::Display for ObservablesInputErrorKind {
278 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
279 let label = match self {
280 Self::NonFinite => "not finite",
281 Self::NotPositive => "not positive",
282 Self::Negative => "negative",
283 Self::OutOfRange => "out of range",
284 Self::Missing => "missing",
285 Self::FloatParse => "invalid float",
286 Self::IntParse => "invalid integer",
287 Self::InvalidCivilDate => "invalid civil date",
288 Self::InvalidCivilTime => "invalid civil time",
289 };
290 f.write_str(label)
291 }
292}
293
294impl From<&validate::FieldError> for ObservablesInputErrorKind {
295 fn from(error: &validate::FieldError) -> Self {
296 match error {
297 validate::FieldError::Missing { .. } => Self::Missing,
298 validate::FieldError::NonFinite { .. } => Self::NonFinite,
299 validate::FieldError::NotPositive { .. } => Self::NotPositive,
300 validate::FieldError::Negative { .. } => Self::Negative,
301 validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
302 validate::FieldError::FloatParse { .. } => Self::FloatParse,
303 validate::FieldError::IntParse { .. } => Self::IntParse,
304 validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
305 validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
306 }
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum ObservablesError {
313 InvalidInput {
316 field: &'static str,
318 kind: ObservablesInputErrorKind,
320 },
321 NoEphemeris,
323 Ephemeris(Error),
325 Media(Error),
327}
328
329impl core::fmt::Display for ObservablesError {
330 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
331 match self {
332 Self::InvalidInput { field, kind } => {
333 write!(f, "invalid observable input {field}: {kind}")
334 }
335 Self::NoEphemeris => write!(f, "no ephemeris"),
336 Self::Ephemeris(err) => write!(f, "{err}"),
337 Self::Media(err) => write!(f, "{err}"),
338 }
339 }
340}
341
342impl std::error::Error for ObservablesError {}
343
344impl ObservableStateBatch {
345 pub fn with_capacity(capacity: usize) -> Self {
347 Self {
348 positions_ecef_m: Vec::with_capacity(capacity),
349 clocks_s: Vec::with_capacity(capacity),
350 element_results: Vec::with_capacity(capacity),
351 }
352 }
353
354 pub fn len(&self) -> usize {
356 self.element_results.len()
357 }
358
359 pub fn is_empty(&self) -> bool {
361 self.element_results.is_empty()
362 }
363
364 pub fn element(&self, index: usize) -> Option<Result<ObservableState, &ObservablesError>> {
368 match self.element_results.get(index)? {
369 Ok(()) => Some(Ok(ObservableState {
370 position_ecef_m: self.positions_ecef_m[index],
371 clock_s: self.clocks_s[index],
372 })),
373 Err(error) => Some(Err(error)),
374 }
375 }
376
377 pub fn element_status(&self, index: usize) -> Option<ObservableStateElementStatus> {
381 match self.element_results.get(index)? {
382 Ok(()) => Some(ObservableStateElementStatus::Valid),
383 Err(error) if is_observable_state_gap(error) => Some(ObservableStateElementStatus::Gap),
384 Err(_) => Some(ObservableStateElementStatus::Error),
385 }
386 }
387
388 fn push_state_result(&mut self, result: Result<ObservableState, ObservablesError>) {
389 match result {
390 Ok(state) => {
391 self.positions_ecef_m.push(state.position_ecef_m);
392 self.clocks_s.push(state.clock_s);
393 self.element_results.push(Ok(()));
394 }
395 Err(error) => {
396 self.positions_ecef_m
397 .push(OBSERVABLE_STATE_MISSING_POSITION_ECEF_M);
398 self.clocks_s.push(None);
399 self.element_results.push(Err(error));
400 }
401 }
402 }
403}
404
405impl EmissionMediaBatch {
406 pub fn with_capacity(capacity: usize) -> Self {
408 Self {
409 positions_ecef_m: Vec::with_capacity(capacity),
410 clocks_s: Vec::with_capacity(capacity),
411 ionosphere_slant_delays_m: Vec::with_capacity(capacity),
412 troposphere_delays_m: Vec::with_capacity(capacity),
413 statuses: Vec::with_capacity(capacity),
414 element_errors: Vec::with_capacity(capacity),
415 }
416 }
417
418 pub fn len(&self) -> usize {
420 self.statuses.len()
421 }
422
423 pub fn is_empty(&self) -> bool {
425 self.statuses.is_empty()
426 }
427
428 pub fn clear(&mut self) {
430 self.positions_ecef_m.clear();
431 self.clocks_s.clear();
432 self.ionosphere_slant_delays_m.clear();
433 self.troposphere_delays_m.clear();
434 self.statuses.clear();
435 self.element_errors.clear();
436 }
437
438 pub fn reserve(&mut self, capacity: usize) {
440 reserve_at_least(&mut self.positions_ecef_m, capacity);
441 reserve_at_least(&mut self.clocks_s, capacity);
442 reserve_at_least(&mut self.ionosphere_slant_delays_m, capacity);
443 reserve_at_least(&mut self.troposphere_delays_m, capacity);
444 reserve_at_least(&mut self.statuses, capacity);
445 reserve_at_least(&mut self.element_errors, capacity);
446 }
447
448 pub fn element_status(&self, index: usize) -> Option<EmissionMediaStatus> {
452 self.statuses.get(index).copied()
453 }
454
455 fn push_valid(&mut self, state: ObservableState, media: AppliedMediaCorrections) {
456 self.positions_ecef_m.push(Some(state.position_ecef_m));
457 self.clocks_s.push(state.clock_s);
458 self.ionosphere_slant_delays_m
459 .push(Some(media.ionosphere_m));
460 self.troposphere_delays_m.push(Some(media.troposphere_m));
461 self.statuses.push(EmissionMediaStatus::Valid);
462 self.element_errors.push(None);
463 }
464
465 fn push_gap(&mut self, error: ObservablesError) {
466 self.positions_ecef_m.push(None);
467 self.clocks_s.push(None);
468 self.ionosphere_slant_delays_m.push(None);
469 self.troposphere_delays_m.push(None);
470 self.statuses.push(EmissionMediaStatus::Gap);
471 self.element_errors.push(Some(error));
472 }
473
474 fn push_below_cutoff(&mut self, state: ObservableState) {
475 self.positions_ecef_m.push(Some(state.position_ecef_m));
476 self.clocks_s.push(state.clock_s);
477 self.ionosphere_slant_delays_m.push(None);
478 self.troposphere_delays_m.push(None);
479 self.statuses
480 .push(EmissionMediaStatus::BelowElevationCutoff);
481 self.element_errors.push(None);
482 }
483
484 fn push_error(&mut self, state: Option<ObservableState>, error: ObservablesError) {
485 self.positions_ecef_m
486 .push(state.map(|state| state.position_ecef_m));
487 self.clocks_s.push(state.and_then(|state| state.clock_s));
488 self.ionosphere_slant_delays_m.push(None);
489 self.troposphere_delays_m.push(None);
490 self.statuses.push(EmissionMediaStatus::Error);
491 self.element_errors.push(Some(error));
492 }
493}
494
495fn reserve_at_least<T>(values: &mut Vec<T>, capacity: usize) {
496 if values.capacity() < capacity {
497 values.reserve(capacity - values.capacity());
498 }
499}
500
501pub fn is_observable_state_gap(error: &ObservablesError) -> bool {
507 matches!(
508 error,
509 ObservablesError::NoEphemeris
510 | ObservablesError::Ephemeris(crate::Error::EpochOutOfRange)
511 | ObservablesError::Ephemeris(crate::Error::UnknownSatellite(_))
512 )
513}
514
515#[derive(Debug, Clone, Copy, PartialEq)]
517pub struct PredictOptions {
518 pub carrier_hz: f64,
520 pub light_time: bool,
522 pub sagnac: bool,
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub struct TransmitTimeOptions {
529 pub light_time: bool,
531 pub sagnac: bool,
533}
534
535impl Default for TransmitTimeOptions {
536 fn default() -> Self {
537 Self {
538 light_time: true,
539 sagnac: true,
540 }
541 }
542}
543
544impl Default for PredictOptions {
545 fn default() -> Self {
546 Self {
547 carrier_hz: F_L1_HZ,
548 light_time: true,
549 sagnac: true,
550 }
551 }
552}
553
554#[derive(Debug, Clone, Copy, PartialEq)]
556pub struct ObservableTroposphereCorrection {
557 pub met: Met,
559 pub mapping: MappingModel,
561}
562
563impl Default for ObservableTroposphereCorrection {
564 fn default() -> Self {
565 Self {
566 met: Met::new_unchecked(1013.25, 288.15, 0.5),
567 mapping: MappingModel::Niell,
568 }
569 }
570}
571
572#[derive(Debug, Clone, Copy, PartialEq)]
574pub enum ObservableIonosphereCorrection<'a> {
575 Broadcast(IonoModel),
577 Ionex(&'a Ionex),
579 IonexWithPolicy(&'a Ionex, IonexCoveragePolicy),
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Default)]
585pub struct ObservableMediaOptions<'a> {
586 pub troposphere: Option<ObservableTroposphereCorrection>,
588 pub ionosphere: Option<ObservableIonosphereCorrection<'a>>,
590}
591
592impl ObservableMediaOptions<'_> {
593 fn is_disabled(self) -> bool {
594 self.troposphere.is_none() && self.ionosphere.is_none()
595 }
596
597 fn needs_instant(self) -> bool {
598 self.troposphere.is_some()
599 || matches!(
600 self.ionosphere,
601 Some(ObservableIonosphereCorrection::Broadcast(_))
602 )
603 }
604
605 fn needs_carrier(self) -> bool {
606 self.ionosphere.is_some()
607 }
608
609 fn needs_ionex_epoch(self) -> bool {
610 matches!(
611 self.ionosphere,
612 Some(
613 ObservableIonosphereCorrection::Ionex(_)
614 | ObservableIonosphereCorrection::IonexWithPolicy(_, _)
615 )
616 )
617 }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Default)]
622pub struct MediaPredictOptions<'a> {
623 pub prediction: PredictOptions,
625 pub media: ObservableMediaOptions<'a>,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq)]
631pub struct AppliedMediaCorrections {
632 pub troposphere_m: f64,
634 pub ionosphere_m: f64,
636 pub total_m: f64,
638}
639
640impl Default for AppliedMediaCorrections {
641 fn default() -> Self {
642 Self {
643 troposphere_m: 0.0,
644 ionosphere_m: 0.0,
645 total_m: 0.0,
646 }
647 }
648}
649
650#[derive(Debug, Clone, Copy, PartialEq)]
652pub struct MediaPredictedObservables {
653 pub prediction: PredictedObservables,
655 pub range_m: f64,
657 pub media: AppliedMediaCorrections,
659}
660
661#[derive(Debug, Clone, Copy, PartialEq)]
669pub struct TransmitTimeSatelliteState {
670 pub signal_flight_time_s: f64,
672 pub transmit_offset_us: i64,
674 pub transmit_time_j2000_s: f64,
676 pub clock_s: Option<f64>,
678 pub transmit_position_ecef_m: [f64; 3],
680 pub position_ecef_m: [f64; 3],
682 pub velocity_m_s: [f64; 3],
684 pub geometric_range_m: f64,
686 pub los_unit: [f64; 3],
688}
689
690#[derive(Debug, Clone, Copy, PartialEq)]
692pub struct PredictedObservables {
693 pub geometric_range_m: f64,
695 pub range_rate_m_s: f64,
697 pub doppler_hz: f64,
699 pub sat_clock_s: Option<f64>,
701 pub elevation_deg: f64,
703 pub azimuth_deg: f64,
711 pub transmit_offset_us: i64,
713 pub transmit_time_j2000_s: f64,
715 pub los_unit: [f64; 3],
717 pub sat_pos_ecef_m: [f64; 3],
719 pub sat_velocity_m_s: [f64; 3],
721}
722
723pub fn j2000_seconds_from_split(jd_whole: f64, jd_fraction: f64) -> Result<f64, ObservablesError> {
725 validate::finite(jd_whole, "jd_whole").map_err(map_input_error)?;
726 validate::finite(jd_fraction, "jd_fraction").map_err(map_input_error)?;
727 validate::finite(
728 civil::j2000_seconds_from_split(jd_whole, jd_fraction),
729 "j2000_seconds",
730 )
731 .map_err(map_input_error)
732}
733
734pub fn observable_media_corrections(
741 receiver: Wgs84Geodetic,
742 elevation_rad: f64,
743 azimuth_rad: f64,
744 t_rx_j2000_s: f64,
745 carrier_hz: f64,
746 options: ObservableMediaOptions<'_>,
747) -> Result<AppliedMediaCorrections, ObservablesError> {
748 if options.is_disabled() {
749 return Ok(AppliedMediaCorrections::default());
750 }
751 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
752 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
753 if options.needs_carrier() {
754 validate::finite_positive(carrier_hz, "carrier_hz").map_err(map_input_error)?;
755 }
756 let epoch = if options.needs_instant() {
757 Some(media_instant(t_rx_j2000_s)?)
758 } else {
759 None
760 };
761 let ionex_epoch_j2000_s = if options.needs_ionex_epoch() {
762 Some(rounded_j2000_seconds(t_rx_j2000_s)?)
763 } else {
764 None
765 };
766
767 let troposphere_m = match options.troposphere {
768 Some(troposphere) => {
769 let epoch = epoch.expect("troposphere media requires an epoch");
770 let zenith = tropo_zenith(TropoModel::Saastamoinen, receiver, troposphere.met)
771 .map_err(map_media_error)?;
772 let mapping = tropo_mapping(troposphere.mapping, elevation_rad, receiver, epoch)
773 .map_err(map_media_error)?;
774 let delay_m = zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet;
775 validate::finite(delay_m, "media.troposphere_m").map_err(map_input_error)?;
776 delay_m
777 }
778 None => 0.0,
779 };
780
781 let ionosphere_m = match options.ionosphere {
782 Some(ObservableIonosphereCorrection::Broadcast(model)) => {
783 let epoch = epoch.expect("broadcast ionosphere media requires an epoch");
784 let delay_m = ionosphere_delay(
785 receiver,
786 elevation_rad,
787 azimuth_rad,
788 epoch,
789 carrier_hz,
790 &model,
791 )
792 .map_err(map_media_error)?;
793 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
794 delay_m
795 }
796 Some(ObservableIonosphereCorrection::Ionex(ionex)) => {
797 let ionex_epoch_j2000_s =
798 ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
799 let delay_m = ionex_slant_delay(
800 ionex,
801 receiver,
802 elevation_rad,
803 azimuth_rad,
804 ionex_epoch_j2000_s,
805 carrier_hz,
806 )
807 .map_err(map_media_error)?;
808 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
809 delay_m
810 }
811 Some(ObservableIonosphereCorrection::IonexWithPolicy(ionex, policy)) => {
812 let ionex_epoch_j2000_s =
813 ionex_epoch_j2000_s.expect("IONEX media requires an integer epoch");
814 let delay_m = ionex_slant_delay_with_policy(
815 ionex,
816 receiver,
817 elevation_rad,
818 azimuth_rad,
819 ionex_epoch_j2000_s,
820 carrier_hz,
821 policy,
822 )
823 .map_err(map_media_error)?
824 .delay_m;
825 validate::finite(delay_m, "media.ionosphere_m").map_err(map_input_error)?;
826 delay_m
827 }
828 None => 0.0,
829 };
830
831 let total_m = troposphere_m + ionosphere_m;
832 validate::finite(total_m, "media.total_m").map_err(map_input_error)?;
833 Ok(AppliedMediaCorrections {
834 troposphere_m,
835 ionosphere_m,
836 total_m,
837 })
838}
839
840pub fn observable_states_at_j2000_s(
844 source: &dyn ObservableEphemerisSource,
845 satellites: &[GnssSatelliteId],
846 epochs_j2000_s: &[f64],
847) -> Result<ObservableStateBatch, ObservablesError> {
848 source.observable_states_at_j2000_s(satellites, epochs_j2000_s)
849}
850
851pub fn observable_states_at_shared_j2000_s(
856 source: &dyn ObservableEphemerisSource,
857 satellites: &[GnssSatelliteId],
858 epoch_j2000_s: f64,
859) -> ObservableStateBatch {
860 source.observable_states_at_shared_j2000_s(satellites, epoch_j2000_s)
861}
862
863pub fn emission_media_batch_at_j2000_s(
878 source: &dyn ObservableEphemerisSource,
879 satellites: &[GnssSatelliteId],
880 emission_epochs_j2000_s: &[f64],
881 receiver_ecef_m: [f64; 3],
882 options: EmissionMediaBatchOptions<'_>,
883) -> Result<EmissionMediaBatch, ObservablesError> {
884 validate_emission_media_batch_inputs(
885 satellites,
886 emission_epochs_j2000_s,
887 receiver_ecef_m,
888 options,
889 )?;
890
891 let mut batch = EmissionMediaBatch::with_capacity(satellites.len());
892 emission_media_batch_at_j2000_s_unchecked(
893 source,
894 satellites,
895 emission_epochs_j2000_s,
896 receiver_ecef_m,
897 options,
898 &mut batch,
899 );
900 Ok(batch)
901}
902
903pub fn emission_media_batch_at_j2000_s_into(
910 source: &dyn ObservableEphemerisSource,
911 satellites: &[GnssSatelliteId],
912 emission_epochs_j2000_s: &[f64],
913 receiver_ecef_m: [f64; 3],
914 options: EmissionMediaBatchOptions<'_>,
915 output: &mut EmissionMediaBatch,
916) -> Result<(), ObservablesError> {
917 validate_emission_media_batch_inputs(
918 satellites,
919 emission_epochs_j2000_s,
920 receiver_ecef_m,
921 options,
922 )?;
923 output.clear();
924 output.reserve(satellites.len());
925 emission_media_batch_at_j2000_s_unchecked(
926 source,
927 satellites,
928 emission_epochs_j2000_s,
929 receiver_ecef_m,
930 options,
931 output,
932 );
933 Ok(())
934}
935
936pub fn emission_media_batch_at_j2000_s_with_receiver_context_into(
941 source: &dyn ObservableEphemerisSource,
942 satellites: &[GnssSatelliteId],
943 emission_epochs_j2000_s: &[f64],
944 receiver: &EmissionMediaReceiverContext,
945 options: EmissionMediaBatchOptions<'_>,
946 output: &mut EmissionMediaBatch,
947) -> Result<(), ObservablesError> {
948 validate_emission_media_batch_context_inputs(satellites, emission_epochs_j2000_s, options)?;
949 output.clear();
950 output.reserve(satellites.len());
951 emission_media_batch_at_j2000_s_with_receiver_unchecked(
952 source,
953 satellites,
954 emission_epochs_j2000_s,
955 TopocentricReceiverSource::Cached(&receiver.topocentric),
956 options,
957 output,
958 );
959 Ok(())
960}
961
962fn validate_emission_media_batch_inputs(
963 satellites: &[GnssSatelliteId],
964 emission_epochs_j2000_s: &[f64],
965 receiver_ecef_m: [f64; 3],
966 options: EmissionMediaBatchOptions<'_>,
967) -> Result<(), ObservablesError> {
968 validate_emission_media_batch_context_inputs(satellites, emission_epochs_j2000_s, options)?;
969 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
970 Ok(())
971}
972
973fn validate_emission_media_batch_context_inputs(
974 satellites: &[GnssSatelliteId],
975 emission_epochs_j2000_s: &[f64],
976 options: EmissionMediaBatchOptions<'_>,
977) -> Result<(), ObservablesError> {
978 if satellites.len() != emission_epochs_j2000_s.len() {
979 return Err(ObservablesError::InvalidInput {
980 field: "emission_epochs_j2000_s",
981 kind: ObservablesInputErrorKind::OutOfRange,
982 });
983 }
984 validate_emission_media_batch_options(options)?;
985 Ok(())
986}
987
988fn emission_media_batch_at_j2000_s_unchecked(
989 source: &dyn ObservableEphemerisSource,
990 satellites: &[GnssSatelliteId],
991 emission_epochs_j2000_s: &[f64],
992 receiver_ecef_m: [f64; 3],
993 options: EmissionMediaBatchOptions<'_>,
994 batch: &mut EmissionMediaBatch,
995) {
996 match topocentric_receiver(receiver_ecef_m) {
997 Ok(receiver) => emission_media_batch_at_j2000_s_with_receiver_unchecked(
998 source,
999 satellites,
1000 emission_epochs_j2000_s,
1001 TopocentricReceiverSource::Cached(&receiver),
1002 options,
1003 batch,
1004 ),
1005 Err(_) => emission_media_batch_at_j2000_s_with_receiver_unchecked(
1006 source,
1007 satellites,
1008 emission_epochs_j2000_s,
1009 TopocentricReceiverSource::Ecef(receiver_ecef_m),
1010 options,
1011 batch,
1012 ),
1013 }
1014}
1015
1016fn emission_media_batch_at_j2000_s_with_receiver_unchecked(
1017 source: &dyn ObservableEphemerisSource,
1018 satellites: &[GnssSatelliteId],
1019 emission_epochs_j2000_s: &[f64],
1020 receiver: TopocentricReceiverSource<'_>,
1021 options: EmissionMediaBatchOptions<'_>,
1022 batch: &mut EmissionMediaBatch,
1023) {
1024 for (&sat, &emission_epoch_j2000_s) in satellites.iter().zip(emission_epochs_j2000_s.iter()) {
1025 let state = match source.observable_state_at_j2000_s(sat, emission_epoch_j2000_s) {
1026 Ok(state) => state,
1027 Err(error) if is_observable_state_gap(&error) => {
1028 batch.push_gap(error);
1029 continue;
1030 }
1031 Err(error) => {
1032 batch.push_error(None, error);
1033 continue;
1034 }
1035 };
1036
1037 if let Err(error) = validate_observable_state(&state) {
1038 batch.push_error(Some(state), error);
1039 continue;
1040 }
1041
1042 let receiver_ecef_m = receiver.ecef_m();
1043 let dx = state.position_ecef_m[0] - receiver_ecef_m[0];
1044 let dy = state.position_ecef_m[1] - receiver_ecef_m[1];
1045 let dz = state.position_ecef_m[2] - receiver_ecef_m[2];
1046 let line_of_sight_m = [dx, dy, dz];
1047 let range = match geometric_range_m(line_of_sight_m) {
1048 Ok(range) => range,
1049 Err(error) => {
1050 batch.push_error(Some(state), error);
1051 continue;
1052 }
1053 };
1054 let topocentric = match receiver.topocentric(line_of_sight_m, range) {
1055 Ok(topocentric) => topocentric,
1056 Err(error) => {
1057 batch.push_error(Some(state), error);
1058 continue;
1059 }
1060 };
1061
1062 if options
1063 .min_elevation_rad
1064 .is_some_and(|cutoff| topocentric.elevation_rad < cutoff)
1065 {
1066 batch.push_below_cutoff(state);
1067 continue;
1068 }
1069
1070 let media = observable_media_corrections(
1071 topocentric.receiver,
1072 topocentric.elevation_rad,
1073 topocentric.azimuth_rad,
1074 emission_epoch_j2000_s,
1075 options.carrier_hz,
1076 options.media,
1077 );
1078 match media {
1079 Ok(media) => batch.push_valid(state, media),
1080 Err(error) => batch.push_error(Some(state), error),
1081 }
1082 }
1083}
1084
1085pub fn transmit_time_satellite_state(
1093 source: &dyn ObservableEphemerisSource,
1094 sat: GnssSatelliteId,
1095 receiver_ecef_m: [f64; 3],
1096 t_rx_j2000_s: f64,
1097 options: TransmitTimeOptions,
1098) -> Result<TransmitTimeSatelliteState, ObservablesError> {
1099 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
1100 let predict_options = PredictOptions {
1101 carrier_hz: F_L1_HZ,
1102 light_time: options.light_time,
1103 sagnac: options.sagnac,
1104 };
1105 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, predict_options)?;
1106
1107 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1108 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1109 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1110 let range = geometric_range_m([dx, dy, dz])?;
1111 let los = [dx / range, dy / range, dz / range];
1112
1113 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
1114 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
1115 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
1116
1117 Ok(TransmitTimeSatelliteState {
1118 signal_flight_time_s: solved.tau_s,
1119 transmit_offset_us: solved.transmit_offset_us,
1120 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1121 clock_s: solved.state.clock_s,
1122 transmit_position_ecef_m: solved.state.position_ecef_m,
1123 position_ecef_m: solved.sat_rot_ecef_m,
1124 velocity_m_s: velocity_rot,
1125 geometric_range_m: range,
1126 los_unit: los,
1127 })
1128}
1129
1130pub fn predict(
1132 source: &dyn ObservableEphemerisSource,
1133 sat: GnssSatelliteId,
1134 receiver_ecef_m: [f64; 3],
1135 t_rx_j2000_s: f64,
1136 options: PredictOptions,
1137) -> Result<PredictedObservables, ObservablesError> {
1138 let (prediction, _) = predict_core(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1139 Ok(prediction)
1140}
1141
1142pub fn predict_with_media(
1150 source: &dyn ObservableEphemerisSource,
1151 sat: GnssSatelliteId,
1152 receiver_ecef_m: [f64; 3],
1153 t_rx_j2000_s: f64,
1154 options: MediaPredictOptions<'_>,
1155) -> Result<MediaPredictedObservables, ObservablesError> {
1156 let (prediction, topocentric) = predict_core(
1157 source,
1158 sat,
1159 receiver_ecef_m,
1160 t_rx_j2000_s,
1161 options.prediction,
1162 )?;
1163 if options.media.is_disabled() {
1164 return Ok(MediaPredictedObservables {
1165 range_m: prediction.geometric_range_m,
1166 prediction,
1167 media: AppliedMediaCorrections::default(),
1168 });
1169 }
1170 let media = observable_media_corrections(
1171 topocentric.receiver,
1172 topocentric.elevation_rad,
1173 topocentric.azimuth_rad,
1174 t_rx_j2000_s,
1175 options.prediction.carrier_hz,
1176 options.media,
1177 )?;
1178 let range_m = prediction.geometric_range_m + media.total_m;
1179 validate::finite(range_m, "range_m").map_err(map_input_error)?;
1180 Ok(MediaPredictedObservables {
1181 prediction,
1182 range_m,
1183 media,
1184 })
1185}
1186
1187fn predict_core(
1188 source: &dyn ObservableEphemerisSource,
1189 sat: GnssSatelliteId,
1190 receiver_ecef_m: [f64; 3],
1191 t_rx_j2000_s: f64,
1192 options: PredictOptions,
1193) -> Result<(PredictedObservables, TopocentricGeometry), ObservablesError> {
1194 validate_predict_inputs(receiver_ecef_m, t_rx_j2000_s, options)?;
1195 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1196
1197 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1198 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1199 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1200 let range = geometric_range_m([dx, dy, dz])?;
1201 let los = [dx / range, dy / range, dz / range];
1202
1203 let velocity = satellite_velocity(source, sat, solved.transmit_time_j2000_s)?;
1204 let velocity_rot = sagnac_rotate(velocity, solved.tau_s, options.sagnac);
1205 validate::finite_vec3(velocity_rot, "satellite velocity_m_s").map_err(map_input_error)?;
1206 let range_rate = los[0] * velocity_rot[0] + los[1] * velocity_rot[1] + los[2] * velocity_rot[2];
1207 validate::finite(range_rate, "range_rate_m_s").map_err(map_input_error)?;
1208 let doppler_hz = -range_rate * options.carrier_hz / C_M_S;
1209 validate::finite(doppler_hz, "doppler_hz").map_err(map_input_error)?;
1210 let topocentric = topocentric(receiver_ecef_m, [dx, dy, dz], range)?;
1211
1212 Ok((
1213 PredictedObservables {
1214 geometric_range_m: range,
1215 range_rate_m_s: range_rate,
1216 doppler_hz,
1217 sat_clock_s: solved.state.clock_s,
1218 elevation_deg: topocentric.elevation_deg,
1219 azimuth_deg: topocentric.azimuth_deg,
1220 transmit_offset_us: solved.transmit_offset_us,
1221 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1222 los_unit: los,
1223 sat_pos_ecef_m: solved.sat_rot_ecef_m,
1224 sat_velocity_m_s: velocity_rot,
1225 },
1226 topocentric,
1227 ))
1228}
1229
1230pub type PredictRequest = (GnssSatelliteId, [f64; 3], f64);
1237
1238pub fn predict_batch(
1246 source: &dyn ObservableEphemerisSource,
1247 requests: &[PredictRequest],
1248 options: PredictOptions,
1249) -> Vec<Result<PredictedObservables, ObservablesError>> {
1250 requests
1251 .iter()
1252 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1253 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1254 })
1255 .collect()
1256}
1257
1258pub fn predict_batch_with_media(
1263 source: &dyn ObservableEphemerisSource,
1264 requests: &[PredictRequest],
1265 options: MediaPredictOptions<'_>,
1266) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1267 requests
1268 .iter()
1269 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1270 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1271 })
1272 .collect()
1273}
1274
1275pub fn predict_batch_parallel(
1285 source: &(dyn ObservableEphemerisSource + Sync),
1286 requests: &[PredictRequest],
1287 options: PredictOptions,
1288) -> Vec<Result<PredictedObservables, ObservablesError>> {
1289 requests
1290 .par_iter()
1291 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1292 predict(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1293 })
1294 .collect()
1295}
1296
1297pub fn predict_batch_with_media_parallel(
1302 source: &(dyn ObservableEphemerisSource + Sync),
1303 requests: &[PredictRequest],
1304 options: MediaPredictOptions<'_>,
1305) -> Vec<Result<MediaPredictedObservables, ObservablesError>> {
1306 requests
1307 .par_iter()
1308 .map(|&(sat, receiver_ecef_m, t_rx_j2000_s)| {
1309 predict_with_media(source, sat, receiver_ecef_m, t_rx_j2000_s, options)
1310 })
1311 .collect()
1312}
1313
1314#[derive(Debug, Clone, Copy, PartialEq)]
1317pub struct RangePredictionRequest {
1318 pub sat: GnssSatelliteId,
1320 pub receiver_ecef_m: [f64; 3],
1322 pub t_rx_j2000_s: f64,
1324}
1325
1326#[derive(Debug, Clone, Copy, PartialEq)]
1331pub struct RangePrediction {
1332 pub geometric_range_m: f64,
1334 pub sat_clock_s: Option<f64>,
1336 pub transmit_time_j2000_s: f64,
1338 pub sat_pos_ecef_m: [f64; 3],
1340}
1341
1342#[derive(Debug, Clone, Copy, PartialEq)]
1344pub struct MediaRangePrediction {
1345 pub prediction: RangePrediction,
1347 pub range_m: f64,
1349 pub media: AppliedMediaCorrections,
1351}
1352
1353pub fn predict_ranges(
1378 source: &dyn ObservableEphemerisSource,
1379 requests: &[RangePredictionRequest],
1380 options: PredictOptions,
1381 out: &mut [RangePrediction],
1382) -> Result<(), ObservablesError> {
1383 if out.len() != requests.len() {
1384 return Err(ObservablesError::InvalidInput {
1385 field: "out",
1386 kind: ObservablesInputErrorKind::OutOfRange,
1387 });
1388 }
1389 for (request, slot) in requests.iter().zip(out.iter_mut()) {
1390 *slot = range_prediction_at_rx(
1391 source,
1392 request.sat,
1393 request.receiver_ecef_m,
1394 request.t_rx_j2000_s,
1395 options,
1396 )?;
1397 }
1398 Ok(())
1399}
1400
1401pub fn predict_ranges_with_media(
1407 source: &dyn ObservableEphemerisSource,
1408 requests: &[RangePredictionRequest],
1409 options: MediaPredictOptions<'_>,
1410 out: &mut [MediaRangePrediction],
1411) -> Result<(), ObservablesError> {
1412 if out.len() != requests.len() {
1413 return Err(ObservablesError::InvalidInput {
1414 field: "out",
1415 kind: ObservablesInputErrorKind::OutOfRange,
1416 });
1417 }
1418 for (request, slot) in requests.iter().zip(out.iter_mut()) {
1419 if options.media.is_disabled() {
1420 let prediction = range_prediction_at_rx(
1421 source,
1422 request.sat,
1423 request.receiver_ecef_m,
1424 request.t_rx_j2000_s,
1425 options.prediction,
1426 )?;
1427 *slot = MediaRangePrediction {
1428 range_m: prediction.geometric_range_m,
1429 prediction,
1430 media: AppliedMediaCorrections::default(),
1431 };
1432 continue;
1433 }
1434 let (prediction, topocentric) = range_prediction_core(
1435 source,
1436 request.sat,
1437 request.receiver_ecef_m,
1438 request.t_rx_j2000_s,
1439 options.prediction,
1440 )?;
1441 let media = observable_media_corrections(
1442 topocentric.receiver,
1443 topocentric.elevation_rad,
1444 topocentric.azimuth_rad,
1445 request.t_rx_j2000_s,
1446 options.prediction.carrier_hz,
1447 options.media,
1448 )?;
1449 let range_m = prediction.geometric_range_m + media.total_m;
1450 validate::finite(range_m, "range_m").map_err(map_input_error)?;
1451 *slot = MediaRangePrediction {
1452 prediction,
1453 range_m,
1454 media,
1455 };
1456 }
1457 Ok(())
1458}
1459
1460fn range_prediction_at_rx(
1469 source: &dyn ObservableEphemerisSource,
1470 sat: GnssSatelliteId,
1471 receiver_ecef_m: [f64; 3],
1472 t_rx_j2000_s: f64,
1473 options: PredictOptions,
1474) -> Result<RangePrediction, ObservablesError> {
1475 let (prediction, _, _) =
1476 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1477 Ok(prediction)
1478}
1479
1480fn range_prediction_core(
1481 source: &dyn ObservableEphemerisSource,
1482 sat: GnssSatelliteId,
1483 receiver_ecef_m: [f64; 3],
1484 t_rx_j2000_s: f64,
1485 options: PredictOptions,
1486) -> Result<(RangePrediction, TopocentricGeometry), ObservablesError> {
1487 let (prediction, line_of_sight_m, range) =
1488 range_prediction_state(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1489 let topocentric = topocentric(receiver_ecef_m, line_of_sight_m, range)?;
1490 Ok((prediction, topocentric))
1491}
1492
1493fn range_prediction_state(
1494 source: &dyn ObservableEphemerisSource,
1495 sat: GnssSatelliteId,
1496 receiver_ecef_m: [f64; 3],
1497 t_rx_j2000_s: f64,
1498 options: PredictOptions,
1499) -> Result<(RangePrediction, [f64; 3], f64), ObservablesError> {
1500 validate_transmit_time_inputs(receiver_ecef_m, t_rx_j2000_s)?;
1501 let solved = solve_transmit_time(source, sat, receiver_ecef_m, t_rx_j2000_s, options)?;
1502 let dx = solved.sat_rot_ecef_m[0] - receiver_ecef_m[0];
1503 let dy = solved.sat_rot_ecef_m[1] - receiver_ecef_m[1];
1504 let dz = solved.sat_rot_ecef_m[2] - receiver_ecef_m[2];
1505 let line_of_sight_m = [dx, dy, dz];
1506 let range = geometric_range_m([dx, dy, dz])?;
1507 Ok((
1508 RangePrediction {
1509 geometric_range_m: range,
1510 sat_clock_s: solved.state.clock_s,
1511 transmit_time_j2000_s: solved.transmit_time_j2000_s,
1512 sat_pos_ecef_m: solved.sat_rot_ecef_m,
1513 },
1514 line_of_sight_m,
1515 range,
1516 ))
1517}
1518
1519#[derive(Debug, Clone, Copy)]
1520struct SolvedTransmitTime {
1521 tau_s: f64,
1522 transmit_offset_us: i64,
1523 transmit_time_j2000_s: f64,
1524 state: ObservableState,
1525 sat_rot_ecef_m: [f64; 3],
1526}
1527
1528fn solve_transmit_time(
1529 source: &dyn ObservableEphemerisSource,
1530 sat: GnssSatelliteId,
1531 receiver_ecef_m: [f64; 3],
1532 t_rx_j2000_s: f64,
1533 options: PredictOptions,
1534) -> Result<SolvedTransmitTime, ObservablesError> {
1535 if !options.light_time {
1536 let state = validated_state_at_j2000_s(source, sat, t_rx_j2000_s)?;
1537 let sat_rot = sagnac_rotate(state.position_ecef_m, 0.0, options.sagnac);
1538 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1539 return Ok(SolvedTransmitTime {
1540 tau_s: 0.0,
1541 transmit_offset_us: 0,
1542 transmit_time_j2000_s: t_rx_j2000_s,
1543 state,
1544 sat_rot_ecef_m: sat_rot,
1545 });
1546 }
1547
1548 let mut tau = 0.0;
1549 for iter in 0..OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1550 let transmit_offset_us = microseconds_from_tau(tau);
1551 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1552 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1553 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, options.sagnac);
1554 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1555 let dx = sat_rot[0] - receiver_ecef_m[0];
1556 let dy = sat_rot[1] - receiver_ecef_m[1];
1557 let dz = sat_rot[2] - receiver_ecef_m[2];
1558 let range = geometric_range_m([dx, dy, dz])?;
1559 let new_tau = range / C_M_S;
1560
1561 if iter + 1 == OBSERVABLE_TRANSMIT_TIME_ITERATIONS {
1562 return finalize_transmit_time(source, sat, t_rx_j2000_s, new_tau, options.sagnac);
1563 }
1564
1565 tau = new_tau;
1566 }
1567
1568 unreachable!("fixed transmit-time loop always returns on its last iteration")
1569}
1570
1571fn finalize_transmit_time(
1572 source: &dyn ObservableEphemerisSource,
1573 sat: GnssSatelliteId,
1574 t_rx_j2000_s: f64,
1575 tau: f64,
1576 sagnac: bool,
1577) -> Result<SolvedTransmitTime, ObservablesError> {
1578 let transmit_offset_us = microseconds_from_tau(tau);
1579 let t_tx = t_rx_j2000_s - transmit_offset_us as f64 / MICROSECONDS_PER_SECOND;
1580 validate::finite(t_tx, "transmit_time_j2000_s").map_err(map_input_error)?;
1581 let state = validated_state_at_j2000_s(source, sat, t_tx)?;
1582 let sat_rot = sagnac_rotate(state.position_ecef_m, tau, sagnac);
1583 validate::finite_vec3(sat_rot, "satellite position_ecef_m").map_err(map_input_error)?;
1584 Ok(SolvedTransmitTime {
1585 tau_s: tau,
1586 transmit_offset_us,
1587 transmit_time_j2000_s: t_tx,
1588 state,
1589 sat_rot_ecef_m: sat_rot,
1590 })
1591}
1592
1593fn microseconds_from_tau(tau_s: f64) -> i64 {
1594 (tau_s * MICROSECONDS_PER_SECOND).round() as i64
1595}
1596
1597fn satellite_velocity(
1598 source: &dyn ObservableEphemerisSource,
1599 sat: GnssSatelliteId,
1600 t_tx_j2000_s: f64,
1601) -> Result<[f64; 3], ObservablesError> {
1602 let plus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s + FD_HALF_S)?;
1603 let minus = validated_state_at_j2000_s(source, sat, t_tx_j2000_s - FD_HALF_S)?;
1604 let denom = 2.0 * FD_HALF_S;
1605 let velocity = [
1606 (plus.position_ecef_m[0] - minus.position_ecef_m[0]) / denom,
1607 (plus.position_ecef_m[1] - minus.position_ecef_m[1]) / denom,
1608 (plus.position_ecef_m[2] - minus.position_ecef_m[2]) / denom,
1609 ];
1610 validate::finite_vec3(velocity, "satellite velocity_m_s").map_err(map_input_error)
1611}
1612
1613fn validate_predict_inputs(
1614 receiver_ecef_m: [f64; 3],
1615 t_rx_j2000_s: f64,
1616 options: PredictOptions,
1617) -> Result<(), ObservablesError> {
1618 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1619 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1620 validate::finite_positive(options.carrier_hz, "options.carrier_hz").map_err(map_input_error)?;
1621 Ok(())
1622}
1623
1624fn validate_transmit_time_inputs(
1625 receiver_ecef_m: [f64; 3],
1626 t_rx_j2000_s: f64,
1627) -> Result<(), ObservablesError> {
1628 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_input_error)?;
1629 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1630 Ok(())
1631}
1632
1633fn validate_emission_media_batch_options(
1634 options: EmissionMediaBatchOptions<'_>,
1635) -> Result<(), ObservablesError> {
1636 if options.media.needs_carrier() {
1637 validate::finite_positive(options.carrier_hz, "options.carrier_hz")
1638 .map_err(map_input_error)?;
1639 }
1640 if let Some(cutoff) = options.min_elevation_rad {
1641 validate::finite(cutoff, "options.min_elevation_rad").map_err(map_input_error)?;
1642 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&cutoff) {
1643 return Err(invalid_observable_input(
1644 "options.min_elevation_rad",
1645 ObservablesInputErrorKind::OutOfRange,
1646 ));
1647 }
1648 }
1649 Ok(())
1650}
1651
1652fn validated_state_at_j2000_s(
1653 source: &dyn ObservableEphemerisSource,
1654 sat: GnssSatelliteId,
1655 t_j2000_s: f64,
1656) -> Result<ObservableState, ObservablesError> {
1657 let state = source.observable_state_at_j2000_s(sat, t_j2000_s)?;
1658 validate_observable_state(&state)?;
1659 Ok(state)
1660}
1661
1662fn validate_observable_state(state: &ObservableState) -> Result<(), ObservablesError> {
1663 validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
1664 .map_err(map_input_error)?;
1665 if let Some(clock_s) = state.clock_s {
1666 validate::finite(clock_s, "observable state clock_s").map_err(map_input_error)?;
1667 }
1668 Ok(())
1669}
1670
1671fn geometric_range_m(delta_ecef_m: [f64; 3]) -> Result<f64, ObservablesError> {
1672 let range = (delta_ecef_m[0] * delta_ecef_m[0]
1673 + delta_ecef_m[1] * delta_ecef_m[1]
1674 + delta_ecef_m[2] * delta_ecef_m[2])
1675 .sqrt();
1676 validate::finite_positive(range, "geometric_range_m").map_err(map_input_error)
1677}
1678
1679fn map_input_error(error: validate::FieldError) -> ObservablesError {
1680 ObservablesError::InvalidInput {
1681 field: error.field(),
1682 kind: ObservablesInputErrorKind::from(&error),
1683 }
1684}
1685
1686fn invalid_observable_input(
1687 field: &'static str,
1688 kind: ObservablesInputErrorKind,
1689) -> ObservablesError {
1690 ObservablesError::InvalidInput { field, kind }
1691}
1692
1693fn media_instant(t_rx_j2000_s: f64) -> Result<Instant, ObservablesError> {
1694 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1695 let days = (t_rx_j2000_s / SECONDS_PER_DAY).floor();
1696 let seconds_into_day = t_rx_j2000_s - days * SECONDS_PER_DAY;
1697 let fraction = seconds_into_day / SECONDS_PER_DAY;
1698 let split = JulianDateSplit::new(J2000_JD + days, fraction).map_err(|_| {
1699 invalid_observable_input("t_rx_j2000_s", ObservablesInputErrorKind::OutOfRange)
1700 })?;
1701 Ok(Instant::from_julian_date(TimeScale::Gpst, split))
1702}
1703
1704fn rounded_j2000_seconds(t_rx_j2000_s: f64) -> Result<i64, ObservablesError> {
1705 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(map_input_error)?;
1706 let rounded = t_rx_j2000_s.round();
1707 if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
1708 return Err(invalid_observable_input(
1709 "t_rx_j2000_s",
1710 ObservablesInputErrorKind::OutOfRange,
1711 ));
1712 }
1713 Ok(rounded as i64)
1714}
1715
1716fn map_media_error(error: Error) -> ObservablesError {
1717 match error {
1718 Error::InvalidInput(message) => map_media_invalid_input(&message),
1719 Error::IonexOutOfCoverage(_) => ObservablesError::Media(error),
1720 _ => invalid_observable_input("media", ObservablesInputErrorKind::OutOfRange),
1721 }
1722}
1723
1724fn map_media_invalid_input(message: &str) -> ObservablesError {
1725 let kind = if message.ends_with("not finite") {
1726 ObservablesInputErrorKind::NonFinite
1727 } else if message.ends_with("not positive") {
1728 ObservablesInputErrorKind::NotPositive
1729 } else if message.ends_with("negative") {
1730 ObservablesInputErrorKind::Negative
1731 } else {
1732 ObservablesInputErrorKind::OutOfRange
1733 };
1734 let field = if message.starts_with("elevation_rad ") {
1735 "media.elevation_rad"
1736 } else if message.starts_with("receiver.lat_rad ") {
1737 "media.receiver.lat_rad"
1738 } else if message.starts_with("receiver.lon_rad ") {
1739 "media.receiver.lon_rad"
1740 } else if message.starts_with("receiver.height_m ") {
1741 "media.receiver.height_m"
1742 } else if message.starts_with("pressure_hpa ") {
1743 "media.pressure_hpa"
1744 } else if message.starts_with("temperature_k ") {
1745 "media.temperature_k"
1746 } else if message.starts_with("relative_humidity ") {
1747 "media.relative_humidity"
1748 } else if message.starts_with("frequency_hz ") {
1749 "media.carrier_hz"
1750 } else if message.starts_with("azimuth_rad ") {
1751 "media.azimuth_rad"
1752 } else {
1753 "media"
1754 };
1755 invalid_observable_input(field, kind)
1756}
1757
1758fn sagnac_rotate(pos: [f64; 3], tau_s: f64, apply: bool) -> [f64; 3] {
1759 let sagnac = if apply {
1760 SagnacRecipe::ClosedFormZRotation
1761 } else {
1762 SagnacRecipe::Off
1763 };
1764 crate::estimation::substrate::range::rotate_transmit_satellite(
1765 sagnac,
1766 pos,
1767 tau_s,
1768 OMEGA_E_DOT_RAD_S,
1769 )
1770}
1771
1772#[derive(Debug, Clone, Copy, PartialEq)]
1773struct TopocentricReceiver {
1774 ecef_m: [f64; 3],
1775 geodetic: Wgs84Geodetic,
1776 sin_lat: f64,
1777 cos_lat: f64,
1778 sin_lon: f64,
1779 cos_lon: f64,
1780}
1781
1782#[derive(Debug, Clone, Copy)]
1783enum TopocentricReceiverSource<'a> {
1784 Cached(&'a TopocentricReceiver),
1785 Ecef([f64; 3]),
1786}
1787
1788impl TopocentricReceiverSource<'_> {
1789 fn ecef_m(self) -> [f64; 3] {
1790 match self {
1791 Self::Cached(receiver) => receiver.ecef_m,
1792 Self::Ecef(receiver_ecef_m) => receiver_ecef_m,
1793 }
1794 }
1795
1796 fn topocentric(
1797 self,
1798 delta_ecef_m: [f64; 3],
1799 range_m: f64,
1800 ) -> Result<TopocentricGeometry, ObservablesError> {
1801 match self {
1802 Self::Cached(receiver) => topocentric_with_receiver(receiver, delta_ecef_m, range_m),
1803 Self::Ecef(receiver_ecef_m) => topocentric(receiver_ecef_m, delta_ecef_m, range_m),
1804 }
1805 }
1806}
1807
1808#[derive(Debug, Clone, Copy, PartialEq)]
1809struct TopocentricGeometry {
1810 receiver: Wgs84Geodetic,
1811 elevation_rad: f64,
1812 azimuth_rad: f64,
1813 elevation_deg: f64,
1814 azimuth_deg: f64,
1815}
1816
1817fn topocentric(
1818 receiver_ecef_m: [f64; 3],
1819 delta_ecef_m: [f64; 3],
1820 range_m: f64,
1821) -> Result<TopocentricGeometry, ObservablesError> {
1822 let receiver = topocentric_receiver(receiver_ecef_m)?;
1823 topocentric_with_receiver(&receiver, delta_ecef_m, range_m)
1824}
1825
1826fn topocentric_receiver(
1827 receiver_ecef_m: [f64; 3],
1828) -> Result<TopocentricReceiver, ObservablesError> {
1829 let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
1830 receiver_ecef_m[0] / KM_TO_M,
1831 receiver_ecef_m[1] / KM_TO_M,
1832 receiver_ecef_m[2] / KM_TO_M,
1833 )
1834 .map_err(|_| ObservablesError::InvalidInput {
1835 field: "receiver_ecef_m",
1836 kind: ObservablesInputErrorKind::OutOfRange,
1837 })?;
1838 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
1840 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
1841 let receiver = Wgs84Geodetic::new(lat, lon, height_km * KM_TO_M).map_err(|_| {
1842 ObservablesError::InvalidInput {
1843 field: "receiver_ecef_m",
1844 kind: ObservablesInputErrorKind::OutOfRange,
1845 }
1846 })?;
1847
1848 Ok(TopocentricReceiver {
1849 ecef_m: receiver_ecef_m,
1850 geodetic: receiver,
1851 sin_lat: lat.sin(),
1852 cos_lat: lat.cos(),
1853 sin_lon: lon.sin(),
1854 cos_lon: lon.cos(),
1855 })
1856}
1857
1858fn topocentric_with_receiver(
1859 receiver: &TopocentricReceiver,
1860 delta_ecef_m: [f64; 3],
1861 range_m: f64,
1862) -> Result<TopocentricGeometry, ObservablesError> {
1863 let dx = delta_ecef_m[0];
1864 let dy = delta_ecef_m[1];
1865 let dz = delta_ecef_m[2];
1866
1867 let e = -receiver.sin_lon * dx + receiver.cos_lon * dy;
1868 let n = -receiver.sin_lat * receiver.cos_lon * dx - receiver.sin_lat * receiver.sin_lon * dy
1869 + receiver.cos_lat * dz;
1870 let u = receiver.cos_lat * receiver.cos_lon * dx
1871 + receiver.cos_lat * receiver.sin_lon * dy
1872 + receiver.sin_lat * dz;
1873
1874 let horiz_sq = e * e + n * n;
1879 let (azimuth_rad, mut azimuth_deg) = if horiz_sq < AZIMUTH_ZENITH_EPS * range_m * range_m {
1880 (0.0, 0.0)
1881 } else {
1882 let raw_azimuth_rad = e.atan2(n);
1883 (
1884 if raw_azimuth_rad < 0.0 {
1885 raw_azimuth_rad + 2.0 * PI
1886 } else {
1887 raw_azimuth_rad
1888 },
1889 raw_azimuth_rad * DEGREES_PER_SEMICIRCLE / PI,
1890 )
1891 };
1892 if azimuth_deg < 0.0 {
1893 azimuth_deg += DEGREES_PER_CIRCLE;
1894 }
1895 let sin_elevation = (u / range_m).clamp(-1.0, 1.0);
1900 let elevation_rad = sin_elevation.asin();
1901 let elevation_deg = elevation_rad * DEGREES_PER_SEMICIRCLE / PI;
1902
1903 validate::finite(elevation_rad, "elevation_rad").map_err(map_input_error)?;
1904 validate::finite(elevation_deg, "elevation_deg").map_err(map_input_error)?;
1905 validate::finite(azimuth_rad, "azimuth_rad").map_err(map_input_error)?;
1906 validate::finite(azimuth_deg, "azimuth_deg").map_err(map_input_error)?;
1907 Ok(TopocentricGeometry {
1908 receiver: receiver.geodetic,
1909 elevation_rad,
1910 azimuth_rad,
1911 elevation_deg,
1912 azimuth_deg,
1913 })
1914}
1915
1916#[cfg(test)]
1917mod public_api_tests {
1918 use super::*;
1919 use crate::{GnssSatelliteId, GnssSystem};
1920
1921 #[derive(Debug, Clone, Copy)]
1922 struct StaticSource {
1923 state: ObservableState,
1924 }
1925
1926 impl ObservableEphemerisSource for StaticSource {
1927 fn observable_state_at_j2000_s(
1928 &self,
1929 _sat: GnssSatelliteId,
1930 _t_j2000_s: f64,
1931 ) -> Result<ObservableState, ObservablesError> {
1932 Ok(self.state)
1933 }
1934 }
1935
1936 #[test]
1937 fn predict_ranges_matches_transmit_time_loop_bitwise() {
1938 let source = StaticSource {
1939 state: ObservableState {
1940 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
1941 clock_s: Some(1.25e-6),
1942 },
1943 };
1944 let options = PredictOptions {
1945 carrier_hz: F_L1_HZ,
1946 light_time: true,
1947 sagnac: true,
1948 };
1949 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1950 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
1951 let requests = [
1952 RangePredictionRequest {
1953 sat: sat1,
1954 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
1955 t_rx_j2000_s: 646_272_000.0,
1956 },
1957 RangePredictionRequest {
1958 sat: sat2,
1959 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
1960 t_rx_j2000_s: 646_272_060.0,
1961 },
1962 ];
1963 let mut out = [RangePrediction {
1964 geometric_range_m: 0.0,
1965 sat_clock_s: None,
1966 transmit_time_j2000_s: 0.0,
1967 sat_pos_ecef_m: [0.0; 3],
1968 }; 2];
1969 predict_ranges(&source, &requests, options, &mut out).expect("batch range prediction");
1970
1971 let tt_options = TransmitTimeOptions {
1972 light_time: options.light_time,
1973 sagnac: options.sagnac,
1974 };
1975 for (request, got) in requests.iter().zip(out.iter()) {
1976 let single = transmit_time_satellite_state(
1977 &source,
1978 request.sat,
1979 request.receiver_ecef_m,
1980 request.t_rx_j2000_s,
1981 tt_options,
1982 )
1983 .expect("single transmit-time state");
1984 assert_eq!(
1985 got.geometric_range_m.to_bits(),
1986 single.geometric_range_m.to_bits()
1987 );
1988 assert_eq!(
1989 got.transmit_time_j2000_s.to_bits(),
1990 single.transmit_time_j2000_s.to_bits()
1991 );
1992 assert_eq!(
1993 got.sat_clock_s.map(f64::to_bits),
1994 single.clock_s.map(f64::to_bits)
1995 );
1996 assert_eq!(
1997 got.sat_pos_ecef_m.map(f64::to_bits),
1998 single.position_ecef_m.map(f64::to_bits)
1999 );
2000 }
2001 }
2002
2003 #[test]
2004 fn predict_ranges_batch_matches_scalar_calls_bitwise() {
2005 let source = StaticSource {
2008 state: ObservableState {
2009 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2010 clock_s: Some(1.25e-6),
2011 },
2012 };
2013 let options = PredictOptions::default();
2014 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2015 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
2016 let requests = [
2017 RangePredictionRequest {
2018 sat: sat1,
2019 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
2020 t_rx_j2000_s: 646_272_000.0,
2021 },
2022 RangePredictionRequest {
2023 sat: sat2,
2024 receiver_ecef_m: [1_130_000.0, -4_830_000.0, 3_994_000.0],
2025 t_rx_j2000_s: 646_272_060.0,
2026 },
2027 RangePredictionRequest {
2028 sat: sat1,
2029 receiver_ecef_m: [-2_700_000.0, -4_290_000.0, 3_855_000.0],
2030 t_rx_j2000_s: 646_272_120.0,
2031 },
2032 ];
2033 let zero = RangePrediction {
2034 geometric_range_m: 0.0,
2035 sat_clock_s: None,
2036 transmit_time_j2000_s: 0.0,
2037 sat_pos_ecef_m: [0.0; 3],
2038 };
2039
2040 let mut batch = [zero; 3];
2041 predict_ranges(&source, &requests, options, &mut batch).expect("batch ranges");
2042
2043 for (i, request) in requests.iter().enumerate() {
2044 let mut single = [zero; 1];
2045 predict_ranges(&source, std::slice::from_ref(request), options, &mut single)
2046 .expect("single range");
2047 assert_eq!(
2048 batch[i].geometric_range_m.to_bits(),
2049 single[0].geometric_range_m.to_bits()
2050 );
2051 assert_eq!(
2052 batch[i].transmit_time_j2000_s.to_bits(),
2053 single[0].transmit_time_j2000_s.to_bits()
2054 );
2055 assert_eq!(
2056 batch[i].sat_clock_s.map(f64::to_bits),
2057 single[0].sat_clock_s.map(f64::to_bits)
2058 );
2059 assert_eq!(
2060 batch[i].sat_pos_ecef_m.map(f64::to_bits),
2061 single[0].sat_pos_ecef_m.map(f64::to_bits)
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn predict_ranges_rejects_length_mismatch() {
2068 let source = StaticSource {
2069 state: ObservableState {
2070 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2071 clock_s: None,
2072 },
2073 };
2074 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2075 let requests = [RangePredictionRequest {
2076 sat,
2077 receiver_ecef_m: [4_027_894.0, 307_046.0, 4_919_474.0],
2078 t_rx_j2000_s: 646_272_000.0,
2079 }];
2080 let mut out: [RangePrediction; 0] = [];
2081 let err = predict_ranges(&source, &requests, PredictOptions::default(), &mut out)
2082 .expect_err("length mismatch must fail");
2083 match err {
2084 ObservablesError::InvalidInput { field, kind } => {
2085 assert_eq!(field, "out");
2086 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
2087 }
2088 other => panic!("expected InvalidInput(out, OutOfRange), got {other:?}"),
2089 }
2090 }
2091
2092 #[test]
2093 fn topocentric_elevation_is_ninety_at_non_equatorial_zenith() {
2094 let rx = [
2103 4_509_179.095_483_66,
2104 275_556.225_682_215_9,
2105 4_487_348.408_865_919,
2106 ];
2107 let (lat_deg, lon_deg, _h) =
2108 itrs_to_geodetic_compute(rx[0] / KM_TO_M, rx[1] / KM_TO_M, rx[2] / KM_TO_M)
2109 .expect("receiver geodetic conversion");
2110 assert!(lat_deg.abs() > 40.0, "receiver must be non-equatorial");
2111
2112 let lat = lat_deg * PI / DEGREES_PER_SEMICIRCLE;
2115 let lon = lon_deg * PI / DEGREES_PER_SEMICIRCLE;
2116 let (sl, cl) = lat.sin_cos();
2117 let (so, co) = lon.sin_cos();
2118 let up = [cl * co, cl * so, sl];
2119
2120 let d = 20_000_000.0_f64;
2121 let delta = [up[0] * d, up[1] * d, up[2] * d];
2122 let range = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
2123 let u = cl * co * delta[0] + cl * so * delta[1] + sl * delta[2];
2126 assert!(
2127 u / range > 1.0,
2128 "test geometry must overshoot the asin domain"
2129 );
2130
2131 let geometry = topocentric(rx, delta, range).expect("non-equatorial zenith must not error");
2132 assert!(geometry.elevation_deg.is_finite());
2133 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
2134 }
2135
2136 #[test]
2137 fn transmit_time_state_matches_predict_substrate_with_no_light_time() {
2138 let source = StaticSource {
2139 state: ObservableState {
2140 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2141 clock_s: Some(1.25e-6),
2142 },
2143 };
2144 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2145 let rx = [4_027_894.0, 307_046.0, 4_919_474.0];
2146 let state = transmit_time_satellite_state(
2147 &source,
2148 sat,
2149 rx,
2150 646_272_000.0,
2151 TransmitTimeOptions {
2152 light_time: false,
2153 sagnac: true,
2154 },
2155 )
2156 .expect("state");
2157 let prediction = predict(
2158 &source,
2159 sat,
2160 rx,
2161 646_272_000.0,
2162 PredictOptions {
2163 carrier_hz: F_L1_HZ,
2164 light_time: false,
2165 sagnac: true,
2166 },
2167 )
2168 .expect("prediction");
2169
2170 assert_eq!(state.signal_flight_time_s.to_bits(), 0.0f64.to_bits());
2171 assert_eq!(state.transmit_offset_us, 0);
2172 assert_eq!(
2173 state.transmit_time_j2000_s.to_bits(),
2174 646_272_000.0f64.to_bits()
2175 );
2176 assert_eq!(state.clock_s.unwrap().to_bits(), 1.25e-6f64.to_bits());
2177 assert_eq!(
2178 state.transmit_position_ecef_m.map(f64::to_bits),
2179 source.state.position_ecef_m.map(f64::to_bits)
2180 );
2181 assert_eq!(
2182 state.position_ecef_m.map(f64::to_bits),
2183 prediction.sat_pos_ecef_m.map(f64::to_bits)
2184 );
2185 assert_eq!(
2186 state.velocity_m_s.map(f64::to_bits),
2187 prediction.sat_velocity_m_s.map(f64::to_bits)
2188 );
2189 assert_eq!(
2190 state.geometric_range_m.to_bits(),
2191 prediction.geometric_range_m.to_bits()
2192 );
2193 assert_eq!(
2194 state.los_unit.map(f64::to_bits),
2195 prediction.los_unit.map(f64::to_bits)
2196 );
2197 }
2198}
2199
2200#[cfg(test)]
2201mod media_validation_tests {
2202 use super::*;
2208 use crate::astro::time::civil::split_julian_date_from_j2000_seconds;
2209 use crate::ionex::TecGridSamples;
2210 use crate::GnssSystem;
2211
2212 const T_RX_J2000_S: f64 = 646_272_000.0;
2213 const T_RX_J2000_I64: i64 = 646_272_000;
2214
2215 #[derive(Debug, Clone, Copy)]
2216 struct StaticSource {
2217 state: ObservableState,
2218 }
2219
2220 impl ObservableEphemerisSource for StaticSource {
2221 fn observable_state_at_j2000_s(
2222 &self,
2223 _sat: GnssSatelliteId,
2224 _t_j2000_s: f64,
2225 ) -> Result<ObservableState, ObservablesError> {
2226 Ok(self.state)
2227 }
2228 }
2229
2230 fn epoch() -> Instant {
2231 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(T_RX_J2000_I64);
2232 Instant::from_julian_date(
2233 TimeScale::Gpst,
2234 JulianDateSplit::new(jd_whole, fraction).expect("valid media epoch"),
2235 )
2236 }
2237
2238 fn receiver() -> Wgs84Geodetic {
2239 Wgs84Geodetic::new(0.0, 0.0, 0.0).expect("valid receiver")
2240 }
2241
2242 fn met() -> Met {
2243 Met::new(1013.25, 288.15, 0.5).expect("valid met")
2244 }
2245
2246 fn klobuchar_model() -> IonoModel {
2247 IonoModel::Klobuchar(crate::ionex::KlobucharParams {
2248 alpha: [0.0; 4],
2249 beta: [0.0; 4],
2250 })
2251 }
2252
2253 fn ionex() -> Ionex {
2254 let map = vec![
2255 vec![12.0, 12.0, 12.0],
2256 vec![12.0, 12.0, 12.0],
2257 vec![12.0, 12.0, 12.0],
2258 ];
2259 Ionex::from_samples(TecGridSamples {
2260 map_epochs: vec![epoch()],
2261 lat_nodes_deg: vec![90.0, 0.0, -90.0],
2262 lon_nodes_deg: vec![-180.0, 0.0, 180.0],
2263 dlat_deg: -90.0,
2264 dlon_deg: 180.0,
2265 shell_height_km: 450.0,
2266 base_radius_km: 6371.0,
2267 exponent: 0,
2268 tec_maps: vec![map],
2269 rms_maps: Vec::new(),
2270 })
2271 .expect("valid IONEX samples")
2272 }
2273
2274 fn direct_troposphere(elevation_rad: f64) -> f64 {
2275 let zenith =
2276 tropo_zenith(TropoModel::Saastamoinen, receiver(), met()).expect("zenith delay");
2277 let mapping = tropo_mapping(MappingModel::Niell, elevation_rad, receiver(), epoch())
2278 .expect("mapping");
2279 zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
2280 }
2281
2282 fn assert_bits_eq(label: &str, got: f64, expected: f64) {
2283 assert_eq!(
2284 got.to_bits(),
2285 expected.to_bits(),
2286 "{label}: got {got:?}, expected {expected:?}"
2287 );
2288 }
2289
2290 fn assert_prediction_bits_eq(got: &PredictedObservables, expected: &PredictedObservables) {
2291 assert_bits_eq(
2292 "geometric range",
2293 got.geometric_range_m,
2294 expected.geometric_range_m,
2295 );
2296 assert_bits_eq("range-rate", got.range_rate_m_s, expected.range_rate_m_s);
2297 assert_bits_eq("Doppler", got.doppler_hz, expected.doppler_hz);
2298 assert_eq!(
2299 got.sat_clock_s.map(f64::to_bits),
2300 expected.sat_clock_s.map(f64::to_bits)
2301 );
2302 assert_bits_eq("elevation", got.elevation_deg, expected.elevation_deg);
2303 assert_bits_eq("azimuth", got.azimuth_deg, expected.azimuth_deg);
2304 assert_eq!(got.transmit_offset_us, expected.transmit_offset_us);
2305 assert_bits_eq(
2306 "transmit time",
2307 got.transmit_time_j2000_s,
2308 expected.transmit_time_j2000_s,
2309 );
2310 for k in 0..3 {
2311 assert_bits_eq("los", got.los_unit[k], expected.los_unit[k]);
2312 assert_bits_eq(
2313 "satellite position",
2314 got.sat_pos_ecef_m[k],
2315 expected.sat_pos_ecef_m[k],
2316 );
2317 assert_bits_eq(
2318 "satellite velocity",
2319 got.sat_velocity_m_s[k],
2320 expected.sat_velocity_m_s[k],
2321 );
2322 }
2323 }
2324
2325 fn assert_range_prediction_bits_eq(got: &RangePrediction, expected: &RangePrediction) {
2326 assert_bits_eq(
2327 "range geometric",
2328 got.geometric_range_m,
2329 expected.geometric_range_m,
2330 );
2331 assert_eq!(
2332 got.sat_clock_s.map(f64::to_bits),
2333 expected.sat_clock_s.map(f64::to_bits)
2334 );
2335 assert_bits_eq(
2336 "range transmit time",
2337 got.transmit_time_j2000_s,
2338 expected.transmit_time_j2000_s,
2339 );
2340 for k in 0..3 {
2341 assert_bits_eq(
2342 "range satellite position",
2343 got.sat_pos_ecef_m[k],
2344 expected.sat_pos_ecef_m[k],
2345 );
2346 }
2347 }
2348
2349 #[test]
2350 fn media_corrections_match_direct_tropo_and_klobuchar_bits() {
2351 for elevation_deg in [5.0_f64, 15.0, 90.0] {
2352 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2353 let azimuth_rad = 0.25;
2354 let options = ObservableMediaOptions {
2355 troposphere: Some(ObservableTroposphereCorrection {
2356 met: met(),
2357 mapping: MappingModel::Niell,
2358 }),
2359 ionosphere: Some(ObservableIonosphereCorrection::Broadcast(klobuchar_model())),
2360 };
2361 let got = observable_media_corrections(
2362 receiver(),
2363 elevation_rad,
2364 azimuth_rad,
2365 T_RX_J2000_S,
2366 F_L1_HZ,
2367 options,
2368 )
2369 .expect("media corrections");
2370 let expected_tropo = direct_troposphere(elevation_rad);
2371 let expected_iono = ionosphere_delay(
2372 receiver(),
2373 elevation_rad,
2374 azimuth_rad,
2375 epoch(),
2376 F_L1_HZ,
2377 &klobuchar_model(),
2378 )
2379 .expect("direct Klobuchar");
2380
2381 assert_bits_eq("troposphere", got.troposphere_m, expected_tropo);
2382 assert_bits_eq("Klobuchar", got.ionosphere_m, expected_iono);
2383 assert_bits_eq("total", got.total_m, expected_tropo + expected_iono);
2384 }
2385 }
2386
2387 #[test]
2388 fn media_corrections_match_direct_ionex_bits() {
2389 let ionex = ionex();
2390 for elevation_deg in [5.0_f64, 15.0, 90.0] {
2391 let elevation_rad = elevation_deg * PI / DEGREES_PER_SEMICIRCLE;
2392 let azimuth_rad = 1.0;
2393 let got = observable_media_corrections(
2394 receiver(),
2395 elevation_rad,
2396 azimuth_rad,
2397 T_RX_J2000_S,
2398 F_L1_HZ,
2399 ObservableMediaOptions {
2400 troposphere: None,
2401 ionosphere: Some(ObservableIonosphereCorrection::Ionex(&ionex)),
2402 },
2403 )
2404 .expect("IONEX media correction");
2405 let expected = ionex_slant_delay(
2406 &ionex,
2407 receiver(),
2408 elevation_rad,
2409 azimuth_rad,
2410 T_RX_J2000_I64,
2411 F_L1_HZ,
2412 )
2413 .expect("direct IONEX");
2414
2415 assert_bits_eq("IONEX", got.ionosphere_m, expected);
2416 assert_bits_eq("IONEX total", got.total_m, expected);
2417 }
2418 }
2419
2420 #[test]
2421 fn default_media_prediction_matches_predict_bits() {
2422 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2423 let rx = [6_378_137.0, 0.0, 0.0];
2424 let source = StaticSource {
2425 state: ObservableState {
2426 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2427 clock_s: Some(0.0),
2428 },
2429 };
2430 let options = PredictOptions {
2431 carrier_hz: F_L1_HZ,
2432 light_time: false,
2433 sagnac: false,
2434 };
2435 let plain = predict(&source, sat, rx, T_RX_J2000_S, options).expect("plain predict");
2436 let media = predict_with_media(
2437 &source,
2438 sat,
2439 rx,
2440 T_RX_J2000_S,
2441 MediaPredictOptions {
2442 prediction: options,
2443 media: ObservableMediaOptions::default(),
2444 },
2445 )
2446 .expect("default media predict");
2447
2448 assert_prediction_bits_eq(&media.prediction, &plain);
2449 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2450 assert_eq!(media.media, AppliedMediaCorrections::default());
2451 }
2452
2453 #[test]
2454 fn default_media_prediction_skips_media_epoch_for_large_epoch() {
2455 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2456 let rx = [6_378_137.0, 0.0, 0.0];
2457 let source = StaticSource {
2458 state: ObservableState {
2459 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2460 clock_s: Some(0.0),
2461 },
2462 };
2463 let options = PredictOptions {
2464 carrier_hz: F_L1_HZ,
2465 light_time: false,
2466 sagnac: false,
2467 };
2468 let t_rx = 1.0e20;
2469 let plain = predict(&source, sat, rx, t_rx, options).expect("plain predict");
2470 let media = predict_with_media(
2471 &source,
2472 sat,
2473 rx,
2474 t_rx,
2475 MediaPredictOptions {
2476 prediction: options,
2477 media: ObservableMediaOptions::default(),
2478 },
2479 )
2480 .expect("default media predict");
2481
2482 assert_prediction_bits_eq(&media.prediction, &plain);
2483 assert_bits_eq("default range", media.range_m, plain.geometric_range_m);
2484 assert_eq!(media.media, AppliedMediaCorrections::default());
2485 }
2486
2487 #[test]
2488 fn below_troposphere_validity_returns_typed_error() {
2489 let err = observable_media_corrections(
2490 receiver(),
2491 2.0 * PI / DEGREES_PER_SEMICIRCLE,
2492 0.0,
2493 T_RX_J2000_S,
2494 F_L1_HZ,
2495 ObservableMediaOptions {
2496 troposphere: Some(ObservableTroposphereCorrection::default()),
2497 ionosphere: None,
2498 },
2499 )
2500 .expect_err("below mapping validity must fail");
2501
2502 match err {
2503 ObservablesError::InvalidInput { field, kind } => {
2504 assert_eq!(field, "media.elevation_rad");
2505 assert_eq!(kind, ObservablesInputErrorKind::OutOfRange);
2506 }
2507 other => panic!("expected typed InvalidInput, got {other:?}"),
2508 }
2509 }
2510
2511 #[test]
2512 fn range_media_prediction_adds_direct_troposphere_bits() {
2513 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2514 let rx = [6_378_137.0, 0.0, 0.0];
2515 let elevation_rad = core::f64::consts::FRAC_PI_2;
2516 let range_m = 20_000_000.0;
2517 let delta = [range_m, 0.0, 0.0];
2518 let source = StaticSource {
2519 state: ObservableState {
2520 position_ecef_m: [rx[0] + delta[0], rx[1] + delta[1], rx[2] + delta[2]],
2521 clock_s: Some(0.0),
2522 },
2523 };
2524 let options = MediaPredictOptions {
2525 prediction: PredictOptions {
2526 carrier_hz: f64::NAN,
2527 light_time: false,
2528 sagnac: false,
2529 },
2530 media: ObservableMediaOptions {
2531 troposphere: Some(ObservableTroposphereCorrection::default()),
2532 ionosphere: None,
2533 },
2534 };
2535 let request = [RangePredictionRequest {
2536 sat,
2537 receiver_ecef_m: rx,
2538 t_rx_j2000_s: T_RX_J2000_S,
2539 }];
2540 let zero_prediction = RangePrediction {
2541 geometric_range_m: 0.0,
2542 sat_clock_s: None,
2543 transmit_time_j2000_s: 0.0,
2544 sat_pos_ecef_m: [0.0; 3],
2545 };
2546 let mut out = [MediaRangePrediction {
2547 prediction: zero_prediction,
2548 range_m: 0.0,
2549 media: AppliedMediaCorrections::default(),
2550 }];
2551 predict_ranges_with_media(&source, &request, options, &mut out)
2552 .expect("range media prediction");
2553 let got = out[0];
2554 let expected = got.prediction.geometric_range_m + direct_troposphere(elevation_rad);
2555 assert_bits_eq("corrected range", got.range_m, expected);
2556 }
2557
2558 #[test]
2559 fn default_range_media_prediction_matches_range_bits_with_unused_carrier() {
2560 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2561 let rx = [6_378_137.0, 0.0, 0.0];
2562 let source = StaticSource {
2563 state: ObservableState {
2564 position_ecef_m: [26_378_137.0, 0.0, 0.0],
2565 clock_s: Some(0.0),
2566 },
2567 };
2568 let options = PredictOptions {
2569 carrier_hz: f64::NAN,
2570 light_time: false,
2571 sagnac: false,
2572 };
2573 let request = [RangePredictionRequest {
2574 sat,
2575 receiver_ecef_m: rx,
2576 t_rx_j2000_s: T_RX_J2000_S,
2577 }];
2578 let zero_prediction = RangePrediction {
2579 geometric_range_m: 0.0,
2580 sat_clock_s: None,
2581 transmit_time_j2000_s: 0.0,
2582 sat_pos_ecef_m: [0.0; 3],
2583 };
2584 let mut plain = [zero_prediction];
2585 predict_ranges(&source, &request, options, &mut plain).expect("plain range");
2586 let mut media = [MediaRangePrediction {
2587 prediction: zero_prediction,
2588 range_m: 0.0,
2589 media: AppliedMediaCorrections::default(),
2590 }];
2591 predict_ranges_with_media(
2592 &source,
2593 &request,
2594 MediaPredictOptions {
2595 prediction: options,
2596 media: ObservableMediaOptions::default(),
2597 },
2598 &mut media,
2599 )
2600 .expect("default media range");
2601
2602 assert_range_prediction_bits_eq(&media[0].prediction, &plain[0]);
2603 assert_bits_eq(
2604 "default range",
2605 media[0].range_m,
2606 plain[0].geometric_range_m,
2607 );
2608 assert_eq!(media[0].media, AppliedMediaCorrections::default());
2609 }
2610}
2611
2612#[cfg(all(test, sidereon_repo_tests))]
2613mod tests {
2614 use super::*;
2615 use crate::{GnssSatelliteId, GnssSystem};
2616
2617 #[derive(Debug, Clone, Copy)]
2618 struct StaticSource {
2619 state: ObservableState,
2620 }
2621
2622 impl ObservableEphemerisSource for StaticSource {
2623 fn observable_state_at_j2000_s(
2624 &self,
2625 _sat: GnssSatelliteId,
2626 _t_j2000_s: f64,
2627 ) -> Result<ObservableState, ObservablesError> {
2628 Ok(self.state)
2629 }
2630 }
2631
2632 fn sp3_fixture() -> Sp3 {
2633 let path = concat!(
2634 env!("CARGO_MANIFEST_DIR"),
2635 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
2636 );
2637 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
2638 Sp3::parse(&bytes).expect("parse SP3 fixture")
2639 }
2640
2641 fn static_source(position_ecef_m: [f64; 3]) -> StaticSource {
2642 StaticSource {
2643 state: ObservableState {
2644 position_ecef_m,
2645 clock_s: Some(0.0),
2646 },
2647 }
2648 }
2649
2650 fn no_light_time_options() -> PredictOptions {
2651 PredictOptions {
2652 carrier_hz: F_L1_HZ,
2653 light_time: false,
2654 sagnac: true,
2655 }
2656 }
2657
2658 fn assert_invalid_observables_input(
2659 err: ObservablesError,
2660 field: &'static str,
2661 kind: ObservablesInputErrorKind,
2662 ) {
2663 match err {
2664 ObservablesError::InvalidInput {
2665 field: got_field,
2666 kind: got_kind,
2667 } => {
2668 assert_eq!(got_field, field);
2669 assert_eq!(got_kind, kind);
2670 }
2671 other => panic!("expected InvalidInput({field}, {kind:?}), got {other:?}"),
2672 }
2673 }
2674
2675 #[test]
2676 fn split_julian_to_j2000_seconds_matches_orbis_time() {
2677 let t = j2000_seconds_from_split(2_459_024.5, 0.5).expect("valid split Julian date");
2678 assert_eq!(t, 646_272_000.0);
2679 }
2680
2681 #[test]
2682 fn split_julian_to_j2000_seconds_rejects_non_finite_parts() {
2683 for (jd_whole, jd_fraction, field) in [
2684 (f64::NAN, 0.5, "jd_whole"),
2685 (f64::INFINITY, 0.5, "jd_whole"),
2686 (2_459_024.5, f64::NAN, "jd_fraction"),
2687 (2_459_024.5, f64::NEG_INFINITY, "jd_fraction"),
2688 ] {
2689 let err = j2000_seconds_from_split(jd_whole, jd_fraction)
2690 .expect_err("non-finite split Julian date part must fail");
2691 assert_invalid_observables_input(err, field, ObservablesInputErrorKind::NonFinite);
2692 }
2693 }
2694
2695 #[test]
2696 fn sp3_predict_reference_case() {
2697 let sp3 = sp3_fixture();
2698 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2699 let rx = [3_512_900.0, 780_500.0, 5_248_700.0];
2700 let obs = predict(&sp3, sat, rx, 646_272_000.0, PredictOptions::default())
2701 .expect("predict observables");
2702
2703 assert_eq!(obs.geometric_range_m.to_bits(), 0x4173cf438ba57358);
2704 assert_eq!(obs.range_rate_m_s.to_bits(), 0x402d7dd36f6b8980);
2705 assert_eq!(obs.doppler_hz.to_bits(), 0xc0535f534ba7c77d);
2706 assert_eq!(obs.sat_clock_s.unwrap().to_bits(), 0x3ef04d2d8279460c);
2707 assert_eq!(obs.elevation_deg.to_bits(), 0x4054590eed870f52);
2708 assert_eq!(obs.azimuth_deg.to_bits(), 0x40645ff5a090a131);
2709 assert_eq!(obs.transmit_offset_us, 69_288);
2710 assert_eq!(obs.transmit_time_j2000_s.to_bits(), 0x41c342a9fff72192);
2711 assert_eq!(
2712 obs.los_unit.map(f64::to_bits),
2713 [0x3fe4c70da9fa70dd, 0x3fc834429adb2bae, 0x3fe792a4f57fdcb1,]
2714 );
2715 assert_eq!(
2716 obs.sat_pos_ecef_m.map(f64::to_bits),
2717 [0x41703667d8c0eb8f, 0x4151f601b1d775f3, 0x4173992c0ec03dcd,]
2718 );
2719 assert_eq!(
2720 obs.sat_velocity_m_s.map(f64::to_bits),
2721 [0xc09c17d81e540ab6, 0x409a192982abbeb7, 0x40926013f2ae8000,]
2722 );
2723 }
2724
2725 #[test]
2726 fn predict_rejects_invalid_entry_inputs() {
2727 let source = static_source([20_200_000.0, 14_000_000.0, 21_700_000.0]);
2728 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2729
2730 let err = predict(
2731 &source,
2732 sat,
2733 [f64::NAN, 0.0, 0.0],
2734 646_272_000.0,
2735 no_light_time_options(),
2736 )
2737 .expect_err("non-finite receiver position must fail");
2738 assert_invalid_observables_input(
2739 err,
2740 "receiver_ecef_m",
2741 ObservablesInputErrorKind::NonFinite,
2742 );
2743
2744 let err = predict(
2745 &source,
2746 sat,
2747 [0.0, 0.0, 0.0],
2748 f64::INFINITY,
2749 no_light_time_options(),
2750 )
2751 .expect_err("non-finite receive time must fail");
2752 assert_invalid_observables_input(err, "t_rx_j2000_s", ObservablesInputErrorKind::NonFinite);
2753
2754 let mut options = no_light_time_options();
2755 options.carrier_hz = 0.0;
2756 let err = predict(&source, sat, [0.0, 0.0, 0.0], 646_272_000.0, options)
2757 .expect_err("non-positive carrier must fail");
2758 assert_invalid_observables_input(
2759 err,
2760 "options.carrier_hz",
2761 ObservablesInputErrorKind::NotPositive,
2762 );
2763 }
2764
2765 #[test]
2766 fn predict_rejects_invalid_source_state_and_zero_range() {
2767 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2768
2769 let source = static_source([f64::NAN, 14_000_000.0, 21_700_000.0]);
2770 let err = predict(
2771 &source,
2772 sat,
2773 [0.0, 0.0, 0.0],
2774 646_272_000.0,
2775 no_light_time_options(),
2776 )
2777 .expect_err("non-finite ephemeris position must fail");
2778 assert_invalid_observables_input(
2779 err,
2780 "observable state position_ecef_m",
2781 ObservablesInputErrorKind::NonFinite,
2782 );
2783
2784 let source = static_source([1_000.0, 2_000.0, 3_000.0]);
2785 let err = predict(
2786 &source,
2787 sat,
2788 [1_000.0, 2_000.0, 3_000.0],
2789 646_272_000.0,
2790 no_light_time_options(),
2791 )
2792 .expect_err("zero geometric range must fail");
2793 assert_invalid_observables_input(
2794 err,
2795 "geometric_range_m",
2796 ObservablesInputErrorKind::NotPositive,
2797 );
2798 }
2799
2800 #[test]
2801 fn topocentric_rejects_invalid_receiver_geodetic_conversion() {
2802 let err = topocentric([f64::MAX, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0)
2803 .expect_err("invalid receiver geodetic conversion must fail");
2804
2805 assert_invalid_observables_input(
2806 err,
2807 "receiver_ecef_m",
2808 ObservablesInputErrorKind::OutOfRange,
2809 );
2810 }
2811
2812 const EQUATORIAL_RX_X_M: f64 = 6_378_137.0;
2816
2817 #[test]
2818 fn topocentric_azimuth_is_zero_at_exact_zenith() {
2819 let geometry = topocentric(
2822 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2823 [20_000_000.0, 0.0, 0.0],
2824 20_000_000.0,
2825 )
2826 .expect("zenith topocentric must not error");
2827 assert_eq!(geometry.azimuth_deg, 0.0);
2828 assert!(geometry.azimuth_deg.is_finite());
2829 assert!((geometry.elevation_deg - 90.0).abs() < 1e-9);
2830 }
2831
2832 #[test]
2833 fn topocentric_azimuth_is_zero_just_off_zenith() {
2834 let geometry = topocentric(
2837 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2838 [20_000_000.0, 1.0e-9, 1.0e-9],
2839 20_000_000.0,
2840 )
2841 .expect("near-zenith topocentric must not error");
2842 assert_eq!(geometry.azimuth_deg, 0.0);
2843 assert!(geometry.azimuth_deg.is_finite());
2844 }
2845
2846 #[test]
2847 fn predict_azimuth_is_zero_at_exact_zenith() {
2848 let source = StaticSource {
2849 state: ObservableState {
2850 position_ecef_m: [EQUATORIAL_RX_X_M + 20_000_000.0, 0.0, 0.0],
2851 clock_s: None,
2852 },
2853 };
2854 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
2855 let obs = predict(
2856 &source,
2857 sat,
2858 [EQUATORIAL_RX_X_M, 0.0, 0.0],
2859 0.0,
2860 PredictOptions {
2861 carrier_hz: F_L1_HZ,
2862 light_time: false,
2863 sagnac: false,
2864 },
2865 )
2866 .expect("zenith predict must not error");
2867 assert_eq!(obs.azimuth_deg, 0.0);
2868 assert!(obs.azimuth_deg.is_finite());
2869 assert!((obs.elevation_deg - 90.0).abs() < 1e-9);
2870 }
2871
2872 fn batch_test_requests() -> Vec<PredictRequest> {
2873 let sat1 = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
2874 let sat2 = GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id");
2875 vec![
2876 (sat1, [4_027_894.0, 307_046.0, 4_919_474.0], 646_272_000.0),
2877 (sat2, [4_027_900.0, 307_050.0, 4_919_480.0], 646_272_030.0),
2878 (
2879 sat1,
2880 [1_130_000.0, -4_830_000.0, 3_994_000.0],
2881 646_272_060.0,
2882 ),
2883 (
2884 sat2,
2885 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
2886 646_272_090.0,
2887 ),
2888 ]
2889 }
2890
2891 fn assert_observables_bits_eq(a: &PredictedObservables, b: &PredictedObservables) {
2892 assert_eq!(a.geometric_range_m.to_bits(), b.geometric_range_m.to_bits());
2893 assert_eq!(a.range_rate_m_s.to_bits(), b.range_rate_m_s.to_bits());
2894 assert_eq!(a.doppler_hz.to_bits(), b.doppler_hz.to_bits());
2895 assert_eq!(a.elevation_deg.to_bits(), b.elevation_deg.to_bits());
2896 assert_eq!(a.azimuth_deg.to_bits(), b.azimuth_deg.to_bits());
2897 assert_eq!(a.transmit_offset_us, b.transmit_offset_us);
2898 assert_eq!(
2899 a.transmit_time_j2000_s.to_bits(),
2900 b.transmit_time_j2000_s.to_bits()
2901 );
2902 for k in 0..3 {
2903 assert_eq!(a.los_unit[k].to_bits(), b.los_unit[k].to_bits());
2904 assert_eq!(a.sat_pos_ecef_m[k].to_bits(), b.sat_pos_ecef_m[k].to_bits());
2905 assert_eq!(
2906 a.sat_velocity_m_s[k].to_bits(),
2907 b.sat_velocity_m_s[k].to_bits()
2908 );
2909 }
2910 }
2911
2912 #[test]
2913 fn predict_batch_matches_scalar_loop_bitwise() {
2914 let source = StaticSource {
2915 state: ObservableState {
2916 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2917 clock_s: Some(1.25e-6),
2918 },
2919 };
2920 let options = PredictOptions {
2921 carrier_hz: F_L1_HZ,
2922 light_time: true,
2923 sagnac: true,
2924 };
2925 let requests = batch_test_requests();
2926 let batch = predict_batch(&source, &requests, options);
2927 assert_eq!(batch.len(), requests.len());
2928 for (entry, &(sat, rx, t)) in batch.iter().zip(requests.iter()) {
2929 let scalar = predict(&source, sat, rx, t, options);
2930 match (entry, &scalar) {
2931 (Ok(b), Ok(s)) => assert_observables_bits_eq(b, s),
2932 (Err(_), Err(_)) => {}
2933 _ => panic!("batch and scalar predict disagree on Ok/Err"),
2934 }
2935 }
2936 }
2937
2938 #[test]
2939 fn predict_batch_parallel_matches_serial_bitwise() {
2940 let source = StaticSource {
2941 state: ObservableState {
2942 position_ecef_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
2943 clock_s: Some(1.25e-6),
2944 },
2945 };
2946 let options = PredictOptions {
2947 carrier_hz: F_L1_HZ,
2948 light_time: true,
2949 sagnac: true,
2950 };
2951 let requests = batch_test_requests();
2952 let serial = predict_batch(&source, &requests, options);
2953 let parallel = predict_batch_parallel(&source, &requests, options);
2954 assert_eq!(serial.len(), parallel.len());
2955 for (s, p) in serial.iter().zip(parallel.iter()) {
2956 match (s, p) {
2957 (Ok(a), Ok(b)) => assert_observables_bits_eq(a, b),
2958 (Err(_), Err(_)) => {}
2959 _ => panic!("serial and parallel batch disagree on Ok/Err"),
2960 }
2961 }
2962 }
2963}