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