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_rad = (receiver_sin * psi_cos + receiver_cos * psi_sin * azimuth_cos).asin();
613 let longitude_step_rad =
614 (azimuth_sin * psi_sin * receiver_cos).atan2(psi_cos - receiver_sin * latitude_rad.sin());
615 let longitude_rad = normalize_longitude_rad(receiver_longitude_rad + longitude_step_rad);
616
617 Ok(IonosphericPiercePoint {
618 latitude_rad,
619 longitude_rad,
620 latitude_deg: latitude_rad.to_degrees(),
621 longitude_deg: longitude_rad.to_degrees(),
622 earth_central_angle_rad,
623 shell_height_m: config.shell_height_m,
624 })
625}
626
627fn tec_geometry_free_denominator_m_per_tecu(f1_hz: f64, f2_hz: f64) -> Result<f64, TecError> {
628 let f1_hz = validate_frequency(f1_hz)?;
629 let f2_hz = validate_frequency(f2_hz)?;
630 if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
631 return Err(TecError::EqualFrequencies);
632 }
633 let denominator = TEC_GROUP_DELAY_COEFFICIENT * (1.0 / (f1_hz * f1_hz) - 1.0 / (f2_hz * f2_hz));
634 validate::finite(denominator, "tec_geometry_free_denominator_m_per_tecu")
635 .map_err(|_| TecError::InvalidFrequency)?;
636 if denominator == 0.0 {
637 return Err(TecError::EqualFrequencies);
638 }
639 Ok(denominator)
640}
641
642fn validate_frequency(frequency_hz: f64) -> Result<f64, TecError> {
643 validate::finite_positive(frequency_hz, "frequency_hz").map_err(|_| TecError::InvalidFrequency)
644}
645
646fn validate_code_observation(observation: &DualFrequencyObservation) -> Result<(), TecError> {
647 if observation.p1_m.is_finite() && observation.p2_m.is_finite() {
648 Ok(())
649 } else {
650 Err(TecError::NonFiniteObservation)
651 }
652}
653
654#[derive(Debug, Clone)]
655struct TecArcBuildSample {
656 time_s: f64,
657 receiver_latitude_rad: f64,
658 receiver_longitude_rad: f64,
659 elevation_rad: f64,
660 azimuth_rad: f64,
661 code_geometry_free_m: f64,
662 phase_geometry_free_m: f64,
663 code_slant_tec_tecu: f64,
664 phase_slant_tec_tecu: f64,
665}
666
667fn validate_tec_epochs(epochs: &[TecEpoch], config: TecConfig) -> Result<(), TecError> {
668 config.validate()?;
669 if epochs.is_empty() {
670 return Err(TecError::NoEpochs);
671 }
672
673 let mut previous_time_s = None;
674 let mut observation_count = 0usize;
675 for epoch in epochs {
676 if !epoch.time_s.is_finite() {
677 return Err(TecError::NonFiniteEpochTime);
678 }
679 if let Some(previous_time_s) = previous_time_s {
680 if epoch.time_s < previous_time_s {
681 return Err(TecError::EpochsNotOrdered);
682 }
683 }
684 previous_time_s = Some(epoch.time_s);
685 validate_receiver_latitude(epoch.receiver_latitude_rad)?;
686 validate_receiver_longitude(epoch.receiver_longitude_rad)?;
687 for observation in &epoch.observations {
688 validate_elevation(observation.elevation_rad)?;
689 validate_azimuth(observation.azimuth_rad)?;
690 observation_count += 1;
691 }
692 }
693
694 if observation_count == 0 {
695 Err(TecError::NoObservations)
696 } else {
697 Ok(())
698 }
699}
700
701fn validate_tec(value: f64) -> Result<(), TecError> {
702 if value.is_finite() {
703 Ok(())
704 } else {
705 Err(TecError::NonFiniteTec)
706 }
707}
708
709fn validate_leveling_sample(sample: &TecLevelingSample) -> Result<(), TecError> {
710 validate_tec(sample.code_slant_tec_tecu)?;
711 validate_tec(sample.phase_slant_tec_tecu)?;
712 validate_elevation(sample.elevation_rad)
713}
714
715fn map_cycle_slip_error(error: CycleSlipError) -> TecError {
716 match error {
717 CycleSlipError::NonFiniteObservation => TecError::NonFiniteObservation,
718 CycleSlipError::InvalidFrequency => TecError::InvalidFrequency,
719 CycleSlipError::EqualFrequencies => TecError::EqualFrequencies,
720 CycleSlipError::InvalidConfig(_)
721 | CycleSlipError::NonFiniteEpochTime
722 | CycleSlipError::EpochsNotOrdered => TecError::NonFiniteObservation,
723 }
724}
725
726fn validate_receiver_latitude(latitude_rad: f64) -> Result<(), TecError> {
727 if latitude_rad.is_finite() && (-FRAC_PI_2..=FRAC_PI_2).contains(&latitude_rad) {
728 Ok(())
729 } else {
730 Err(TecError::InvalidReceiverLatitude)
731 }
732}
733
734fn validate_receiver_longitude(longitude_rad: f64) -> Result<(), TecError> {
735 if longitude_rad.is_finite() {
736 Ok(())
737 } else {
738 Err(TecError::InvalidReceiverLongitude)
739 }
740}
741
742fn validate_elevation(elevation_rad: f64) -> Result<(), TecError> {
743 if elevation_rad.is_finite() && (0.0..=FRAC_PI_2).contains(&elevation_rad) {
744 Ok(())
745 } else {
746 Err(TecError::InvalidElevation)
747 }
748}
749
750fn validate_azimuth(azimuth_rad: f64) -> Result<(), TecError> {
751 if azimuth_rad.is_finite() {
752 Ok(())
753 } else {
754 Err(TecError::InvalidAzimuth)
755 }
756}
757
758fn normalize_longitude_rad(longitude_rad: f64) -> f64 {
759 let mut normalized = (longitude_rad + PI) % TAU;
760 if normalized < 0.0 {
761 normalized += TAU;
762 }
763 normalized - PI
764}
765
766#[cfg(test)]
767mod tests {
768 use crate::constants::{F_L1_HZ, F_L2_HZ};
769
770 use super::*;
771
772 fn deg(value: f64) -> f64 {
773 value.to_radians()
774 }
775
776 fn observation_with_code_geometry_free(code_geometry_free_m: f64) -> DualFrequencyObservation {
777 let (p1_m, p2_m) = if code_geometry_free_m.is_sign_negative() {
778 (0.0, -code_geometry_free_m)
779 } else {
780 (code_geometry_free_m, 0.0)
781 };
782 DualFrequencyObservation {
783 satellite_id: "G01".to_string(),
784 ambiguity_id: "G01".to_string(),
785 p1_m,
786 p2_m,
787 phi1_cyc: 0.0,
788 phi2_cyc: 0.0,
789 f1_hz: F_L1_HZ,
790 f2_hz: F_L2_HZ,
791 lli1: None,
792 lli2: None,
793 }
794 }
795
796 fn observation_from_slant_tec(
797 satellite_id: &str,
798 ambiguity_id: &str,
799 code_slant_tec_tecu: f64,
800 phase_slant_tec_tecu: f64,
801 ) -> DualFrequencyObservation {
802 let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
803 .expect("GPS L1/L2 TEC denominator");
804 let code_geometry_free_m = denominator * code_slant_tec_tecu;
805 let phase_geometry_free_m = -denominator * phase_slant_tec_tecu;
806 DualFrequencyObservation {
807 satellite_id: satellite_id.to_string(),
808 ambiguity_id: ambiguity_id.to_string(),
809 p1_m: 0.0,
810 p2_m: -code_geometry_free_m,
811 phi1_cyc: phase_geometry_free_m / (crate::constants::C_M_S / F_L1_HZ),
812 phi2_cyc: 0.0,
813 f1_hz: F_L1_HZ,
814 f2_hz: F_L2_HZ,
815 lli1: None,
816 lli2: None,
817 }
818 }
819
820 fn arc_by_satellite<'a>(estimate: &'a TecEstimate, satellite_id: &str) -> &'a TecSatelliteArc {
821 estimate
822 .arcs
823 .iter()
824 .find(|arc| arc.satellite_id == satellite_id)
825 .expect("satellite arc")
826 }
827
828 fn assert_close(left: f64, right: f64, tolerance: f64) {
829 assert!(
830 (left - right).abs() <= tolerance,
831 "{left} differs from {right} by more than {tolerance}"
832 );
833 }
834
835 #[test]
836 fn code_geometry_free_delay_maps_to_expected_slant_tec() {
837 let expected_slant_tec_tecu = 17.25;
838 let code_geometry_free_m = expected_slant_tec_tecu
839 * tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
840 .expect("GPS L1/L2 TEC denominator");
841 let observation = observation_with_code_geometry_free(code_geometry_free_m);
842
843 let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
844
845 assert_close(estimate.code_geometry_free_m, code_geometry_free_m, 1.0e-9);
846 assert_close(estimate.slant_tec_tecu, expected_slant_tec_tecu, 1.0e-12);
847 }
848
849 #[test]
850 fn zero_code_geometry_free_delay_gives_zero_slant_tec() {
851 let observation = observation_with_code_geometry_free(0.0);
852
853 let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
854
855 assert_eq!(estimate.code_geometry_free_m.to_bits(), 0.0f64.to_bits());
856 assert_eq!(estimate.slant_tec_tecu.to_bits(), 0.0f64.to_bits());
857 }
858
859 #[test]
860 fn phase_geometry_free_delay_maps_to_biased_slant_tec() {
861 let true_slant_tec_tecu = 21.0;
862 let phase_bias_tecu = 9.5;
863 let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
864 .expect("GPS L1/L2 TEC denominator");
865 let phase_geometry_free_m = -(true_slant_tec_tecu + phase_bias_tecu) * denominator;
866
867 let slant_tec_tecu =
868 slant_tec_from_phase_geometry_free_m(phase_geometry_free_m, F_L1_HZ, F_L2_HZ)
869 .expect("phase slant TEC");
870
871 assert_close(
872 slant_tec_tecu,
873 true_slant_tec_tecu + phase_bias_tecu,
874 1.0e-12,
875 );
876 }
877
878 #[test]
879 fn phase_slant_tec_rejects_collapsed_frequency_denominator() {
880 assert_eq!(
881 slant_tec_from_phase_geometry_free_m(1.0, f64::MAX, f64::MAX / 2.0),
882 Err(TecError::EqualFrequencies)
883 );
884 }
885
886 #[test]
887 fn mapping_function_is_one_at_zenith_and_increases_toward_horizon() {
888 let config = TecConfig::default();
889
890 let zenith = thin_shell_mapping_function(FRAC_PI_2, config).expect("zenith mapping");
891 let high = thin_shell_mapping_function(deg(60.0), config).expect("high mapping");
892 let low = thin_shell_mapping_function(deg(30.0), config).expect("low mapping");
893 let horizon = thin_shell_mapping_function(0.0, config).expect("horizon mapping");
894
895 assert_close(zenith, 1.0, 1.0e-15);
896 assert!(high > zenith);
897 assert!(low > high);
898 assert!(horizon > low);
899 }
900
901 #[test]
902 fn mapping_function_rejects_degenerate_shell_geometry() {
903 let config = TecConfig {
904 shell_height_m: f64::MIN_POSITIVE,
905 earth_radius_m: 1.0,
906 };
907
908 assert_eq!(
909 thin_shell_mapping_function(0.0, config),
910 Err(TecError::InvalidShellHeight)
911 );
912 }
913
914 #[test]
915 fn synthetic_leveled_arc_recovers_constant_vertical_tec() {
916 let config = TecConfig::default();
917 let vertical_tec_tecu = 14.0;
918 let phase_bias_tecu = 37.5;
919 let noise_tecu = [0.6, -0.2, -0.4, 0.0];
920 let elevations_rad = [deg(30.0), deg(45.0), deg(60.0), deg(75.0)];
921 let samples = elevations_rad
922 .iter()
923 .zip(noise_tecu)
924 .map(|(&elevation_rad, noise_tecu)| {
925 let mapping_function =
926 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
927 let true_slant_tec_tecu = vertical_tec_tecu * mapping_function;
928 TecLevelingSample {
929 code_slant_tec_tecu: true_slant_tec_tecu + noise_tecu,
930 phase_slant_tec_tecu: true_slant_tec_tecu + phase_bias_tecu,
931 elevation_rad,
932 }
933 })
934 .collect::<Vec<_>>();
935
936 let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
937
938 assert_close(result.phase_bias_tecu, phase_bias_tecu, 1.0e-12);
939 for sample in result.samples {
940 assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
941 }
942 }
943
944 #[test]
945 fn known_elevation_profile_yields_expected_slant_to_vertical_reduction() {
946 let config = TecConfig::default();
947 let vertical_tec_tecu = 8.25;
948 let elevations_rad = [deg(25.0), deg(55.0), deg(85.0)];
949 let samples = elevations_rad
950 .iter()
951 .map(|&elevation_rad| {
952 let mapping_function =
953 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
954 let slant_tec_tecu = vertical_tec_tecu * mapping_function;
955 TecLevelingSample {
956 code_slant_tec_tecu: slant_tec_tecu,
957 phase_slant_tec_tecu: slant_tec_tecu,
958 elevation_rad,
959 }
960 })
961 .collect::<Vec<_>>();
962
963 let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
964
965 assert_close(result.phase_bias_tecu, 0.0, 1.0e-12);
966 for (sample, elevation_rad) in result.samples.iter().zip(elevations_rad) {
967 let mapping_function =
968 thin_shell_mapping_function(elevation_rad, config).expect("mapping");
969 assert_close(sample.mapping_function, mapping_function, 1.0e-15);
970 assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
971 }
972 }
973
974 #[test]
975 fn estimate_tec_multi_epoch_stream_returns_vertical_tec_and_pierce_points() {
976 let config = TecConfig::default();
977 let receiver_latitude_rad = 0.0;
978 let receiver_longitude_rad = 0.0;
979 let g01_vertical_tec_tecu = 11.0;
980 let g02_vertical_tec_tecu = 16.0;
981 let g01_phase_bias_tecu = 25.0;
982 let g02_phase_bias_tecu = -13.0;
983 let epochs = [0.0, 30.0, 60.0]
984 .into_iter()
985 .enumerate()
986 .map(|(idx, time_s)| {
987 let g01_elevation_rad = [deg(45.0), deg(55.0), deg(65.0)][idx];
988 let g02_elevation_rad = [deg(40.0), deg(50.0), deg(70.0)][idx];
989 let g01_mapping =
990 thin_shell_mapping_function(g01_elevation_rad, config).expect("G01 mapping");
991 let g02_mapping =
992 thin_shell_mapping_function(g02_elevation_rad, config).expect("G02 mapping");
993 let g01_slant_tec_tecu = g01_vertical_tec_tecu * g01_mapping;
994 let g02_slant_tec_tecu = g02_vertical_tec_tecu * g02_mapping;
995 TecEpoch {
996 time_s,
997 receiver_latitude_rad,
998 receiver_longitude_rad,
999 observations: vec![
1000 TecObservation {
1001 observation: observation_from_slant_tec(
1002 "G01",
1003 "G01",
1004 g01_slant_tec_tecu,
1005 g01_slant_tec_tecu + g01_phase_bias_tecu,
1006 ),
1007 elevation_rad: g01_elevation_rad,
1008 azimuth_rad: deg(90.0),
1009 },
1010 TecObservation {
1011 observation: observation_from_slant_tec(
1012 "G02",
1013 "G02",
1014 g02_slant_tec_tecu,
1015 g02_slant_tec_tecu + g02_phase_bias_tecu,
1016 ),
1017 elevation_rad: g02_elevation_rad,
1018 azimuth_rad: 0.0,
1019 },
1020 ],
1021 }
1022 })
1023 .collect::<Vec<_>>();
1024
1025 let estimate = estimate_tec(&epochs, config).expect("TEC estimate");
1026
1027 assert_eq!(estimate.arcs.len(), 2);
1028 let g01 = arc_by_satellite(&estimate, "G01");
1029 let g02 = arc_by_satellite(&estimate, "G02");
1030 assert_close(g01.phase_bias_tecu, g01_phase_bias_tecu, 1.0e-12);
1031 assert_close(g02.phase_bias_tecu, g02_phase_bias_tecu, 1.0e-12);
1032 for sample in &g01.samples {
1033 assert_close(sample.vertical_tec_tecu, g01_vertical_tec_tecu, 1.0e-12);
1034 assert_close(sample.pierce_point.latitude_rad, 0.0, 1.0e-12);
1035 assert!(sample.pierce_point.longitude_rad > 0.0);
1036 }
1037 for sample in &g02.samples {
1038 assert_close(sample.vertical_tec_tecu, g02_vertical_tec_tecu, 1.0e-12);
1039 assert!(sample.pierce_point.latitude_rad > 0.0);
1040 assert_close(sample.pierce_point.longitude_rad, 0.0, 1.0e-12);
1041 }
1042 }
1043
1044 #[test]
1045 fn estimate_tec_rejects_insufficient_and_invalid_inputs() {
1046 let config = TecConfig::default();
1047 assert_eq!(estimate_tec(&[], config), Err(TecError::NoEpochs));
1048
1049 let single_epoch = vec![TecEpoch {
1050 time_s: 0.0,
1051 receiver_latitude_rad: 0.0,
1052 receiver_longitude_rad: 0.0,
1053 observations: vec![TecObservation {
1054 observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1055 elevation_rad: deg(45.0),
1056 azimuth_rad: 0.0,
1057 }],
1058 }];
1059 assert_eq!(
1060 estimate_tec(&single_epoch, config),
1061 Err(TecError::InsufficientArcSamples)
1062 );
1063
1064 let unordered = vec![
1065 TecEpoch {
1066 time_s: 30.0,
1067 receiver_latitude_rad: 0.0,
1068 receiver_longitude_rad: 0.0,
1069 observations: Vec::new(),
1070 },
1071 TecEpoch {
1072 time_s: 0.0,
1073 receiver_latitude_rad: 0.0,
1074 receiver_longitude_rad: 0.0,
1075 observations: Vec::new(),
1076 },
1077 ];
1078 assert_eq!(
1079 estimate_tec(&unordered, config),
1080 Err(TecError::EpochsNotOrdered)
1081 );
1082
1083 let invalid_elevation = vec![TecEpoch {
1084 time_s: 0.0,
1085 receiver_latitude_rad: 0.0,
1086 receiver_longitude_rad: 0.0,
1087 observations: vec![TecObservation {
1088 observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1089 elevation_rad: -0.1,
1090 azimuth_rad: 0.0,
1091 }],
1092 }];
1093 assert_eq!(
1094 estimate_tec(&invalid_elevation, config),
1095 Err(TecError::InvalidElevation)
1096 );
1097 }
1098
1099 #[test]
1100 fn pierce_point_at_zenith_equals_receiver_horizontal_position() {
1101 let config = TecConfig::default();
1102 let receiver_latitude_rad = deg(34.25);
1103 let receiver_longitude_rad = deg(-118.125);
1104
1105 let pierce_point = ionospheric_pierce_point(
1106 receiver_latitude_rad,
1107 receiver_longitude_rad,
1108 FRAC_PI_2,
1109 deg(127.0),
1110 config,
1111 )
1112 .expect("zenith pierce point");
1113
1114 assert_close(pierce_point.latitude_rad, receiver_latitude_rad, 1.0e-12);
1115 assert_close(pierce_point.longitude_rad, receiver_longitude_rad, 1.0e-12);
1116 assert_close(pierce_point.earth_central_angle_rad, 0.0, 1.0e-12);
1117 }
1118
1119 #[test]
1120 fn pierce_point_moves_toward_satellite_azimuth_as_elevation_decreases() {
1121 let config = TecConfig::default();
1122 let receiver_latitude_rad = 0.0;
1123 let receiver_longitude_rad = 0.0;
1124 let east_azimuth_rad = deg(90.0);
1125
1126 let high = ionospheric_pierce_point(
1127 receiver_latitude_rad,
1128 receiver_longitude_rad,
1129 deg(80.0),
1130 east_azimuth_rad,
1131 config,
1132 )
1133 .expect("high-elevation pierce point");
1134 let low = ionospheric_pierce_point(
1135 receiver_latitude_rad,
1136 receiver_longitude_rad,
1137 deg(30.0),
1138 east_azimuth_rad,
1139 config,
1140 )
1141 .expect("low-elevation pierce point");
1142
1143 assert_close(high.latitude_rad, 0.0, 1.0e-12);
1144 assert_close(low.latitude_rad, 0.0, 1.0e-12);
1145 assert!(high.longitude_rad > 0.0);
1146 assert!(low.longitude_rad > high.longitude_rad);
1147 }
1148}