Skip to main content

sidereon_core/
ppp_corrections.rs

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