1use core::f64::consts::{FRAC_PI_2, PI, TAU};
54use std::collections::BTreeMap;
55
56use crate::constants::MEAN_EARTH_RADIUS_M;
57use crate::tolerances::FREQUENCY_DENOMINATOR_EPS_HZ;
58use crate::validate;
59
60use super::cycle_slip::{geometry_free_m as phase_geometry_free_combination_m, CycleSlipError};
61use super::prep::DualFrequencyObservation;
62
63pub const DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M: f64 = 350_000.0;
65
66pub const ELECTRONS_PER_TECU_M2: f64 = 1.0e16;
68
69pub const TEC_GROUP_DELAY_COEFFICIENT: f64 = 40.308 * ELECTRONS_PER_TECU_M2;
74
75#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct TecConfig {
78 pub shell_height_m: f64,
80 pub earth_radius_m: f64,
82}
83
84impl TecConfig {
85 pub fn validate(&self) -> Result<(), TecError> {
87 validate::finite_positive(self.shell_height_m, "shell_height_m")
88 .map_err(|_| TecError::InvalidShellHeight)?;
89 validate::finite_positive(self.earth_radius_m, "earth_radius_m")
90 .map_err(|_| TecError::InvalidEarthRadius)?;
91 Ok(())
92 }
93}
94
95impl Default for TecConfig {
96 fn default() -> Self {
97 Self {
98 shell_height_m: DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M,
99 earth_radius_m: MEAN_EARTH_RADIUS_M,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct TecObservation {
107 pub observation: DualFrequencyObservation,
109 pub elevation_rad: f64,
111 pub azimuth_rad: f64,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct TecEpoch {
118 pub time_s: f64,
120 pub receiver_latitude_rad: f64,
122 pub receiver_longitude_rad: f64,
124 pub observations: Vec<TecObservation>,
126}
127
128#[derive(Debug, Clone, PartialEq)]
130pub struct TecEstimate {
131 pub arcs: Vec<TecSatelliteArc>,
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct TecSatelliteArc {
138 pub satellite_id: String,
140 pub ambiguity_id: String,
142 pub phase_bias_tecu: f64,
144 pub samples: Vec<TecEstimateSample>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq)]
150pub struct TecEstimateSample {
151 pub time_s: f64,
153 pub elevation_rad: f64,
155 pub azimuth_rad: f64,
157 pub code_geometry_free_m: f64,
159 pub phase_geometry_free_m: f64,
161 pub code_slant_tec_tecu: f64,
163 pub phase_slant_tec_tecu: f64,
165 pub leveled_slant_tec_tecu: f64,
167 pub mapping_function: f64,
169 pub vertical_tec_tecu: f64,
171 pub pierce_point: IonosphericPiercePoint,
173}
174
175#[derive(Debug, Clone, PartialEq)]
177pub struct CodeSlantTecEstimate {
178 pub satellite_id: String,
180 pub ambiguity_id: String,
182 pub code_geometry_free_m: f64,
184 pub slant_tec_tecu: f64,
186}
187
188#[derive(Debug, Clone, PartialEq)]
190pub struct PhaseSlantTecEstimate {
191 pub satellite_id: String,
193 pub ambiguity_id: String,
195 pub phase_geometry_free_m: f64,
197 pub slant_tec_tecu: f64,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq)]
203pub struct TecLevelingSample {
204 pub code_slant_tec_tecu: f64,
206 pub phase_slant_tec_tecu: f64,
208 pub elevation_rad: f64,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct LeveledTecSample {
215 pub code_slant_tec_tecu: f64,
217 pub phase_slant_tec_tecu: f64,
219 pub leveled_slant_tec_tecu: f64,
221 pub mapping_function: f64,
223 pub vertical_tec_tecu: f64,
225}
226
227#[derive(Debug, Clone, PartialEq)]
229pub struct TecLevelingResult {
230 pub phase_bias_tecu: f64,
232 pub samples: Vec<LeveledTecSample>,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct IonosphericPiercePoint {
239 pub latitude_rad: f64,
241 pub longitude_rad: f64,
243 pub latitude_deg: f64,
245 pub longitude_deg: f64,
247 pub earth_central_angle_rad: f64,
249 pub shell_height_m: f64,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TecError {
256 NonFiniteObservation,
258 InvalidShellHeight,
260 InvalidEarthRadius,
262 InvalidFrequency,
264 EqualFrequencies,
266 InvalidReceiverLatitude,
268 InvalidReceiverLongitude,
270 InvalidElevation,
272 InvalidAzimuth,
274 NonFiniteTec,
276 EmptyArc,
278 NoEpochs,
280 NoObservations,
282 NonFiniteEpochTime,
284 EpochsNotOrdered,
286 InsufficientArcSamples,
288}
289
290impl core::fmt::Display for TecError {
291 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
292 match self {
293 Self::NonFiniteObservation => write!(f, "TEC observation must be finite"),
294 Self::InvalidShellHeight => write!(f, "TEC shell height must be positive and finite"),
295 Self::InvalidEarthRadius => write!(f, "TEC Earth radius must be positive and finite"),
296 Self::InvalidFrequency => write!(f, "carrier frequency must be positive and finite"),
297 Self::EqualFrequencies => write!(f, "carrier frequencies must be distinct"),
298 Self::InvalidReceiverLatitude => {
299 write!(
300 f,
301 "receiver latitude must be finite and within [-pi/2, pi/2]"
302 )
303 }
304 Self::InvalidReceiverLongitude => write!(f, "receiver longitude must be finite"),
305 Self::InvalidElevation => {
306 write!(f, "satellite elevation must be finite and within [0, pi/2]")
307 }
308 Self::InvalidAzimuth => write!(f, "satellite azimuth must be finite"),
309 Self::NonFiniteTec => write!(f, "TEC value must be finite"),
310 Self::EmptyArc => write!(f, "TEC leveling arc must contain at least one sample"),
311 Self::NoEpochs => write!(f, "TEC epoch stream must contain at least one epoch"),
312 Self::NoObservations => {
313 write!(f, "TEC epoch stream must contain at least one observation")
314 }
315 Self::NonFiniteEpochTime => write!(f, "TEC epoch time must be finite"),
316 Self::EpochsNotOrdered => write!(f, "TEC epochs must be time ordered"),
317 Self::InsufficientArcSamples => {
318 write!(f, "TEC satellite arc must contain at least two samples")
319 }
320 }
321 }
322}
323
324impl std::error::Error for TecError {}
325
326pub fn code_geometry_free_m(observation: &DualFrequencyObservation) -> Result<f64, TecError> {
328 validate_code_observation(observation)?;
329 let geometry_free_m = observation.p1_m - observation.p2_m;
330 validate::finite(geometry_free_m, "code_geometry_free_m")
331 .map_err(|_| TecError::NonFiniteObservation)?;
332 Ok(geometry_free_m)
333}
334
335pub fn slant_tec_from_code_geometry_free_m(
337 code_geometry_free_m: f64,
338 f1_hz: f64,
339 f2_hz: f64,
340) -> Result<f64, TecError> {
341 validate::finite(code_geometry_free_m, "code_geometry_free_m")
342 .map_err(|_| TecError::NonFiniteObservation)?;
343 if code_geometry_free_m == 0.0 {
344 return Ok(0.0);
345 }
346 let denominator = tec_geometry_free_denominator_m_per_tecu(f1_hz, f2_hz)?;
347 let slant_tec_tecu = code_geometry_free_m / denominator;
348 validate::finite(slant_tec_tecu, "slant_tec_tecu").map_err(|_| TecError::NonFiniteTec)?;
349 Ok(slant_tec_tecu)
350}
351
352pub fn phase_geometry_free_m(observation: &DualFrequencyObservation) -> Result<f64, TecError> {
354 let geometry_free_m =
355 phase_geometry_free_combination_m(observation).map_err(map_cycle_slip_error)?;
356 validate::finite(geometry_free_m, "phase_geometry_free_m")
357 .map_err(|_| TecError::NonFiniteObservation)?;
358 Ok(geometry_free_m)
359}
360
361pub fn slant_tec_from_phase_geometry_free_m(
367 phase_geometry_free_m: f64,
368 f1_hz: f64,
369 f2_hz: f64,
370) -> Result<f64, TecError> {
371 validate::finite(phase_geometry_free_m, "phase_geometry_free_m")
372 .map_err(|_| TecError::NonFiniteObservation)?;
373 if phase_geometry_free_m == 0.0 {
374 return Ok(0.0);
375 }
376 let denominator = tec_geometry_free_denominator_m_per_tecu(f1_hz, f2_hz)?;
377 let slant_tec_tecu = -phase_geometry_free_m / denominator;
378 validate::finite(slant_tec_tecu, "slant_tec_tecu").map_err(|_| TecError::NonFiniteTec)?;
379 Ok(slant_tec_tecu)
380}
381
382pub fn estimate_code_slant_tec(
384 observation: &DualFrequencyObservation,
385) -> Result<CodeSlantTecEstimate, TecError> {
386 let code_geometry_free_m = code_geometry_free_m(observation)?;
387 let slant_tec_tecu = slant_tec_from_code_geometry_free_m(
388 code_geometry_free_m,
389 observation.f1_hz,
390 observation.f2_hz,
391 )?;
392 Ok(CodeSlantTecEstimate {
393 satellite_id: observation.satellite_id.clone(),
394 ambiguity_id: observation.ambiguity_id.clone(),
395 code_geometry_free_m,
396 slant_tec_tecu,
397 })
398}
399
400pub fn estimate_phase_slant_tec(
402 observation: &DualFrequencyObservation,
403) -> Result<PhaseSlantTecEstimate, TecError> {
404 let phase_geometry_free_m = phase_geometry_free_m(observation)?;
405 let slant_tec_tecu = slant_tec_from_phase_geometry_free_m(
406 phase_geometry_free_m,
407 observation.f1_hz,
408 observation.f2_hz,
409 )?;
410 Ok(PhaseSlantTecEstimate {
411 satellite_id: observation.satellite_id.clone(),
412 ambiguity_id: observation.ambiguity_id.clone(),
413 phase_geometry_free_m,
414 slant_tec_tecu,
415 })
416}
417
418pub fn thin_shell_mapping_function(elevation_rad: f64, config: TecConfig) -> Result<f64, TecError> {
424 config.validate()?;
425 validate_elevation(elevation_rad)?;
426 let shell_radius_m = config.earth_radius_m + config.shell_height_m;
427 validate::finite_positive(shell_radius_m, "shell_radius_m")
428 .map_err(|_| TecError::InvalidShellHeight)?;
429 let obliquity_arg = config.earth_radius_m * elevation_rad.cos() / shell_radius_m;
430 validate::finite(obliquity_arg, "obliquity_arg").map_err(|_| TecError::InvalidShellHeight)?;
431 let mapping_denominator = 1.0 - obliquity_arg * obliquity_arg;
432 validate::finite_positive(mapping_denominator, "mapping_denominator")
433 .map_err(|_| TecError::InvalidShellHeight)?;
434 let mapping_function = 1.0 / mapping_denominator.sqrt();
435 validate::finite(mapping_function, "mapping_function")
436 .map_err(|_| TecError::InvalidShellHeight)?;
437 Ok(mapping_function)
438}
439
440pub fn vertical_tec_from_slant_tec(
442 slant_tec_tecu: f64,
443 elevation_rad: f64,
444 config: TecConfig,
445) -> Result<f64, TecError> {
446 validate_tec(slant_tec_tecu)?;
447 let mapping_function = thin_shell_mapping_function(elevation_rad, config)?;
448 Ok(slant_tec_tecu / mapping_function)
449}
450
451pub fn level_slant_tec_arc(
458 samples: &[TecLevelingSample],
459 config: TecConfig,
460) -> Result<TecLevelingResult, TecError> {
461 config.validate()?;
462 if samples.is_empty() {
463 return Err(TecError::EmptyArc);
464 }
465
466 let mut bias_sum_tecu = 0.0;
467 for sample in samples {
468 validate_leveling_sample(sample)?;
469 bias_sum_tecu += sample.phase_slant_tec_tecu - sample.code_slant_tec_tecu;
470 }
471 let phase_bias_tecu = bias_sum_tecu / samples.len() as f64;
472
473 let leveled_samples = samples
474 .iter()
475 .map(|sample| {
476 let mapping_function = thin_shell_mapping_function(sample.elevation_rad, config)?;
477 let leveled_slant_tec_tecu = sample.phase_slant_tec_tecu - phase_bias_tecu;
478 let vertical_tec_tecu = leveled_slant_tec_tecu / mapping_function;
479 Ok(LeveledTecSample {
480 code_slant_tec_tecu: sample.code_slant_tec_tecu,
481 phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
482 leveled_slant_tec_tecu,
483 mapping_function,
484 vertical_tec_tecu,
485 })
486 })
487 .collect::<Result<Vec<_>, TecError>>()?;
488
489 Ok(TecLevelingResult {
490 phase_bias_tecu,
491 samples: leveled_samples,
492 })
493}
494
495pub fn estimate_tec(epochs: &[TecEpoch], config: TecConfig) -> Result<TecEstimate, TecError> {
502 validate_tec_epochs(epochs, config)?;
503
504 let mut arcs = BTreeMap::<(String, String), Vec<TecArcBuildSample>>::new();
505 for epoch in epochs {
506 for observation in &epoch.observations {
507 let code_estimate = estimate_code_slant_tec(&observation.observation)?;
508 let phase_estimate = estimate_phase_slant_tec(&observation.observation)?;
509 arcs.entry((
510 observation.observation.satellite_id.clone(),
511 observation.observation.ambiguity_id.clone(),
512 ))
513 .or_default()
514 .push(TecArcBuildSample {
515 time_s: epoch.time_s,
516 receiver_latitude_rad: epoch.receiver_latitude_rad,
517 receiver_longitude_rad: epoch.receiver_longitude_rad,
518 elevation_rad: observation.elevation_rad,
519 azimuth_rad: observation.azimuth_rad,
520 code_geometry_free_m: code_estimate.code_geometry_free_m,
521 phase_geometry_free_m: phase_estimate.phase_geometry_free_m,
522 code_slant_tec_tecu: code_estimate.slant_tec_tecu,
523 phase_slant_tec_tecu: phase_estimate.slant_tec_tecu,
524 });
525 }
526 }
527
528 if arcs.is_empty() {
529 return Err(TecError::NoObservations);
530 }
531
532 let mut out_arcs = Vec::with_capacity(arcs.len());
533 for ((satellite_id, ambiguity_id), samples) in arcs {
534 if samples.len() < 2 {
535 return Err(TecError::InsufficientArcSamples);
536 }
537 let leveling_samples = samples
538 .iter()
539 .map(|sample| TecLevelingSample {
540 code_slant_tec_tecu: sample.code_slant_tec_tecu,
541 phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
542 elevation_rad: sample.elevation_rad,
543 })
544 .collect::<Vec<_>>();
545 let leveled = level_slant_tec_arc(&leveling_samples, config)?;
546 let output_samples = samples
547 .iter()
548 .zip(leveled.samples.iter())
549 .map(|(sample, leveled)| {
550 let pierce_point = ionospheric_pierce_point(
551 sample.receiver_latitude_rad,
552 sample.receiver_longitude_rad,
553 sample.elevation_rad,
554 sample.azimuth_rad,
555 config,
556 )?;
557 Ok(TecEstimateSample {
558 time_s: sample.time_s,
559 elevation_rad: sample.elevation_rad,
560 azimuth_rad: sample.azimuth_rad,
561 code_geometry_free_m: sample.code_geometry_free_m,
562 phase_geometry_free_m: sample.phase_geometry_free_m,
563 code_slant_tec_tecu: sample.code_slant_tec_tecu,
564 phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
565 leveled_slant_tec_tecu: leveled.leveled_slant_tec_tecu,
566 mapping_function: leveled.mapping_function,
567 vertical_tec_tecu: leveled.vertical_tec_tecu,
568 pierce_point,
569 })
570 })
571 .collect::<Result<Vec<_>, TecError>>()?;
572 out_arcs.push(TecSatelliteArc {
573 satellite_id,
574 ambiguity_id,
575 phase_bias_tecu: leveled.phase_bias_tecu,
576 samples: output_samples,
577 });
578 }
579
580 Ok(TecEstimate { arcs: out_arcs })
581}
582
583pub fn ionospheric_pierce_point(
589 receiver_latitude_rad: f64,
590 receiver_longitude_rad: f64,
591 elevation_rad: f64,
592 azimuth_rad: f64,
593 config: TecConfig,
594) -> Result<IonosphericPiercePoint, TecError> {
595 config.validate()?;
596 validate_receiver_latitude(receiver_latitude_rad)?;
597 validate_receiver_longitude(receiver_longitude_rad)?;
598 validate_elevation(elevation_rad)?;
599 validate_azimuth(azimuth_rad)?;
600
601 let shell_radius_m = config.earth_radius_m + config.shell_height_m;
602 let shell_scaled_cosine = config.earth_radius_m / shell_radius_m * elevation_rad.cos();
603 let earth_central_angle_rad = FRAC_PI_2 - elevation_rad - shell_scaled_cosine.asin();
604
605 let receiver_sin = receiver_latitude_rad.sin();
606 let receiver_cos = receiver_latitude_rad.cos();
607 let psi_sin = earth_central_angle_rad.sin();
608 let psi_cos = earth_central_angle_rad.cos();
609 let azimuth_sin = azimuth_rad.sin();
610 let azimuth_cos = azimuth_rad.cos();
611
612 let latitude_sine =
613 (receiver_sin * psi_cos + receiver_cos * psi_sin * azimuth_cos).clamp(-1.0, 1.0);
614 let latitude_rad = latitude_sine.asin();
615 let longitude_step_rad =
616 (azimuth_sin * psi_sin * receiver_cos).atan2(psi_cos - receiver_sin * latitude_rad.sin());
617 let longitude_rad = normalize_longitude_rad(receiver_longitude_rad + longitude_step_rad);
618
619 Ok(IonosphericPiercePoint {
620 latitude_rad,
621 longitude_rad,
622 latitude_deg: latitude_rad.to_degrees(),
623 longitude_deg: longitude_rad.to_degrees(),
624 earth_central_angle_rad,
625 shell_height_m: config.shell_height_m,
626 })
627}
628
629fn tec_geometry_free_denominator_m_per_tecu(f1_hz: f64, f2_hz: f64) -> Result<f64, TecError> {
630 let f1_hz = validate_frequency(f1_hz)?;
631 let f2_hz = validate_frequency(f2_hz)?;
632 if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
633 return Err(TecError::EqualFrequencies);
634 }
635 let denominator = TEC_GROUP_DELAY_COEFFICIENT * (1.0 / (f1_hz * f1_hz) - 1.0 / (f2_hz * f2_hz));
636 validate::finite(denominator, "tec_geometry_free_denominator_m_per_tecu")
637 .map_err(|_| TecError::InvalidFrequency)?;
638 if denominator == 0.0 {
639 return Err(TecError::EqualFrequencies);
640 }
641 Ok(denominator)
642}
643
644fn validate_frequency(frequency_hz: f64) -> Result<f64, TecError> {
645 validate::finite_positive(frequency_hz, "frequency_hz").map_err(|_| TecError::InvalidFrequency)
646}
647
648fn validate_code_observation(observation: &DualFrequencyObservation) -> Result<(), TecError> {
649 if observation.p1_m.is_finite() && observation.p2_m.is_finite() {
650 Ok(())
651 } else {
652 Err(TecError::NonFiniteObservation)
653 }
654}
655
656#[derive(Debug, Clone)]
657struct TecArcBuildSample {
658 time_s: f64,
659 receiver_latitude_rad: f64,
660 receiver_longitude_rad: f64,
661 elevation_rad: f64,
662 azimuth_rad: f64,
663 code_geometry_free_m: f64,
664 phase_geometry_free_m: f64,
665 code_slant_tec_tecu: f64,
666 phase_slant_tec_tecu: f64,
667}
668
669fn validate_tec_epochs(epochs: &[TecEpoch], config: TecConfig) -> Result<(), TecError> {
670 config.validate()?;
671 if epochs.is_empty() {
672 return Err(TecError::NoEpochs);
673 }
674
675 let mut previous_time_s = None;
676 let mut observation_count = 0usize;
677 for epoch in epochs {
678 if !epoch.time_s.is_finite() {
679 return Err(TecError::NonFiniteEpochTime);
680 }
681 if let Some(previous_time_s) = previous_time_s {
682 if epoch.time_s < previous_time_s {
683 return Err(TecError::EpochsNotOrdered);
684 }
685 }
686 previous_time_s = Some(epoch.time_s);
687 validate_receiver_latitude(epoch.receiver_latitude_rad)?;
688 validate_receiver_longitude(epoch.receiver_longitude_rad)?;
689 for observation in &epoch.observations {
690 validate_elevation(observation.elevation_rad)?;
691 validate_azimuth(observation.azimuth_rad)?;
692 observation_count += 1;
693 }
694 }
695
696 if observation_count == 0 {
697 Err(TecError::NoObservations)
698 } else {
699 Ok(())
700 }
701}
702
703fn validate_tec(value: f64) -> Result<(), TecError> {
704 if value.is_finite() {
705 Ok(())
706 } else {
707 Err(TecError::NonFiniteTec)
708 }
709}
710
711fn validate_leveling_sample(sample: &TecLevelingSample) -> Result<(), TecError> {
712 validate_tec(sample.code_slant_tec_tecu)?;
713 validate_tec(sample.phase_slant_tec_tecu)?;
714 validate_elevation(sample.elevation_rad)
715}
716
717fn map_cycle_slip_error(error: CycleSlipError) -> TecError {
718 match error {
719 CycleSlipError::NonFiniteObservation => TecError::NonFiniteObservation,
720 CycleSlipError::InvalidFrequency => TecError::InvalidFrequency,
721 CycleSlipError::EqualFrequencies => TecError::EqualFrequencies,
722 CycleSlipError::InvalidConfig(_)
723 | CycleSlipError::NonFiniteEpochTime
724 | CycleSlipError::EpochsNotOrdered => TecError::NonFiniteObservation,
725 }
726}
727
728fn validate_receiver_latitude(latitude_rad: f64) -> Result<(), TecError> {
729 if latitude_rad.is_finite() && (-FRAC_PI_2..=FRAC_PI_2).contains(&latitude_rad) {
730 Ok(())
731 } else {
732 Err(TecError::InvalidReceiverLatitude)
733 }
734}
735
736fn validate_receiver_longitude(longitude_rad: f64) -> Result<(), TecError> {
737 if longitude_rad.is_finite() {
738 Ok(())
739 } else {
740 Err(TecError::InvalidReceiverLongitude)
741 }
742}
743
744fn validate_elevation(elevation_rad: f64) -> Result<(), TecError> {
745 if elevation_rad.is_finite() && (0.0..=FRAC_PI_2).contains(&elevation_rad) {
746 Ok(())
747 } else {
748 Err(TecError::InvalidElevation)
749 }
750}
751
752fn validate_azimuth(azimuth_rad: f64) -> Result<(), TecError> {
753 if azimuth_rad.is_finite() {
754 Ok(())
755 } else {
756 Err(TecError::InvalidAzimuth)
757 }
758}
759
760fn normalize_longitude_rad(longitude_rad: f64) -> f64 {
761 let mut normalized = (longitude_rad + PI) % TAU;
762 if normalized < 0.0 {
763 normalized += TAU;
764 }
765 normalized - PI
766}
767
768#[cfg(test)]
769mod tests {
770 use crate::constants::{F_L1_HZ, F_L2_HZ};
771
772 use super::*;
773
774 fn deg(value: f64) -> f64 {
775 value.to_radians()
776 }
777
778 fn observation_with_code_geometry_free(code_geometry_free_m: f64) -> DualFrequencyObservation {
779 let (p1_m, p2_m) = if code_geometry_free_m.is_sign_negative() {
780 (0.0, -code_geometry_free_m)
781 } else {
782 (code_geometry_free_m, 0.0)
783 };
784 DualFrequencyObservation {
785 satellite_id: "G01".to_string(),
786 ambiguity_id: "G01".to_string(),
787 p1_m,
788 p2_m,
789 phi1_cyc: 0.0,
790 phi2_cyc: 0.0,
791 f1_hz: F_L1_HZ,
792 f2_hz: F_L2_HZ,
793 lli1: None,
794 lli2: None,
795 }
796 }
797
798 fn observation_from_slant_tec(
799 satellite_id: &str,
800 ambiguity_id: &str,
801 code_slant_tec_tecu: f64,
802 phase_slant_tec_tecu: f64,
803 ) -> DualFrequencyObservation {
804 let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
805 .expect("GPS L1/L2 TEC denominator");
806 let code_geometry_free_m = denominator * code_slant_tec_tecu;
807 let phase_geometry_free_m = -denominator * phase_slant_tec_tecu;
808 DualFrequencyObservation {
809 satellite_id: satellite_id.to_string(),
810 ambiguity_id: ambiguity_id.to_string(),
811 p1_m: 0.0,
812 p2_m: -code_geometry_free_m,
813 phi1_cyc: phase_geometry_free_m / (crate::constants::C_M_S / F_L1_HZ),
814 phi2_cyc: 0.0,
815 f1_hz: F_L1_HZ,
816 f2_hz: F_L2_HZ,
817 lli1: None,
818 lli2: None,
819 }
820 }
821
822 fn arc_by_satellite<'a>(estimate: &'a TecEstimate, satellite_id: &str) -> &'a TecSatelliteArc {
823 estimate
824 .arcs
825 .iter()
826 .find(|arc| arc.satellite_id == satellite_id)
827 .expect("satellite arc")
828 }
829
830 fn assert_close(left: f64, right: f64, tolerance: f64) {
831 assert!(
832 (left - right).abs() <= tolerance,
833 "{left} differs from {right} by more than {tolerance}"
834 );
835 }
836
837 #[test]
838 fn code_geometry_free_delay_maps_to_expected_slant_tec() {
839 let expected_slant_tec_tecu = 17.25;
840 let code_geometry_free_m = expected_slant_tec_tecu
841 * tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
842 .expect("GPS L1/L2 TEC denominator");
843 let observation = observation_with_code_geometry_free(code_geometry_free_m);
844
845 let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
846
847 assert_close(estimate.code_geometry_free_m, code_geometry_free_m, 1.0e-9);
848 assert_close(estimate.slant_tec_tecu, expected_slant_tec_tecu, 1.0e-12);
849 }
850
851 #[test]
852 fn zero_code_geometry_free_delay_gives_zero_slant_tec() {
853 let observation = observation_with_code_geometry_free(0.0);
854
855 let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
856
857 assert_eq!(estimate.code_geometry_free_m.to_bits(), 0.0f64.to_bits());
858 assert_eq!(estimate.slant_tec_tecu.to_bits(), 0.0f64.to_bits());
859 }
860
861 #[test]
862 fn phase_geometry_free_delay_maps_to_biased_slant_tec() {
863 let true_slant_tec_tecu = 21.0;
864 let phase_bias_tecu = 9.5;
865 let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
866 .expect("GPS L1/L2 TEC denominator");
867 let phase_geometry_free_m = -(true_slant_tec_tecu + phase_bias_tecu) * denominator;
868
869 let slant_tec_tecu =
870 slant_tec_from_phase_geometry_free_m(phase_geometry_free_m, F_L1_HZ, F_L2_HZ)
871 .expect("phase slant TEC");
872
873 assert_close(
874 slant_tec_tecu,
875 true_slant_tec_tecu + phase_bias_tecu,
876 1.0e-12,
877 );
878 }
879
880 #[test]
881 fn phase_slant_tec_rejects_collapsed_frequency_denominator() {
882 assert_eq!(
883 slant_tec_from_phase_geometry_free_m(1.0, f64::MAX, f64::MAX / 2.0),
884 Err(TecError::EqualFrequencies)
885 );
886 }
887
888 #[test]
889 fn mapping_function_is_one_at_zenith_and_increases_toward_horizon() {
890 let config = TecConfig::default();
891
892 let zenith = thin_shell_mapping_function(FRAC_PI_2, config).expect("zenith mapping");
893 let high = thin_shell_mapping_function(deg(60.0), config).expect("high mapping");
894 let low = thin_shell_mapping_function(deg(30.0), config).expect("low mapping");
895 let horizon = thin_shell_mapping_function(0.0, config).expect("horizon mapping");
896
897 assert_close(zenith, 1.0, 1.0e-15);
898 assert!(high > zenith);
899 assert!(low > high);
900 assert!(horizon > low);
901 }
902
903 #[test]
904 fn mapping_function_rejects_degenerate_shell_geometry() {
905 let config = TecConfig {
906 shell_height_m: f64::MIN_POSITIVE,
907 earth_radius_m: 1.0,
908 };
909
910 assert_eq!(
911 thin_shell_mapping_function(0.0, config),
912 Err(TecError::InvalidShellHeight)
913 );
914 }
915
916 #[test]
917 fn synthetic_leveled_arc_recovers_constant_vertical_tec() {
918 let config = TecConfig::default();
919 let vertical_tec_tecu = 14.0;
920 let phase_bias_tecu = 37.5;
921 let noise_tecu = [0.6, -0.2, -0.4, 0.0];
922 let elevations_rad = [deg(30.0), deg(45.0), deg(60.0), deg(75.0)];
923 let samples = elevations_rad
924 .iter()
925 .zip(noise_tecu)
926 .map(|(&elevation_rad, noise_tecu)| {
927 let mapping_function =
928 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
929 let true_slant_tec_tecu = vertical_tec_tecu * mapping_function;
930 TecLevelingSample {
931 code_slant_tec_tecu: true_slant_tec_tecu + noise_tecu,
932 phase_slant_tec_tecu: true_slant_tec_tecu + phase_bias_tecu,
933 elevation_rad,
934 }
935 })
936 .collect::<Vec<_>>();
937
938 let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
939
940 assert_close(result.phase_bias_tecu, phase_bias_tecu, 1.0e-12);
941 for sample in result.samples {
942 assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
943 }
944 }
945
946 #[test]
947 fn known_elevation_profile_yields_expected_slant_to_vertical_reduction() {
948 let config = TecConfig::default();
949 let vertical_tec_tecu = 8.25;
950 let elevations_rad = [deg(25.0), deg(55.0), deg(85.0)];
951 let samples = elevations_rad
952 .iter()
953 .map(|&elevation_rad| {
954 let mapping_function =
955 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
956 let slant_tec_tecu = vertical_tec_tecu * mapping_function;
957 TecLevelingSample {
958 code_slant_tec_tecu: slant_tec_tecu,
959 phase_slant_tec_tecu: slant_tec_tecu,
960 elevation_rad,
961 }
962 })
963 .collect::<Vec<_>>();
964
965 let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
966
967 assert_close(result.phase_bias_tecu, 0.0, 1.0e-12);
968 for (sample, elevation_rad) in result.samples.iter().zip(elevations_rad) {
969 let mapping_function =
970 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
971 assert_close(sample.mapping_function, mapping_function, 1.0e-15);
972 assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
973 }
974 }
975
976 #[test]
977 fn estimate_tec_multi_epoch_stream_returns_vertical_tec_and_pierce_points() {
978 let config = TecConfig::default();
979 let receiver_latitude_rad = 0.0;
980 let receiver_longitude_rad = 0.0;
981 let g01_vertical_tec_tecu = 11.0;
982 let g02_vertical_tec_tecu = 16.0;
983 let g01_phase_bias_tecu = 25.0;
984 let g02_phase_bias_tecu = -13.0;
985 let epochs = [0.0, 30.0, 60.0]
986 .into_iter()
987 .enumerate()
988 .map(|(idx, time_s)| {
989 let g01_elevation_rad = [deg(45.0), deg(55.0), deg(65.0)][idx];
990 let g02_elevation_rad = [deg(40.0), deg(50.0), deg(70.0)][idx];
991 let g01_mapping =
992 thin_shell_mapping_function(g01_elevation_rad, config).expect("G01 mapping");
993 let g02_mapping =
994 thin_shell_mapping_function(g02_elevation_rad, config).expect("G02 mapping");
995 let g01_slant_tec_tecu = g01_vertical_tec_tecu * g01_mapping;
996 let g02_slant_tec_tecu = g02_vertical_tec_tecu * g02_mapping;
997 TecEpoch {
998 time_s,
999 receiver_latitude_rad,
1000 receiver_longitude_rad,
1001 observations: vec![
1002 TecObservation {
1003 observation: observation_from_slant_tec(
1004 "G01",
1005 "G01",
1006 g01_slant_tec_tecu,
1007 g01_slant_tec_tecu + g01_phase_bias_tecu,
1008 ),
1009 elevation_rad: g01_elevation_rad,
1010 azimuth_rad: deg(90.0),
1011 },
1012 TecObservation {
1013 observation: observation_from_slant_tec(
1014 "G02",
1015 "G02",
1016 g02_slant_tec_tecu,
1017 g02_slant_tec_tecu + g02_phase_bias_tecu,
1018 ),
1019 elevation_rad: g02_elevation_rad,
1020 azimuth_rad: 0.0,
1021 },
1022 ],
1023 }
1024 })
1025 .collect::<Vec<_>>();
1026
1027 let estimate = estimate_tec(&epochs, config).expect("TEC estimate");
1028
1029 assert_eq!(estimate.arcs.len(), 2);
1030 let g01 = arc_by_satellite(&estimate, "G01");
1031 let g02 = arc_by_satellite(&estimate, "G02");
1032 assert_close(g01.phase_bias_tecu, g01_phase_bias_tecu, 1.0e-12);
1033 assert_close(g02.phase_bias_tecu, g02_phase_bias_tecu, 1.0e-12);
1034 for sample in &g01.samples {
1035 assert_close(sample.vertical_tec_tecu, g01_vertical_tec_tecu, 1.0e-12);
1036 assert_close(sample.pierce_point.latitude_rad, 0.0, 1.0e-12);
1037 assert!(sample.pierce_point.longitude_rad > 0.0);
1038 }
1039 for sample in &g02.samples {
1040 assert_close(sample.vertical_tec_tecu, g02_vertical_tec_tecu, 1.0e-12);
1041 assert!(sample.pierce_point.latitude_rad > 0.0);
1042 assert_close(sample.pierce_point.longitude_rad, 0.0, 1.0e-12);
1043 }
1044 }
1045
1046 #[test]
1047 fn estimate_tec_rejects_insufficient_and_invalid_inputs() {
1048 let config = TecConfig::default();
1049 assert_eq!(estimate_tec(&[], config), Err(TecError::NoEpochs));
1050
1051 let single_epoch = vec![TecEpoch {
1052 time_s: 0.0,
1053 receiver_latitude_rad: 0.0,
1054 receiver_longitude_rad: 0.0,
1055 observations: vec![TecObservation {
1056 observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1057 elevation_rad: deg(45.0),
1058 azimuth_rad: 0.0,
1059 }],
1060 }];
1061 assert_eq!(
1062 estimate_tec(&single_epoch, config),
1063 Err(TecError::InsufficientArcSamples)
1064 );
1065
1066 let unordered = vec![
1067 TecEpoch {
1068 time_s: 30.0,
1069 receiver_latitude_rad: 0.0,
1070 receiver_longitude_rad: 0.0,
1071 observations: Vec::new(),
1072 },
1073 TecEpoch {
1074 time_s: 0.0,
1075 receiver_latitude_rad: 0.0,
1076 receiver_longitude_rad: 0.0,
1077 observations: Vec::new(),
1078 },
1079 ];
1080 assert_eq!(
1081 estimate_tec(&unordered, config),
1082 Err(TecError::EpochsNotOrdered)
1083 );
1084
1085 let invalid_elevation = vec![TecEpoch {
1086 time_s: 0.0,
1087 receiver_latitude_rad: 0.0,
1088 receiver_longitude_rad: 0.0,
1089 observations: vec![TecObservation {
1090 observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1091 elevation_rad: -0.1,
1092 azimuth_rad: 0.0,
1093 }],
1094 }];
1095 assert_eq!(
1096 estimate_tec(&invalid_elevation, config),
1097 Err(TecError::InvalidElevation)
1098 );
1099 }
1100
1101 #[test]
1102 fn pierce_point_at_zenith_equals_receiver_horizontal_position() {
1103 let config = TecConfig::default();
1104 let receiver_latitude_rad = deg(34.25);
1105 let receiver_longitude_rad = deg(-118.125);
1106
1107 let pierce_point = ionospheric_pierce_point(
1108 receiver_latitude_rad,
1109 receiver_longitude_rad,
1110 FRAC_PI_2,
1111 deg(127.0),
1112 config,
1113 )
1114 .expect("zenith pierce point");
1115
1116 assert_close(pierce_point.latitude_rad, receiver_latitude_rad, 1.0e-12);
1117 assert_close(pierce_point.longitude_rad, receiver_longitude_rad, 1.0e-12);
1118 assert_close(pierce_point.earth_central_angle_rad, 0.0, 1.0e-12);
1119 }
1120
1121 #[test]
1122 fn pierce_point_near_pole_remains_finite() {
1123 let config = TecConfig {
1126 shell_height_m: f64::from_bits(0x0800_003f_f000_0000),
1127 earth_radius_m: f64::from_bits(0x0000_003f_7000_0000),
1128 };
1129
1130 let pierce_point = ionospheric_pierce_point(
1131 f64::from_bits(0x3ff0_0000_0000_0014),
1132 f64::from_bits(0x0000_3f00_f000_0000),
1133 f64::from_bits(0x3ff0_0000_0001_c600),
1134 f64::from_bits(0x0000_0000_0900_0000),
1135 config,
1136 )
1137 .expect("valid near-polar pierce point");
1138
1139 assert!(pierce_point.latitude_rad.is_finite());
1140 assert!(pierce_point.longitude_rad.is_finite());
1141 }
1142
1143 #[test]
1144 fn pierce_point_moves_toward_satellite_azimuth_as_elevation_decreases() {
1145 let config = TecConfig::default();
1146 let receiver_latitude_rad = 0.0;
1147 let receiver_longitude_rad = 0.0;
1148 let east_azimuth_rad = deg(90.0);
1149
1150 let high = ionospheric_pierce_point(
1151 receiver_latitude_rad,
1152 receiver_longitude_rad,
1153 deg(80.0),
1154 east_azimuth_rad,
1155 config,
1156 )
1157 .expect("high-elevation pierce point");
1158 let low = ionospheric_pierce_point(
1159 receiver_latitude_rad,
1160 receiver_longitude_rad,
1161 deg(30.0),
1162 east_azimuth_rad,
1163 config,
1164 )
1165 .expect("low-elevation pierce point");
1166
1167 assert_close(high.latitude_rad, 0.0, 1.0e-12);
1168 assert_close(low.latitude_rad, 0.0, 1.0e-12);
1169 assert!(high.longitude_rad > 0.0);
1170 assert!(low.longitude_rad > high.longitude_rad);
1171 }
1172}