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