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