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