1use crate::astro::angles::beta_angle_from_cos_rad;
9use crate::astro::bodies::{sun_moon_ecef, SunMoon};
10use crate::astro::math::vec3::{add3, cross3, dot3, neg3, norm3, scale3, sub3, unit3};
11use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
12use crate::astro::time::{CoverageError, TimeScaleInputErrorKind, TimeScales, ValidityMode};
13use crate::validate;
14use std::collections::BTreeMap;
15use std::f64::consts::PI;
16
17use crate::antenna;
18use crate::bias::{BiasError, BiasSet, ClockReferenceObservables};
19use crate::constants::{
20 C_M_S, F_L1_HZ, J2000_JD, MICROSECONDS_PER_SECOND, OMEGA_E_DOT_RAD_S, RAD_TO_DEG,
21 SECONDS_PER_DAY, SECONDS_PER_HOUR,
22};
23use crate::ephemeris::Sp3;
24use crate::frequencies;
25use crate::observables::{
26 predict, ObservablesError, ObservablesInputErrorKind, PredictOptions, PredictedObservables,
27};
28use crate::tides::{ocean_tide_loading, solid_earth_pole_tide, solid_earth_tide, TideError};
29
30pub use crate::tides::{OceanLoadingBlq, NUM_OCEAN_CONSTITUENTS};
39use crate::tolerances::{
40 FREQUENCY_DENOMINATOR_EPS_HZ, PPP_FREQUENCY_ABS_EPS_HZ, PPP_FREQUENCY_REL_EPS,
41 YAW_SINGULARITY_EPS_RAD,
42};
43use crate::{GnssSatelliteId, GnssSystem};
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct CivilDateTime {
48 pub year: i32,
49 pub month: u8,
50 pub day: u8,
51 pub hour: u8,
52 pub minute: u8,
53 pub second: f64,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct PppCorrectionObservation {
59 pub sat: GnssSatelliteId,
60 pub freq1_hz: f64,
61 pub freq2_hz: f64,
62 pub glonass_channel: Option<i8>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct PppCorrectionEpoch {
68 pub epoch: CivilDateTime,
69 pub t_rx_j2000_s: f64,
70 pub observations: Vec<PppCorrectionObservation>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct SatelliteAntennaFrequency {
76 pub label: String,
77 pub pco_m: [f64; 3],
78 pub noazi_pcv_m: Vec<(f64, f64)>,
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct SatelliteAntenna {
84 pub sat: GnssSatelliteId,
85 pub valid_from: Option<CivilDateTime>,
86 pub valid_until: Option<CivilDateTime>,
87 pub frequencies: Vec<SatelliteAntennaFrequency>,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct SatelliteAntennaOptions {
93 pub freq1_label: String,
94 pub freq1_hz: f64,
95 pub freq2_label: String,
96 pub freq2_hz: f64,
97 pub antennas: Vec<SatelliteAntenna>,
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub struct CodeBiasOptions {
103 pub bias_set: BiasSet,
104 pub used_observables_per_sat: BTreeMap<GnssSatelliteId, (String, String)>,
105 pub used_observables_default: BTreeMap<GnssSystem, (String, String)>,
106 pub clock_reference: Option<ClockReferenceObservables>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct PoleTideOptions {
118 pub xp_arcsec: f64,
120 pub yp_arcsec: f64,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub struct PppCorrectionsOptions {
127 pub solid_earth_tide: bool,
128 pub pole_tide: Option<PoleTideOptions>,
129 pub ocean_loading: Option<OceanLoadingBlq>,
134 pub phase_windup: bool,
135 pub satellite_antenna: Option<SatelliteAntennaOptions>,
136 pub code_bias: Option<CodeBiasOptions>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq)]
141pub struct EpochVectorCorrection {
142 pub epoch_index: usize,
143 pub vector_m: [f64; 3],
144}
145
146#[derive(Debug, Clone, PartialEq)]
148pub struct SatScalarCorrection {
149 pub sat: GnssSatelliteId,
150 pub epoch_index: usize,
151 pub value_m: f64,
152}
153
154#[derive(Debug, Clone, PartialEq)]
156pub struct SatVectorCorrection {
157 pub sat: GnssSatelliteId,
158 pub epoch_index: usize,
159 pub vector_m: [f64; 3],
160}
161
162#[derive(Debug, Clone, PartialEq, Default)]
164pub struct PppCorrections {
165 pub tide: Vec<EpochVectorCorrection>,
166 pub pole_tide: Vec<EpochVectorCorrection>,
167 pub ocean_loading: Vec<EpochVectorCorrection>,
168 pub windup_m: Vec<SatScalarCorrection>,
169 pub sat_pco_ecef: Vec<SatVectorCorrection>,
170 pub sat_pcv_m: Vec<SatScalarCorrection>,
171 pub code_bias_m: Vec<SatScalarCorrection>,
172 pub diagnostics: crate::format::Diagnostics,
173}
174
175#[derive(Debug, Clone, PartialEq, thiserror::Error)]
176pub enum PppCorrectionsError {
177 #[error("invalid PPP correction input {field}: {reason}")]
178 InvalidInput {
179 field: &'static str,
180 reason: &'static str,
181 },
182 #[error("invalid PPP correction epoch at epoch {epoch_index}: {source}")]
183 Epoch {
184 epoch_index: usize,
185 #[source]
186 source: CoverageError,
187 },
188 #[error("solid Earth tide correction failed at epoch {epoch_index}: {source}")]
189 Tide {
190 epoch_index: usize,
191 #[source]
192 source: TideError,
193 },
194 #[error("solid Earth pole tide correction failed at epoch {epoch_index}: {source}")]
195 PoleTide {
196 epoch_index: usize,
197 #[source]
198 source: TideError,
199 },
200 #[error("ocean tide loading correction failed at epoch {epoch_index}: {source}")]
201 OceanLoading {
202 epoch_index: usize,
203 #[source]
204 source: TideError,
205 },
206 #[error(
207 "invalid phase wind-up carrier frequencies at epoch {epoch_index} for {sat}: {field} {reason}"
208 )]
209 WindupFrequency {
210 epoch_index: usize,
211 sat: GnssSatelliteId,
212 field: &'static str,
213 reason: &'static str,
214 },
215 #[error("invalid satellite antenna carrier frequencies: {field} {reason}")]
216 SatelliteAntennaFrequency {
217 field: &'static str,
218 reason: &'static str,
219 },
220 #[error("code-bias correction failed: {source}")]
221 Bias {
222 #[source]
223 source: BiasError,
224 },
225 #[error("invalid code-bias observable at epoch {epoch_index} for {sat}: {field} {reason}")]
226 CodeBiasObservable {
227 epoch_index: usize,
228 sat: GnssSatelliteId,
229 field: &'static str,
230 reason: &'static str,
231 },
232}
233
234pub fn build(
236 sp3: &Sp3,
237 epochs: &[PppCorrectionEpoch],
238 receiver_ecef_m: [f64; 3],
239 options: &PppCorrectionsOptions,
240) -> Result<PppCorrections, PppCorrectionsError> {
241 validate_receiver_state(receiver_ecef_m)?;
242
243 let mut corrections = PppCorrections::default();
244 if !options.solid_earth_tide
245 && options.pole_tide.is_none()
246 && options.ocean_loading.is_none()
247 && !options.phase_windup
248 && options.satellite_antenna.is_none()
249 && options.code_bias.is_none()
250 {
251 return Ok(corrections);
252 }
253
254 let satellite_antenna_frequencies = options
255 .satellite_antenna
256 .as_ref()
257 .map(validate_satellite_antenna_options)
258 .transpose()?;
259
260 let mut previous_windup_cycles: BTreeMap<GnssSatelliteId, f64> = BTreeMap::new();
261
262 let need_sun_moon =
267 options.solid_earth_tide || options.phase_windup || options.satellite_antenna.is_some();
268 let need_obs_loop = options.phase_windup || options.satellite_antenna.is_some();
271
272 for (epoch_index, epoch_row) in epochs.iter().enumerate() {
273 let sun_moon = if need_sun_moon {
274 Some(
275 sun_moon_at(epoch_row.epoch).map_err(|source| PppCorrectionsError::Epoch {
276 epoch_index,
277 source,
278 })?,
279 )
280 } else {
281 None
282 };
283
284 if options.solid_earth_tide {
285 let sun_moon = sun_moon.expect("Sun/Moon computed when solid-earth tide is enabled");
286 let d = tide_at(
287 receiver_ecef_m,
288 epoch_row.epoch,
289 sun_moon.sun,
290 sun_moon.moon,
291 )
292 .map_err(|source| PppCorrectionsError::Tide {
293 epoch_index,
294 source,
295 })?;
296 corrections.tide.push(EpochVectorCorrection {
297 epoch_index,
298 vector_m: d,
299 });
300 }
301
302 if let Some(pole) = options.pole_tide {
303 let d = pole_tide_at(receiver_ecef_m, epoch_row.epoch, pole).map_err(|source| {
304 PppCorrectionsError::PoleTide {
305 epoch_index,
306 source,
307 }
308 })?;
309 corrections.pole_tide.push(EpochVectorCorrection {
310 epoch_index,
311 vector_m: d,
312 });
313 }
314
315 if let Some(blq) = options.ocean_loading.as_ref() {
316 let d = ocean_loading_at(receiver_ecef_m, epoch_row.epoch, blq).map_err(|source| {
317 PppCorrectionsError::OceanLoading {
318 epoch_index,
319 source,
320 }
321 })?;
322 corrections.ocean_loading.push(EpochVectorCorrection {
323 epoch_index,
324 vector_m: d,
325 });
326 }
327
328 if let Some(code_bias) = options.code_bias.as_ref() {
329 for observation in &epoch_row.observations {
330 if let Some(value_m) =
331 code_bias_correction_m(code_bias, observation, epoch_row, epoch_index)?
332 {
333 corrections.code_bias_m.push(SatScalarCorrection {
334 sat: observation.sat,
335 epoch_index,
336 value_m,
337 });
338 } else {
339 corrections
340 .diagnostics
341 .push_warning(crate::format::Warning {
342 at: crate::format::RecordRef::at_record(epoch_index)
343 .with_satellite(observation.sat.to_string()),
344 kind: crate::format::WarningKind::MissingMetadata,
345 });
346 }
347 }
348 }
349
350 if !need_obs_loop {
351 continue;
352 }
353 let sun_moon = sun_moon.expect("Sun/Moon computed when the observation loop runs");
354
355 for observation in &epoch_row.observations {
356 let obs = match predict(
357 sp3,
358 observation.sat,
359 receiver_ecef_m,
360 epoch_row.t_rx_j2000_s,
361 PredictOptions {
362 carrier_hz: F_L1_HZ,
363 light_time: true,
364 sagnac: true,
365 },
366 ) {
367 Ok(obs) => obs,
368 Err(ObservablesError::InvalidInput { field, kind }) => {
369 return Err(PppCorrectionsError::InvalidInput {
370 field,
371 reason: observables_input_reason(kind),
372 });
373 }
374 Err(ObservablesError::NoEphemeris | ObservablesError::Ephemeris(_)) => continue,
375 };
376
377 if options.phase_windup {
378 let prev = previous_windup_cycles.get(&observation.sat).copied();
379 if let Some(phw) = windup_cycles(&obs, receiver_ecef_m, sun_moon.sun, prev) {
380 let (f1, f2) = windup_frequency_pair(options, observation, epoch_index)?;
381 corrections.windup_m.push(SatScalarCorrection {
382 sat: observation.sat,
383 epoch_index,
384 value_m: windup_metres(phw, f1, f2),
385 });
386 previous_windup_cycles.insert(observation.sat, phw);
387 }
388 }
389
390 if let Some(sat_ant) = &options.satellite_antenna {
391 if let Some((pco_ecef, pcv_m)) = satellite_antenna_correction(
392 &obs,
393 sun_moon.sun,
394 observation.sat,
395 epoch_row.epoch,
396 sat_ant,
397 satellite_antenna_frequencies
398 .expect("satellite antenna frequencies are validated when enabled"),
399 ) {
400 corrections.sat_pco_ecef.push(SatVectorCorrection {
401 sat: observation.sat,
402 epoch_index,
403 vector_m: pco_ecef,
404 });
405 corrections.sat_pcv_m.push(SatScalarCorrection {
406 sat: observation.sat,
407 epoch_index,
408 value_m: pcv_m,
409 });
410 }
411 }
412 }
413 }
414
415 Ok(corrections)
416}
417
418fn validate_receiver_state(receiver_ecef_m: [f64; 3]) -> Result<(), PppCorrectionsError> {
419 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(ppp_invalid_input)?;
420 validate::finite_positive(norm3(receiver_ecef_m), "receiver radius_m")
421 .map_err(ppp_invalid_input)?;
422 Ok(())
423}
424
425fn ppp_invalid_input(error: validate::FieldError) -> PppCorrectionsError {
426 PppCorrectionsError::InvalidInput {
427 field: error.field(),
428 reason: error.reason(),
429 }
430}
431
432fn observables_input_reason(kind: ObservablesInputErrorKind) -> &'static str {
433 match kind {
434 ObservablesInputErrorKind::NonFinite => "not finite",
435 ObservablesInputErrorKind::NotPositive => "not positive",
436 ObservablesInputErrorKind::Negative => "negative",
437 ObservablesInputErrorKind::OutOfRange => "out of range",
438 ObservablesInputErrorKind::Missing => "missing",
439 ObservablesInputErrorKind::FloatParse => "invalid float",
440 ObservablesInputErrorKind::IntParse => "invalid integer",
441 ObservablesInputErrorKind::InvalidCivilDate => "invalid civil date",
442 ObservablesInputErrorKind::InvalidCivilTime => "invalid civil time",
443 }
444}
445
446fn windup_frequency_pair(
447 options: &PppCorrectionsOptions,
448 observation: &PppCorrectionObservation,
449 epoch_index: usize,
450) -> Result<(f64, f64), PppCorrectionsError> {
451 let (f1_hz, f2_hz) = options
452 .satellite_antenna
453 .as_ref()
454 .map(|a| (a.freq1_hz, a.freq2_hz))
455 .unwrap_or((observation.freq1_hz, observation.freq2_hz));
456 validate_frequency_pair(
457 f1_hz,
458 f2_hz,
459 FrequencyPairFields {
460 freq1: "phase wind-up freq1_hz",
461 freq2: "phase wind-up freq2_hz",
462 pair: "phase wind-up frequency pair",
463 },
464 |field, reason| PppCorrectionsError::WindupFrequency {
465 epoch_index,
466 sat: observation.sat,
467 field,
468 reason,
469 },
470 )
471}
472
473fn validate_satellite_antenna_frequency_pair(
474 options: &SatelliteAntennaOptions,
475) -> Result<(f64, f64), PppCorrectionsError> {
476 validate_frequency_pair(
477 options.freq1_hz,
478 options.freq2_hz,
479 FrequencyPairFields {
480 freq1: "satellite antenna freq1_hz",
481 freq2: "satellite antenna freq2_hz",
482 pair: "satellite antenna frequency pair",
483 },
484 |field, reason| PppCorrectionsError::SatelliteAntennaFrequency { field, reason },
485 )
486}
487
488fn validate_satellite_antenna_options(
489 options: &SatelliteAntennaOptions,
490) -> Result<(f64, f64), PppCorrectionsError> {
491 let frequencies_hz = validate_satellite_antenna_frequency_pair(options)?;
492 validate_satellite_antenna_pcv_samples(options)?;
493 Ok(frequencies_hz)
494}
495
496fn code_bias_correction_m(
497 options: &CodeBiasOptions,
498 observation: &PppCorrectionObservation,
499 epoch_row: &PppCorrectionEpoch,
500 epoch_index: usize,
501) -> Result<Option<f64>, PppCorrectionsError> {
502 let Some(used) = options
503 .used_observables_per_sat
504 .get(&observation.sat)
505 .or_else(|| {
506 options
507 .used_observables_default
508 .get(&observation.sat.system)
509 })
510 else {
511 return Ok(None);
512 };
513 validate_code_observable_frequency(
514 observation,
515 epoch_index,
516 "used observable 1",
517 &used.0,
518 observation.freq1_hz,
519 observation_glonass_channel(observation),
520 )?;
521 validate_code_observable_frequency(
522 observation,
523 epoch_index,
524 "used observable 2",
525 &used.1,
526 observation.freq2_hz,
527 observation_glonass_channel(observation),
528 )?;
529 let reference = options
530 .clock_reference
531 .as_ref()
532 .unwrap_or(&options.bias_set.clock_reference);
533 if reference.per_system.is_empty() {
534 return Err(PppCorrectionsError::Bias {
535 source: BiasError::MissingClockReference,
536 });
537 }
538 let Some(clock_pair) = reference.per_system.get(&observation.sat.system) else {
539 return Err(PppCorrectionsError::Bias {
540 source: BiasError::MissingClockReference,
541 });
542 };
543 let epoch = code_bias_epoch(epoch_row.t_rx_j2000_s, options.bias_set.time_scale)
544 .map_err(|source| PppCorrectionsError::Bias { source })?;
545 Ok(options.bias_set.code_bias_model_m(
546 observation.sat,
547 (&used.0, &used.1),
548 (observation.freq1_hz, observation.freq2_hz),
549 observation_glonass_channel(observation),
550 (&clock_pair.0, &clock_pair.1),
551 epoch,
552 ))
553}
554
555fn code_bias_epoch(t_rx_j2000_s: f64, time_scale: TimeScale) -> Result<Instant, BiasError> {
556 validate::finite(t_rx_j2000_s, "t_rx_j2000_s").map_err(|error| BiasError::InvalidInput {
557 field: error.field(),
558 reason: error.reason(),
559 })?;
560 let days_since_j2000 = t_rx_j2000_s / SECONDS_PER_DAY;
561 let whole_days = days_since_j2000.floor();
562 let fraction = days_since_j2000 - whole_days;
563 let jd = JulianDateSplit::new(J2000_JD + whole_days, fraction)
564 .map_err(|_| BiasError::InvalidEpoch)?;
565 Ok(Instant::from_julian_date(time_scale, jd))
566}
567
568fn validate_code_observable_frequency(
569 observation: &PppCorrectionObservation,
570 epoch_index: usize,
571 field: &'static str,
572 obs: &str,
573 actual_hz: f64,
574 glonass_channel: Option<i8>,
575) -> Result<(), PppCorrectionsError> {
576 validate::finite_positive(actual_hz, field).map_err(|error| {
577 PppCorrectionsError::CodeBiasObservable {
578 epoch_index,
579 sat: observation.sat,
580 field: error.field(),
581 reason: error.reason(),
582 }
583 })?;
584 let Some(expected_hz) = frequencies::rinex_observation_frequency_hz(
585 observation.sat.system,
586 obs,
587 3.04,
588 glonass_channel,
589 ) else {
590 return Ok(());
591 };
592 let tol_hz = (expected_hz.abs().max(actual_hz.abs()) * PPP_FREQUENCY_REL_EPS)
593 .max(PPP_FREQUENCY_ABS_EPS_HZ);
594 if (expected_hz - actual_hz).abs() > tol_hz {
595 return Err(PppCorrectionsError::CodeBiasObservable {
596 epoch_index,
597 sat: observation.sat,
598 field,
599 reason: "frequency mismatch",
600 });
601 }
602 Ok(())
603}
604
605fn observation_glonass_channel(observation: &PppCorrectionObservation) -> Option<i8> {
606 observation.glonass_channel.or_else(|| {
607 infer_glonass_channel(observation.sat, observation.freq1_hz, observation.freq2_hz)
608 })
609}
610
611fn infer_glonass_channel(sat: GnssSatelliteId, freq1_hz: f64, freq2_hz: f64) -> Option<i8> {
612 if sat.system != GnssSystem::Glonass {
613 return None;
614 }
615 (-7..=6).find(|&channel| {
616 let g1 = frequencies::rinex_observation_frequency_hz(
617 GnssSystem::Glonass,
618 "C1C",
619 3.04,
620 Some(channel),
621 );
622 let g2 = frequencies::rinex_observation_frequency_hz(
623 GnssSystem::Glonass,
624 "C2C",
625 3.04,
626 Some(channel),
627 );
628 matches!(
629 (g1, g2),
630 (Some(expected1), Some(expected2))
631 if (expected1 - freq1_hz).abs() <= PPP_FREQUENCY_ABS_EPS_HZ
632 && (expected2 - freq2_hz).abs() <= PPP_FREQUENCY_ABS_EPS_HZ
633 )
634 })
635}
636
637fn validate_satellite_antenna_pcv_samples(
638 options: &SatelliteAntennaOptions,
639) -> Result<(), PppCorrectionsError> {
640 for antenna in &options.antennas {
641 for frequency in &antenna.frequencies {
642 for &(nadir_deg, pcv_m) in &frequency.noazi_pcv_m {
643 validate::finite(nadir_deg, "satellite antenna noazi_pcv_m")
644 .map_err(ppp_invalid_input)?;
645 validate::finite(pcv_m, "satellite antenna noazi_pcv_m")
646 .map_err(ppp_invalid_input)?;
647 }
648 }
649 }
650 Ok(())
651}
652
653#[derive(Debug, Clone, Copy)]
654struct FrequencyPairFields {
655 freq1: &'static str,
656 freq2: &'static str,
657 pair: &'static str,
658}
659
660fn validate_frequency_pair(
661 f1_hz: f64,
662 f2_hz: f64,
663 fields: FrequencyPairFields,
664 invalid: impl Fn(&'static str, &'static str) -> PppCorrectionsError,
665) -> Result<(f64, f64), PppCorrectionsError> {
666 let f1_hz = validate::finite_positive(f1_hz, fields.freq1)
667 .map_err(|e| invalid(e.field(), e.reason()))?;
668 let f2_hz = validate::finite_positive(f2_hz, fields.freq2)
669 .map_err(|e| invalid(e.field(), e.reason()))?;
670 if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
671 Err(invalid(fields.pair, "must differ"))
672 } else {
673 Ok((f1_hz, f2_hz))
674 }
675}
676
677fn sun_moon_at(epoch: CivilDateTime) -> Result<SunMoon, CoverageError> {
678 let ts = time_scales_at(epoch)?;
679 Ok(sun_moon_ecef(&ts).expect("validated time scales produce Sun/Moon vectors"))
680}
681
682fn time_scales_at(epoch: CivilDateTime) -> Result<TimeScales, CoverageError> {
683 let civil = validate::civil_datetime_with_second_policy(
684 i64::from(epoch.year),
685 i64::from(epoch.month),
686 i64::from(epoch.day),
687 i64::from(epoch.hour),
688 i64::from(epoch.minute),
689 epoch.second,
690 validate::CivilSecondPolicy::UtcLike,
691 )
692 .map_err(|error| CoverageError::InvalidInput {
693 field: error.field(),
694 kind: TimeScaleInputErrorKind::from(&error),
695 })?;
696
697 TimeScales::from_utc_validated(
698 civil.year as i32,
699 civil.month as i32,
700 civil.day as i32,
701 civil.hour as i32,
702 civil.minute as i32,
703 civil.second,
704 ValidityMode::Strict,
705 )
706 .map(|validated| validated.value)
707}
708
709fn tide_at(
710 receiver_ecef_m: [f64; 3],
711 epoch: CivilDateTime,
712 sun_ecef_m: [f64; 3],
713 moon_ecef_m: [f64; 3],
714) -> Result<[f64; 3], TideError> {
715 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / SECONDS_PER_HOUR;
716 solid_earth_tide(
717 &receiver_ecef_m,
718 epoch.year,
719 epoch.month as i32,
720 epoch.day as i32,
721 fhr,
722 &sun_ecef_m,
723 &moon_ecef_m,
724 )
725}
726
727fn pole_tide_at(
728 receiver_ecef_m: [f64; 3],
729 epoch: CivilDateTime,
730 pole: PoleTideOptions,
731) -> Result<[f64; 3], TideError> {
732 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / SECONDS_PER_HOUR;
733 solid_earth_pole_tide(
734 &receiver_ecef_m,
735 epoch.year,
736 epoch.month as i32,
737 epoch.day as i32,
738 fhr,
739 pole.xp_arcsec,
740 pole.yp_arcsec,
741 )
742}
743
744fn ocean_loading_at(
745 receiver_ecef_m: [f64; 3],
746 epoch: CivilDateTime,
747 blq: &OceanLoadingBlq,
748) -> Result<[f64; 3], TideError> {
749 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / SECONDS_PER_HOUR;
750 ocean_tide_loading(
751 &receiver_ecef_m,
752 epoch.year,
753 epoch.month as i32,
754 epoch.day as i32,
755 fhr,
756 blq,
757 )
758}
759
760fn windup_metres(phw_cycles: f64, f1_hz: f64, f2_hz: f64) -> f64 {
761 let lam1 = C_M_S / f1_hz;
762 let lam2 = C_M_S / f2_hz;
763 let gamma = ionosphere_free_gamma(f1_hz, f2_hz);
764 (gamma * lam1 - (gamma - 1.0) * lam2) * phw_cycles
765}
766
767fn windup_cycles(
768 pred: &PredictedObservables,
769 receiver_ecef_m: [f64; 3],
770 sun_ecef_m: [f64; 3],
771 prev_phw: Option<f64>,
772) -> Option<f64> {
773 let rs = pred.sat_pos_ecef_m;
774 let vs = pred.sat_velocity_m_s;
775 let (exs, eys) = sat_yaw(rs, vs, sun_ecef_m)?;
776 let ek = unit3(sub3(receiver_ecef_m, rs))?;
777
778 let (n, e, _u) = crate::estimation::substrate::frames::local_neu_basis(
779 crate::estimation::recipe::FrameRecipe::GeodeticNeuCrossProduct,
780 receiver_ecef_m,
781 );
782 let exr = n;
783 let eyr = neg3(e);
784
785 let eks = cross3(ek, eys);
786 let ekr = cross3(ek, eyr);
787 let ds = sub3(exs, add3(scale3(ek, dot3(ek, exs)), eks));
788 let dr = sub3(exr, sub3(scale3(ek, dot3(ek, exr)), ekr));
789
790 let nds = norm3(ds);
791 let ndr = norm3(dr);
792 if nds == 0.0 || ndr == 0.0 {
793 return None;
794 }
795
796 let cosp = clamp(dot3(ds, dr) / nds / ndr);
797 let mut ph = cosp.acos() / std::f64::consts::TAU;
798 let drs = cross3(ds, dr);
799 if dot3(ek, drs) < 0.0 {
800 ph = -ph;
801 }
802
803 Some(match prev_phw {
804 None => ph,
805 Some(prev) => ph + (prev - ph + 0.5).floor(),
806 })
807}
808
809fn sat_yaw(rs: [f64; 3], vs: [f64; 3], sun_ecef_m: [f64; 3]) -> Option<([f64; 3], [f64; 3])> {
810 let ri_v = [
811 vs[0] - OMEGA_E_DOT_RAD_S * rs[1],
812 vs[1] + OMEGA_E_DOT_RAD_S * rs[0],
813 vs[2],
814 ];
815 let n = cross3(rs, ri_v);
816 let p = cross3(sun_ecef_m, n);
817
818 let es = unit3(rs)?;
819 let esun = unit3(sun_ecef_m)?;
820 let en = unit3(n)?;
821 let ep = unit3(p)?;
822
823 let beta = beta_angle_from_cos_rad(dot3(esun, en));
824 let ee = clamp(dot3(es, ep)).acos();
825 let mut mu = PI / 2.0 + if dot3(es, esun) <= 0.0 { -ee } else { ee };
826
827 if mu < -PI / 2.0 {
828 mu += std::f64::consts::TAU;
829 } else if mu >= PI / 2.0 {
830 mu -= std::f64::consts::TAU;
831 }
832
833 let yaw = yaw_nominal(beta, mu);
834 let ex = cross3(en, es);
835 let cosy = yaw.cos();
836 let siny = yaw.sin();
837 let exs = add3(scale3(en, -siny), scale3(ex, cosy));
838 let eys = add3(scale3(en, -cosy), scale3(ex, -siny));
839 Some((exs, eys))
840}
841
842fn yaw_nominal(beta: f64, mu: f64) -> f64 {
843 if beta.abs() < YAW_SINGULARITY_EPS_RAD && mu.abs() < YAW_SINGULARITY_EPS_RAD {
844 PI
845 } else {
846 (-beta.tan()).atan2(mu.sin()) + PI
847 }
848}
849
850fn satellite_antenna_correction(
851 pred: &PredictedObservables,
852 sun_ecef_m: [f64; 3],
853 sat: GnssSatelliteId,
854 epoch: CivilDateTime,
855 options: &SatelliteAntennaOptions,
856 frequencies_hz: (f64, f64),
857) -> Option<([f64; 3], f64)> {
858 let rs = pred.sat_pos_ecef_m;
859 let ant = options.antenna_for(sat, epoch)?;
860
861 let ez = unit3(neg3(rs))?;
862 let es = unit3(sub3(sun_ecef_m, rs))?;
863 let ey = unit3(cross3(ez, es))?;
864 let ex = cross3(ey, ez);
865
866 let off1 = ant.pco(&options.freq1_label)?;
867 let off2 = ant.pco(&options.freq2_label)?;
868 let gamma = ionosphere_free_gamma(frequencies_hz.0, frequencies_hz.1);
869
870 let dant1 = body_to_ecef(off1, ex, ey, ez);
871 let dant2 = body_to_ecef(off2, ex, ey, ez);
872 let dant_ecef = sub3(scale3(dant1, gamma), scale3(dant2, gamma - 1.0));
873 let pcv_m = nadir_pcv_if(ant, pred, options, gamma)?;
874
875 Some((dant_ecef, pcv_m))
876}
877
878fn body_to_ecef(pco_body_m: [f64; 3], ex: [f64; 3], ey: [f64; 3], ez: [f64; 3]) -> [f64; 3] {
879 add3(
880 add3(scale3(ex, pco_body_m[0]), scale3(ey, pco_body_m[1])),
881 scale3(ez, pco_body_m[2]),
882 )
883}
884
885fn ionosphere_free_gamma(f1_hz: f64, f2_hz: f64) -> f64 {
886 let f1_sq = f1_hz * f1_hz;
887 f1_sq / (f1_sq - f2_hz * f2_hz)
888}
889
890fn nadir_pcv_if(
891 ant: &SatelliteAntenna,
892 pred: &PredictedObservables,
893 options: &SatelliteAntennaOptions,
894 gamma: f64,
895) -> Option<f64> {
896 let eu = unit3(neg3(pred.los_unit))?;
897 let ez = unit3(neg3(pred.sat_pos_ecef_m))?;
898 let nadir_deg = clamp(dot3(eu, ez)).acos() * RAD_TO_DEG;
899 let p1 = ant.pcv_noazi(&options.freq1_label, nadir_deg)?;
900 let p2 = ant.pcv_noazi(&options.freq2_label, nadir_deg)?;
901 Some(gamma * p1 - (gamma - 1.0) * p2)
902}
903
904impl SatelliteAntennaOptions {
905 fn antenna_for(&self, sat: GnssSatelliteId, epoch: CivilDateTime) -> Option<&SatelliteAntenna> {
906 self.antennas
907 .iter()
908 .find(|ant| ant.sat == sat && ant.valid_at(epoch))
909 }
910}
911
912impl SatelliteAntenna {
913 fn valid_at(&self, epoch: CivilDateTime) -> bool {
914 let after_from = self
915 .valid_from
916 .is_none_or(|from| civil_cmp(epoch, from) != std::cmp::Ordering::Less);
917 let before_until = self
918 .valid_until
919 .is_none_or(|until| civil_cmp(epoch, until) != std::cmp::Ordering::Greater);
920 after_from && before_until
921 }
922
923 fn frequency(&self, label: &str) -> Option<&SatelliteAntennaFrequency> {
924 self.frequencies
925 .iter()
926 .find(|f| f.label.trim() == label.trim())
927 }
928
929 fn pco(&self, label: &str) -> Option<[f64; 3]> {
930 self.frequency(label).map(|f| f.pco_m)
931 }
932
933 fn pcv_noazi(&self, label: &str, zenith_deg: f64) -> Option<f64> {
934 let frequency = self.frequency(label)?;
935 interpolate_samples(&frequency.noazi_pcv_m, zenith_deg)
936 }
937}
938
939fn civil_cmp(a: CivilDateTime, b: CivilDateTime) -> std::cmp::Ordering {
940 (
941 a.year,
942 a.month,
943 a.day,
944 a.hour,
945 a.minute,
946 ordered_seconds(a.second),
947 )
948 .cmp(&(
949 b.year,
950 b.month,
951 b.day,
952 b.hour,
953 b.minute,
954 ordered_seconds(b.second),
955 ))
956}
957
958fn ordered_seconds(second: f64) -> i64 {
959 (second * MICROSECONDS_PER_SECOND).round() as i64
960}
961
962fn interpolate_samples(samples: &[(f64, f64)], zenith_deg: f64) -> Option<f64> {
963 let mut sorted = samples.to_vec();
964 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
965 antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
966}
967
968fn clamp(x: f64) -> f64 {
969 x.clamp(-1.0, 1.0)
970}
971
972#[cfg(all(test, sidereon_repo_tests))]
973mod tests {
974 use super::*;
975 use crate::astro::time::split_julian_date;
976 use crate::constants::F_L2_HZ;
977 use crate::observables::j2000_seconds_from_split;
978 use crate::GnssSystem;
979
980 const REAL_CODE_BIA: &[u8] = include_bytes!(concat!(
981 env!("CARGO_MANIFEST_DIR"),
982 "/tests/fixtures/bias/CODE.BIA"
983 ));
984
985 fn sp3_fixture() -> Sp3 {
986 let path = concat!(
987 env!("CARGO_MANIFEST_DIR"),
988 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
989 );
990 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
991 Sp3::parse(&bytes).expect("parse SP3 fixture")
992 }
993
994 fn civil(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> CivilDateTime {
995 CivilDateTime {
996 year,
997 month,
998 day,
999 hour,
1000 minute,
1001 second,
1002 }
1003 }
1004
1005 fn split_jd(epoch: CivilDateTime) -> (f64, f64) {
1006 split_julian_date(
1007 epoch.year,
1008 i32::from(epoch.month),
1009 i32::from(epoch.day),
1010 i32::from(epoch.hour),
1011 i32::from(epoch.minute),
1012 epoch.second,
1013 )
1014 }
1015
1016 fn fake_antenna_options(sat: GnssSatelliteId) -> SatelliteAntennaOptions {
1017 SatelliteAntennaOptions {
1018 freq1_label: "G01".to_string(),
1019 freq1_hz: F_L1_HZ,
1020 freq2_label: "G02".to_string(),
1021 freq2_hz: F_L2_HZ,
1022 antennas: vec![SatelliteAntenna {
1023 sat,
1024 valid_from: Some(civil(2020, 1, 1, 0, 0, 0.0)),
1025 valid_until: Some(civil(2021, 1, 1, 0, 0, 0.0)),
1026 frequencies: vec![
1027 SatelliteAntennaFrequency {
1028 label: "G01".to_string(),
1029 pco_m: [0.1, -0.2, 1.0],
1030 noazi_pcv_m: vec![(0.0, 0.001), (5.0, 0.002), (10.0, 0.004)],
1031 },
1032 SatelliteAntennaFrequency {
1033 label: "G02".to_string(),
1034 pco_m: [-0.1, 0.3, 0.5],
1035 noazi_pcv_m: vec![(0.0, -0.001), (5.0, -0.002), (10.0, -0.003)],
1036 },
1037 ],
1038 }],
1039 }
1040 }
1041
1042 fn windup_epoch(sat: GnssSatelliteId, freq1_hz: f64, freq2_hz: f64) -> PppCorrectionEpoch {
1043 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1044 let (jd_whole, jd_fraction) = split_jd(epoch);
1045 PppCorrectionEpoch {
1046 epoch,
1047 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1048 .expect("valid split Julian date"),
1049 observations: vec![PppCorrectionObservation {
1050 sat,
1051 freq1_hz,
1052 freq2_hz,
1053 glonass_channel: None,
1054 }],
1055 }
1056 }
1057
1058 #[test]
1059 fn ppp_corrections_match_elixir_reference_fixture() {
1060 let sp3 = sp3_fixture();
1061 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1062 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1063 let (jd_whole, jd_fraction) = split_jd(epoch);
1064 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1065 let epochs = vec![PppCorrectionEpoch {
1066 epoch,
1067 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1068 .expect("valid split Julian date"),
1069 observations: vec![PppCorrectionObservation {
1070 sat,
1071 freq1_hz: F_L1_HZ,
1072 freq2_hz: F_L2_HZ,
1073 glonass_channel: None,
1074 }],
1075 }];
1076 let options = PppCorrectionsOptions {
1077 solid_earth_tide: true,
1078 pole_tide: None,
1079 ocean_loading: None,
1080 phase_windup: true,
1081 satellite_antenna: Some(fake_antenna_options(sat)),
1082 code_bias: None,
1083 };
1084
1085 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1086
1087 assert_eq!(got.tide.len(), 1);
1088 assert_eq!(
1089 got.tide[0].vector_m.map(f64::to_bits),
1090 [0x3FB8BC98E788ED00, 0x3FAA54D8C1097508, 0x3FB03498C46B3B50]
1091 );
1092 assert_eq!(got.windup_m.len(), 1);
1093 assert_eq!(got.windup_m[0].value_m.to_bits(), 0xBF808DE79DBD2C16);
1094 assert_eq!(got.sat_pco_ecef.len(), 1);
1095 assert_eq!(
1096 got.sat_pco_ecef[0].vector_m.map(f64::to_bits),
1097 [0xBFE58ED947570048, 0x3FDEDBB280CEB1BE, 0xBFFE3BCA6A354E4A]
1098 );
1099 assert_eq!(got.sat_pcv_m.len(), 1);
1100 assert_eq!(got.sat_pcv_m[0].value_m.to_bits(), 0x3F77617E95BD232C);
1101 }
1102
1103 #[test]
1104 fn pole_tide_correction_is_emitted_and_matches_standalone() {
1105 let sp3 = sp3_fixture();
1106 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1107 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1108 let (jd_whole, jd_fraction) = split_jd(epoch);
1109 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1110 let epochs = vec![PppCorrectionEpoch {
1111 epoch,
1112 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1113 .expect("valid split Julian date"),
1114 observations: vec![PppCorrectionObservation {
1115 sat,
1116 freq1_hz: F_L1_HZ,
1117 freq2_hz: F_L2_HZ,
1118 glonass_channel: None,
1119 }],
1120 }];
1121 let pole = PoleTideOptions {
1122 xp_arcsec: 0.169_051,
1123 yp_arcsec: 0.411_760,
1124 };
1125 let options = PppCorrectionsOptions {
1126 solid_earth_tide: false,
1127 pole_tide: Some(pole),
1128 ocean_loading: None,
1129 phase_windup: false,
1130 satellite_antenna: None,
1131 code_bias: None,
1132 };
1133
1134 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1135
1136 assert_eq!(got.pole_tide.len(), 1);
1137 assert_eq!(got.pole_tide[0].epoch_index, 0);
1138 let expected = crate::tides::solid_earth_pole_tide(
1139 &receiver,
1140 2020,
1141 6,
1142 24,
1143 12.0,
1144 pole.xp_arcsec,
1145 pole.yp_arcsec,
1146 )
1147 .expect("valid pole tide");
1148 assert_eq!(got.pole_tide[0].vector_m, expected);
1149 assert!(got.tide.is_empty());
1151 }
1152
1153 fn zim2_blq() -> OceanLoadingBlq {
1157 OceanLoadingBlq {
1158 amplitude_m: [
1159 [
1160 0.00693, 0.00228, 0.00148, 0.00061, 0.00220, 0.00094, 0.00070, 0.00001,
1161 0.00047, 0.00025, 0.00019,
1162 ],
1163 [
1164 0.00272, 0.00076, 0.00061, 0.00020, 0.00036, 0.00025, 0.00011, 0.00005,
1165 0.00004, 0.00001, 0.00002,
1166 ],
1167 [
1168 0.00061, 0.00026, 0.00010, 0.00009, 0.00025, 0.00002, 0.00008, 0.00003,
1169 0.00002, 0.00000, 0.00001,
1170 ],
1171 ],
1172 phase_deg: [
1173 [
1174 -72.3, -44.2, -90.8, -44.1, -62.9, -94.5, -64.3, 171.0, 3.4, 3.6, 1.1,
1175 ],
1176 [
1177 84.3, 115.4, 63.3, 113.7, 98.6, 20.7, 94.2, -44.5, -170.0, -162.7, -177.8,
1178 ],
1179 [
1180 -29.3, 1.7, -44.0, -4.2, 44.2, -39.1, 43.7, 170.1, -93.3, -118.3, -176.4,
1181 ],
1182 ],
1183 }
1184 }
1185
1186 #[test]
1187 fn ocean_loading_correction_is_emitted_and_matches_standalone() {
1188 let sp3 = sp3_fixture();
1189 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1190 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1191 let (jd_whole, jd_fraction) = split_jd(epoch);
1192 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1193 let epochs = vec![PppCorrectionEpoch {
1194 epoch,
1195 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1196 .expect("valid split Julian date"),
1197 observations: vec![PppCorrectionObservation {
1198 sat,
1199 freq1_hz: F_L1_HZ,
1200 freq2_hz: F_L2_HZ,
1201 glonass_channel: None,
1202 }],
1203 }];
1204 let blq = zim2_blq();
1205 let options = PppCorrectionsOptions {
1206 solid_earth_tide: false,
1207 pole_tide: None,
1208 ocean_loading: Some(blq),
1209 phase_windup: false,
1210 satellite_antenna: None,
1211 code_bias: None,
1212 };
1213
1214 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1215
1216 assert_eq!(got.ocean_loading.len(), 1);
1217 assert_eq!(got.ocean_loading[0].epoch_index, 0);
1218 let expected = crate::tides::ocean_tide_loading(&receiver, 2020, 6, 24, 12.0, &blq)
1219 .expect("valid ocean loading");
1220 assert_eq!(got.ocean_loading[0].vector_m, expected);
1221 assert!(got.tide.is_empty());
1223 assert!(got.pole_tide.is_empty());
1224 }
1225
1226 #[test]
1227 fn pole_or_ocean_only_skips_sun_moon_and_prediction() {
1228 let sp3 = sp3_fixture();
1229 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1230 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1231
1232 let epochs = vec![PppCorrectionEpoch {
1240 epoch: civil(2100, 1, 1, 12, 0, 0.0),
1241 t_rx_j2000_s: f64::NAN,
1242 observations: vec![PppCorrectionObservation {
1243 sat,
1244 freq1_hz: F_L1_HZ,
1245 freq2_hz: F_L2_HZ,
1246 glonass_channel: None,
1247 }],
1248 }];
1249
1250 let pole = PoleTideOptions {
1252 xp_arcsec: 0.169_051,
1253 yp_arcsec: 0.411_760,
1254 };
1255 let got = build(
1256 &sp3,
1257 &epochs,
1258 receiver,
1259 &PppCorrectionsOptions {
1260 solid_earth_tide: false,
1261 pole_tide: Some(pole),
1262 ocean_loading: None,
1263 phase_windup: false,
1264 satellite_antenna: None,
1265 code_bias: None,
1266 },
1267 )
1268 .expect("pole-only build must not touch the Sun/Moon or predict paths");
1269 assert_eq!(got.pole_tide.len(), 1);
1270 assert!(got.tide.is_empty());
1271 assert!(got.ocean_loading.is_empty());
1272 assert!(got.windup_m.is_empty());
1273 assert!(got.sat_pco_ecef.is_empty());
1274 assert!(got.sat_pcv_m.is_empty());
1275
1276 let blq = zim2_blq();
1278 let got = build(
1279 &sp3,
1280 &epochs,
1281 receiver,
1282 &PppCorrectionsOptions {
1283 solid_earth_tide: false,
1284 pole_tide: None,
1285 ocean_loading: Some(blq),
1286 phase_windup: false,
1287 satellite_antenna: None,
1288 code_bias: None,
1289 },
1290 )
1291 .expect("ocean-only build must not touch the Sun/Moon or predict paths");
1292 assert_eq!(got.ocean_loading.len(), 1);
1293 assert!(got.tide.is_empty());
1294 assert!(got.pole_tide.is_empty());
1295 assert!(got.windup_m.is_empty());
1296 assert!(got.sat_pco_ecef.is_empty());
1297 assert!(got.sat_pcv_m.is_empty());
1298 }
1299
1300 #[test]
1301 fn phase_windup_rejects_invalid_observation_frequency_pairs() {
1302 let sp3 = sp3_fixture();
1303 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1304 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1305 let options = PppCorrectionsOptions {
1306 solid_earth_tide: false,
1307 pole_tide: None,
1308 ocean_loading: None,
1309 phase_windup: true,
1310 satellite_antenna: None,
1311 code_bias: None,
1312 };
1313 let cases = [
1314 (0.0, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1315 (-F_L1_HZ, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1316 (
1317 F_L1_HZ,
1318 F_L1_HZ,
1319 "phase wind-up frequency pair",
1320 "must differ",
1321 ),
1322 ];
1323
1324 for (freq1_hz, freq2_hz, field, reason) in cases {
1325 let epochs = vec![windup_epoch(sat, freq1_hz, freq2_hz)];
1326 let err = build(&sp3, &epochs, receiver, &options)
1327 .expect_err("invalid phase wind-up frequencies must error");
1328
1329 assert_eq!(
1330 err,
1331 PppCorrectionsError::WindupFrequency {
1332 epoch_index: 0,
1333 sat,
1334 field,
1335 reason,
1336 }
1337 );
1338 }
1339 }
1340
1341 #[test]
1342 fn phase_windup_observation_frequency_pair_computes_finite_correction() {
1343 let sp3 = sp3_fixture();
1344 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1345 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1346 let options = PppCorrectionsOptions {
1347 solid_earth_tide: false,
1348 pole_tide: None,
1349 ocean_loading: None,
1350 phase_windup: true,
1351 satellite_antenna: None,
1352 code_bias: None,
1353 };
1354 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1355
1356 let got =
1357 build(&sp3, &epochs, receiver, &options).expect("valid phase wind-up frequencies");
1358
1359 assert_eq!(got.windup_m.len(), 1);
1360 assert!(got.windup_m[0].value_m.is_finite());
1361 }
1362
1363 fn code_bias_product() -> crate::bias::BiasSet {
1364 let text = "\
1365%=BIA 1.00 TST
1366+FILE/REFERENCE
1367 DESCRIPTION TEST
1368-FILE/REFERENCE
1369+BIAS/DESCRIPTION
1370 BIAS_MODE ABSOLUTE
1371 TIME_SYSTEM G
1372 SATELLITE_CLOCK_REFERENCE_OBSERVABLES G C1W C2W
1373-BIAS/DESCRIPTION
1374+BIAS/SOLUTION 3
1375 OSB G021 G21 C1C 2020:176:00000 2020:177:00000 ns -1.234567890000E+00 2.00000E-02
1376 OSB G021 G21 C1W 2020:176:00000 2020:177:00000 ns 5.600000000000E-01 2.00000E-02
1377 OSB G021 G21 C2W 2020:176:00000 2020:177:00000 ns -3.000000000000E-01 2.00000E-02
1378-BIAS/SOLUTION
1379";
1380 crate::bias::BiasSet::parse_bias_sinex(text.as_bytes())
1381 .expect("parse code-bias product")
1382 .value
1383 }
1384
1385 fn real_code_bias_product() -> crate::bias::BiasSet {
1386 crate::bias::BiasSet::parse_bias_sinex(REAL_CODE_BIA)
1387 .expect("parse real CODE Bias-SINEX product")
1388 .value
1389 }
1390
1391 fn code_bias_epoch(sat: GnssSatelliteId) -> Vec<PppCorrectionEpoch> {
1392 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1393 let (jd_whole, jd_fraction) = split_jd(epoch);
1394 vec![PppCorrectionEpoch {
1395 epoch,
1396 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1397 .expect("valid split Julian date"),
1398 observations: vec![PppCorrectionObservation {
1399 sat,
1400 freq1_hz: F_L1_HZ,
1401 freq2_hz: F_L2_HZ,
1402 glonass_channel: None,
1403 }],
1404 }]
1405 }
1406
1407 fn code_bias_options(bias_set: crate::bias::BiasSet, used: (&str, &str)) -> CodeBiasOptions {
1408 let mut used_observables_default = BTreeMap::new();
1409 used_observables_default.insert(GnssSystem::Gps, (used.0.to_string(), used.1.to_string()));
1410 CodeBiasOptions {
1411 bias_set,
1412 used_observables_per_sat: BTreeMap::new(),
1413 used_observables_default,
1414 clock_reference: None,
1415 }
1416 }
1417
1418 #[test]
1419 fn code_bias_builds_exact_zero_for_matched_clock_datum() {
1420 let sp3 = sp3_fixture();
1421 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1422 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1423 let epochs = code_bias_epoch(sat);
1424 let options = PppCorrectionsOptions {
1425 solid_earth_tide: false,
1426 pole_tide: None,
1427 ocean_loading: None,
1428 phase_windup: false,
1429 satellite_antenna: None,
1430 code_bias: Some(code_bias_options(code_bias_product(), ("C1W", "C2W"))),
1431 };
1432
1433 let got = build(&sp3, &epochs, receiver, &options).expect("code-bias build");
1434
1435 assert_eq!(got.code_bias_m.len(), 1);
1436 assert_eq!(got.code_bias_m[0].value_m.to_bits(), 0.0_f64.to_bits());
1437 }
1438
1439 #[test]
1440 fn code_bias_builds_mismatched_pair_scalar() {
1441 let sp3 = sp3_fixture();
1442 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1443 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1444 let epochs = code_bias_epoch(sat);
1445 let options = PppCorrectionsOptions {
1446 solid_earth_tide: false,
1447 pole_tide: None,
1448 ocean_loading: None,
1449 phase_windup: false,
1450 satellite_antenna: None,
1451 code_bias: Some(code_bias_options(code_bias_product(), ("C1C", "C2W"))),
1452 };
1453
1454 let got = build(&sp3, &epochs, receiver, &options).expect("code-bias build");
1455 let alpha = F_L1_HZ * F_L1_HZ / (F_L1_HZ * F_L1_HZ - F_L2_HZ * F_L2_HZ);
1456 let beta = -(F_L2_HZ * F_L2_HZ) / (F_L1_HZ * F_L1_HZ - F_L2_HZ * F_L2_HZ);
1457 let used_if =
1458 alpha * (-1.234567890000_f64 * 1.0e-9) + beta * (-0.300000000000_f64 * 1.0e-9);
1459 let ref_if = alpha * (0.560000000000_f64 * 1.0e-9) + beta * (-0.300000000000_f64 * 1.0e-9);
1460 let expected = (used_if - ref_if) * C_M_S;
1461
1462 assert_eq!(got.code_bias_m.len(), 1);
1463 assert_eq!(got.code_bias_m[0].value_m.to_bits(), expected.to_bits());
1464 }
1465
1466 #[test]
1467 fn code_bias_build_applies_real_glonass_osb_with_fdma_channel() {
1468 let sp3 = sp3_fixture();
1469 let sat = GnssSatelliteId::new(GnssSystem::Glonass, 2).expect("valid satellite id");
1470 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1471 let channel = -4;
1472 let freq1_hz = frequencies::rinex_observation_frequency_hz(
1473 GnssSystem::Glonass,
1474 "C1C",
1475 3.04,
1476 Some(channel),
1477 )
1478 .expect("GLONASS G1 frequency");
1479 let freq2_hz = frequencies::rinex_observation_frequency_hz(
1480 GnssSystem::Glonass,
1481 "C2C",
1482 3.04,
1483 Some(channel),
1484 )
1485 .expect("GLONASS G2 frequency");
1486 let epoch = civil(2026, 6, 24, 12, 0, 0.0);
1487 let (jd_whole, jd_fraction) = split_jd(epoch);
1488 let epochs = vec![PppCorrectionEpoch {
1489 epoch,
1490 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1491 .expect("valid split Julian date"),
1492 observations: vec![PppCorrectionObservation {
1493 sat,
1494 freq1_hz,
1495 freq2_hz,
1496 glonass_channel: Some(channel),
1497 }],
1498 }];
1499 let mut used_observables_default = BTreeMap::new();
1500 used_observables_default
1501 .insert(GnssSystem::Glonass, ("C1C".to_string(), "C2C".to_string()));
1502 let options = PppCorrectionsOptions {
1503 solid_earth_tide: false,
1504 pole_tide: None,
1505 ocean_loading: None,
1506 phase_windup: false,
1507 satellite_antenna: None,
1508 code_bias: Some(CodeBiasOptions {
1509 bias_set: real_code_bias_product(),
1510 used_observables_per_sat: BTreeMap::new(),
1511 used_observables_default,
1512 clock_reference: None,
1513 }),
1514 };
1515
1516 let got = build(&sp3, &epochs, receiver, &options).expect("GLONASS code-bias build");
1517 let (alpha, beta) = crate::bias::ionosphere_free_coefficients(freq1_hz, freq2_hz).unwrap();
1518 let used_if = alpha * (0.2114_f64 * 1.0e-9) + beta * (2.6597_f64 * 1.0e-9);
1519 let ref_if = alpha * (1.7840_f64 * 1.0e-9) + beta * (2.9490_f64 * 1.0e-9);
1520 let expected = (used_if - ref_if) * C_M_S;
1521
1522 assert_eq!(got.code_bias_m.len(), 1);
1523 assert_eq!(got.code_bias_m[0].sat, sat);
1524 assert_eq!(got.code_bias_m[0].value_m.to_bits(), expected.to_bits());
1525 }
1526
1527 #[test]
1528 fn code_bias_build_requires_clock_reference() {
1529 let sp3 = sp3_fixture();
1530 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1531 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1532 let epochs = code_bias_epoch(sat);
1533 let dcb = crate::bias::BiasSet::parse_code_dcb(
1534 b"# DCB P1-C1 2020-06 G\n G21 1.000 0.100\n",
1535 Some(crate::bias::CodeDcbOptions {
1536 pair: ("P1".to_string(), "C1".to_string()),
1537 year: 2020,
1538 month: 6,
1539 time_scale: crate::astro::time::model::TimeScale::Gpst,
1540 receiver_system: None,
1541 }),
1542 )
1543 .expect("parse DCB")
1544 .value;
1545 let options = PppCorrectionsOptions {
1546 solid_earth_tide: false,
1547 pole_tide: None,
1548 ocean_loading: None,
1549 phase_windup: false,
1550 satellite_antenna: None,
1551 code_bias: Some(code_bias_options(dcb, ("C1C", "C2W"))),
1552 };
1553
1554 let err = build(&sp3, &epochs, receiver, &options)
1555 .expect_err("missing clock reference must error");
1556 assert!(matches!(
1557 err,
1558 PppCorrectionsError::Bias {
1559 source: BiasError::MissingClockReference
1560 }
1561 ));
1562 }
1563
1564 #[test]
1565 fn satellite_antenna_rejects_invalid_frequency_pairs_without_windup() {
1566 let sp3 = sp3_fixture();
1567 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1568 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1569 let cases = [
1570 (0.0, F_L2_HZ, "satellite antenna freq1_hz", "not positive"),
1571 (
1572 F_L1_HZ,
1573 f64::INFINITY,
1574 "satellite antenna freq2_hz",
1575 "not finite",
1576 ),
1577 (
1578 f64::NAN,
1579 F_L2_HZ,
1580 "satellite antenna freq1_hz",
1581 "not finite",
1582 ),
1583 (
1584 F_L1_HZ,
1585 F_L1_HZ,
1586 "satellite antenna frequency pair",
1587 "must differ",
1588 ),
1589 ];
1590
1591 for (freq1_hz, freq2_hz, field, reason) in cases {
1592 let mut antenna = fake_antenna_options(sat);
1593 antenna.freq1_hz = freq1_hz;
1594 antenna.freq2_hz = freq2_hz;
1595 let options = PppCorrectionsOptions {
1596 solid_earth_tide: false,
1597 pole_tide: None,
1598 ocean_loading: None,
1599 phase_windup: false,
1600 satellite_antenna: Some(antenna),
1601 code_bias: None,
1602 };
1603 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1604
1605 let err = build(&sp3, &epochs, receiver, &options)
1606 .expect_err("invalid satellite antenna frequencies must error");
1607
1608 assert_eq!(
1609 err,
1610 PppCorrectionsError::SatelliteAntennaFrequency { field, reason }
1611 );
1612 }
1613 }
1614
1615 #[test]
1616 fn satellite_antenna_frequency_pair_computes_finite_corrections_without_windup() {
1617 let sp3 = sp3_fixture();
1618 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1619 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1620 let options = PppCorrectionsOptions {
1621 solid_earth_tide: false,
1622 pole_tide: None,
1623 ocean_loading: None,
1624 phase_windup: false,
1625 satellite_antenna: Some(fake_antenna_options(sat)),
1626 code_bias: None,
1627 };
1628 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1629
1630 let got =
1631 build(&sp3, &epochs, receiver, &options).expect("valid satellite antenna frequencies");
1632
1633 assert!(got.windup_m.is_empty());
1634 assert_eq!(got.sat_pco_ecef.len(), 1);
1635 assert!(got.sat_pco_ecef[0]
1636 .vector_m
1637 .iter()
1638 .all(|value| value.is_finite()));
1639 assert_eq!(got.sat_pcv_m.len(), 1);
1640 assert!(got.sat_pcv_m[0].value_m.is_finite());
1641 }
1642
1643 #[test]
1644 fn satellite_antenna_rejects_non_finite_pcv_samples() {
1645 let sp3 = sp3_fixture();
1646 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1647 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1648 let mut antenna = fake_antenna_options(sat);
1649 antenna.antennas[0].frequencies[0].noazi_pcv_m[1] = (5.0, f64::NAN);
1650 let options = PppCorrectionsOptions {
1651 solid_earth_tide: false,
1652 pole_tide: None,
1653 ocean_loading: None,
1654 phase_windup: false,
1655 satellite_antenna: Some(antenna),
1656 code_bias: None,
1657 };
1658 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1659
1660 let err = build(&sp3, &epochs, receiver, &options)
1661 .expect_err("non-finite satellite PCV samples must error");
1662
1663 assert_eq!(
1664 err,
1665 PppCorrectionsError::InvalidInput {
1666 field: "satellite antenna noazi_pcv_m",
1667 reason: "not finite",
1668 }
1669 );
1670 }
1671
1672 #[test]
1673 fn satellite_antenna_empty_pcv_grid_is_not_materialized_as_zero() {
1674 let sp3 = sp3_fixture();
1675 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1676 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1677 let (jd_whole, jd_fraction) = split_jd(epoch);
1678 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1679 let epochs = vec![PppCorrectionEpoch {
1680 epoch,
1681 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1682 .expect("valid split Julian date"),
1683 observations: vec![PppCorrectionObservation {
1684 sat,
1685 freq1_hz: F_L1_HZ,
1686 freq2_hz: F_L2_HZ,
1687 glonass_channel: None,
1688 }],
1689 }];
1690 let mut antenna = fake_antenna_options(sat);
1691 antenna.antennas[0].frequencies[0].noazi_pcv_m.clear();
1692 let options = PppCorrectionsOptions {
1693 solid_earth_tide: false,
1694 pole_tide: None,
1695 ocean_loading: None,
1696 phase_windup: false,
1697 satellite_antenna: Some(antenna),
1698 code_bias: None,
1699 };
1700
1701 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1702
1703 assert!(got.sat_pco_ecef.is_empty());
1704 assert!(got.sat_pcv_m.is_empty());
1705 }
1706
1707 #[test]
1708 fn build_rejects_non_finite_receive_time_for_satellite_corrections() {
1709 let sp3 = sp3_fixture();
1710 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1711 let options = PppCorrectionsOptions {
1712 solid_earth_tide: false,
1713 pole_tide: None,
1714 ocean_loading: None,
1715 phase_windup: true,
1716 satellite_antenna: None,
1717 code_bias: None,
1718 };
1719 let epochs = vec![PppCorrectionEpoch {
1720 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1721 t_rx_j2000_s: f64::NAN,
1722 observations: vec![PppCorrectionObservation {
1723 sat,
1724 freq1_hz: F_L1_HZ,
1725 freq2_hz: F_L2_HZ,
1726 glonass_channel: None,
1727 }],
1728 }];
1729
1730 let err = build(
1731 &sp3,
1732 &epochs,
1733 [3_512_900.0, 780_500.0, 5_248_700.0],
1734 &options,
1735 )
1736 .expect_err("non-finite receive time must be reported");
1737
1738 assert_eq!(
1739 err,
1740 PppCorrectionsError::InvalidInput {
1741 field: "t_rx_j2000_s",
1742 reason: "not finite",
1743 }
1744 );
1745 }
1746
1747 #[test]
1748 fn noazi_pcv_interpolation_clamps_and_interpolates() {
1749 let samples = vec![(10.0, 4.0), (0.0, 1.0), (5.0, 2.0)];
1750
1751 assert_eq!(interpolate_samples(&samples, -1.0), Some(1.0));
1752 assert_eq!(interpolate_samples(&samples, 2.5), Some(1.5));
1753 assert_eq!(interpolate_samples(&samples, 99.0), Some(4.0));
1754 }
1755
1756 #[test]
1757 fn build_rejects_invalid_receiver_state_before_disabled_short_circuit() {
1758 let sp3 = sp3_fixture();
1759 let options = PppCorrectionsOptions {
1760 solid_earth_tide: false,
1761 pole_tide: None,
1762 ocean_loading: None,
1763 phase_windup: false,
1764 satellite_antenna: None,
1765 code_bias: None,
1766 };
1767
1768 for (receiver, field, reason) in [
1769 (
1770 [f64::NAN, 780_500.0, 5_248_700.0],
1771 "receiver_ecef_m",
1772 "not finite",
1773 ),
1774 ([0.0, 0.0, 0.0], "receiver radius_m", "not positive"),
1775 ] {
1776 let err = build(&sp3, &[], receiver, &options)
1777 .expect_err("invalid receiver state must error before empty success");
1778
1779 assert_eq!(err, PppCorrectionsError::InvalidInput { field, reason });
1780 }
1781 }
1782
1783 #[test]
1784 fn build_rejects_invalid_correction_epoch_without_panicking() {
1785 let sp3 = sp3_fixture();
1786 let options = PppCorrectionsOptions {
1787 solid_earth_tide: true,
1788 pole_tide: None,
1789 ocean_loading: None,
1790 phase_windup: false,
1791 satellite_antenna: None,
1792 code_bias: None,
1793 };
1794 let epochs = vec![PppCorrectionEpoch {
1795 epoch: civil(2021, 2, 29, 12, 0, 0.0),
1796 t_rx_j2000_s: 0.0,
1797 observations: Vec::new(),
1798 }];
1799
1800 let err = build(
1801 &sp3,
1802 &epochs,
1803 [3_512_900.0, 780_500.0, 5_248_700.0],
1804 &options,
1805 )
1806 .expect_err("invalid PPP correction epoch must return an error");
1807
1808 assert_eq!(
1809 err,
1810 PppCorrectionsError::Epoch {
1811 epoch_index: 0,
1812 source: CoverageError::InvalidInput {
1813 field: "civil datetime",
1814 kind: TimeScaleInputErrorKind::InvalidCivilDate,
1815 },
1816 }
1817 );
1818 }
1819
1820 #[test]
1821 fn build_rejects_non_finite_correction_epoch_without_panicking() {
1822 let sp3 = sp3_fixture();
1823 let options = PppCorrectionsOptions {
1824 solid_earth_tide: true,
1825 pole_tide: None,
1826 ocean_loading: None,
1827 phase_windup: false,
1828 satellite_antenna: None,
1829 code_bias: None,
1830 };
1831 let epochs = vec![PppCorrectionEpoch {
1832 epoch: civil(2020, 6, 24, 12, 0, f64::NAN),
1833 t_rx_j2000_s: 0.0,
1834 observations: Vec::new(),
1835 }];
1836
1837 let err = build(
1838 &sp3,
1839 &epochs,
1840 [3_512_900.0, 780_500.0, 5_248_700.0],
1841 &options,
1842 )
1843 .expect_err("non-finite PPP correction epoch must return an error");
1844
1845 assert_eq!(
1846 err,
1847 PppCorrectionsError::Epoch {
1848 epoch_index: 0,
1849 source: CoverageError::InvalidInput {
1850 field: "civil datetime",
1851 kind: TimeScaleInputErrorKind::NonFinite,
1852 },
1853 }
1854 );
1855 }
1856
1857 #[test]
1858 fn build_rejects_correction_epoch_after_eop_coverage() {
1859 let sp3 = sp3_fixture();
1860 let options = PppCorrectionsOptions {
1861 solid_earth_tide: true,
1862 pole_tide: None,
1863 ocean_loading: None,
1864 phase_windup: false,
1865 satellite_antenna: None,
1866 code_bias: None,
1867 };
1868 let epochs = vec![PppCorrectionEpoch {
1869 epoch: civil(2100, 1, 1, 0, 0, 0.0),
1870 t_rx_j2000_s: 0.0,
1871 observations: Vec::new(),
1872 }];
1873
1874 let err = build(
1875 &sp3,
1876 &epochs,
1877 [3_512_900.0, 780_500.0, 5_248_700.0],
1878 &options,
1879 )
1880 .expect_err("post-coverage PPP correction epoch must return an error");
1881
1882 assert_eq!(
1883 err,
1884 PppCorrectionsError::Epoch {
1885 epoch_index: 0,
1886 source: CoverageError::OutsideCoverage(
1887 crate::astro::time::DegradeReason::AfterCoverage
1888 ),
1889 }
1890 );
1891 }
1892
1893 #[test]
1894 fn build_accepts_valid_correction_epoch() {
1895 let sp3 = sp3_fixture();
1896 let options = PppCorrectionsOptions {
1897 solid_earth_tide: true,
1898 pole_tide: None,
1899 ocean_loading: None,
1900 phase_windup: false,
1901 satellite_antenna: None,
1902 code_bias: None,
1903 };
1904 let epochs = vec![PppCorrectionEpoch {
1905 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1906 t_rx_j2000_s: 0.0,
1907 observations: Vec::new(),
1908 }];
1909
1910 let got = build(
1911 &sp3,
1912 &epochs,
1913 [3_512_900.0, 780_500.0, 5_248_700.0],
1914 &options,
1915 )
1916 .expect("valid PPP correction epoch must build");
1917
1918 assert_eq!(got.tide.len(), 1);
1919 }
1920
1921 #[test]
1922 fn build_rejects_degenerate_receiver_state_before_tide() {
1923 let sp3 = sp3_fixture();
1924 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1925 let options = PppCorrectionsOptions {
1926 solid_earth_tide: true,
1927 pole_tide: None,
1928 ocean_loading: None,
1929 phase_windup: false,
1930 satellite_antenna: None,
1931 code_bias: None,
1932 };
1933 let epochs = vec![PppCorrectionEpoch {
1934 epoch,
1935 t_rx_j2000_s: 0.0,
1936 observations: Vec::new(),
1937 }];
1938
1939 let err = build(&sp3, &epochs, [0.0, 0.0, 0.0], &options)
1940 .expect_err("degenerate tide geometry must error");
1941
1942 assert_eq!(
1943 err,
1944 PppCorrectionsError::InvalidInput {
1945 field: "receiver radius_m",
1946 reason: "not positive",
1947 }
1948 );
1949 }
1950}