1use crate::astro::bodies::{sun_moon_ecef, SunMoon};
9use crate::astro::math::vec3::{add3, cross3, dot3, neg3, norm3, scale3, sub3, unit3};
10use crate::astro::time::{CoverageError, TimeScaleInputErrorKind, TimeScales, ValidityMode};
11use crate::validate;
12use std::collections::BTreeMap;
13use std::f64::consts::PI;
14
15use crate::antenna;
16use crate::constants::{C_M_S, F_L1_HZ, OMEGA_E_DOT_RAD_S, RAD_TO_DEG};
17use crate::ephemeris::Sp3;
18use crate::observables::{
19 predict, ObservablesError, ObservablesInputErrorKind, PredictOptions, PredictedObservables,
20};
21use crate::tides::{ocean_tide_loading, solid_earth_pole_tide, solid_earth_tide, TideError};
22
23pub use crate::tides::{OceanLoadingBlq, NUM_OCEAN_CONSTITUENTS};
32use crate::tolerances::{FREQUENCY_DENOMINATOR_EPS_HZ, YAW_SINGULARITY_EPS_RAD};
33use crate::GnssSatelliteId;
34
35const TWO_PI: f64 = 2.0 * PI;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct CivilDateTime {
40 pub year: i32,
41 pub month: u8,
42 pub day: u8,
43 pub hour: u8,
44 pub minute: u8,
45 pub second: f64,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct PppCorrectionObservation {
51 pub sat: GnssSatelliteId,
52 pub freq1_hz: f64,
53 pub freq2_hz: f64,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct PppCorrectionEpoch {
59 pub epoch: CivilDateTime,
60 pub t_rx_j2000_s: f64,
61 pub observations: Vec<PppCorrectionObservation>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct SatelliteAntennaFrequency {
67 pub label: String,
68 pub pco_m: [f64; 3],
69 pub noazi_pcv_m: Vec<(f64, f64)>,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub struct SatelliteAntenna {
75 pub sat: GnssSatelliteId,
76 pub valid_from: Option<CivilDateTime>,
77 pub valid_until: Option<CivilDateTime>,
78 pub frequencies: Vec<SatelliteAntennaFrequency>,
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct SatelliteAntennaOptions {
84 pub freq1_label: String,
85 pub freq1_hz: f64,
86 pub freq2_label: String,
87 pub freq2_hz: f64,
88 pub antennas: Vec<SatelliteAntenna>,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct PoleTideOptions {
100 pub xp_arcsec: f64,
102 pub yp_arcsec: f64,
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub struct PppCorrectionsOptions {
109 pub solid_earth_tide: bool,
110 pub pole_tide: Option<PoleTideOptions>,
111 pub ocean_loading: Option<OceanLoadingBlq>,
116 pub phase_windup: bool,
117 pub satellite_antenna: Option<SatelliteAntennaOptions>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct EpochVectorCorrection {
123 pub epoch_index: usize,
124 pub vector_m: [f64; 3],
125}
126
127#[derive(Debug, Clone, PartialEq)]
129pub struct SatScalarCorrection {
130 pub sat: GnssSatelliteId,
131 pub epoch_index: usize,
132 pub value_m: f64,
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct SatVectorCorrection {
138 pub sat: GnssSatelliteId,
139 pub epoch_index: usize,
140 pub vector_m: [f64; 3],
141}
142
143#[derive(Debug, Clone, PartialEq, Default)]
145pub struct PppCorrections {
146 pub tide: Vec<EpochVectorCorrection>,
147 pub pole_tide: Vec<EpochVectorCorrection>,
148 pub ocean_loading: Vec<EpochVectorCorrection>,
149 pub windup_m: Vec<SatScalarCorrection>,
150 pub sat_pco_ecef: Vec<SatVectorCorrection>,
151 pub sat_pcv_m: Vec<SatScalarCorrection>,
152}
153
154#[derive(Debug, Clone, PartialEq, thiserror::Error)]
155pub enum PppCorrectionsError {
156 #[error("invalid PPP correction input {field}: {reason}")]
157 InvalidInput {
158 field: &'static str,
159 reason: &'static str,
160 },
161 #[error("invalid PPP correction epoch at epoch {epoch_index}: {source}")]
162 Epoch {
163 epoch_index: usize,
164 #[source]
165 source: CoverageError,
166 },
167 #[error("solid Earth tide correction failed at epoch {epoch_index}: {source}")]
168 Tide {
169 epoch_index: usize,
170 #[source]
171 source: TideError,
172 },
173 #[error("solid Earth pole tide correction failed at epoch {epoch_index}: {source}")]
174 PoleTide {
175 epoch_index: usize,
176 #[source]
177 source: TideError,
178 },
179 #[error("ocean tide loading correction failed at epoch {epoch_index}: {source}")]
180 OceanLoading {
181 epoch_index: usize,
182 #[source]
183 source: TideError,
184 },
185 #[error(
186 "invalid phase wind-up carrier frequencies at epoch {epoch_index} for {sat}: {field} {reason}"
187 )]
188 WindupFrequency {
189 epoch_index: usize,
190 sat: GnssSatelliteId,
191 field: &'static str,
192 reason: &'static str,
193 },
194 #[error("invalid satellite antenna carrier frequencies: {field} {reason}")]
195 SatelliteAntennaFrequency {
196 field: &'static str,
197 reason: &'static str,
198 },
199}
200
201pub fn build(
203 sp3: &Sp3,
204 epochs: &[PppCorrectionEpoch],
205 receiver_ecef_m: [f64; 3],
206 options: &PppCorrectionsOptions,
207) -> Result<PppCorrections, PppCorrectionsError> {
208 validate_receiver_state(receiver_ecef_m)?;
209
210 let mut corrections = PppCorrections::default();
211 if !options.solid_earth_tide
212 && options.pole_tide.is_none()
213 && options.ocean_loading.is_none()
214 && !options.phase_windup
215 && options.satellite_antenna.is_none()
216 {
217 return Ok(corrections);
218 }
219
220 let satellite_antenna_frequencies = options
221 .satellite_antenna
222 .as_ref()
223 .map(validate_satellite_antenna_options)
224 .transpose()?;
225
226 let mut previous_windup_cycles: BTreeMap<GnssSatelliteId, f64> = BTreeMap::new();
227
228 let need_sun_moon =
233 options.solid_earth_tide || options.phase_windup || options.satellite_antenna.is_some();
234 let need_obs_loop = options.phase_windup || options.satellite_antenna.is_some();
237
238 for (epoch_index, epoch_row) in epochs.iter().enumerate() {
239 let sun_moon = if need_sun_moon {
240 Some(
241 sun_moon_at(epoch_row.epoch).map_err(|source| PppCorrectionsError::Epoch {
242 epoch_index,
243 source,
244 })?,
245 )
246 } else {
247 None
248 };
249
250 if options.solid_earth_tide {
251 let sun_moon = sun_moon.expect("Sun/Moon computed when solid-earth tide is enabled");
252 let d = tide_at(
253 receiver_ecef_m,
254 epoch_row.epoch,
255 sun_moon.sun,
256 sun_moon.moon,
257 )
258 .map_err(|source| PppCorrectionsError::Tide {
259 epoch_index,
260 source,
261 })?;
262 corrections.tide.push(EpochVectorCorrection {
263 epoch_index,
264 vector_m: d,
265 });
266 }
267
268 if let Some(pole) = options.pole_tide {
269 let d = pole_tide_at(receiver_ecef_m, epoch_row.epoch, pole).map_err(|source| {
270 PppCorrectionsError::PoleTide {
271 epoch_index,
272 source,
273 }
274 })?;
275 corrections.pole_tide.push(EpochVectorCorrection {
276 epoch_index,
277 vector_m: d,
278 });
279 }
280
281 if let Some(blq) = options.ocean_loading.as_ref() {
282 let d = ocean_loading_at(receiver_ecef_m, epoch_row.epoch, blq).map_err(|source| {
283 PppCorrectionsError::OceanLoading {
284 epoch_index,
285 source,
286 }
287 })?;
288 corrections.ocean_loading.push(EpochVectorCorrection {
289 epoch_index,
290 vector_m: d,
291 });
292 }
293
294 if !need_obs_loop {
295 continue;
296 }
297 let sun_moon = sun_moon.expect("Sun/Moon computed when the observation loop runs");
298
299 for observation in &epoch_row.observations {
300 let obs = match predict(
301 sp3,
302 observation.sat,
303 receiver_ecef_m,
304 epoch_row.t_rx_j2000_s,
305 PredictOptions {
306 carrier_hz: F_L1_HZ,
307 light_time: true,
308 sagnac: true,
309 },
310 ) {
311 Ok(obs) => obs,
312 Err(ObservablesError::InvalidInput { field, kind }) => {
313 return Err(PppCorrectionsError::InvalidInput {
314 field,
315 reason: observables_input_reason(kind),
316 });
317 }
318 Err(ObservablesError::NoEphemeris | ObservablesError::Ephemeris(_)) => continue,
319 };
320
321 if options.phase_windup {
322 let prev = previous_windup_cycles.get(&observation.sat).copied();
323 if let Some(phw) = windup_cycles(&obs, receiver_ecef_m, sun_moon.sun, prev) {
324 let (f1, f2) = windup_frequency_pair(options, observation, epoch_index)?;
325 corrections.windup_m.push(SatScalarCorrection {
326 sat: observation.sat,
327 epoch_index,
328 value_m: windup_metres(phw, f1, f2),
329 });
330 previous_windup_cycles.insert(observation.sat, phw);
331 }
332 }
333
334 if let Some(sat_ant) = &options.satellite_antenna {
335 if let Some((pco_ecef, pcv_m)) = satellite_antenna_correction(
336 &obs,
337 sun_moon.sun,
338 observation.sat,
339 epoch_row.epoch,
340 sat_ant,
341 satellite_antenna_frequencies
342 .expect("satellite antenna frequencies are validated when enabled"),
343 ) {
344 corrections.sat_pco_ecef.push(SatVectorCorrection {
345 sat: observation.sat,
346 epoch_index,
347 vector_m: pco_ecef,
348 });
349 corrections.sat_pcv_m.push(SatScalarCorrection {
350 sat: observation.sat,
351 epoch_index,
352 value_m: pcv_m,
353 });
354 }
355 }
356 }
357 }
358
359 Ok(corrections)
360}
361
362fn validate_receiver_state(receiver_ecef_m: [f64; 3]) -> Result<(), PppCorrectionsError> {
363 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(ppp_invalid_input)?;
364 validate::finite_positive(norm3(receiver_ecef_m), "receiver radius_m")
365 .map_err(ppp_invalid_input)?;
366 Ok(())
367}
368
369fn ppp_invalid_input(error: validate::FieldError) -> PppCorrectionsError {
370 PppCorrectionsError::InvalidInput {
371 field: error.field(),
372 reason: error.reason(),
373 }
374}
375
376fn observables_input_reason(kind: ObservablesInputErrorKind) -> &'static str {
377 match kind {
378 ObservablesInputErrorKind::NonFinite => "not finite",
379 ObservablesInputErrorKind::NotPositive => "not positive",
380 ObservablesInputErrorKind::Negative => "negative",
381 ObservablesInputErrorKind::OutOfRange => "out of range",
382 ObservablesInputErrorKind::Missing => "missing",
383 ObservablesInputErrorKind::FloatParse => "invalid float",
384 ObservablesInputErrorKind::IntParse => "invalid integer",
385 ObservablesInputErrorKind::InvalidCivilDate => "invalid civil date",
386 ObservablesInputErrorKind::InvalidCivilTime => "invalid civil time",
387 }
388}
389
390fn windup_frequency_pair(
391 options: &PppCorrectionsOptions,
392 observation: &PppCorrectionObservation,
393 epoch_index: usize,
394) -> Result<(f64, f64), PppCorrectionsError> {
395 let (f1_hz, f2_hz) = options
396 .satellite_antenna
397 .as_ref()
398 .map(|a| (a.freq1_hz, a.freq2_hz))
399 .unwrap_or((observation.freq1_hz, observation.freq2_hz));
400 validate_frequency_pair(
401 f1_hz,
402 f2_hz,
403 FrequencyPairFields {
404 freq1: "phase wind-up freq1_hz",
405 freq2: "phase wind-up freq2_hz",
406 pair: "phase wind-up frequency pair",
407 },
408 |field, reason| PppCorrectionsError::WindupFrequency {
409 epoch_index,
410 sat: observation.sat,
411 field,
412 reason,
413 },
414 )
415}
416
417fn validate_satellite_antenna_frequency_pair(
418 options: &SatelliteAntennaOptions,
419) -> Result<(f64, f64), PppCorrectionsError> {
420 validate_frequency_pair(
421 options.freq1_hz,
422 options.freq2_hz,
423 FrequencyPairFields {
424 freq1: "satellite antenna freq1_hz",
425 freq2: "satellite antenna freq2_hz",
426 pair: "satellite antenna frequency pair",
427 },
428 |field, reason| PppCorrectionsError::SatelliteAntennaFrequency { field, reason },
429 )
430}
431
432fn validate_satellite_antenna_options(
433 options: &SatelliteAntennaOptions,
434) -> Result<(f64, f64), PppCorrectionsError> {
435 let frequencies_hz = validate_satellite_antenna_frequency_pair(options)?;
436 validate_satellite_antenna_pcv_samples(options)?;
437 Ok(frequencies_hz)
438}
439
440fn validate_satellite_antenna_pcv_samples(
441 options: &SatelliteAntennaOptions,
442) -> Result<(), PppCorrectionsError> {
443 for antenna in &options.antennas {
444 for frequency in &antenna.frequencies {
445 for &(nadir_deg, pcv_m) in &frequency.noazi_pcv_m {
446 validate::finite(nadir_deg, "satellite antenna noazi_pcv_m")
447 .map_err(ppp_invalid_input)?;
448 validate::finite(pcv_m, "satellite antenna noazi_pcv_m")
449 .map_err(ppp_invalid_input)?;
450 }
451 }
452 }
453 Ok(())
454}
455
456#[derive(Debug, Clone, Copy)]
457struct FrequencyPairFields {
458 freq1: &'static str,
459 freq2: &'static str,
460 pair: &'static str,
461}
462
463fn validate_frequency_pair(
464 f1_hz: f64,
465 f2_hz: f64,
466 fields: FrequencyPairFields,
467 invalid: impl Fn(&'static str, &'static str) -> PppCorrectionsError,
468) -> Result<(f64, f64), PppCorrectionsError> {
469 let f1_hz = validate::finite_positive(f1_hz, fields.freq1)
470 .map_err(|e| invalid(e.field(), e.reason()))?;
471 let f2_hz = validate::finite_positive(f2_hz, fields.freq2)
472 .map_err(|e| invalid(e.field(), e.reason()))?;
473 if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
474 Err(invalid(fields.pair, "must differ"))
475 } else {
476 Ok((f1_hz, f2_hz))
477 }
478}
479
480fn sun_moon_at(epoch: CivilDateTime) -> Result<SunMoon, CoverageError> {
481 let ts = time_scales_at(epoch)?;
482 Ok(sun_moon_ecef(&ts).expect("validated time scales produce Sun/Moon vectors"))
483}
484
485fn time_scales_at(epoch: CivilDateTime) -> Result<TimeScales, CoverageError> {
486 let civil = validate::civil_datetime_with_second_policy(
487 i64::from(epoch.year),
488 i64::from(epoch.month),
489 i64::from(epoch.day),
490 i64::from(epoch.hour),
491 i64::from(epoch.minute),
492 epoch.second,
493 validate::CivilSecondPolicy::UtcLike,
494 )
495 .map_err(|error| CoverageError::InvalidInput {
496 field: error.field(),
497 kind: TimeScaleInputErrorKind::from(&error),
498 })?;
499
500 TimeScales::from_utc_validated(
501 civil.year as i32,
502 civil.month as i32,
503 civil.day as i32,
504 civil.hour as i32,
505 civil.minute as i32,
506 civil.second,
507 ValidityMode::Strict,
508 )
509 .map(|validated| validated.value)
510}
511
512fn tide_at(
513 receiver_ecef_m: [f64; 3],
514 epoch: CivilDateTime,
515 sun_ecef_m: [f64; 3],
516 moon_ecef_m: [f64; 3],
517) -> Result<[f64; 3], TideError> {
518 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / 3600.0;
519 solid_earth_tide(
520 &receiver_ecef_m,
521 epoch.year,
522 epoch.month as i32,
523 epoch.day as i32,
524 fhr,
525 &sun_ecef_m,
526 &moon_ecef_m,
527 )
528}
529
530fn pole_tide_at(
531 receiver_ecef_m: [f64; 3],
532 epoch: CivilDateTime,
533 pole: PoleTideOptions,
534) -> Result<[f64; 3], TideError> {
535 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / 3600.0;
536 solid_earth_pole_tide(
537 &receiver_ecef_m,
538 epoch.year,
539 epoch.month as i32,
540 epoch.day as i32,
541 fhr,
542 pole.xp_arcsec,
543 pole.yp_arcsec,
544 )
545}
546
547fn ocean_loading_at(
548 receiver_ecef_m: [f64; 3],
549 epoch: CivilDateTime,
550 blq: &OceanLoadingBlq,
551) -> Result<[f64; 3], TideError> {
552 let fhr = epoch.hour as f64 + epoch.minute as f64 / 60.0 + epoch.second / 3600.0;
553 ocean_tide_loading(
554 &receiver_ecef_m,
555 epoch.year,
556 epoch.month as i32,
557 epoch.day as i32,
558 fhr,
559 blq,
560 )
561}
562
563fn windup_metres(phw_cycles: f64, f1_hz: f64, f2_hz: f64) -> f64 {
564 let lam1 = C_M_S / f1_hz;
565 let lam2 = C_M_S / f2_hz;
566 let gamma = ionosphere_free_gamma(f1_hz, f2_hz);
567 (gamma * lam1 - (gamma - 1.0) * lam2) * phw_cycles
568}
569
570fn windup_cycles(
571 pred: &PredictedObservables,
572 receiver_ecef_m: [f64; 3],
573 sun_ecef_m: [f64; 3],
574 prev_phw: Option<f64>,
575) -> Option<f64> {
576 let rs = pred.sat_pos_ecef_m;
577 let vs = pred.sat_velocity_m_s;
578 let (exs, eys) = sat_yaw(rs, vs, sun_ecef_m)?;
579 let ek = unit3(sub3(receiver_ecef_m, rs))?;
580
581 let (n, e, _u) = crate::estimation::substrate::frames::local_neu_basis(
582 crate::estimation::recipe::FrameRecipe::GeodeticNeuCrossProduct,
583 receiver_ecef_m,
584 );
585 let exr = n;
586 let eyr = neg3(e);
587
588 let eks = cross3(ek, eys);
589 let ekr = cross3(ek, eyr);
590 let ds = sub3(exs, add3(scale3(ek, dot3(ek, exs)), eks));
591 let dr = sub3(exr, sub3(scale3(ek, dot3(ek, exr)), ekr));
592
593 let nds = norm3(ds);
594 let ndr = norm3(dr);
595 if nds == 0.0 || ndr == 0.0 {
596 return None;
597 }
598
599 let cosp = clamp(dot3(ds, dr) / nds / ndr);
600 let mut ph = cosp.acos() / TWO_PI;
601 let drs = cross3(ds, dr);
602 if dot3(ek, drs) < 0.0 {
603 ph = -ph;
604 }
605
606 Some(match prev_phw {
607 None => ph,
608 Some(prev) => ph + (prev - ph + 0.5).floor(),
609 })
610}
611
612fn sat_yaw(rs: [f64; 3], vs: [f64; 3], sun_ecef_m: [f64; 3]) -> Option<([f64; 3], [f64; 3])> {
613 let ri_v = [
614 vs[0] - OMEGA_E_DOT_RAD_S * rs[1],
615 vs[1] + OMEGA_E_DOT_RAD_S * rs[0],
616 vs[2],
617 ];
618 let n = cross3(rs, ri_v);
619 let p = cross3(sun_ecef_m, n);
620
621 let es = unit3(rs)?;
622 let esun = unit3(sun_ecef_m)?;
623 let en = unit3(n)?;
624 let ep = unit3(p)?;
625
626 let beta = PI / 2.0 - clamp(dot3(esun, en)).acos();
627 let ee = clamp(dot3(es, ep)).acos();
628 let mut mu = PI / 2.0 + if dot3(es, esun) <= 0.0 { -ee } else { ee };
629
630 if mu < -PI / 2.0 {
631 mu += TWO_PI;
632 } else if mu >= PI / 2.0 {
633 mu -= TWO_PI;
634 }
635
636 let yaw = yaw_nominal(beta, mu);
637 let ex = cross3(en, es);
638 let cosy = yaw.cos();
639 let siny = yaw.sin();
640 let exs = add3(scale3(en, -siny), scale3(ex, cosy));
641 let eys = add3(scale3(en, -cosy), scale3(ex, -siny));
642 Some((exs, eys))
643}
644
645fn yaw_nominal(beta: f64, mu: f64) -> f64 {
646 if beta.abs() < YAW_SINGULARITY_EPS_RAD && mu.abs() < YAW_SINGULARITY_EPS_RAD {
647 PI
648 } else {
649 (-beta.tan()).atan2(mu.sin()) + PI
650 }
651}
652
653fn satellite_antenna_correction(
654 pred: &PredictedObservables,
655 sun_ecef_m: [f64; 3],
656 sat: GnssSatelliteId,
657 epoch: CivilDateTime,
658 options: &SatelliteAntennaOptions,
659 frequencies_hz: (f64, f64),
660) -> Option<([f64; 3], f64)> {
661 let rs = pred.sat_pos_ecef_m;
662 let ant = options.antenna_for(sat, epoch)?;
663
664 let ez = unit3(neg3(rs))?;
665 let es = unit3(sub3(sun_ecef_m, rs))?;
666 let ey = unit3(cross3(ez, es))?;
667 let ex = cross3(ey, ez);
668
669 let off1 = ant.pco(&options.freq1_label)?;
670 let off2 = ant.pco(&options.freq2_label)?;
671 let gamma = ionosphere_free_gamma(frequencies_hz.0, frequencies_hz.1);
672
673 let dant1 = body_to_ecef(off1, ex, ey, ez);
674 let dant2 = body_to_ecef(off2, ex, ey, ez);
675 let dant_ecef = sub3(scale3(dant1, gamma), scale3(dant2, gamma - 1.0));
676 let pcv_m = nadir_pcv_if(ant, pred, options, gamma)?;
677
678 Some((dant_ecef, pcv_m))
679}
680
681fn body_to_ecef(pco_body_m: [f64; 3], ex: [f64; 3], ey: [f64; 3], ez: [f64; 3]) -> [f64; 3] {
682 add3(
683 add3(scale3(ex, pco_body_m[0]), scale3(ey, pco_body_m[1])),
684 scale3(ez, pco_body_m[2]),
685 )
686}
687
688fn ionosphere_free_gamma(f1_hz: f64, f2_hz: f64) -> f64 {
689 let f1_sq = f1_hz * f1_hz;
690 f1_sq / (f1_sq - f2_hz * f2_hz)
691}
692
693fn nadir_pcv_if(
694 ant: &SatelliteAntenna,
695 pred: &PredictedObservables,
696 options: &SatelliteAntennaOptions,
697 gamma: f64,
698) -> Option<f64> {
699 let eu = unit3(neg3(pred.los_unit))?;
700 let ez = unit3(neg3(pred.sat_pos_ecef_m))?;
701 let nadir_deg = clamp(dot3(eu, ez)).acos() * RAD_TO_DEG;
702 let p1 = ant.pcv_noazi(&options.freq1_label, nadir_deg)?;
703 let p2 = ant.pcv_noazi(&options.freq2_label, nadir_deg)?;
704 Some(gamma * p1 - (gamma - 1.0) * p2)
705}
706
707impl SatelliteAntennaOptions {
708 fn antenna_for(&self, sat: GnssSatelliteId, epoch: CivilDateTime) -> Option<&SatelliteAntenna> {
709 self.antennas
710 .iter()
711 .find(|ant| ant.sat == sat && ant.valid_at(epoch))
712 }
713}
714
715impl SatelliteAntenna {
716 fn valid_at(&self, epoch: CivilDateTime) -> bool {
717 let after_from = self
718 .valid_from
719 .is_none_or(|from| civil_cmp(epoch, from) != std::cmp::Ordering::Less);
720 let before_until = self
721 .valid_until
722 .is_none_or(|until| civil_cmp(epoch, until) != std::cmp::Ordering::Greater);
723 after_from && before_until
724 }
725
726 fn frequency(&self, label: &str) -> Option<&SatelliteAntennaFrequency> {
727 self.frequencies
728 .iter()
729 .find(|f| f.label.trim() == label.trim())
730 }
731
732 fn pco(&self, label: &str) -> Option<[f64; 3]> {
733 self.frequency(label).map(|f| f.pco_m)
734 }
735
736 fn pcv_noazi(&self, label: &str, zenith_deg: f64) -> Option<f64> {
737 let frequency = self.frequency(label)?;
738 interpolate_samples(&frequency.noazi_pcv_m, zenith_deg)
739 }
740}
741
742fn civil_cmp(a: CivilDateTime, b: CivilDateTime) -> std::cmp::Ordering {
743 (
744 a.year,
745 a.month,
746 a.day,
747 a.hour,
748 a.minute,
749 ordered_seconds(a.second),
750 )
751 .cmp(&(
752 b.year,
753 b.month,
754 b.day,
755 b.hour,
756 b.minute,
757 ordered_seconds(b.second),
758 ))
759}
760
761fn ordered_seconds(second: f64) -> i64 {
762 (second * 1_000_000.0).round() as i64
763}
764
765fn interpolate_samples(samples: &[(f64, f64)], zenith_deg: f64) -> Option<f64> {
766 let mut sorted = samples.to_vec();
767 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
768 antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
769}
770
771fn clamp(x: f64) -> f64 {
772 x.clamp(-1.0, 1.0)
773}
774
775#[cfg(all(test, sidereon_repo_tests))]
776mod tests {
777 use super::*;
778 use crate::astro::time::split_julian_date;
779 use crate::constants::F_L2_HZ;
780 use crate::observables::j2000_seconds_from_split;
781 use crate::GnssSystem;
782
783 fn sp3_fixture() -> Sp3 {
784 let path = concat!(
785 env!("CARGO_MANIFEST_DIR"),
786 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
787 );
788 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
789 Sp3::parse(&bytes).expect("parse SP3 fixture")
790 }
791
792 fn civil(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> CivilDateTime {
793 CivilDateTime {
794 year,
795 month,
796 day,
797 hour,
798 minute,
799 second,
800 }
801 }
802
803 fn split_jd(epoch: CivilDateTime) -> (f64, f64) {
804 split_julian_date(
805 epoch.year,
806 i32::from(epoch.month),
807 i32::from(epoch.day),
808 i32::from(epoch.hour),
809 i32::from(epoch.minute),
810 epoch.second,
811 )
812 }
813
814 fn fake_antenna_options(sat: GnssSatelliteId) -> SatelliteAntennaOptions {
815 SatelliteAntennaOptions {
816 freq1_label: "G01".to_string(),
817 freq1_hz: F_L1_HZ,
818 freq2_label: "G02".to_string(),
819 freq2_hz: F_L2_HZ,
820 antennas: vec![SatelliteAntenna {
821 sat,
822 valid_from: Some(civil(2020, 1, 1, 0, 0, 0.0)),
823 valid_until: Some(civil(2021, 1, 1, 0, 0, 0.0)),
824 frequencies: vec![
825 SatelliteAntennaFrequency {
826 label: "G01".to_string(),
827 pco_m: [0.1, -0.2, 1.0],
828 noazi_pcv_m: vec![(0.0, 0.001), (5.0, 0.002), (10.0, 0.004)],
829 },
830 SatelliteAntennaFrequency {
831 label: "G02".to_string(),
832 pco_m: [-0.1, 0.3, 0.5],
833 noazi_pcv_m: vec![(0.0, -0.001), (5.0, -0.002), (10.0, -0.003)],
834 },
835 ],
836 }],
837 }
838 }
839
840 fn windup_epoch(sat: GnssSatelliteId, freq1_hz: f64, freq2_hz: f64) -> PppCorrectionEpoch {
841 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
842 let (jd_whole, jd_fraction) = split_jd(epoch);
843 PppCorrectionEpoch {
844 epoch,
845 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
846 .expect("valid split Julian date"),
847 observations: vec![PppCorrectionObservation {
848 sat,
849 freq1_hz,
850 freq2_hz,
851 }],
852 }
853 }
854
855 #[test]
856 fn ppp_corrections_match_elixir_reference_fixture() {
857 let sp3 = sp3_fixture();
858 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
859 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
860 let (jd_whole, jd_fraction) = split_jd(epoch);
861 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
862 let epochs = vec![PppCorrectionEpoch {
863 epoch,
864 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
865 .expect("valid split Julian date"),
866 observations: vec![PppCorrectionObservation {
867 sat,
868 freq1_hz: F_L1_HZ,
869 freq2_hz: F_L2_HZ,
870 }],
871 }];
872 let options = PppCorrectionsOptions {
873 solid_earth_tide: true,
874 pole_tide: None,
875 ocean_loading: None,
876 phase_windup: true,
877 satellite_antenna: Some(fake_antenna_options(sat)),
878 };
879
880 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
881
882 assert_eq!(got.tide.len(), 1);
883 assert_eq!(
884 got.tide[0].vector_m.map(f64::to_bits),
885 [0x3FB8BC98E788ED00, 0x3FAA54D8C1097508, 0x3FB03498C46B3B50]
886 );
887 assert_eq!(got.windup_m.len(), 1);
888 assert_eq!(got.windup_m[0].value_m.to_bits(), 0xBF808DE79DBD2C16);
889 assert_eq!(got.sat_pco_ecef.len(), 1);
890 assert_eq!(
891 got.sat_pco_ecef[0].vector_m.map(f64::to_bits),
892 [0xBFE58ED947570048, 0x3FDEDBB280CEB1BE, 0xBFFE3BCA6A354E4A]
893 );
894 assert_eq!(got.sat_pcv_m.len(), 1);
895 assert_eq!(got.sat_pcv_m[0].value_m.to_bits(), 0x3F77617E95BD232C);
896 }
897
898 #[test]
899 fn pole_tide_correction_is_emitted_and_matches_standalone() {
900 let sp3 = sp3_fixture();
901 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
902 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
903 let (jd_whole, jd_fraction) = split_jd(epoch);
904 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
905 let epochs = vec![PppCorrectionEpoch {
906 epoch,
907 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
908 .expect("valid split Julian date"),
909 observations: vec![PppCorrectionObservation {
910 sat,
911 freq1_hz: F_L1_HZ,
912 freq2_hz: F_L2_HZ,
913 }],
914 }];
915 let pole = PoleTideOptions {
916 xp_arcsec: 0.169_051,
917 yp_arcsec: 0.411_760,
918 };
919 let options = PppCorrectionsOptions {
920 solid_earth_tide: false,
921 pole_tide: Some(pole),
922 ocean_loading: None,
923 phase_windup: false,
924 satellite_antenna: None,
925 };
926
927 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
928
929 assert_eq!(got.pole_tide.len(), 1);
930 assert_eq!(got.pole_tide[0].epoch_index, 0);
931 let expected = crate::tides::solid_earth_pole_tide(
932 &receiver,
933 2020,
934 6,
935 24,
936 12.0,
937 pole.xp_arcsec,
938 pole.yp_arcsec,
939 )
940 .expect("valid pole tide");
941 assert_eq!(got.pole_tide[0].vector_m, expected);
942 assert!(got.tide.is_empty());
944 }
945
946 fn zim2_blq() -> OceanLoadingBlq {
950 OceanLoadingBlq {
951 amplitude_m: [
952 [
953 0.00693, 0.00228, 0.00148, 0.00061, 0.00220, 0.00094, 0.00070, 0.00001,
954 0.00047, 0.00025, 0.00019,
955 ],
956 [
957 0.00272, 0.00076, 0.00061, 0.00020, 0.00036, 0.00025, 0.00011, 0.00005,
958 0.00004, 0.00001, 0.00002,
959 ],
960 [
961 0.00061, 0.00026, 0.00010, 0.00009, 0.00025, 0.00002, 0.00008, 0.00003,
962 0.00002, 0.00000, 0.00001,
963 ],
964 ],
965 phase_deg: [
966 [
967 -72.3, -44.2, -90.8, -44.1, -62.9, -94.5, -64.3, 171.0, 3.4, 3.6, 1.1,
968 ],
969 [
970 84.3, 115.4, 63.3, 113.7, 98.6, 20.7, 94.2, -44.5, -170.0, -162.7, -177.8,
971 ],
972 [
973 -29.3, 1.7, -44.0, -4.2, 44.2, -39.1, 43.7, 170.1, -93.3, -118.3, -176.4,
974 ],
975 ],
976 }
977 }
978
979 #[test]
980 fn ocean_loading_correction_is_emitted_and_matches_standalone() {
981 let sp3 = sp3_fixture();
982 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
983 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
984 let (jd_whole, jd_fraction) = split_jd(epoch);
985 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
986 let epochs = vec![PppCorrectionEpoch {
987 epoch,
988 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
989 .expect("valid split Julian date"),
990 observations: vec![PppCorrectionObservation {
991 sat,
992 freq1_hz: F_L1_HZ,
993 freq2_hz: F_L2_HZ,
994 }],
995 }];
996 let blq = zim2_blq();
997 let options = PppCorrectionsOptions {
998 solid_earth_tide: false,
999 pole_tide: None,
1000 ocean_loading: Some(blq),
1001 phase_windup: false,
1002 satellite_antenna: None,
1003 };
1004
1005 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1006
1007 assert_eq!(got.ocean_loading.len(), 1);
1008 assert_eq!(got.ocean_loading[0].epoch_index, 0);
1009 let expected = crate::tides::ocean_tide_loading(&receiver, 2020, 6, 24, 12.0, &blq)
1010 .expect("valid ocean loading");
1011 assert_eq!(got.ocean_loading[0].vector_m, expected);
1012 assert!(got.tide.is_empty());
1014 assert!(got.pole_tide.is_empty());
1015 }
1016
1017 #[test]
1018 fn pole_or_ocean_only_skips_sun_moon_and_prediction() {
1019 let sp3 = sp3_fixture();
1020 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1021 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1022
1023 let epochs = vec![PppCorrectionEpoch {
1031 epoch: civil(2100, 1, 1, 12, 0, 0.0),
1032 t_rx_j2000_s: f64::NAN,
1033 observations: vec![PppCorrectionObservation {
1034 sat,
1035 freq1_hz: F_L1_HZ,
1036 freq2_hz: F_L2_HZ,
1037 }],
1038 }];
1039
1040 let pole = PoleTideOptions {
1042 xp_arcsec: 0.169_051,
1043 yp_arcsec: 0.411_760,
1044 };
1045 let got = build(
1046 &sp3,
1047 &epochs,
1048 receiver,
1049 &PppCorrectionsOptions {
1050 solid_earth_tide: false,
1051 pole_tide: Some(pole),
1052 ocean_loading: None,
1053 phase_windup: false,
1054 satellite_antenna: None,
1055 },
1056 )
1057 .expect("pole-only build must not touch the Sun/Moon or predict paths");
1058 assert_eq!(got.pole_tide.len(), 1);
1059 assert!(got.tide.is_empty());
1060 assert!(got.ocean_loading.is_empty());
1061 assert!(got.windup_m.is_empty());
1062 assert!(got.sat_pco_ecef.is_empty());
1063 assert!(got.sat_pcv_m.is_empty());
1064
1065 let blq = zim2_blq();
1067 let got = build(
1068 &sp3,
1069 &epochs,
1070 receiver,
1071 &PppCorrectionsOptions {
1072 solid_earth_tide: false,
1073 pole_tide: None,
1074 ocean_loading: Some(blq),
1075 phase_windup: false,
1076 satellite_antenna: None,
1077 },
1078 )
1079 .expect("ocean-only build must not touch the Sun/Moon or predict paths");
1080 assert_eq!(got.ocean_loading.len(), 1);
1081 assert!(got.tide.is_empty());
1082 assert!(got.pole_tide.is_empty());
1083 assert!(got.windup_m.is_empty());
1084 assert!(got.sat_pco_ecef.is_empty());
1085 assert!(got.sat_pcv_m.is_empty());
1086 }
1087
1088 #[test]
1089 fn phase_windup_rejects_invalid_observation_frequency_pairs() {
1090 let sp3 = sp3_fixture();
1091 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1092 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1093 let options = PppCorrectionsOptions {
1094 solid_earth_tide: false,
1095 pole_tide: None,
1096 ocean_loading: None,
1097 phase_windup: true,
1098 satellite_antenna: None,
1099 };
1100 let cases = [
1101 (0.0, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1102 (-F_L1_HZ, F_L2_HZ, "phase wind-up freq1_hz", "not positive"),
1103 (
1104 F_L1_HZ,
1105 F_L1_HZ,
1106 "phase wind-up frequency pair",
1107 "must differ",
1108 ),
1109 ];
1110
1111 for (freq1_hz, freq2_hz, field, reason) in cases {
1112 let epochs = vec![windup_epoch(sat, freq1_hz, freq2_hz)];
1113 let err = build(&sp3, &epochs, receiver, &options)
1114 .expect_err("invalid phase wind-up frequencies must error");
1115
1116 assert_eq!(
1117 err,
1118 PppCorrectionsError::WindupFrequency {
1119 epoch_index: 0,
1120 sat,
1121 field,
1122 reason,
1123 }
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn phase_windup_observation_frequency_pair_computes_finite_correction() {
1130 let sp3 = sp3_fixture();
1131 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1132 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1133 let options = PppCorrectionsOptions {
1134 solid_earth_tide: false,
1135 pole_tide: None,
1136 ocean_loading: None,
1137 phase_windup: true,
1138 satellite_antenna: None,
1139 };
1140 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1141
1142 let got =
1143 build(&sp3, &epochs, receiver, &options).expect("valid phase wind-up frequencies");
1144
1145 assert_eq!(got.windup_m.len(), 1);
1146 assert!(got.windup_m[0].value_m.is_finite());
1147 }
1148
1149 #[test]
1150 fn satellite_antenna_rejects_invalid_frequency_pairs_without_windup() {
1151 let sp3 = sp3_fixture();
1152 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1153 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1154 let cases = [
1155 (0.0, F_L2_HZ, "satellite antenna freq1_hz", "not positive"),
1156 (
1157 F_L1_HZ,
1158 f64::INFINITY,
1159 "satellite antenna freq2_hz",
1160 "not finite",
1161 ),
1162 (
1163 f64::NAN,
1164 F_L2_HZ,
1165 "satellite antenna freq1_hz",
1166 "not finite",
1167 ),
1168 (
1169 F_L1_HZ,
1170 F_L1_HZ,
1171 "satellite antenna frequency pair",
1172 "must differ",
1173 ),
1174 ];
1175
1176 for (freq1_hz, freq2_hz, field, reason) in cases {
1177 let mut antenna = fake_antenna_options(sat);
1178 antenna.freq1_hz = freq1_hz;
1179 antenna.freq2_hz = freq2_hz;
1180 let options = PppCorrectionsOptions {
1181 solid_earth_tide: false,
1182 pole_tide: None,
1183 ocean_loading: None,
1184 phase_windup: false,
1185 satellite_antenna: Some(antenna),
1186 };
1187 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1188
1189 let err = build(&sp3, &epochs, receiver, &options)
1190 .expect_err("invalid satellite antenna frequencies must error");
1191
1192 assert_eq!(
1193 err,
1194 PppCorrectionsError::SatelliteAntennaFrequency { field, reason }
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn satellite_antenna_frequency_pair_computes_finite_corrections_without_windup() {
1201 let sp3 = sp3_fixture();
1202 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1203 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1204 let options = PppCorrectionsOptions {
1205 solid_earth_tide: false,
1206 pole_tide: None,
1207 ocean_loading: None,
1208 phase_windup: false,
1209 satellite_antenna: Some(fake_antenna_options(sat)),
1210 };
1211 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1212
1213 let got =
1214 build(&sp3, &epochs, receiver, &options).expect("valid satellite antenna frequencies");
1215
1216 assert!(got.windup_m.is_empty());
1217 assert_eq!(got.sat_pco_ecef.len(), 1);
1218 assert!(got.sat_pco_ecef[0]
1219 .vector_m
1220 .iter()
1221 .all(|value| value.is_finite()));
1222 assert_eq!(got.sat_pcv_m.len(), 1);
1223 assert!(got.sat_pcv_m[0].value_m.is_finite());
1224 }
1225
1226 #[test]
1227 fn satellite_antenna_rejects_non_finite_pcv_samples() {
1228 let sp3 = sp3_fixture();
1229 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1230 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1231 let mut antenna = fake_antenna_options(sat);
1232 antenna.antennas[0].frequencies[0].noazi_pcv_m[1] = (5.0, f64::NAN);
1233 let options = PppCorrectionsOptions {
1234 solid_earth_tide: false,
1235 pole_tide: None,
1236 ocean_loading: None,
1237 phase_windup: false,
1238 satellite_antenna: Some(antenna),
1239 };
1240 let epochs = vec![windup_epoch(sat, F_L1_HZ, F_L2_HZ)];
1241
1242 let err = build(&sp3, &epochs, receiver, &options)
1243 .expect_err("non-finite satellite PCV samples must error");
1244
1245 assert_eq!(
1246 err,
1247 PppCorrectionsError::InvalidInput {
1248 field: "satellite antenna noazi_pcv_m",
1249 reason: "not finite",
1250 }
1251 );
1252 }
1253
1254 #[test]
1255 fn satellite_antenna_empty_pcv_grid_is_not_materialized_as_zero() {
1256 let sp3 = sp3_fixture();
1257 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1258 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1259 let (jd_whole, jd_fraction) = split_jd(epoch);
1260 let receiver = [3_512_900.0, 780_500.0, 5_248_700.0];
1261 let epochs = vec![PppCorrectionEpoch {
1262 epoch,
1263 t_rx_j2000_s: j2000_seconds_from_split(jd_whole, jd_fraction)
1264 .expect("valid split Julian date"),
1265 observations: vec![PppCorrectionObservation {
1266 sat,
1267 freq1_hz: F_L1_HZ,
1268 freq2_hz: F_L2_HZ,
1269 }],
1270 }];
1271 let mut antenna = fake_antenna_options(sat);
1272 antenna.antennas[0].frequencies[0].noazi_pcv_m.clear();
1273 let options = PppCorrectionsOptions {
1274 solid_earth_tide: false,
1275 pole_tide: None,
1276 ocean_loading: None,
1277 phase_windup: false,
1278 satellite_antenna: Some(antenna),
1279 };
1280
1281 let got = build(&sp3, &epochs, receiver, &options).expect("valid PPP corrections");
1282
1283 assert!(got.sat_pco_ecef.is_empty());
1284 assert!(got.sat_pcv_m.is_empty());
1285 }
1286
1287 #[test]
1288 fn build_rejects_non_finite_receive_time_for_satellite_corrections() {
1289 let sp3 = sp3_fixture();
1290 let sat = GnssSatelliteId::new(GnssSystem::Gps, 21).expect("valid satellite id");
1291 let options = PppCorrectionsOptions {
1292 solid_earth_tide: false,
1293 pole_tide: None,
1294 ocean_loading: None,
1295 phase_windup: true,
1296 satellite_antenna: None,
1297 };
1298 let epochs = vec![PppCorrectionEpoch {
1299 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1300 t_rx_j2000_s: f64::NAN,
1301 observations: vec![PppCorrectionObservation {
1302 sat,
1303 freq1_hz: F_L1_HZ,
1304 freq2_hz: F_L2_HZ,
1305 }],
1306 }];
1307
1308 let err = build(
1309 &sp3,
1310 &epochs,
1311 [3_512_900.0, 780_500.0, 5_248_700.0],
1312 &options,
1313 )
1314 .expect_err("non-finite receive time must be reported");
1315
1316 assert_eq!(
1317 err,
1318 PppCorrectionsError::InvalidInput {
1319 field: "t_rx_j2000_s",
1320 reason: "not finite",
1321 }
1322 );
1323 }
1324
1325 #[test]
1326 fn noazi_pcv_interpolation_clamps_and_interpolates() {
1327 let samples = vec![(10.0, 4.0), (0.0, 1.0), (5.0, 2.0)];
1328
1329 assert_eq!(interpolate_samples(&samples, -1.0), Some(1.0));
1330 assert_eq!(interpolate_samples(&samples, 2.5), Some(1.5));
1331 assert_eq!(interpolate_samples(&samples, 99.0), Some(4.0));
1332 }
1333
1334 #[test]
1335 fn build_rejects_invalid_receiver_state_before_disabled_short_circuit() {
1336 let sp3 = sp3_fixture();
1337 let options = PppCorrectionsOptions {
1338 solid_earth_tide: false,
1339 pole_tide: None,
1340 ocean_loading: None,
1341 phase_windup: false,
1342 satellite_antenna: None,
1343 };
1344
1345 for (receiver, field, reason) in [
1346 (
1347 [f64::NAN, 780_500.0, 5_248_700.0],
1348 "receiver_ecef_m",
1349 "not finite",
1350 ),
1351 ([0.0, 0.0, 0.0], "receiver radius_m", "not positive"),
1352 ] {
1353 let err = build(&sp3, &[], receiver, &options)
1354 .expect_err("invalid receiver state must error before empty success");
1355
1356 assert_eq!(err, PppCorrectionsError::InvalidInput { field, reason });
1357 }
1358 }
1359
1360 #[test]
1361 fn build_rejects_invalid_correction_epoch_without_panicking() {
1362 let sp3 = sp3_fixture();
1363 let options = PppCorrectionsOptions {
1364 solid_earth_tide: true,
1365 pole_tide: None,
1366 ocean_loading: None,
1367 phase_windup: false,
1368 satellite_antenna: None,
1369 };
1370 let epochs = vec![PppCorrectionEpoch {
1371 epoch: civil(2021, 2, 29, 12, 0, 0.0),
1372 t_rx_j2000_s: 0.0,
1373 observations: Vec::new(),
1374 }];
1375
1376 let err = build(
1377 &sp3,
1378 &epochs,
1379 [3_512_900.0, 780_500.0, 5_248_700.0],
1380 &options,
1381 )
1382 .expect_err("invalid PPP correction epoch must return an error");
1383
1384 assert_eq!(
1385 err,
1386 PppCorrectionsError::Epoch {
1387 epoch_index: 0,
1388 source: CoverageError::InvalidInput {
1389 field: "civil datetime",
1390 kind: TimeScaleInputErrorKind::InvalidCivilDate,
1391 },
1392 }
1393 );
1394 }
1395
1396 #[test]
1397 fn build_rejects_non_finite_correction_epoch_without_panicking() {
1398 let sp3 = sp3_fixture();
1399 let options = PppCorrectionsOptions {
1400 solid_earth_tide: true,
1401 pole_tide: None,
1402 ocean_loading: None,
1403 phase_windup: false,
1404 satellite_antenna: None,
1405 };
1406 let epochs = vec![PppCorrectionEpoch {
1407 epoch: civil(2020, 6, 24, 12, 0, f64::NAN),
1408 t_rx_j2000_s: 0.0,
1409 observations: Vec::new(),
1410 }];
1411
1412 let err = build(
1413 &sp3,
1414 &epochs,
1415 [3_512_900.0, 780_500.0, 5_248_700.0],
1416 &options,
1417 )
1418 .expect_err("non-finite PPP correction epoch must return an error");
1419
1420 assert_eq!(
1421 err,
1422 PppCorrectionsError::Epoch {
1423 epoch_index: 0,
1424 source: CoverageError::InvalidInput {
1425 field: "civil datetime",
1426 kind: TimeScaleInputErrorKind::NonFinite,
1427 },
1428 }
1429 );
1430 }
1431
1432 #[test]
1433 fn build_rejects_correction_epoch_after_eop_coverage() {
1434 let sp3 = sp3_fixture();
1435 let options = PppCorrectionsOptions {
1436 solid_earth_tide: true,
1437 pole_tide: None,
1438 ocean_loading: None,
1439 phase_windup: false,
1440 satellite_antenna: None,
1441 };
1442 let epochs = vec![PppCorrectionEpoch {
1443 epoch: civil(2100, 1, 1, 0, 0, 0.0),
1444 t_rx_j2000_s: 0.0,
1445 observations: Vec::new(),
1446 }];
1447
1448 let err = build(
1449 &sp3,
1450 &epochs,
1451 [3_512_900.0, 780_500.0, 5_248_700.0],
1452 &options,
1453 )
1454 .expect_err("post-coverage PPP correction epoch must return an error");
1455
1456 assert_eq!(
1457 err,
1458 PppCorrectionsError::Epoch {
1459 epoch_index: 0,
1460 source: CoverageError::OutsideCoverage(
1461 crate::astro::time::DegradeReason::AfterCoverage
1462 ),
1463 }
1464 );
1465 }
1466
1467 #[test]
1468 fn build_accepts_valid_correction_epoch() {
1469 let sp3 = sp3_fixture();
1470 let options = PppCorrectionsOptions {
1471 solid_earth_tide: true,
1472 pole_tide: None,
1473 ocean_loading: None,
1474 phase_windup: false,
1475 satellite_antenna: None,
1476 };
1477 let epochs = vec![PppCorrectionEpoch {
1478 epoch: civil(2020, 6, 24, 12, 0, 0.0),
1479 t_rx_j2000_s: 0.0,
1480 observations: Vec::new(),
1481 }];
1482
1483 let got = build(
1484 &sp3,
1485 &epochs,
1486 [3_512_900.0, 780_500.0, 5_248_700.0],
1487 &options,
1488 )
1489 .expect("valid PPP correction epoch must build");
1490
1491 assert_eq!(got.tide.len(), 1);
1492 }
1493
1494 #[test]
1495 fn build_rejects_degenerate_receiver_state_before_tide() {
1496 let sp3 = sp3_fixture();
1497 let epoch = civil(2020, 6, 24, 12, 0, 0.0);
1498 let options = PppCorrectionsOptions {
1499 solid_earth_tide: true,
1500 pole_tide: None,
1501 ocean_loading: None,
1502 phase_windup: false,
1503 satellite_antenna: None,
1504 };
1505 let epochs = vec![PppCorrectionEpoch {
1506 epoch,
1507 t_rx_j2000_s: 0.0,
1508 observations: Vec::new(),
1509 }];
1510
1511 let err = build(&sp3, &epochs, [0.0, 0.0, 0.0], &options)
1512 .expect_err("degenerate tide geometry must error");
1513
1514 assert_eq!(
1515 err,
1516 PppCorrectionsError::InvalidInput {
1517 field: "receiver radius_m",
1518 reason: "not positive",
1519 }
1520 );
1521 }
1522}