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::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 (ex, ey, ez) = satellite_sun_fixed_axes(rs, sun_ecef_m)?;
862
863 let off1 = ant.pco(&options.freq1_label)?;
864 let off2 = ant.pco(&options.freq2_label)?;
865 let gamma = ionosphere_free_gamma(frequencies_hz.0, frequencies_hz.1);
866
867 let dant1 = body_to_ecef(off1, ex, ey, ez);
868 let dant2 = body_to_ecef(off2, ex, ey, ez);
869 let dant_ecef = sub3(scale3(dant1, gamma), scale3(dant2, gamma - 1.0));
870 let pcv_m = nadir_pcv_if(ant, pred, options, gamma)?;
871
872 Some((dant_ecef, pcv_m))
873}
874
875pub(crate) fn satellite_body_pco_to_ecef(
877 pco_body_m: [f64; 3],
878 sat_position_ecef_m: [f64; 3],
879 sun_ecef_m: [f64; 3],
880) -> Option<[f64; 3]> {
881 let (ex, ey, ez) = satellite_sun_fixed_axes(sat_position_ecef_m, sun_ecef_m)?;
882 Some(body_to_ecef(pco_body_m, ex, ey, ez))
883}
884
885fn satellite_sun_fixed_axes(
886 sat_position_ecef_m: [f64; 3],
887 sun_ecef_m: [f64; 3],
888) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
889 let sat_norm_m = norm3(sat_position_ecef_m);
890 if !sat_norm_m.is_finite() || sat_norm_m <= VECTOR_NORM_ZERO_EPS {
891 return None;
892 }
893 let ez = scale3(neg3(sat_position_ecef_m), 1.0 / sat_norm_m);
894
895 let sun_delta_m = sub3(sun_ecef_m, sat_position_ecef_m);
896 let sun_delta_norm_m = norm3(sun_delta_m);
897 if !sun_delta_norm_m.is_finite() || sun_delta_norm_m <= VECTOR_NORM_ZERO_EPS {
898 return None;
899 }
900 let es = scale3(sun_delta_m, 1.0 / sun_delta_norm_m);
901
902 let normal = cross3(ez, es);
903 let normal_norm = norm3(normal);
904 if !normal_norm.is_finite() || normal_norm <= VECTOR_NORM_ZERO_EPS {
905 return None;
906 }
907 let ey = scale3(normal, 1.0 / normal_norm);
908 let ex = cross3(ey, ez);
909 Some((ex, ey, ez))
910}
911
912fn body_to_ecef(pco_body_m: [f64; 3], ex: [f64; 3], ey: [f64; 3], ez: [f64; 3]) -> [f64; 3] {
913 add3(
914 add3(scale3(ex, pco_body_m[0]), scale3(ey, pco_body_m[1])),
915 scale3(ez, pco_body_m[2]),
916 )
917}
918
919fn ionosphere_free_gamma(f1_hz: f64, f2_hz: f64) -> f64 {
920 let f1_sq = f1_hz * f1_hz;
921 f1_sq / (f1_sq - f2_hz * f2_hz)
922}
923
924fn nadir_pcv_if(
925 ant: &SatelliteAntenna,
926 pred: &PredictedObservables,
927 options: &SatelliteAntennaOptions,
928 gamma: f64,
929) -> Option<f64> {
930 let eu = unit3(neg3(pred.los_unit))?;
931 let ez = unit3(neg3(pred.sat_pos_ecef_m))?;
932 let nadir_deg = clamp(dot3(eu, ez)).acos() * RAD_TO_DEG;
933 let p1 = ant.pcv_noazi(&options.freq1_label, nadir_deg)?;
934 let p2 = ant.pcv_noazi(&options.freq2_label, nadir_deg)?;
935 Some(gamma * p1 - (gamma - 1.0) * p2)
936}
937
938impl SatelliteAntennaOptions {
939 fn antenna_for(&self, sat: GnssSatelliteId, epoch: CivilDateTime) -> Option<&SatelliteAntenna> {
940 self.antennas
941 .iter()
942 .find(|ant| ant.sat == sat && ant.valid_at(epoch))
943 }
944}
945
946impl SatelliteAntenna {
947 fn valid_at(&self, epoch: CivilDateTime) -> bool {
948 let after_from = self
949 .valid_from
950 .is_none_or(|from| civil_cmp(epoch, from) != std::cmp::Ordering::Less);
951 let before_until = self
952 .valid_until
953 .is_none_or(|until| civil_cmp(epoch, until) != std::cmp::Ordering::Greater);
954 after_from && before_until
955 }
956
957 fn frequency(&self, label: &str) -> Option<&SatelliteAntennaFrequency> {
958 self.frequencies
959 .iter()
960 .find(|f| f.label.trim() == label.trim())
961 }
962
963 fn pco(&self, label: &str) -> Option<[f64; 3]> {
964 self.frequency(label).map(|f| f.pco_m)
965 }
966
967 fn pcv_noazi(&self, label: &str, zenith_deg: f64) -> Option<f64> {
968 let frequency = self.frequency(label)?;
969 interpolate_samples(&frequency.noazi_pcv_m, zenith_deg)
970 }
971}
972
973fn civil_cmp(a: CivilDateTime, b: CivilDateTime) -> std::cmp::Ordering {
974 (
975 a.year,
976 a.month,
977 a.day,
978 a.hour,
979 a.minute,
980 ordered_seconds(a.second),
981 )
982 .cmp(&(
983 b.year,
984 b.month,
985 b.day,
986 b.hour,
987 b.minute,
988 ordered_seconds(b.second),
989 ))
990}
991
992fn ordered_seconds(second: f64) -> i64 {
993 (second * MICROSECONDS_PER_SECOND).round() as i64
994}
995
996fn interpolate_samples(samples: &[(f64, f64)], zenith_deg: f64) -> Option<f64> {
997 let mut sorted = samples.to_vec();
998 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
999 antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
1000}
1001
1002fn clamp(x: f64) -> f64 {
1003 x.clamp(-1.0, 1.0)
1004}
1005
1006#[cfg(all(test, sidereon_repo_tests))]
1007mod tests {
1008 use super::*;
1009 use crate::astro::time::split_julian_date;
1010 use crate::constants::F_L2_HZ;
1011 use crate::observables::j2000_seconds_from_split;
1012 use crate::GnssSystem;
1013
1014 const REAL_CODE_BIA: &[u8] = include_bytes!(concat!(
1015 env!("CARGO_MANIFEST_DIR"),
1016 "/tests/fixtures/bias/CODE.BIA"
1017 ));
1018
1019 fn sp3_fixture() -> Sp3 {
1020 let path = concat!(
1021 env!("CARGO_MANIFEST_DIR"),
1022 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
1023 );
1024 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
1025 Sp3::parse(&bytes).expect("parse SP3 fixture")
1026 }
1027
1028 fn civil(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> CivilDateTime {
1029 CivilDateTime {
1030 year,
1031 month,
1032 day,
1033 hour,
1034 minute,
1035 second,
1036 }
1037 }
1038
1039 fn split_jd(epoch: CivilDateTime) -> (f64, f64) {
1040 split_julian_date(
1041 epoch.year,
1042 i32::from(epoch.month),
1043 i32::from(epoch.day),
1044 i32::from(epoch.hour),
1045 i32::from(epoch.minute),
1046 epoch.second,
1047 )
1048 }
1049
1050 fn fake_antenna_options(sat: GnssSatelliteId) -> SatelliteAntennaOptions {
1051 SatelliteAntennaOptions {
1052 freq1_label: "G01".to_string(),
1053 freq1_hz: F_L1_HZ,
1054 freq2_label: "G02".to_string(),
1055 freq2_hz: F_L2_HZ,
1056 antennas: vec![SatelliteAntenna {
1057 sat,
1058 valid_from: Some(civil(2020, 1, 1, 0, 0, 0.0)),
1059 valid_until: Some(civil(2021, 1, 1, 0, 0, 0.0)),
1060 frequencies: vec![
1061 SatelliteAntennaFrequency {
1062 label: "G01".to_string(),
1063 pco_m: [0.1, -0.2, 1.0],
1064 noazi_pcv_m: vec![(0.0, 0.001), (5.0, 0.002), (10.0, 0.004)],
1065 },
1066 SatelliteAntennaFrequency {
1067 label: "G02".to_string(),
1068 pco_m: [-0.1, 0.3, 0.5],
1069 noazi_pcv_m: vec![(0.0, -0.001), (5.0, -0.002), (10.0, -0.003)],
1070 },
1071 ],
1072 }],
1073 }
1074 }
1075
1076 fn windup_epoch(sat: GnssSatelliteId, freq1_hz: f64, freq2_hz: f64) -> PppCorrectionEpoch {
1077 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1078 let (jd_whole, jd_fraction) = split_jd(epoch);
1079 PppCorrectionEpoch {
1080 epoch,
1081 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1082 .expect("valid split Julian date"),
1083 observations: vec![PppCorrectionObservation {
1084 sat,
1085 freq1_hz,
1086 freq2_hz,
1087 glonass_channel: None,
1088 }],
1089 }
1090 }
1091
1092 #[test]
1093 fn ppp_corrections_match_elixir_reference_fixture() {
1094 let sp3 = sp3_fixture();
1095 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1096 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1097 let (jd_whole, jd_fraction) = split_jd(epoch);
1098 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1099 let epochs = vec![PppCorrectionEpoch {
1100 epoch,
1101 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1102 .expect("valid split Julian date"),
1103 observations: vec![PppCorrectionObservation {
1104 sat,
1105 freq1_hz: F_L1_HZ,
1106 freq2_hz: F_L2_HZ,
1107 glonass_channel: None,
1108 }],
1109 }];
1110 let options = PppCorrectionsOptions {
1111 solid_earth_tide: true,
1112 pole_tide: None,
1113 ocean_loading: None,
1114 phase_windup: true,
1115 satellite_antenna: Some(fake_antenna_options(sat)),
1116 code_bias: None,
1117 };
1118
1119 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1120
1121 assert_eq!(got.tide.len(), 1);
1122 assert_eq!(
1123 got.tide[0].vector_m.map(f64::to_bits),
1124 [0x3FB8BC98E788ED00, 0x3FAA54D8C1097508, 0x3FB03498C46B3B50]
1125 );
1126 assert_eq!(got.windup_m.len(), 1);
1127 assert_eq!(got.windup_m[0].value_m.to_bits(), 0xBF808DE79DBD2C16);
1128 assert_eq!(got.sat_pco_ecef.len(), 1);
1129 assert_eq!(
1130 got.sat_pco_ecef[0].vector_m.map(f64::to_bits),
1131 [0xBFE58ED947570048, 0x3FDEDBB280CEB1BE, 0xBFFE3BCA6A354E4A]
1132 );
1133 assert_eq!(got.sat_pcv_m.len(), 1);
1134 assert_eq!(got.sat_pcv_m[0].value_m.to_bits(), 0x3F77617E95BD232C);
1135 }
1136
1137 #[test]
1138 fn pole_tide_correction_is_emitted_and_matches_standalone() {
1139 let sp3 = sp3_fixture();
1140 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1141 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1142 let (jd_whole, jd_fraction) = split_jd(epoch);
1143 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1144 let epochs = vec![PppCorrectionEpoch {
1145 epoch,
1146 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1147 .expect("valid split Julian date"),
1148 observations: vec![PppCorrectionObservation {
1149 sat,
1150 freq1_hz: F_L1_HZ,
1151 freq2_hz: F_L2_HZ,
1152 glonass_channel: None,
1153 }],
1154 }];
1155 let pole = PoleTideOptions {
1156 xp_arcsec: 0.169_051,
1157 yp_arcsec: 0.411_760,
1158 };
1159 let options = PppCorrectionsOptions {
1160 solid_earth_tide: false,
1161 pole_tide: Some(pole),
1162 ocean_loading: None,
1163 phase_windup: false,
1164 satellite_antenna: None,
1165 code_bias: None,
1166 };
1167
1168 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1169
1170 assert_eq!(got.pole_tide.len(), 1);
1171 assert_eq!(got.pole_tide[0].epoch_index, 0);
1172 let expected = crate::tides::solid_earth_pole_tide(
1173 &receiver,
1174 2020,
1175 6,
1176 24,
1177 12.0,
1178 pole.xp_arcsec,
1179 pole.yp_arcsec,
1180 )
1181 .expect("valid pole tide");
1182 assert_eq!(got.pole_tide[0].vector_m, expected);
1183 assert!(got.tide.is_empty());
1185 }
1186
1187 fn zim2_blq() -> OceanLoadingBlq {
1191 OceanLoadingBlq {
1192 amplitude_m: [
1193 [
1194 0.00693, 0.00228, 0.00148, 0.00061, 0.00220, 0.00094, 0.00070, 0.00001,
1195 0.00047, 0.00025, 0.00019,
1196 ],
1197 [
1198 0.00272, 0.00076, 0.00061, 0.00020, 0.00036, 0.00025, 0.00011, 0.00005,
1199 0.00004, 0.00001, 0.00002,
1200 ],
1201 [
1202 0.00061, 0.00026, 0.00010, 0.00009, 0.00025, 0.00002, 0.00008, 0.00003,
1203 0.00002, 0.00000, 0.00001,
1204 ],
1205 ],
1206 phase_deg: [
1207 [
1208 -72.3, -44.2, -90.8, -44.1, -62.9, -94.5, -64.3, 171.0, 3.4, 3.6, 1.1,
1209 ],
1210 [
1211 84.3, 115.4, 63.3, 113.7, 98.6, 20.7, 94.2, -44.5, -170.0, -162.7, -177.8,
1212 ],
1213 [
1214 -29.3, 1.7, -44.0, -4.2, 44.2, -39.1, 43.7, 170.1, -93.3, -118.3, -176.4,
1215 ],
1216 ],
1217 }
1218 }
1219
1220 #[test]
1221 fn ocean_loading_correction_is_emitted_and_matches_standalone() {
1222 let sp3 = sp3_fixture();
1223 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1224 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1225 let (jd_whole, jd_fraction) = split_jd(epoch);
1226 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1227 let epochs = vec![PppCorrectionEpoch {
1228 epoch,
1229 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1230 .expect("valid split Julian date"),
1231 observations: vec![PppCorrectionObservation {
1232 sat,
1233 freq1_hz: F_L1_HZ,
1234 freq2_hz: F_L2_HZ,
1235 glonass_channel: None,
1236 }],
1237 }];
1238 let blq = zim2_blq();
1239 let options = PppCorrectionsOptions {
1240 solid_earth_tide: false,
1241 pole_tide: None,
1242 ocean_loading: Some(blq),
1243 phase_windup: false,
1244 satellite_antenna: None,
1245 code_bias: None,
1246 };
1247
1248 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1249
1250 assert_eq!(got.ocean_loading.len(), 1);
1251 assert_eq!(got.ocean_loading[0].epoch_index, 0);
1252 let expected = crate::tides::ocean_tide_loading(&receiver, 2020, 6, 24, 12.0, &blq)
1253 .expect("valid ocean loading");
1254 assert_eq!(got.ocean_loading[0].vector_m, expected);
1255 assert!(got.tide.is_empty());
1257 assert!(got.pole_tide.is_empty());
1258 }
1259
1260 #[test]
1261 fn pole_or_ocean_only_skips_sun_moon_and_prediction() {
1262 let sp3 = sp3_fixture();
1263 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1264 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1265
1266 let epochs = vec![PppCorrectionEpoch {
1274 epoch: civil(2100, 1, 1, 12, 0, 0.0),
1275 t_rx_j2000_s: f64::NAN,
1276 observations: vec![PppCorrectionObservation {
1277 sat,
1278 freq1_hz: F_L1_HZ,
1279 freq2_hz: F_L2_HZ,
1280 glonass_channel: None,
1281 }],
1282 }];
1283
1284 let pole = PoleTideOptions {
1286 xp_arcsec: 0.169_051,
1287 yp_arcsec: 0.411_760,
1288 };
1289 let got = build(
1290 &sp3,
1291 &epochs,
1292 receiver,
1293 &PppCorrectionsOptions {
1294 solid_earth_tide: false,
1295 pole_tide: Some(pole),
1296 ocean_loading: None,
1297 phase_windup: false,
1298 satellite_antenna: None,
1299 code_bias: None,
1300 },
1301 )
1302 .expect("pole-only build must not touch the Sun/Moon or predict paths");
1303 assert_eq!(got.pole_tide.len(), 1);
1304 assert!(got.tide.is_empty());
1305 assert!(got.ocean_loading.is_empty());
1306 assert!(got.windup_m.is_empty());
1307 assert!(got.sat_pco_ecef.is_empty());
1308 assert!(got.sat_pcv_m.is_empty());
1309
1310 let blq = zim2_blq();
1312 let got = build(
1313 &sp3,
1314 &epochs,
1315 receiver,
1316 &PppCorrectionsOptions {
1317 solid_earth_tide: false,
1318 pole_tide: None,
1319 ocean_loading: Some(blq),
1320 phase_windup: false,
1321 satellite_antenna: None,
1322 code_bias: None,
1323 },
1324 )
1325 .expect("ocean-only build must not touch the Sun/Moon or predict paths");
1326 assert_eq!(got.ocean_loading.len(), 1);
1327 assert!(got.tide.is_empty());
1328 assert!(got.pole_tide.is_empty());
1329 assert!(got.windup_m.is_empty());
1330 assert!(got.sat_pco_ecef.is_empty());
1331 assert!(got.sat_pcv_m.is_empty());
1332 }
1333
1334 #[test]
1335 fn phase_windup_rejects_invalid_observation_frequency_pairs() {
1336 let sp3 = sp3_fixture();
1337 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1338 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1339 let options = PppCorrectionsOptions {
1340 solid_earth_tide: false,
1341 pole_tide: None,
1342 ocean_loading: None,
1343 phase_windup: true,
1344 satellite_antenna: None,
1345 code_bias: None,
1346 };
1347 let cases = [
1348 (0.0, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1349 (-F_L1_HZ, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1350 (
1351 F_L1_HZ,
1352 F_L1_HZ,
1353 "phase wind-up frequency pair",
1354 "must differ",
1355 ),
1356 ];
1357
1358 for (freq1_hz, freq2_hz, field, reason) in cases {
1359 let epochs = vec![windup_epoch(sat, freq1_hz, freq2_hz)];
1360 let err = build(&sp3, &epochs, receiver, &options)
1361 .expect_err("invalid phase wind-up frequencies must error");
1362
1363 assert_eq!(
1364 err,
1365 PppCorrectionsError::WindupFrequency {
1366 epoch_index: 0,
1367 sat,
1368 field,
1369 reason,
1370 }
1371 );
1372 }
1373 }
1374
1375 #[test]
1376 fn phase_windup_observation_frequency_pair_computes_finite_correction() {
1377 let sp3 = sp3_fixture();
1378 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1379 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1380 let options = PppCorrectionsOptions {
1381 solid_earth_tide: false,
1382 pole_tide: None,
1383 ocean_loading: None,
1384 phase_windup: true,
1385 satellite_antenna: None,
1386 code_bias: None,
1387 };
1388 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1389
1390 let got =
1391 build(&sp3, &epochs, receiver, &options).expect("valid phase wind-up frequencies");
1392
1393 assert_eq!(got.windup_m.len(), 1);
1394 assert!(got.windup_m[0].value_m.is_finite());
1395 }
1396
1397 fn code_bias_product() -> crate::bias::BiasSet {
1398 let text = "\
1399%=BIA 1.00 TST
1400+FILE/REFERENCE
1401 DESCRIPTION TEST
1402-FILE/REFERENCE
1403+BIAS/DESCRIPTION
1404 BIAS_MODE ABSOLUTE
1405 TIME_SYSTEM G
1406 SATELLITE_CLOCK_REFERENCE_OBSERVABLES G C1W C2W
1407-BIAS/DESCRIPTION
1408+BIAS/SOLUTION 3
1409 OSB G021 G21 C1C 2020:176:00000 2020:177:00000 ns -1.234567890000E+00 2.00000E-02
1410 OSB G021 G21 C1W 2020:176:00000 2020:177:00000 ns 5.600000000000E-01 2.00000E-02
1411 OSB G021 G21 C2W 2020:176:00000 2020:177:00000 ns -3.000000000000E-01 2.00000E-02
1412-BIAS/SOLUTION
1413";
1414 crate::bias::BiasSet::parse_bias_sinex(text.as_bytes())
1415 .expect("parse code-bias product")
1416 .value
1417 }
1418
1419 fn real_code_bias_product() -> crate::bias::BiasSet {
1420 crate::bias::BiasSet::parse_bias_sinex(REAL_CODE_BIA)
1421 .expect("parse real CODE Bias-SINEX product")
1422 .value
1423 }
1424
1425 fn code_bias_epoch(sat: GnssSatelliteId) -> Vec<PppCorrectionEpoch> {
1426 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1427 let (jd_whole, jd_fraction) = split_jd(epoch);
1428 vec![PppCorrectionEpoch {
1429 epoch,
1430 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1431 .expect("valid split Julian date"),
1432 observations: vec![PppCorrectionObservation {
1433 sat,
1434 freq1_hz: F_L1_HZ,
1435 freq2_hz: F_L2_HZ,
1436 glonass_channel: None,
1437 }],
1438 }]
1439 }
1440
1441 fn code_bias_options(bias_set: crate::bias::BiasSet, used: (&str, &str)) -> CodeBiasOptions {
1442 let mut used_observables_default = BTreeMap::new();
1443 used_observables_default.insert(GnssSystem::Gps, (used.0.to_string(), used.1.to_string()));
1444 CodeBiasOptions {
1445 bias_set,
1446 used_observables_per_sat: BTreeMap::new(),
1447 used_observables_default,
1448 clock_reference: None,
1449 }
1450 }
1451
1452 #[test]
1453 fn code_bias_builds_exact_zero_for_matched_clock_datum() {
1454 let sp3 = sp3_fixture();
1455 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1456 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1457 let epochs = code_bias_epoch(sat);
1458 let options = PppCorrectionsOptions {
1459 solid_earth_tide: false,
1460 pole_tide: None,
1461 ocean_loading: None,
1462 phase_windup: false,
1463 satellite_antenna: None,
1464 code_bias: Some(code_bias_options(code_bias_product(), ("C1W", "C2W"))),
1465 };
1466
1467 let got = build(&sp3, &epochs, receiver, &options).expect("code-bias build");
1468
1469 assert_eq!(got.code_bias_m.len(), 1);
1470 assert_eq!(got.code_bias_m[0].value_m.to_bits(), 0.0_f64.to_bits());
1471 }
1472
1473 #[test]
1474 fn code_bias_builds_mismatched_pair_scalar() {
1475 let sp3 = sp3_fixture();
1476 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1477 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1478 let epochs = code_bias_epoch(sat);
1479 let options = PppCorrectionsOptions {
1480 solid_earth_tide: false,
1481 pole_tide: None,
1482 ocean_loading: None,
1483 phase_windup: false,
1484 satellite_antenna: None,
1485 code_bias: Some(code_bias_options(code_bias_product(), ("C1C", "C2W"))),
1486 };
1487
1488 let got = build(&sp3, &epochs, receiver, &options).expect("code-bias build");
1489 let alpha = F_L1_HZ * F_L1_HZ / (F_L1_HZ * F_L1_HZ - F_L2_HZ * F_L2_HZ);
1490 let beta = -(F_L2_HZ * F_L2_HZ) / (F_L1_HZ * F_L1_HZ - F_L2_HZ * F_L2_HZ);
1491 let used_if =
1492 alpha * (-1.234567890000_f64 * 1.0e-9) + beta * (-0.300000000000_f64 * 1.0e-9);
1493 let ref_if = alpha * (0.560000000000_f64 * 1.0e-9) + beta * (-0.300000000000_f64 * 1.0e-9);
1494 let expected = (used_if - ref_if) * C_M_S;
1495
1496 assert_eq!(got.code_bias_m.len(), 1);
1497 assert_eq!(got.code_bias_m[0].value_m.to_bits(), expected.to_bits());
1498 }
1499
1500 #[test]
1501 fn code_bias_build_applies_real_glonass_osb_with_fdma_channel() {
1502 let sp3 = sp3_fixture();
1503 let sat = GnssSatelliteId::new(GnssSystem::Glonass, 2).expect("valid satellite id");
1504 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1505 let channel = -4;
1506 let freq1_hz = frequencies::rinex_observation_frequency_hz(
1507 GnssSystem::Glonass,
1508 "C1C",
1509 3.04,
1510 Some(channel),
1511 )
1512 .expect("GLONASS G1 frequency");
1513 let freq2_hz = frequencies::rinex_observation_frequency_hz(
1514 GnssSystem::Glonass,
1515 "C2C",
1516 3.04,
1517 Some(channel),
1518 )
1519 .expect("GLONASS G2 frequency");
1520 let epoch = civil(2026, 6, 24, 12, 0, 0.0);
1521 let (jd_whole, jd_fraction) = split_jd(epoch);
1522 let epochs = vec![PppCorrectionEpoch {
1523 epoch,
1524 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1525 .expect("valid split Julian date"),
1526 observations: vec![PppCorrectionObservation {
1527 sat,
1528 freq1_hz,
1529 freq2_hz,
1530 glonass_channel: Some(channel),
1531 }],
1532 }];
1533 let mut used_observables_default = BTreeMap::new();
1534 used_observables_default
1535 .insert(GnssSystem::Glonass, ("C1C".to_string(), "C2C".to_string()));
1536 let options = PppCorrectionsOptions {
1537 solid_earth_tide: false,
1538 pole_tide: None,
1539 ocean_loading: None,
1540 phase_windup: false,
1541 satellite_antenna: None,
1542 code_bias: Some(CodeBiasOptions {
1543 bias_set: real_code_bias_product(),
1544 used_observables_per_sat: BTreeMap::new(),
1545 used_observables_default,
1546 clock_reference: None,
1547 }),
1548 };
1549
1550 let got = build(&sp3, &epochs, receiver, &options).expect("GLONASS code-bias build");
1551 let (alpha, beta) = crate::bias::ionosphere_free_coefficients(freq1_hz, freq2_hz).unwrap();
1552 let used_if = alpha * (0.2114_f64 * 1.0e-9) + beta * (2.6597_f64 * 1.0e-9);
1553 let ref_if = alpha * (1.7840_f64 * 1.0e-9) + beta * (2.9490_f64 * 1.0e-9);
1554 let expected = (used_if - ref_if) * C_M_S;
1555
1556 assert_eq!(got.code_bias_m.len(), 1);
1557 assert_eq!(got.code_bias_m[0].sat, sat);
1558 assert_eq!(got.code_bias_m[0].value_m.to_bits(), expected.to_bits());
1559 }
1560
1561 #[test]
1562 fn code_bias_build_requires_clock_reference() {
1563 let sp3 = sp3_fixture();
1564 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1565 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1566 let epochs = code_bias_epoch(sat);
1567 let dcb = crate::bias::BiasSet::parse_code_dcb(
1568 b"# DCB P1-C1 2020-06 G\n G21 1.000 0.100\n",
1569 Some(crate::bias::CodeDcbOptions {
1570 pair: ("P1".to_string(), "C1".to_string()),
1571 year: 2020,
1572 month: 6,
1573 time_scale: crate::astro::time::model::TimeScale::Gpst,
1574 receiver_system: None,
1575 }),
1576 )
1577 .expect("parse DCB")
1578 .value;
1579 let options = PppCorrectionsOptions {
1580 solid_earth_tide: false,
1581 pole_tide: None,
1582 ocean_loading: None,
1583 phase_windup: false,
1584 satellite_antenna: None,
1585 code_bias: Some(code_bias_options(dcb, ("C1C", "C2W"))),
1586 };
1587
1588 let err = build(&sp3, &epochs, receiver, &options)
1589 .expect_err("missing clock reference must error");
1590 assert!(matches!(
1591 err,
1592 PppCorrectionsError::Bias {
1593 source: BiasError::MissingClockReference
1594 }
1595 ));
1596 }
1597
1598 #[test]
1599 fn satellite_antenna_rejects_invalid_frequency_pairs_without_windup() {
1600 let sp3 = sp3_fixture();
1601 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1602 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1603 let cases = [
1604 (0.0, F_L2_HZ, "satellite antenna freq1_hz", "not positive"),
1605 (
1606 F_L1_HZ,
1607 f64::INFINITY,
1608 "satellite antenna freq2_hz",
1609 "not finite",
1610 ),
1611 (
1612 f64::NAN,
1613 F_L2_HZ,
1614 "satellite antenna freq1_hz",
1615 "not finite",
1616 ),
1617 (
1618 F_L1_HZ,
1619 F_L1_HZ,
1620 "satellite antenna frequency pair",
1621 "must differ",
1622 ),
1623 ];
1624
1625 for (freq1_hz, freq2_hz, field, reason) in cases {
1626 let mut antenna = fake_antenna_options(sat);
1627 antenna.freq1_hz = freq1_hz;
1628 antenna.freq2_hz = freq2_hz;
1629 let options = PppCorrectionsOptions {
1630 solid_earth_tide: false,
1631 pole_tide: None,
1632 ocean_loading: None,
1633 phase_windup: false,
1634 satellite_antenna: Some(antenna),
1635 code_bias: None,
1636 };
1637 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1638
1639 let err = build(&sp3, &epochs, receiver, &options)
1640 .expect_err("invalid satellite antenna frequencies must error");
1641
1642 assert_eq!(
1643 err,
1644 PppCorrectionsError::SatelliteAntennaFrequency { field, reason }
1645 );
1646 }
1647 }
1648
1649 #[test]
1650 fn satellite_antenna_frequency_pair_computes_finite_corrections_without_windup() {
1651 let sp3 = sp3_fixture();
1652 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1653 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1654 let options = PppCorrectionsOptions {
1655 solid_earth_tide: false,
1656 pole_tide: None,
1657 ocean_loading: None,
1658 phase_windup: false,
1659 satellite_antenna: Some(fake_antenna_options(sat)),
1660 code_bias: None,
1661 };
1662 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1663
1664 let got =
1665 build(&sp3, &epochs, receiver, &options).expect("valid satellite antenna frequencies");
1666
1667 assert!(got.windup_m.is_empty());
1668 assert_eq!(got.sat_pco_ecef.len(), 1);
1669 assert!(got.sat_pco_ecef[0]
1670 .vector_m
1671 .iter()
1672 .all(|value| value.is_finite()));
1673 assert_eq!(got.sat_pcv_m.len(), 1);
1674 assert!(got.sat_pcv_m[0].value_m.is_finite());
1675 }
1676
1677 #[test]
1678 fn satellite_antenna_rejects_non_finite_pcv_samples() {
1679 let sp3 = sp3_fixture();
1680 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1681 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1682 let mut antenna = fake_antenna_options(sat);
1683 antenna.antennas[0].frequencies[0].noazi_pcv_m[1] = (5.0, f64::NAN);
1684 let options = PppCorrectionsOptions {
1685 solid_earth_tide: false,
1686 pole_tide: None,
1687 ocean_loading: None,
1688 phase_windup: false,
1689 satellite_antenna: Some(antenna),
1690 code_bias: None,
1691 };
1692 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1693
1694 let err = build(&sp3, &epochs, receiver, &options)
1695 .expect_err("non-finite satellite PCV samples must error");
1696
1697 assert_eq!(
1698 err,
1699 PppCorrectionsError::InvalidInput {
1700 field: "satellite antenna noazi_pcv_m",
1701 reason: "not finite",
1702 }
1703 );
1704 }
1705
1706 #[test]
1707 fn satellite_antenna_empty_pcv_grid_is_not_materialized_as_zero() {
1708 let sp3 = sp3_fixture();
1709 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1710 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1711 let (jd_whole, jd_fraction) = split_jd(epoch);
1712 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1713 let epochs = vec![PppCorrectionEpoch {
1714 epoch,
1715 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1716 .expect("valid split Julian date"),
1717 observations: vec![PppCorrectionObservation {
1718 sat,
1719 freq1_hz: F_L1_HZ,
1720 freq2_hz: F_L2_HZ,
1721 glonass_channel: None,
1722 }],
1723 }];
1724 let mut antenna = fake_antenna_options(sat);
1725 antenna.antennas[0].frequencies[0].noazi_pcv_m.clear();
1726 let options = PppCorrectionsOptions {
1727 solid_earth_tide: false,
1728 pole_tide: None,
1729 ocean_loading: None,
1730 phase_windup: false,
1731 satellite_antenna: Some(antenna),
1732 code_bias: None,
1733 };
1734
1735 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1736
1737 assert!(got.sat_pco_ecef.is_empty());
1738 assert!(got.sat_pcv_m.is_empty());
1739 }
1740
1741 #[test]
1742 fn build_rejects_non_finite_receive_time_for_satellite_corrections() {
1743 let sp3 = sp3_fixture();
1744 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1745 let options = PppCorrectionsOptions {
1746 solid_earth_tide: false,
1747 pole_tide: None,
1748 ocean_loading: None,
1749 phase_windup: true,
1750 satellite_antenna: None,
1751 code_bias: None,
1752 };
1753 let epochs = vec![PppCorrectionEpoch {
1754 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1755 t_rx_j2000_s: f64::NAN,
1756 observations: vec![PppCorrectionObservation {
1757 sat,
1758 freq1_hz: F_L1_HZ,
1759 freq2_hz: F_L2_HZ,
1760 glonass_channel: None,
1761 }],
1762 }];
1763
1764 let err = build(
1765 &sp3,
1766 &epochs,
1767 [3_512_900.0, 780_500.0, 5_248_700.0],
1768 &options,
1769 )
1770 .expect_err("non-finite receive time must be reported");
1771
1772 assert_eq!(
1773 err,
1774 PppCorrectionsError::InvalidInput {
1775 field: "t_rx_j2000_s",
1776 reason: "not finite",
1777 }
1778 );
1779 }
1780
1781 #[test]
1782 fn noazi_pcv_interpolation_clamps_and_interpolates() {
1783 let samples = vec![(10.0, 4.0), (0.0, 1.0), (5.0, 2.0)];
1784
1785 assert_eq!(interpolate_samples(&samples, -1.0), Some(1.0));
1786 assert_eq!(interpolate_samples(&samples, 2.5), Some(1.5));
1787 assert_eq!(interpolate_samples(&samples, 99.0), Some(4.0));
1788 }
1789
1790 #[test]
1791 fn build_rejects_invalid_receiver_state_before_disabled_short_circuit() {
1792 let sp3 = sp3_fixture();
1793 let options = PppCorrectionsOptions {
1794 solid_earth_tide: false,
1795 pole_tide: None,
1796 ocean_loading: None,
1797 phase_windup: false,
1798 satellite_antenna: None,
1799 code_bias: None,
1800 };
1801
1802 for (receiver, field, reason) in [
1803 (
1804 [f64::NAN, 780_500.0, 5_248_700.0],
1805 "receiver_ecef_m",
1806 "not finite",
1807 ),
1808 ([0.0, 0.0, 0.0], "receiver radius_m", "not positive"),
1809 ] {
1810 let err = build(&sp3, &[], receiver, &options)
1811 .expect_err("invalid receiver state must error before empty success");
1812
1813 assert_eq!(err, PppCorrectionsError::InvalidInput { field, reason });
1814 }
1815 }
1816
1817 #[test]
1818 fn build_rejects_invalid_correction_epoch_without_panicking() {
1819 let sp3 = sp3_fixture();
1820 let options = PppCorrectionsOptions {
1821 solid_earth_tide: true,
1822 pole_tide: None,
1823 ocean_loading: None,
1824 phase_windup: false,
1825 satellite_antenna: None,
1826 code_bias: None,
1827 };
1828 let epochs = vec![PppCorrectionEpoch {
1829 epoch: civil(2021, 2, 29, 12, 0, 0.0),
1830 t_rx_j2000_s: 0.0,
1831 observations: Vec::new(),
1832 }];
1833
1834 let err = build(
1835 &sp3,
1836 &epochs,
1837 [3_512_900.0, 780_500.0, 5_248_700.0],
1838 &options,
1839 )
1840 .expect_err("invalid PPP correction epoch must return an error");
1841
1842 assert_eq!(
1843 err,
1844 PppCorrectionsError::Epoch {
1845 epoch_index: 0,
1846 source: CoverageError::InvalidInput {
1847 field: "civil datetime",
1848 kind: TimeScaleInputErrorKind::InvalidCivilDate,
1849 },
1850 }
1851 );
1852 }
1853
1854 #[test]
1855 fn build_rejects_non_finite_correction_epoch_without_panicking() {
1856 let sp3 = sp3_fixture();
1857 let options = PppCorrectionsOptions {
1858 solid_earth_tide: true,
1859 pole_tide: None,
1860 ocean_loading: None,
1861 phase_windup: false,
1862 satellite_antenna: None,
1863 code_bias: None,
1864 };
1865 let epochs = vec![PppCorrectionEpoch {
1866 epoch: civil(2020, 6, 24, 12, 0, f64::NAN),
1867 t_rx_j2000_s: 0.0,
1868 observations: Vec::new(),
1869 }];
1870
1871 let err = build(
1872 &sp3,
1873 &epochs,
1874 [3_512_900.0, 780_500.0, 5_248_700.0],
1875 &options,
1876 )
1877 .expect_err("non-finite PPP correction epoch must return an error");
1878
1879 assert_eq!(
1880 err,
1881 PppCorrectionsError::Epoch {
1882 epoch_index: 0,
1883 source: CoverageError::InvalidInput {
1884 field: "civil datetime",
1885 kind: TimeScaleInputErrorKind::NonFinite,
1886 },
1887 }
1888 );
1889 }
1890
1891 #[test]
1892 fn build_rejects_correction_epoch_after_eop_coverage() {
1893 let sp3 = sp3_fixture();
1894 let options = PppCorrectionsOptions {
1895 solid_earth_tide: true,
1896 pole_tide: None,
1897 ocean_loading: None,
1898 phase_windup: false,
1899 satellite_antenna: None,
1900 code_bias: None,
1901 };
1902 let epochs = vec![PppCorrectionEpoch {
1903 epoch: civil(2100, 1, 1, 0, 0, 0.0),
1904 t_rx_j2000_s: 0.0,
1905 observations: Vec::new(),
1906 }];
1907
1908 let err = build(
1909 &sp3,
1910 &epochs,
1911 [3_512_900.0, 780_500.0, 5_248_700.0],
1912 &options,
1913 )
1914 .expect_err("post-coverage PPP correction epoch must return an error");
1915
1916 assert_eq!(
1917 err,
1918 PppCorrectionsError::Epoch {
1919 epoch_index: 0,
1920 source: CoverageError::OutsideCoverage(
1921 crate::astro::time::DegradeReason::AfterCoverage
1922 ),
1923 }
1924 );
1925 }
1926
1927 #[test]
1928 fn build_accepts_valid_correction_epoch() {
1929 let sp3 = sp3_fixture();
1930 let options = PppCorrectionsOptions {
1931 solid_earth_tide: true,
1932 pole_tide: None,
1933 ocean_loading: None,
1934 phase_windup: false,
1935 satellite_antenna: None,
1936 code_bias: None,
1937 };
1938 let epochs = vec![PppCorrectionEpoch {
1939 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1940 t_rx_j2000_s: 0.0,
1941 observations: Vec::new(),
1942 }];
1943
1944 let got = build(
1945 &sp3,
1946 &epochs,
1947 [3_512_900.0, 780_500.0, 5_248_700.0],
1948 &options,
1949 )
1950 .expect("valid PPP correction epoch must build");
1951
1952 assert_eq!(got.tide.len(), 1);
1953 }
1954
1955 #[test]
1956 fn build_rejects_degenerate_receiver_state_before_tide() {
1957 let sp3 = sp3_fixture();
1958 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1959 let options = PppCorrectionsOptions {
1960 solid_earth_tide: true,
1961 pole_tide: None,
1962 ocean_loading: None,
1963 phase_windup: false,
1964 satellite_antenna: None,
1965 code_bias: None,
1966 };
1967 let epochs = vec![PppCorrectionEpoch {
1968 epoch,
1969 t_rx_j2000_s: 0.0,
1970 observations: Vec::new(),
1971 }];
1972
1973 let err = build(&sp3, &epochs, [0.0, 0.0, 0.0], &options)
1974 .expect_err("degenerate tide geometry must error");
1975
1976 assert_eq!(
1977 err,
1978 PppCorrectionsError::InvalidInput {
1979 field: "receiver radius_m",
1980 reason: "not positive",
1981 }
1982 );
1983 }
1984}