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